ForcePilot/backend/package/yuxi/channel/extensions/tlon/approval.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

304 lines
10 KiB
Python

import json
import logging
import time
import uuid
from dataclasses import asdict
from pathlib import Path
from yuxi.channel.extensions.tlon.types import ApprovalType, PendingApproval
from yuxi.channel.extensions.tlon.utils import normalize_ship, is_owner
from yuxi.channel.extensions.tlon.send import send_dm
from yuxi.channel.extensions.tlon.security import (
check_dm_allowlist, check_channel_authorization,
block_ship, unblock_ship, get_blocked_ships,
)
logger = logging.getLogger(__name__)
PENDING_APPROVALS_KEY = "pending_approvals"
def _approvals_file_path() -> Path:
return Path(__file__).parent / "pending_approvals.json"
def load_pending_approvals() -> list[PendingApproval]:
filepath = _approvals_file_path()
if not filepath.exists():
return []
try:
data = json.loads(filepath.read_text(encoding="utf-8"))
result = []
for item in data:
ap_type = ApprovalType.DM
raw_type = item.get("type", "dm")
if raw_type == "channel":
ap_type = ApprovalType.CHANNEL
elif raw_type == "group":
ap_type = ApprovalType.GROUP
result.append(PendingApproval(
id=item.get("id", ""),
type=ap_type,
requesting_ship=item.get("requesting_ship", ""),
account_id=item.get("account_id", "default"),
channel_nest=item.get("channel_nest"),
group_flag=item.get("group_flag"),
original_message=item.get("original_message"),
message_preview=item.get("message_preview"),
created_at=item.get("created_at", time.time()),
))
return result
except Exception as e:
logger.warning("[tlon] Failed to load pending approvals: %s", e)
return []
def save_pending_approvals(approvals: list[PendingApproval]) -> None:
filepath = _approvals_file_path()
try:
data = []
for a in approvals:
data.append({
"id": a.id,
"type": a.type.value,
"requesting_ship": a.requesting_ship,
"account_id": a.account_id,
"channel_nest": a.channel_nest,
"group_flag": a.group_flag,
"original_message": a.original_message,
"message_preview": a.message_preview,
"created_at": a.created_at,
})
filepath.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as e:
logger.warning("[tlon] Failed to save pending approvals: %s", e)
async def create_pending_approval(approval_type: str, requesting_ship: str, *,
account_id: str = "default",
channel_nest: str | None = None,
group_flag: str | None = None,
original_message: str | None = None,
message_preview: str | None = None,
config: dict | None = None,
approval_store: list[PendingApproval]) -> PendingApproval:
approval_id = f"{approval_type}-{int(time.time() * 1000)}-{uuid.uuid4().hex[:6]}"
existing = [a for a in approval_store
if a.requesting_ship == normalize_ship(requesting_ship)
and a.type.value == approval_type
and (not channel_nest or a.channel_nest == channel_nest)]
if existing:
return existing[0]
ap_type = ApprovalType.DM
if approval_type == "channel":
ap_type = ApprovalType.CHANNEL
elif approval_type == "group":
ap_type = ApprovalType.GROUP
approval = PendingApproval(
id=approval_id,
type=ap_type,
requesting_ship=normalize_ship(requesting_ship),
account_id=account_id,
channel_nest=channel_nest,
group_flag=group_flag,
original_message=original_message,
message_preview=message_preview,
)
approval_store.append(approval)
save_pending_approvals(approval_store)
return approval
async def send_owner_notification(ctx, client, account: dict,
approval_type: str, requesting_ship: str, *,
nest: str | None = None,
group_flag: str | None = None) -> None:
owner_ship = account.get("owner_ship")
if not owner_ship:
return
ship_display = account.get("ship", "bot")
if approval_type == "dm":
msg = (
f"**Tlon Approval Request**\n\n"
f"Type: DM Request\n"
f"From: {requesting_ship}\n\n"
f"Reply with:\n"
f"- `approve` — Allow this user to DM\n"
f"- `deny` — Reject this request\n"
f"- `block` — Block this user"
)
elif approval_type == "channel":
msg = (
f"**Tlon Approval Request**\n\n"
f"Type: Channel Access\n"
f"From: {requesting_ship}\n"
f"Channel: {nest or 'unknown'}\n\n"
f"Reply with:\n"
f"- `approve` — Grant channel access\n"
f"- `deny` — Reject request\n"
f"- `block` — Block this user"
)
elif approval_type == "group":
msg = (
f"**Tlon Approval Request**\n\n"
f"Type: Group Invite\n"
f"From: {requesting_ship}\n"
f"Group: {group_flag or 'unknown'}\n\n"
f"Reply with:\n"
f"- `approve` — Accept invite\n"
f"- `deny` — Reject invite\n"
f"- `block` — Block this user"
)
else:
msg = f"**Tlon Approval Request**\n\nFrom: {requesting_ship}"
try:
await send_dm(client, ship_display, owner_ship, msg)
except Exception as e:
logger.warning("[tlon] Failed to send approval notification: %s", e)
async def handle_approval_command(text: str, ctx, client, account: dict, *,
pending_approvals: list[PendingApproval],
dm_allowlist: set[str]) -> str | None:
parts = text.strip().split()
if not parts:
return None
cmd = parts[0].lower()
ship_display = account.get("ship", "bot")
if cmd == "approve":
target_id = parts[1] if len(parts) > 1 else None
return await _handle_approve(target_id, pending_approvals, dm_allowlist,
client, ship_display, account)
elif cmd == "deny":
target_id = parts[1] if len(parts) > 1 else None
return await _handle_deny(target_id, pending_approvals, account)
elif cmd == "block":
target_id = parts[1] if len(parts) > 1 else None
return await _handle_block(target_id, pending_approvals, client, account)
elif cmd == "blocked":
blocked = await get_blocked_ships(client)
if blocked:
return "**Blocked Ships:**\n" + "\n".join(f"- {s}" for s in blocked)
return "No blocked ships."
elif cmd == "pending":
if not pending_approvals:
return "No pending approval requests."
lines = ["**Pending Approvals:**"]
for a in pending_approvals:
lines.append(
f"- `{a.id[:8]}` | {a.type.value} | {a.requesting_ship} "
f"{'in ' + a.channel_nest if a.channel_nest else ''}"
)
return "\n".join(lines)
elif cmd == "unblock" and len(parts) > 1:
target_ship = normalize_ship(parts[1])
await unblock_ship(client, target_ship)
return f"Unblocked {target_ship}"
return None
async def _handle_approve(target_id: str | None,
approvals: list[PendingApproval],
dm_allowlist: set[str],
client, ship_display: str,
account: dict) -> str:
if target_id:
matching = [a for a in approvals if a.id.startswith(target_id)]
else:
matching = [approvals[-1]] if approvals else []
if not matching:
return "No matching approval found."
for a in matching:
if a.type == ApprovalType.DM:
dm_allowlist.add(a.requesting_ship)
elif a.type == ApprovalType.CHANNEL:
pass
elif a.type == ApprovalType.GROUP:
try:
await client.poke("groups", "group-join", {
"flag": a.group_flag,
"share-contact": True,
})
except Exception:
pass
approvals.remove(a)
save_pending_approvals(approvals)
approved_ships = [a.requesting_ship for a in matching]
return f"Approved: {', '.join(approved_ships)}"
async def _handle_deny(target_id: str | None,
approvals: list[PendingApproval],
account: dict) -> str:
if target_id:
matching = [a for a in approvals if a.id.startswith(target_id)]
else:
matching = [approvals[-1]] if approvals else []
if not matching:
return "No matching approval found."
denied_ships = []
for a in matching:
denied_ships.append(a.requesting_ship)
approvals.remove(a)
save_pending_approvals(approvals)
return f"Denied: {', '.join(denied_ships)}"
async def _handle_block(target_id: str | None,
approvals: list[PendingApproval],
client, account: dict) -> str:
if target_id:
matching = [a for a in approvals if a.id.startswith(target_id)]
else:
matching = [approvals[-1]] if approvals else []
if not matching:
return "No matching approval found."
blocked_ships = []
for a in matching:
await block_ship(client, a.requesting_ship)
blocked_ships.append(a.requesting_ship)
approvals.remove(a)
save_pending_approvals(approvals)
return f"Blocked: {', '.join(blocked_ships)}"
def get_approval_by_id(approval_id: str,
approvals: list[PendingApproval]) -> PendingApproval | None:
for a in approvals:
if a.id == approval_id:
return a
return None
def remove_approval(approval_id: str,
approvals: list[PendingApproval]) -> bool:
a = get_approval_by_id(approval_id, approvals)
if a:
approvals.remove(a)
save_pending_approvals(approvals)
return True
return False