新增 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: 类型定义
304 lines
10 KiB
Python
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 |