import asyncio import json import logging import time from yuxi.channel.extensions.tlon.sse_client import UrbitSSEClient from yuxi.channel.extensions.tlon.story import story_to_text from yuxi.channel.extensions.tlon.send import send_dm, send_group_message from yuxi.channel.extensions.tlon.utils import ( is_bot_mentioned, extract_dm_partner_ship, normalize_ship, is_owner, is_admin_mentioned, ) from yuxi.channel.extensions.tlon.types import ProcessedMessageTracker logger = logging.getLogger(__name__) CHANNEL_DISCOVERY_INTERVAL = 120 async def monitor_tlon_provider(ctx, client: UrbitSSEClient, account: dict) -> None: ship = account.get("ship", "") dm_allowlist_raw = account.get("dm_allowlist", []) dm_allowlist = {normalize_ship(s) for s in dm_allowlist_raw if s.strip()} owner_ship = account.get("owner_ship") group_channels = account.get("group_channels", []) auto_accept_dm = account.get("auto_accept_dm_invites", False) auto_accept_group = account.get("auto_accept_group_invites", False) auto_discover = account.get("auto_discover_channels", False) watched_channels: set[str] = set() for ch in group_channels: if ch and ch.strip(): watched_channels.add(ch.strip()) processed_tracker = ProcessedMessageTracker() participated_threads: set[str] = set() dm_chat_history: dict[str, list[dict]] = {} bot_nickname: str | None = None from yuxi.channel.extensions.tlon.approval import ( load_pending_approvals, create_pending_approval, handle_approval_command, send_owner_notification, ) from yuxi.channel.extensions.tlon.security import ( check_dm_allowlist, check_channel_authorization, check_group_invite_allowlist, detect_multi_user_dm_session, is_ship_blocked_check, ) from yuxi.channel.extensions.tlon.settings_store import TlonSettingsStore pending_approvals = load_pending_approvals() settings_store = TlonSettingsStore() async def handle_channels_firehose(event: dict) -> None: if not isinstance(event, dict): return if event.get("del-post"): deleted_id = event.get("del-post", "") deletion = { "channel_type": "tlon", "chat_type": "system", "content": f"[message deleted: {deleted_id}]", "message_id": f"del-{int(time.time() * 1000)}", "timestamp": int(time.time() * 1000), "channel_id": event.get("nest", ""), "metadata": {"event_type": "message_deleted", "deleted_id": deleted_id}, } if ctx.queue: await ctx.queue.put(deletion) return nest = event.get("nest", "") if nest and watched_channels and nest not in watched_channels: return post = event.get("post") or event.get("r-post") if not post: return essay = post.get("essay") or post.get("memo") if not essay: return author = essay.get("author", "") content = essay.get("content", "") sent_at = essay.get("sent", 0) if normalize_ship(author) == normalize_ship(ship): return msg_id = event.get("uid", "") or f"{nest}/{author}/{sent_at}" async def process(): text = story_to_text(content) if isinstance(content, list) else str(content) is_thread_reply = bool(event.get("r-post")) parent_id = event.get("parent", {}).get("uid") if is_thread_reply else None mentioned = is_bot_mentioned(text, ship, bot_nickname) or is_admin_mentioned(text) is_participated = parent_id and parent_id in participated_threads if not mentioned and not is_participated: return {"action": "skip"} sender_allowed = True if account.get("authorization", {}).get("channelRules", {}).get(nest, {}).get("mode") == "restricted": channel_rules = account.get("authorization", {}).get("channelRules", {}) sender_allowed = check_channel_authorization( author, channel_rules.get(nest, {}), account.get("default_authorized_ships", []), owner_ship, ) if not sender_allowed: blocked_ships_check = getattr(client, "_blocked_ships", []) if is_ship_blocked_check(author, blocked_ships_check): return {"action": "skip"} await create_pending_approval( "channel", author, account_id=account.get("account_id", "default"), channel_nest=nest, original_message=text, message_preview=text[:200], config={"ship": ship}, approval_store=pending_approvals, ) await send_owner_notification(ctx, client, account, "channel", author, nest=nest) return {"action": "pending_approval"} attachments = [] if isinstance(content, list): from yuxi.channel.extensions.tlon.media import download_message_images attachments = await download_message_images(content) cites = [] if isinstance(content, list): from yuxi.channel.extensions.tlon.cite_parser import resolve_all_cites try: raw_cites = [] for verse in content: if isinstance(verse, dict): for inline in (verse.get("inline") or []): if isinstance(inline, dict) and "cite" in inline: raw_cites.append(inline["cite"]) if raw_cites: cites = await resolve_all_cites(raw_cites) except Exception: pass from yuxi.channel.extensions.tlon.history import is_summarization_request, fetch_channel_history, format_channel_history_for_summary if is_summarization_request(text): channel_msgs = [v for vals in dm_chat_history.values() for v in vals] history = await fetch_channel_history(channel_msgs, 50) if history: summary_text = format_channel_history_for_summary(history) text = f"[Channel Summary Request]\n\nRecent messages:\n{summary_text}\n\n[Request]\n{text}" thread_context = "" if is_thread_reply and parent_id: thread_history = dm_chat_history.get(parent_id, []) if thread_history: recent = thread_history[-10:] thread_lines = [f"{h['author']}: {h['content']}" for h in recent] thread_context = ( f"[Thread conversation - {len(thread_history)} previous replies. " "You are participating in this thread.\n\n" f"[Previous messages]\n" + "\n".join(thread_lines) + "\n\n" ) body = thread_context + text if thread_context else text if attachments: media_lines = [ f"[media attached: {a['path']} ({a['content_type']}) | {a['path']}]" for a in attachments ] body = "\n".join(media_lines) + "\n" + body sender_role = "owner" if is_owner(author, owner_ship) else "user" from_label = f"{author} [{sender_role}] in {nest or 'dm'}" history_entry = {"author": author, "content": text, "sent_at": sent_at} history_key = parent_id or msg_id if history_key not in dm_chat_history: dm_chat_history[history_key] = [] dm_chat_history[history_key].append(history_entry) if len(dm_chat_history[history_key]) > 50: dm_chat_history[history_key] = dm_chat_history[history_key][-50:] msg = { "channel_type": "tlon", "chat_type": "group" if nest else "direct", "author": author, "content": body, "message_id": msg_id, "timestamp": sent_at, "channel_id": nest, "thread_id": parent_id if is_thread_reply else None, "is_thread_reply": is_thread_reply, "metadata": {"sender_role": sender_role, "from_label": from_label}, } return {"action": "deliver", "msg": msg, "parent_id": parent_id} if processed_tracker.has(msg_id): return result = await processed_tracker.run_with_claim(msg_id, process) if result.get("kind") == "processed" and result["result"].get("action") == "deliver": r = result["result"] msg = r["msg"] parent_id = r.get("parent_id") if ctx.queue: await ctx.queue.put(msg) if parent_id: participated_threads.add(parent_id) async def handle_chat_firehose(event: dict) -> None: if not isinstance(event, dict): return if isinstance(event.get("invites"), list): for invite in event["invites"]: from_ship = invite.get("ship", "") if not from_ship: continue blocked_ships_check = getattr(client, "_blocked_ships", []) if is_ship_blocked_check(from_ship, blocked_ships_check): continue if is_owner(from_ship, owner_ship): continue if auto_accept_dm and check_dm_allowlist(from_ship, dm_allowlist): continue await create_pending_approval( "dm", from_ship, account_id=account.get("account_id", "default"), original_message="DM invite", config={"ship": ship}, approval_store=pending_approvals, ) await send_owner_notification(ctx, client, account, "dm", from_ship) return essay = event.get("essay") if not essay: return author = essay.get("author", "") content = essay.get("content", "") if normalize_ship(author) == normalize_ship(ship): return whom_info = event.get("whom", "") partner_ship = extract_dm_partner_ship(whom_info) text = story_to_text(content) if isinstance(content, list) else str(content) msg_id = f"dm/{author}/{event.get('uid', int(time.time() * 1000))}" if processed_tracker.has(msg_id): return is_cmd = text.strip().startswith("/") if is_cmd and is_owner(author, owner_ship): result_text = await handle_approval_command( text, ctx, client, account, pending_approvals=pending_approvals, dm_allowlist=dm_allowlist, ) if result_text: await send_dm(client, ship, author, result_text) return if not is_owner(author, owner_ship): approval_result = await _handle_dm_approval(text, author, ctx, client, account, pending_approvals) if approval_result == "approval": if not check_dm_allowlist(author, dm_allowlist): await create_pending_approval( "dm", author, account_id=account.get("account_id", "default"), original_message=text, message_preview=text[:200], config={"ship": ship}, approval_store=pending_approvals, ) await send_owner_notification(ctx, client, account, "dm", author) return elif approval_result == "blocked": return dm_session_key = f"dm_session_{partner_ship}" if dm_session_key not in dm_chat_history: dm_chat_history[dm_session_key] = [] dm_chat_history[dm_session_key].append({"author": author, "content": text}) if detect_multi_user_dm_session(dm_chat_history[dm_session_key]): logger.warning("[tlon] Multi-user DM session detected for %s, notifying owner", partner_ship) if owner_ship: try: await send_dm(client, ship, owner_ship, f"⚠ Security warning: Multiple users detected in DM session with {partner_ship}") except Exception: pass sender_role = "owner" if is_owner(author, owner_ship) else "user" from_label = f"{author} [{sender_role}] in DM" msg = { "channel_type": "tlon", "chat_type": "direct", "author": author, "content": text, "message_id": msg_id, "timestamp": int(time.time() * 1000), "channel_id": None, "thread_id": None, "metadata": {"sender_role": sender_role, "from_label": from_label, "partner_ship": partner_ship}, } if ctx.queue: await ctx.queue.put(msg) async def handle_groups_foreigns(event: dict) -> None: if not isinstance(event, dict): return group_flag = event.get("flag", "") from_ship = event.get("ship", "") if not group_flag or not from_ship: return blocked_ships_check = getattr(client, "_blocked_ships", []) if is_ship_blocked_check(from_ship, blocked_ships_check): return if auto_accept_group and check_group_invite_allowlist( from_ship, set(account.get("group_invite_allowlist", [])) ): return await create_pending_approval( "group", from_ship, account_id=account.get("account_id", "default"), group_flag=group_flag, config={"ship": ship}, approval_store=pending_approvals, ) await send_owner_notification(ctx, client, account, "group", from_ship, group_flag=group_flag) async def handle_contacts_news(event: dict) -> None: nonlocal bot_nickname if not isinstance(event, dict): return con = event.get("con", {}) if isinstance(con, dict): nick = con.get("nickname", "") if nick and isinstance(nick, str) and nick.strip(): bot_nickname = nick.strip() logger.info("[tlon] Bot nickname updated: %s", bot_nickname) async def handle_groups_ui(event: dict) -> None: if not isinstance(event, dict): return channels = event.get("channels", []) for nest in channels: if isinstance(nest, str) and nest.startswith("chat/") and nest not in watched_channels: watched_channels.add(nest) logger.info("[tlon] New channel detected via groups-ui: %s", nest) group_flag = event.get("flag", "") if group_flag: name = event.get("name", "") privacy = event.get("privacy", "") members = event.get("members", []) roles = event.get("roles", {}) logger.info("[tlon] Group update: %s name=%s privacy=%s members=%d roles=%d", group_flag, name, privacy, len(members), len(roles)) try: await settings_store.subscribe_to_changes(client) logger.info("[tlon] Settings store subscription registered") except Exception as e: logger.warning("[tlon] Settings store subscription failed: %s", e) async def handle_activity_event(event: dict) -> None: if not isinstance(event, dict): return mentions = event.get("mentions", event.get("alerts", {}).get("mentions", [])) replies = event.get("replies", event.get("alerts", {}).get("replies", [])) if not mentions and not replies: return logger.info("[tlon] Activity: %d mentions, %d replies", len(mentions), len(replies)) if ctx.queue: activity_msg = { "channel_type": "tlon", "chat_type": "activity", "content": json.dumps({"mentions": mentions, "replies": replies}), "message_id": f"activity-{int(time.time() * 1000)}", "timestamp": int(time.time() * 1000), "metadata": {"event_type": "activity"}, } await ctx.queue.put(activity_msg) await client.subscribe("channels", "/v2", handle_channels_firehose) await client.subscribe("chat", "/v3", handle_chat_firehose) await client.subscribe("groups", "/v1/foreigns", handle_groups_foreigns) await client.subscribe("contacts", "/v1/news", handle_contacts_news) await client.subscribe("groups", "/groups/ui", handle_groups_ui) try: await client.subscribe("activity", "/v1", handle_activity_event) logger.info("[tlon] Activity subscription registered") except Exception as e: logger.warning("[tlon] Activity subscription failed (may require Tlon v7.0+): %s", e) async def handle_groups_changed(event: dict) -> None: if not isinstance(event, dict): return flag = event.get("flag", "") action = event.get("change", event.get("action", "")) logger.info("[tlon] Group changed: %s action=%s", flag, action) try: await client.subscribe("groups", "/v1/changed", handle_groups_changed) logger.info("[tlon] Groups changed subscription registered") except Exception as e: logger.warning("[tlon] Groups changed subscription failed (may require Tlon v10.0+): %s", e) async def periodic_channel_discovery(): while not ctx.cancel_event.is_set(): await asyncio.sleep(CHANNEL_DISCOVERY_INTERVAL) if ctx.cancel_event.is_set(): break if auto_discover: try: from yuxi.channel.extensions.tlon.discovery import discover_and_track new_channels = await discover_and_track(client, watched_channels) if new_channels: logger.info("[tlon] Periodic discovery found %d new channels", len(new_channels)) except Exception as e: logger.warning("[tlon] Periodic channel discovery failed: %s", e) discovery_task = asyncio.create_task(periodic_channel_discovery()) logger.info("[tlon] Monitor subscriptions registered for %s (6 subscriptions)", ship) try: await client.listen() finally: discovery_task.cancel() try: await discovery_task except asyncio.CancelledError: pass async def apply_settings_to_account(account: dict, store) -> None: dm_allowlist = store.get("tlon.dm_allowlist") if dm_allowlist is not None: try: parsed = json.loads(dm_allowlist) if isinstance(dm_allowlist, str) else dm_allowlist account["dm_allowlist"] = parsed if isinstance(parsed, list) else [parsed] except (json.JSONDecodeError, TypeError): pass group_channels = store.get("tlon.group_channels") if group_channels is not None: try: parsed = json.loads(group_channels) if isinstance(group_channels, str) else group_channels account["group_channels"] = parsed if isinstance(parsed, list) else [parsed] except (json.JSONDecodeError, TypeError): pass auto_accept_dm = store.get("tlon.auto_accept_dm_invites") if auto_accept_dm is not None: account["auto_accept_dm_invites"] = _to_bool(auto_accept_dm) auto_accept_group = store.get("tlon.auto_accept_group_invites") if auto_accept_group is not None: account["auto_accept_group_invites"] = _to_bool(auto_accept_group) auto_discover = store.get("tlon.auto_discover") if auto_discover is not None: account["auto_discover_channels"] = _to_bool(auto_discover) def _to_bool(value) -> bool: if isinstance(value, bool): return value if isinstance(value, str): return value.lower() in ("true", "1", "yes") return bool(value) async def _handle_dm_approval(text: str, author: str, ctx, client, account: dict, pending_approvals: list) -> str: normalized = normalize_ship(author) dm_allowlist = {normalize_ship(s) for s in account.get("dm_allowlist", []) if s.strip()} if normalized in dm_allowlist: return "allowed" blocked_ships = getattr(client, "_blocked_ships", []) if blocked_ships and is_ship_blocked_check(author, blocked_ships): return "blocked" return "approval"