From 6add3c6b70ee3a1b80e8bc3429458f7ac5f4b880 Mon Sep 17 00:00:00 2001 From: Guy Van Sanden Date: Sun, 31 Aug 2025 21:24:34 +0200 Subject: [PATCH] Room joins from control work --- src/simplex_customerservicebot.py | 86 ++++++++++++++++++------------- 1 file changed, 50 insertions(+), 36 deletions(-) diff --git a/src/simplex_customerservicebot.py b/src/simplex_customerservicebot.py index 42f6203..275db31 100644 --- a/src/simplex_customerservicebot.py +++ b/src/simplex_customerservicebot.py @@ -8,9 +8,9 @@ import websockets from collections import defaultdict from typing import Any, Dict, Iterable, Tuple -__version__ = "0.5.5" # fix announce typo, keep robust sender + case-insensitive join +__version__ = "0.5.6" # exact/recency name resolver + robust sender + case-insensitive join + announce fix -# Defaults match your setup +# Defaults WS_URL = os.environ.get("SXC_WS", "ws://127.0.0.1:5225") CONTROL_GROUP = (os.environ.get("SXC_CONTROL_GROUP") or "BotTest").strip() ANNOUNCE_NEW = os.environ.get("SXC_ANNOUNCE", "1").strip() != "0" @@ -28,10 +28,11 @@ async def say(ws, group: str, text: str): await ws.send(cmd(f"#{group} {text}")) # Caches -# gid -> {"name": str, "customerId": str} -groups: Dict[int, Dict[str, str]] = {} +# gid -> {"name": str, "customerId": str, "seq": int} +groups: Dict[int, Dict[str, Any]] = {} # gid -> { groupMemberId(int) : alias(str) } alias_map: Dict[int, Dict[int, str]] = defaultdict(dict) +_seq = 0 # monotonically increases as we discover new business chats def _first(*vals): for v in vals: @@ -66,17 +67,14 @@ def extract_business_chat(resp: dict): return None, None, None def _content_and_meta(it: dict): - """Return (content_dict, meta_dict) from either top-level or chatItem.*.""" chatItem = it.get("chatItem") or {} cont = it.get("content") or chatItem.get("content") or {} meta = it.get("meta") or chatItem.get("meta") or {} return cont, meta def extract_text_from_item(it: dict) -> str: - """Support msgContent, direct text/markdown, and meta.itemText fallback.""" cont, meta = _content_and_meta(it) - # 1) Standard nested message content if cont.get("type") in ("rcvMsgContent", "sndMsgContent"): mc = cont.get("msgContent") or {} for key in ("text", "markdown"): @@ -84,13 +82,11 @@ def extract_text_from_item(it: dict) -> str: if isinstance(txt, str) and txt.strip(): return txt.strip() - # 2) Some builds may put text directly on content for key in ("text", "markdown"): txt = cont.get(key) if isinstance(txt, str) and txt.strip(): return txt.strip() - # 3) Fallback to meta.itemText, skipping known noisy system lines mt = meta.get("itemText") if isinstance(mt, str): t = mt.strip() @@ -102,7 +98,6 @@ def extract_text_from_item(it: dict) -> str: return "" def deep_iter_members(obj: Any) -> Iterable[dict]: - """Recursively yield any dicts that look like groupMember objects.""" if isinstance(obj, dict): if ("groupMemberId" in obj) and ("memberProfile" in obj or "localDisplayName" in obj): yield obj @@ -113,7 +108,6 @@ def deep_iter_members(obj: Any) -> Iterable[dict]: yield from deep_iter_members(v) def update_alias_map_from_member(gid: int, gm: dict): - """Capture alias against groupMemberId whenever we see a member object.""" try: gmid = gm.get("groupMemberId") except Exception: @@ -132,7 +126,6 @@ def _direction_from(it: dict) -> str: return "rcv" if dtyp == "grouprcv" else "snd" def _primary_group_member(it: dict) -> dict: - """Prefer the member embedded under chatItem.chatDir.groupMember for sender.""" chatItem = it.get("chatItem") or {} chatDir = chatItem.get("chatDir") or {} gm = chatDir.get("groupMember") @@ -145,10 +138,6 @@ def _primary_group_member(it: dict) -> dict: return gm or {} def extract_group_items(resp: dict) -> Iterable[Tuple[str, int, str, str, Any, str, str, dict]]: - """ - Yield tuples: - (group_name, group_id, direction, sender_alias, sender_gmid, text, ctype, raw_item) - """ if resp.get("type") != "newChatItems": return for it in resp.get("chatItems", []): @@ -163,11 +152,9 @@ def extract_group_items(resp: dict) -> Iterable[Tuple[str, int, str, str, Any, s direction = _direction_from(it) - # Deep-scan to populate alias map from any member objects for gm in deep_iter_members(it): update_alias_map_from_member(gid, gm) - # Extract sender from preferred location gm = _primary_group_member(it) try: gmid = gm.get("groupMemberId") @@ -185,18 +172,39 @@ def extract_group_items(resp: dict) -> Iterable[Tuple[str, int, str, str, Any, s def is_control_group(name: str) -> bool: return name == CONTROL_GROUP -def resolve_chat_name(user_supplied: str) -> str: - """Resolve to exact business chat name by exact or unambiguous prefix match; else return as-is.""" +def _canonical_name_set() -> Dict[str, str]: + """lower(name) -> canonical name""" + return {info["name"].lower(): info["name"] for info in groups.values()} + +def resolve_chat_name(user_supplied: str) -> Tuple[str, str]: + """ + Return (resolved_name, note). + Strategy: + 1) Exact case-insensitive name match wins. + 2) Otherwise, pick the most recently discovered business chat whose name startswith the query (case-insensitive). + 3) If no matches at all, return the raw query. + """ if not user_supplied: - return user_supplied - for info in groups.values(): - if info.get("name") == user_supplied: - return user_supplied - cand = [info["name"] for info in groups.values() if info.get("name", "").startswith(user_supplied)] - return cand[0] if len(cand) == 1 else user_supplied + return "", "empty" + + q = user_supplied.strip() + ql = q.lower() + name_map = _canonical_name_set() + + # 1) Exact case-insensitive match + if ql in name_map: + return name_map[ql], "exact" + + # 2) Prefix matches -> choose most recent by seq + matches = [(gid, info) for gid, info in groups.items() if info["name"].lower().startswith(ql)] + if matches: + gid_best, info_best = max(matches, key=lambda t: t[1].get("seq", 0)) + return info_best["name"], f"prefix:{len(matches)}" + + # 3) No knowledge -> return as-is + return q, "raw" def resolve_sender_alias(gid: int, gmid, fallback_alias: str) -> str: - """Use explicit alias if present; else look up by groupMemberId; else return empty.""" if fallback_alias: return fallback_alias if isinstance(gid, int) and isinstance(gmid, int): @@ -211,7 +219,6 @@ async def handle_control_command(ws, group_name: str, gid: int, sender_alias: st if not m: return - # Allow 'join as ' to override when daemon doesn't expose alias raw_chat = (m.group(1) or "").strip() override_alias = (m.group(2) or "").strip() @@ -220,18 +227,23 @@ async def handle_control_command(ws, group_name: str, gid: int, sender_alias: st await say(ws, group_name, "Cannot resolve your contact alias. Use a token alias or type: join as ") return - chatname = resolve_chat_name(raw_chat) - if not chatname: - await say(ws, group_name, "usage: join ") - return + resolved_name, note = resolve_chat_name(raw_chat) + if DEBUG: + print(f"[resolve] '{raw_chat}' -> '{resolved_name}' ({note})") + # If we had to choose among multiple prefix matches, tell the operator what we picked. + if note.startswith("prefix:"): + count = note.split(":")[1] + await say(ws, group_name, f"Multiple chats match '{raw_chat}'. Using most recent: {resolved_name} (matches={count}).") + + # Invite for c in INVITE_CMDS: - invite = f"{c} {chatname} {eff_alias}" # no quotes + invite = f"{c} {resolved_name} {eff_alias}" # no quotes if DEBUG: print(f"[join] trying: {invite}") await ws.send(cmd(invite)) - await say(ws, group_name, f"Invited {eff_alias} to {chatname}") + await say(ws, group_name, f"Invited {eff_alias} to {resolved_name}") async def announce_new_chat(ws, gname: str): if ANNOUNCE_NEW and CONTROL_GROUP: @@ -240,6 +252,7 @@ async def announce_new_chat(ws, gname: str): await say(ws, CONTROL_GROUP, f"New business chat: {gname} — type: join {gname}") async def run(): + global _seq print(f"[cfg] WS={WS_URL} CONTROL_GROUP={CONTROL_GROUP} ANNOUNCE={ANNOUNCE_NEW} DEBUG={DEBUG} LOG_RAW={LOG_RAW}") print("[cfg] Auto-invite: DISABLED") while True: @@ -275,8 +288,9 @@ async def run(): gid_b, gname_b, cust = extract_business_chat(resp) if isinstance(gid_b, int) and gname_b and cust: if gid_b not in groups: - groups[gid_b] = {"name": gname_b, "customerId": cust} # fixed key - print(f"[chat] business chat gid={gid_b} name='{gname_b}' customerId={cust}") + _seq += 1 + groups[gid_b] = {"name": gname_b, "customerId": cust, "seq": _seq} + print(f"[chat] business chat gid={gid_b} name='{gname_b}' customerId={cust} seq={_seq}") await announce_new_chat(ws, gname_b) # Inspect group items and process control commands