diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..e69de29 diff --git a/src/simplex_customerservicebot.py b/src/simplex_customerservicebot.py new file mode 100644 index 0000000..e63d299 --- /dev/null +++ b/src/simplex_customerservicebot.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +import asyncio +import json +import os +import uuid +import websockets + +__version__ = '0.1.0' + +# SimpleX business bot, invite a preset SimpleX user to an auto-created group +# AGPL 3.0 or later. Written by Taurix IT https://www.taurix.net + +WS_URL = os.environ.get("SXC_WS", "ws://127.0.0.1:5225") +OPERATOR_ALIAS = os.environ.get("SXC_OPERATOR_ALIAS", "@Operator") +OPERATOR_ADDR = os.environ.get("SXC_OPERATOR_ADDR", "").strip() + +def cmd(c: str) -> str: + return json.dumps({"corrId": str(uuid.uuid4()), "cmd": c}, separators=(",", ":")) + +def q(s: str) -> str: + return '"' + s.replace('"', r'\"') + '"' + +# Cache per-group info +groups = {} # gid -> {"name": str, "customerId": str, "invited": bool} + +async def ensure_operator_contact(ws): + if OPERATOR_ADDR: + # Idempotent: fine to call on start + await ws.send(cmd(f"/c {OPERATOR_ADDR}")) + +def extract_business_chat(resp: dict): + # Primary event in your logs + if resp.get("type") == "acceptingBusinessRequest": + gi = resp.get("groupInfo", {}) + gname = gi.get("localDisplayName") or gi.get("groupProfile", {}).get("displayName") + gid = gi.get("groupId") + bchat = gi.get("businessChat") or {} + cust = bchat.get("customerId") + return gid, gname, cust + # Fallback from the first items burst + if resp.get("type") == "newChatItems": + for it in resp.get("chatItems", []): + ci = it.get("chatInfo", {}) + if ci.get("type") == "group" and ci.get("businessChat"): + gi = ci.get("groupInfo", {}) + gname = gi.get("localDisplayName") or gi.get("groupProfile", {}).get("displayName") + gid = gi.get("groupId") + cust = ci.get("businessChat", {}).get("customerId") + return gid, gname, cust + return None, None, None + +def member_connected(resp: dict): + if resp.get("type") != "newChatItems": + return None, None + for it in resp.get("chatItems", []): + ci = it.get("chatInfo", {}) + if ci.get("type") != "group": + continue + gid = ci.get("groupInfo", {}).get("groupId") + cont = it.get("content", {}) + if cont.get("type") == "rcvGroupEvent" and cont.get("rcvGroupEvent", {}).get("type") == "memberConnected": + gm = it.get("chatItem", {}).get("groupMember") or {} + member_id = gm.get("memberId") + disp = (gm.get("memberProfile", {}) or {}).get("displayName") or gm.get("localDisplayName") or "" + return gid, (member_id, disp) + return None, None + +async def invite_operator(ws, gid: int): + info = groups.get(gid) + if not info or info.get("invited"): + return + gname = info["name"] + + await ws.send(cmd(f"/a {gname} {OPERATOR_ALIAS}")) + await ws.send(cmd(f"#{gname} Added {OPERATOR_ALIAS} to assist.")) + info["invited"] = True + print(f"[invited] {OPERATOR_ALIAS} -> group {gname} (id {gid})") + +async def run(): + while True: + try: + async with websockets.connect(WS_URL, max_size=None) as ws: + print(f"[ok] connected {WS_URL}") + await ensure_operator_contact(ws) + await ws.send(cmd("/help")) + + async for raw in ws: + # print(f"[frame] {raw}") + try: + msg = json.loads(raw) + except Exception: + continue + resp = msg.get("resp") or {} + rtype = resp.get("type") + + # 1) See a business chat -> cache it and invite operator + gid, gname, cust = extract_business_chat(resp) + if isinstance(gid, int) and gname and cust: + if gid not in groups: + groups[gid] = {"name": gname, "customerId": cust, "invited": False} + print(f"[chat] business chat created gid={gid} name='{gname}' customerId={cust}") + await invite_operator(ws, gid) + + # 2) Member joins + jgid, member = member_connected(resp) + if isinstance(jgid, int) and member: + mem_id, disp = member + info = groups.get(jgid) + if info: + if mem_id == info["customerId"]: + print(f"[join] customer connected to gid={jgid} as '{disp}'") + else: + print(f"[join] non-customer joined gid={jgid}: '{disp}'") + except Exception as e: + print(f"[ws] {e}; retrying in 2s") + await asyncio.sleep(2) + +if __name__ == "__main__": + # env: + # export SXC_WS=ws://127.0.0.1:5225 + # export SXC_OPERATOR_ALIAS="Taurix-Operator" # pick a unique alias + # export SXC_OPERATOR_ADDR="simplex://..." # optional + asyncio.run(run())