ForcePilot/backend/package/yuxi/channel/extensions/tlon/monitor.py
Kris 0babb1ca8e feat(channel): 添加 Tlon 渠道扩展
新增 Tlon 渠道扩展,支持在 Yuxi 平台中集成 Tlon/Urbit 去中心化通讯平台。

包含以下功能模块:
- tlon_api: Tlon API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- sse_client: SSE 客户端
- outbound: 外发消息管理
- send: 消息发送
- security: 安全校验
- auth: 认证管理
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- approval: 审批流程
- channel_mgmt: 频道管理
- channel_ops: 频道操作
- contacts: 联系人管理
- discovery: 服务发现
- doctor: 健康诊断
- expose: 服务暴露
- gallery: 图库管理
- history: 历史记录
- hooks: 钩子管理
- media: 媒体资源处理
- notebook: 笔记本功能
- settings_store: 设置存储
- setup: 初始化设置
- story: 故事功能
- targets: 目标管理
- cite_parser: 引用解析
- utils: 工具函数
- types: 类型定义
2026-05-21 11:55:25 +08:00

500 lines
20 KiB
Python

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"