新增 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: 类型定义
500 lines
20 KiB
Python
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" |