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