Room joins from control work
This commit is contained in:
parent
2cbb8c1e01
commit
6add3c6b70
@ -8,9 +8,9 @@ import websockets
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Any, Dict, Iterable, Tuple
|
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")
|
WS_URL = os.environ.get("SXC_WS", "ws://127.0.0.1:5225")
|
||||||
CONTROL_GROUP = (os.environ.get("SXC_CONTROL_GROUP") or "BotTest").strip()
|
CONTROL_GROUP = (os.environ.get("SXC_CONTROL_GROUP") or "BotTest").strip()
|
||||||
ANNOUNCE_NEW = os.environ.get("SXC_ANNOUNCE", "1").strip() != "0"
|
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}"))
|
await ws.send(cmd(f"#{group} {text}"))
|
||||||
|
|
||||||
# Caches
|
# Caches
|
||||||
# gid -> {"name": str, "customerId": str}
|
# gid -> {"name": str, "customerId": str, "seq": int}
|
||||||
groups: Dict[int, Dict[str, str]] = {}
|
groups: Dict[int, Dict[str, Any]] = {}
|
||||||
# gid -> { groupMemberId(int) : alias(str) }
|
# gid -> { groupMemberId(int) : alias(str) }
|
||||||
alias_map: Dict[int, Dict[int, str]] = defaultdict(dict)
|
alias_map: Dict[int, Dict[int, str]] = defaultdict(dict)
|
||||||
|
_seq = 0 # monotonically increases as we discover new business chats
|
||||||
|
|
||||||
def _first(*vals):
|
def _first(*vals):
|
||||||
for v in vals:
|
for v in vals:
|
||||||
@ -66,17 +67,14 @@ def extract_business_chat(resp: dict):
|
|||||||
return None, None, None
|
return None, None, None
|
||||||
|
|
||||||
def _content_and_meta(it: dict):
|
def _content_and_meta(it: dict):
|
||||||
"""Return (content_dict, meta_dict) from either top-level or chatItem.*."""
|
|
||||||
chatItem = it.get("chatItem") or {}
|
chatItem = it.get("chatItem") or {}
|
||||||
cont = it.get("content") or chatItem.get("content") or {}
|
cont = it.get("content") or chatItem.get("content") or {}
|
||||||
meta = it.get("meta") or chatItem.get("meta") or {}
|
meta = it.get("meta") or chatItem.get("meta") or {}
|
||||||
return cont, meta
|
return cont, meta
|
||||||
|
|
||||||
def extract_text_from_item(it: dict) -> str:
|
def extract_text_from_item(it: dict) -> str:
|
||||||
"""Support msgContent, direct text/markdown, and meta.itemText fallback."""
|
|
||||||
cont, meta = _content_and_meta(it)
|
cont, meta = _content_and_meta(it)
|
||||||
|
|
||||||
# 1) Standard nested message content
|
|
||||||
if cont.get("type") in ("rcvMsgContent", "sndMsgContent"):
|
if cont.get("type") in ("rcvMsgContent", "sndMsgContent"):
|
||||||
mc = cont.get("msgContent") or {}
|
mc = cont.get("msgContent") or {}
|
||||||
for key in ("text", "markdown"):
|
for key in ("text", "markdown"):
|
||||||
@ -84,13 +82,11 @@ def extract_text_from_item(it: dict) -> str:
|
|||||||
if isinstance(txt, str) and txt.strip():
|
if isinstance(txt, str) and txt.strip():
|
||||||
return txt.strip()
|
return txt.strip()
|
||||||
|
|
||||||
# 2) Some builds may put text directly on content
|
|
||||||
for key in ("text", "markdown"):
|
for key in ("text", "markdown"):
|
||||||
txt = cont.get(key)
|
txt = cont.get(key)
|
||||||
if isinstance(txt, str) and txt.strip():
|
if isinstance(txt, str) and txt.strip():
|
||||||
return txt.strip()
|
return txt.strip()
|
||||||
|
|
||||||
# 3) Fallback to meta.itemText, skipping known noisy system lines
|
|
||||||
mt = meta.get("itemText")
|
mt = meta.get("itemText")
|
||||||
if isinstance(mt, str):
|
if isinstance(mt, str):
|
||||||
t = mt.strip()
|
t = mt.strip()
|
||||||
@ -102,7 +98,6 @@ def extract_text_from_item(it: dict) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def deep_iter_members(obj: Any) -> Iterable[dict]:
|
def deep_iter_members(obj: Any) -> Iterable[dict]:
|
||||||
"""Recursively yield any dicts that look like groupMember objects."""
|
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
if ("groupMemberId" in obj) and ("memberProfile" in obj or "localDisplayName" in obj):
|
if ("groupMemberId" in obj) and ("memberProfile" in obj or "localDisplayName" in obj):
|
||||||
yield obj
|
yield obj
|
||||||
@ -113,7 +108,6 @@ def deep_iter_members(obj: Any) -> Iterable[dict]:
|
|||||||
yield from deep_iter_members(v)
|
yield from deep_iter_members(v)
|
||||||
|
|
||||||
def update_alias_map_from_member(gid: int, gm: dict):
|
def update_alias_map_from_member(gid: int, gm: dict):
|
||||||
"""Capture alias against groupMemberId whenever we see a member object."""
|
|
||||||
try:
|
try:
|
||||||
gmid = gm.get("groupMemberId")
|
gmid = gm.get("groupMemberId")
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -132,7 +126,6 @@ def _direction_from(it: dict) -> str:
|
|||||||
return "rcv" if dtyp == "grouprcv" else "snd"
|
return "rcv" if dtyp == "grouprcv" else "snd"
|
||||||
|
|
||||||
def _primary_group_member(it: dict) -> dict:
|
def _primary_group_member(it: dict) -> dict:
|
||||||
"""Prefer the member embedded under chatItem.chatDir.groupMember for sender."""
|
|
||||||
chatItem = it.get("chatItem") or {}
|
chatItem = it.get("chatItem") or {}
|
||||||
chatDir = chatItem.get("chatDir") or {}
|
chatDir = chatItem.get("chatDir") or {}
|
||||||
gm = chatDir.get("groupMember")
|
gm = chatDir.get("groupMember")
|
||||||
@ -145,10 +138,6 @@ def _primary_group_member(it: dict) -> dict:
|
|||||||
return gm or {}
|
return gm or {}
|
||||||
|
|
||||||
def extract_group_items(resp: dict) -> Iterable[Tuple[str, int, str, str, Any, str, str, dict]]:
|
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":
|
if resp.get("type") != "newChatItems":
|
||||||
return
|
return
|
||||||
for it in resp.get("chatItems", []):
|
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)
|
direction = _direction_from(it)
|
||||||
|
|
||||||
# Deep-scan to populate alias map from any member objects
|
|
||||||
for gm in deep_iter_members(it):
|
for gm in deep_iter_members(it):
|
||||||
update_alias_map_from_member(gid, gm)
|
update_alias_map_from_member(gid, gm)
|
||||||
|
|
||||||
# Extract sender from preferred location
|
|
||||||
gm = _primary_group_member(it)
|
gm = _primary_group_member(it)
|
||||||
try:
|
try:
|
||||||
gmid = gm.get("groupMemberId")
|
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:
|
def is_control_group(name: str) -> bool:
|
||||||
return name == CONTROL_GROUP
|
return name == CONTROL_GROUP
|
||||||
|
|
||||||
def resolve_chat_name(user_supplied: str) -> str:
|
def _canonical_name_set() -> Dict[str, str]:
|
||||||
"""Resolve to exact business chat name by exact or unambiguous prefix match; else return as-is."""
|
"""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:
|
if not user_supplied:
|
||||||
return user_supplied
|
return "", "empty"
|
||||||
for info in groups.values():
|
|
||||||
if info.get("name") == user_supplied:
|
q = user_supplied.strip()
|
||||||
return user_supplied
|
ql = q.lower()
|
||||||
cand = [info["name"] for info in groups.values() if info.get("name", "").startswith(user_supplied)]
|
name_map = _canonical_name_set()
|
||||||
return cand[0] if len(cand) == 1 else user_supplied
|
|
||||||
|
# 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:
|
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:
|
if fallback_alias:
|
||||||
return fallback_alias
|
return fallback_alias
|
||||||
if isinstance(gid, int) and isinstance(gmid, int):
|
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:
|
if not m:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Allow 'join <chat> as <alias>' to override when daemon doesn't expose alias
|
|
||||||
raw_chat = (m.group(1) or "").strip()
|
raw_chat = (m.group(1) or "").strip()
|
||||||
override_alias = (m.group(2) 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 <chat> as <your_alias>")
|
await say(ws, group_name, "Cannot resolve your contact alias. Use a token alias or type: join <chat> as <your_alias>")
|
||||||
return
|
return
|
||||||
|
|
||||||
chatname = resolve_chat_name(raw_chat)
|
resolved_name, note = resolve_chat_name(raw_chat)
|
||||||
if not chatname:
|
if DEBUG:
|
||||||
await say(ws, group_name, "usage: join <chatname>")
|
print(f"[resolve] '{raw_chat}' -> '{resolved_name}' ({note})")
|
||||||
return
|
|
||||||
|
|
||||||
|
# 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:
|
for c in INVITE_CMDS:
|
||||||
invite = f"{c} {chatname} {eff_alias}" # no quotes
|
invite = f"{c} {resolved_name} {eff_alias}" # no quotes
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
print(f"[join] trying: {invite}")
|
print(f"[join] trying: {invite}")
|
||||||
await ws.send(cmd(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):
|
async def announce_new_chat(ws, gname: str):
|
||||||
if ANNOUNCE_NEW and CONTROL_GROUP:
|
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}")
|
await say(ws, CONTROL_GROUP, f"New business chat: {gname} — type: join {gname}")
|
||||||
|
|
||||||
async def run():
|
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(f"[cfg] WS={WS_URL} CONTROL_GROUP={CONTROL_GROUP} ANNOUNCE={ANNOUNCE_NEW} DEBUG={DEBUG} LOG_RAW={LOG_RAW}")
|
||||||
print("[cfg] Auto-invite: DISABLED")
|
print("[cfg] Auto-invite: DISABLED")
|
||||||
while True:
|
while True:
|
||||||
@ -275,8 +288,9 @@ async def run():
|
|||||||
gid_b, gname_b, cust = extract_business_chat(resp)
|
gid_b, gname_b, cust = extract_business_chat(resp)
|
||||||
if isinstance(gid_b, int) and gname_b and cust:
|
if isinstance(gid_b, int) and gname_b and cust:
|
||||||
if gid_b not in groups:
|
if gid_b not in groups:
|
||||||
groups[gid_b] = {"name": gname_b, "customerId": cust} # fixed key
|
_seq += 1
|
||||||
print(f"[chat] business chat gid={gid_b} name='{gname_b}' customerId={cust}")
|
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)
|
await announce_new_chat(ws, gname_b)
|
||||||
|
|
||||||
# Inspect group items and process control commands
|
# Inspect group items and process control commands
|
||||||
|
Loading…
x
Reference in New Issue
Block a user