, tabs, new [url], close. "
- f"Use this for web interaction, authenticated sites, downloads, form filling. "
- f"Run `ls /opt/agent-core/common-tools/` to see all. "
- f"Prefer existing tools over writing new code."
- f"{user_context}"
- f"{workspace_context}"
- f"{conv_context}"
- )
-
- claude_args = [
- cmd,
- *session_flag,
- "-p",
- "--verbose",
- "--output-format", "stream-json",
- "--append-system-prompt", system_extra,
- "--allowedTools", ",".join(config.allowed_tools),
- "--max-turns", "50",
- ]
- if model_override:
- claude_args.extend(["--model", model_override])
- claude_args.append(message)
-
- # Wrap with bwrap if available
- bwrap_path = Path(__file__).resolve().parent.parent / "bwrap-claude"
- if bwrap_path.exists() and shutil.which("bwrap"):
- args = [str(bwrap_path)] + claude_args
- else:
- args = claude_args
-
- # Build clean environment for Claude subprocess
- _strip_prefixes = ("CLAUDECODE", "CLAUDE_CODE")
- _strip_keys = {
- "BOT_TOKEN", "MATRIX_ACCESS_TOKEN", "MATRIX_HOMESERVER",
- "MATRIX_USER_ID", "MATRIX_OWNER_MXID", "MATRIX_DEVICE_ID",
- }
- # Auth env vars that must pass through to Claude CLI
- _passthrough_keys = {"CLAUDE_CODE_OAUTH_TOKEN"}
- env = {
- k: v for k, v in os.environ.items()
- if k in _passthrough_keys
- or (not any(k.startswith(p) for p in _strip_prefixes) and k not in _strip_keys)
- }
- # Add common-tools to PATH so Claude can use send-to-user, generate-image, etc.
- common_tools = str(Path(__file__).resolve().parent.parent / "common-tools")
- env["PATH"] = common_tools + ":" + env.get("PATH", "")
-
- # Load per-user workspace .env (Readest keys, Linkwarden keys, etc.)
- if workspace_dir:
- ws_env = workspace_dir / ".env"
- if ws_env.exists():
- for line in ws_env.read_text().splitlines():
- line = line.strip()
- if line and not line.startswith("#") and "=" in line:
- key, _, val = line.partition("=")
- env[key.strip()] = val.strip().strip("'\"") # handle KEY="value" and KEY='value'
-
- session_label = existing_session[:8] if existing_session else f"new:{new_id[:8]}"
- logger.info("Claude CLI: topic=%s session=%s cmd=%s", topic_id, session_label, cmd)
-
- proc = await asyncio.create_subprocess_exec(
- *args,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- cwd=str(topic_dir),
- env=env,
- limit=10 * 1024 * 1024, # 10MB — stream-json lines can be huge (base64 images)
- )
-
- response_parts: list[str] = []
- full_text = ""
- result_text = "" # clean final response from result event
- result_session_id = None
- timeout_reason = None
-
- # Tool tracking for status events
- block_tools: dict[str, str] = {} # tool_use_id -> tool name
-
- # Idle timeout state — mutable so watchdog can read, user can extend
- idle_timeout = idle_timeout_ref if idle_timeout_ref is not None else [config.claude_idle_timeout]
- last_activity = [time.monotonic()]
- start_time = time.monotonic()
-
- # Start question watcher if callback provided
- question_task = None
- if on_question:
- question_task = asyncio.create_task(_watch_questions(topic_dir, on_question))
-
- # Watchdog: checks idle timeout, hard timeout, and cancel
- async def _watchdog():
- nonlocal timeout_reason
- while True:
- await asyncio.sleep(2)
- now = time.monotonic()
- if cancel_event and cancel_event.is_set():
- timeout_reason = "cancelled"
- proc.kill()
- return
- idle = now - last_activity[0]
- if idle > idle_timeout[0]:
- timeout_reason = "idle"
- proc.kill()
- return
- elapsed = now - start_time
- if elapsed > config.claude_max_timeout:
- timeout_reason = "max"
- proc.kill()
- return
-
- watchdog_task = asyncio.create_task(_watchdog())
-
- # Stream log — save all events from Claude CLI for debugging/replay
- stream_log_path = topic_dir / "stream.jsonl"
- stream_log = open(stream_log_path, "a")
-
- try:
- async for line in proc.stdout:
- last_activity[0] = time.monotonic() # reset idle timer on ANY output
-
- line = line.decode("utf-8", errors="replace").strip()
- if not line:
- continue
-
- # Log raw event to stream.jsonl
- stream_log.write(line + "\n")
- stream_log.flush()
-
- try:
- event = json.loads(line)
- except json.JSONDecodeError:
- logger.debug("Non-JSON stdout: %s", line[:200])
- continue
-
- etype = event.get("type")
-
- # Capture session_id from init or result events
- if etype == "system" and event.get("session_id"):
- result_session_id = event["session_id"]
- elif etype == "result" and event.get("session_id"):
- result_session_id = event["session_id"]
-
- # Handle result events — this has the clean final response
- if etype == "result":
- if event.get("is_error"):
- errors = event.get("errors", [])
- logger.error("Claude CLI error: %s", "; ".join(errors))
- if event.get("result"):
- result_text = event["result"]
-
- # --- Status events from stream-json ---
- # Claude CLI emits full "assistant" snapshots (with tool_use blocks)
- # followed by "user" events (with tool_result).
- if etype == "assistant":
- content = event.get("message", {}).get("content", [])
- has_tools = any(b.get("type") == "tool_use" for b in content)
-
- for block in content:
- if block.get("type") == "tool_use" and on_status:
- tool_name = block.get("name", "")
- tool_id = block.get("id", "")
- inp = block.get("input", {})
- preview = _tool_preview(tool_name, json.dumps(inp, ensure_ascii=False))
- if tool_id:
- block_tools[tool_id] = tool_name
- if tool_name == "Agent":
- desc = inp.get("description", "")
- bg = inp.get("run_in_background", False)
- await on_status({
- "event": "agent_start",
- "description": desc,
- "background": bg,
- })
- else:
- await on_status({
- "event": "tool_start",
- "tool": tool_name,
- "input_preview": preview,
- })
-
- # All assistant text goes to thread as narration.
- # Only result.result is the final clean response.
- if block.get("type") == "text" and block.get("text"):
- text = block["text"]
- if on_status:
- await on_status({
- "event": "thinking",
- "text": text,
- })
- # Also accumulate for on_chunk (Telegram streaming)
- response_parts.append(text)
- full_text = "".join(response_parts)
- if on_chunk:
- await on_chunk(full_text)
-
- # Tool results mark tool completion
- if etype == "user" and on_status:
- content = event.get("message", {}).get("content", [])
- if isinstance(content, list):
- for block in content:
- if isinstance(block, dict) and block.get("type") == "tool_result":
- tool_id = block.get("tool_use_id", "")
- tool_name = block_tools.pop(tool_id, "tool")
- await on_status({"event": "tool_end", "tool": tool_name})
-
- # Check if watchdog killed the process
- if watchdog_task.done():
- break
-
- await proc.wait()
-
- except Exception:
- if not watchdog_task.done():
- watchdog_task.cancel()
- raise
- finally:
- stream_log.close()
- if not watchdog_task.done():
- watchdog_task.cancel()
- try:
- await watchdog_task
- except asyncio.CancelledError:
- pass
- if question_task:
- question_task.cancel()
- try:
- await question_task
- except asyncio.CancelledError:
- pass
-
- elapsed = int(time.monotonic() - start_time)
-
- # Handle timeout/cancel
- if timeout_reason:
- await proc.wait()
- if timeout_reason == "cancelled":
- logger.info("Claude CLI cancelled by user after %ds", elapsed)
- suffix = "\n\n[cancelled by user]"
- elif timeout_reason == "idle":
- logger.warning("Claude CLI idle timeout after %ds (idle limit: %ds)", elapsed, idle_timeout[0])
- suffix = f"\n\n[idle timeout — no output for {idle_timeout[0]}s]"
- else:
- logger.error("Claude CLI hard timeout after %ds (max: %ds)", elapsed, config.claude_max_timeout)
- suffix = f"\n\n[timeout — {elapsed}s elapsed]"
-
- # Save session even on timeout — don't lose conversation history
- if result_session_id:
- save_session(config.data_dir, topic_id, result_session_id, provider)
-
- # On timeout: prefer result_text (clean), fall back to full_text (has thinking)
- response = result_text or full_text
- error_patterns = ["Failed to authenticate", "API Error:", "authentication_error", "401"]
- if response and not any(p in response for p in error_patterns):
- return response + suffix
- raise RuntimeError(f"Claude CLI {timeout_reason} after {elapsed}s (error response: {full_text[:100]})")
-
- # Save session ID for future resume
- if result_session_id:
- save_session(config.data_dir, topic_id, result_session_id, provider)
-
- # Check for error responses (auth failures, API errors) - these should trigger fallback
- error_patterns = ["Failed to authenticate", "API Error:", "authentication_error", "401"]
- is_error_response = any(p in full_text for p in error_patterns)
-
- if proc.returncode != 0 or is_error_response:
- stderr = await proc.stderr.read()
- stderr_text = stderr.decode("utf-8", errors="replace").strip()
- logger.error("Claude CLI failed (rc=%d): %s", proc.returncode, stderr_text[:500])
- if is_error_response:
- raise RuntimeError(f"Claude CLI returned error: {full_text[:200]}")
- response = result_text or full_text
- if response:
- return response
- # Non-auth failure with no output — raise to trigger fallback
- # but preserve session file (conversation history is valuable)
- raise RuntimeError(f"Claude CLI exited with code {proc.returncode}")
-
- response = result_text or full_text
- if not response and _retry_count < 1:
- logger.warning("Claude CLI returned empty response, retrying (attempt %d)", _retry_count + 1)
- return await _send_with_provider(
- config, topic_id, message, on_chunk, on_question,
- on_status=on_status, cancel_event=cancel_event,
- idle_timeout_ref=idle_timeout_ref,
- provider=provider, cmd_override=cmd_override, model_override=model_override,
- user_profile=user_profile, workspace_dir=workspace_dir,
- _retry_count=_retry_count + 1,
- )
-
- return response or "(no response)"
-
-
-def _extract_text(event: dict) -> str | None:
- """Extract text content from a stream-json event."""
- etype = event.get("type")
-
- if etype == "assistant":
- content = event.get("message", {}).get("content", [])
- texts = []
- for block in content:
- if block.get("type") == "text":
- texts.append(block.get("text", ""))
- return "".join(texts) if texts else None
-
- if etype == "content_block_delta":
- delta = event.get("delta", {})
- if delta.get("type") == "text_delta":
- return delta.get("text", "")
-
- # Don't extract from "result" — it duplicates what was already
- # streamed via "assistant" events. The caller uses it as fallback
- # only if full_text is empty after processing all events.
-
- return None
diff --git a/bot-examples/matrix_bot_rooms.py b/bot-examples/matrix_bot_rooms.py
deleted file mode 100755
index 8e6eadf..0000000
--- a/bot-examples/matrix_bot_rooms.py
+++ /dev/null
@@ -1,2667 +0,0 @@
-"""Matrix bot frontend.
-
-Connects to a Matrix homeserver, listens for messages in rooms,
-routes them through Claude CLI sessions. Same session layer as Telegram bot.
-
-Commands:
- !new [topic] — Create a new conversation room with optional topic name.
- !claude-auth — Refresh Claude Code OAuth token (manual browser flow).
-"""
-
-import asyncio
-import json
-import logging
-import os
-import re
-import time
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from pathlib import Path
-
-import httpx
-from nio import (
- AsyncClient,
- AsyncClientConfig,
- MatrixRoom,
- MegolmEvent,
- RoomEncryptedAudio,
- RoomEncryptedFile,
- RoomEncryptedImage,
- RoomMemberEvent,
- RoomMessageAudio,
- RoomMessageImage,
- RoomMessageText,
- RoomMessageFile,
- RoomMessageUnknown,
- SyncResponse,
- UnknownEvent,
-)
-from nio.events.to_device import (
- KeyVerificationCancel,
- KeyVerificationKey,
- KeyVerificationMac,
- KeyVerificationStart,
-)
-
-from nio.crypto import decrypt_attachment
-
-from core.asr import transcribe
-from core.claude_session import send_message as claude_send
-from core.config import Config
-
-logger = logging.getLogger(__name__)
-
-
-@dataclass
-class SessionState:
- """Tracks an active Claude session for a room."""
- cancel_event: asyncio.Event
- user_event_id: str # original user message (thread root)
- status_event_id: str | None = None # status message in thread
- status_lines: list[str] = field(default_factory=list)
- last_status_edit: float = 0.0
- idle_timeout_ref: list = field(default_factory=lambda: [120])
- start_time: float = field(default_factory=time.monotonic)
-
-
-class MatrixBot:
- def __init__(self, config: Config, homeserver: str, user_id: str, access_token: str,
- owner_mxid: str = "", users: dict[str, dict] | None = None,
- device_id: str = "AGENT_CORE", admin_mxid: str = ""):
- self.config = config
- self.owner_mxid = owner_mxid
- self.admin_mxid = admin_mxid # For admin notifications (fallback, errors)
- self._users = users or {}
- # If single-owner mode (no users map), treat owner as the only allowed user
- if not self._users and owner_mxid:
- self._users = {owner_mxid: {}}
- # E2E: crypto store for keys, auto-decrypt/encrypt
- store_path = str(config.data_dir / "crypto_store")
- Path(store_path).mkdir(parents=True, exist_ok=True)
- client_config = AsyncClientConfig(
- encryption_enabled=True,
- store_sync_tokens=True,
- )
- self.client = AsyncClient(
- homeserver, user_id,
- device_id=device_id,
- store_path=store_path,
- config=client_config,
- )
- self.client.restore_login(user_id, device_id, access_token)
- self._synced = False
- self._default_room_prefix = "Bot: "
- self._pending_questions: dict[str, asyncio.Future] = {}
- self._active_sessions: dict[str, SessionState] = {} # room_id -> session state
- # Persistent message queue removed — using queue.jsonl files instead
- self._auth_flows: dict[str, dict] = {} # safe_id -> {tmux_session, started}
- self._collect_preambles: dict[str, str] = {} # safe_id -> preamble for next Claude call
- self._processed_events: set[str] = set()
- self._room_verifications: dict[str, dict] = {} # tx_id → state
- self._sync_token_path = config.data_dir / "matrix_sync_token.txt"
- self._avatar_mxc: str | None = None # cached after upload
-
- def _is_allowed_user(self, sender: str) -> bool:
- return sender in self._users
-
- def _get_user_workspace(self, sender: str) -> Path | None:
- """Get workspace directory for a user, or None."""
- user_info = self._users.get(sender, {})
- ws = user_info.get("workspace")
- if ws:
- path = Path(ws)
- if path.is_dir():
- return path
- return None
-
- def _get_user_profile(self, sender: str) -> str:
- """Load user.md content for a sender, or empty string."""
- user_info = self._users.get(sender, {})
- profile_file = user_info.get("profile")
- if profile_file and self.config.workspace_dir:
- path = self.config.workspace_dir / profile_file
- if path.exists():
- return path.read_text().strip()
- # Fallback: single-user mode with user.md
- if self.config.workspace_dir:
- path = self.config.workspace_dir / "user.md"
- if path.exists():
- return path.read_text().strip()
- return ""
-
- def _is_group_room(self, room: MatrixRoom) -> bool:
- """Room has more than 2 members (joined + invited, not a 1:1 chat)."""
- return (room.member_count + room.invited_count) > 2
-
- def _text_mentions_bot(self, text: str) -> bool:
- """Check if text contains a bot mention (@user_id, localpart, or display name)."""
- text = text.lower()
- # Check user_id (@bot:your.homeserver.example)
- if self.client.user_id.lower() in text:
- return True
- # Check localpart (bot)
- local_name = self.client.user_id.split(":")[0].lstrip("@").lower()
- if local_name in text:
- return True
- # Check display name from any room
- for room in self.client.rooms.values():
- me = room.users.get(self.client.user_id)
- if me and me.display_name and me.display_name.lower() in text:
- return True
- return False
-
- def _strip_mention_prefix(self, text: str) -> str:
- """Strip bot mention prefix from text (e.g. '@[bot-dev] !status' → '!status')."""
- import re
- local_name = self.client.user_id.split(":")[0].lstrip("@")
- names = [re.escape(self.client.user_id), re.escape(local_name)]
- for room in self.client.rooms.values():
- me = room.users.get(self.client.user_id)
- if me and me.display_name:
- names.append(re.escape(me.display_name))
- break
- alts = "|".join(names)
- # Match: @[name], @name, name: , name, — with optional @[] wrapping and trailing punctuation
- pattern = r"^@?\[?(?:" + alts + r")\]?[\s:,]*"
- return re.sub(pattern, "", text, flags=re.IGNORECASE)
-
- def _is_bot_mentioned(self, event: RoomMessageText) -> bool:
- """Check if bot is mentioned in a message event."""
- # Check structured mentions first (m.mentions in content)
- mentions = event.source.get("content", {}).get("m.mentions", {})
- user_ids = mentions.get("user_ids", [])
- if self.client.user_id in user_ids:
- return True
- return self._text_mentions_bot(event.body)
-
- def _room_dir(self, room_id: str) -> Path:
- safe_id = room_id.replace(":", "_").replace("!", "")
- d = self.config.data_dir / "rooms" / safe_id
- d.mkdir(parents=True, exist_ok=True)
- return d
-
- def _topic_dir(self, safe_id: str) -> Path:
- return self.config.data_dir / "topics" / safe_id
-
- # --- Room history ---
-
- def _save_room_message(self, room_id: str, sender: str, msg_type: str, text: str,
- file_path: str | None = None) -> None:
- """Append a message to room history. Called for ALL messages in ALL rooms."""
- history_file = self._room_dir(room_id) / "history.jsonl"
- display = sender.split(":")[0].lstrip("@")
- entry: dict = {
- "ts": datetime.now(timezone.utc).isoformat(),
- "sender": sender,
- "name": display,
- "type": msg_type,
- "text": text,
- }
- if file_path:
- entry["file"] = file_path
- with open(history_file, "a") as f:
- f.write(json.dumps(entry, ensure_ascii=False) + "\n")
-
- def _get_room_context(self, room_id: str, limit: int = 50) -> str:
- """Read last N messages from history.jsonl and format as chat context."""
- history_file = self._room_dir(room_id) / "history.jsonl"
- if not history_file.exists():
- return ""
- lines = []
- try:
- with open(history_file) as f:
- all_lines = f.readlines()
- for line in all_lines[-limit:]:
- line = line.strip()
- if line:
- lines.append(json.loads(line))
- except Exception as e:
- logger.warning("Failed to read room history: %s", e)
- return ""
- if not lines:
- return ""
- parts = []
- for msg in lines:
- name = msg.get("name", "?")
- text = msg.get("text", "")
- msg_type = msg.get("type", "text")
- ts = msg.get("ts", "")[:16].replace("T", " ")
- if msg_type == "image":
- parts.append(f"[{ts}] {name}: [sent an image] {text}")
- elif msg_type == "audio":
- parts.append(f"[{ts}] {name}: [voice] {text}")
- elif msg_type == "file":
- parts.append(f"[{ts}] {name}: [sent a file] {text}")
- else:
- parts.append(f"[{ts}] {name}: {text}")
- context = "\n".join(parts)
- return (
- "[Recent room history — you can see what participants discussed before mentioning you. "
- "Use this context to understand the conversation. Do NOT repeat this history back.]\n\n"
- + context
- )
-
- # --- Room mode (quiet / context / full / collect) ---
-
- ROOM_MODES = ("quiet", "context", "full", "collect")
-
- def _get_room_mode(self, room_id: str) -> str:
- """Get room mode from config.json. Default: quiet for groups, full for 1:1."""
- config_file = self._room_dir(room_id) / "config.json"
- if config_file.exists():
- try:
- data = json.loads(config_file.read_text())
- mode = data.get("mode", "")
- if mode in self.ROOM_MODES:
- return mode
- except Exception:
- pass
- room = self.client.rooms.get(room_id)
- if room and self._is_group_room(room):
- return "quiet"
- return "full"
-
- def _set_room_mode(self, room_id: str, mode: str) -> None:
- """Save room mode to config.json."""
- config_file = self._room_dir(room_id) / "config.json"
- data = {}
- if config_file.exists():
- try:
- data = json.loads(config_file.read_text())
- except Exception:
- pass
- data["mode"] = mode
- config_file.write_text(json.dumps(data, ensure_ascii=False, indent=2))
-
- # --- Room security mode (strict / guarded / open) ---
-
- SECURITY_MODES = ("strict", "guarded", "open")
-
- def _get_security_mode(self, room_id: str) -> str:
- """Get room security mode from config.json. Default: guarded."""
- config_file = self._room_dir(room_id) / "config.json"
- if config_file.exists():
- try:
- data = json.loads(config_file.read_text())
- mode = data.get("security", "")
- if mode in self.SECURITY_MODES:
- return mode
- except Exception:
- pass
- return "guarded"
-
- def _set_security_mode(self, room_id: str, mode: str) -> None:
- """Save room security mode to config.json."""
- config_file = self._room_dir(room_id) / "config.json"
- data = {}
- if config_file.exists():
- try:
- data = json.loads(config_file.read_text())
- except Exception:
- pass
- data["security"] = mode
- config_file.write_text(json.dumps(data, ensure_ascii=False, indent=2))
-
- def _get_unverified_devices(self, room_id: str) -> dict[str, list[str]]:
- """Return {user_id: [device_id, ...]} for unverified devices in a room.
-
- Only checks allowed users (room members known to the bot).
- """
- if not self.client.olm:
- return {}
- room = self.client.rooms.get(room_id)
- if not room:
- return {}
- unverified: dict[str, list[str]] = {}
- for user_id in room.users:
- if user_id == self.client.user_id:
- continue
- for device in self.client.device_store.active_user_devices(user_id):
- if not device.verified:
- unverified.setdefault(user_id, []).append(device.id)
- return unverified
-
- def _user_fully_verified(self, sender: str) -> bool:
- """Check if all of sender's devices are verified."""
- if not self.client.olm:
- return True # no E2E, no verification needed
- for device in self.client.device_store.active_user_devices(sender):
- if not device.verified:
- return False
- return True
-
- def _format_unverified_warning(self, unverified: dict[str, list[str]]) -> str:
- """Format a warning string listing unverified devices."""
- parts = []
- for user_id, devices in unverified.items():
- dev_str = ", ".join(f"`{d}`" for d in devices)
- parts.append(f"{user_id}: {dev_str}")
- return "\u26a0 Unverified devices in room: " + "; ".join(parts)
-
- async def _check_security(self, room_id: str, sender: str) -> tuple[bool, str | None]:
- """Check room security policy for a sender.
-
- Returns:
- (allowed, warning_or_error):
- - (True, None) — proceed, no warning
- - (True, warning) — proceed, append warning to response
- - (False, error) — refuse, send error message
- """
- security = self._get_security_mode(room_id)
- if security == "open":
- unverified = self._get_unverified_devices(room_id)
- if unverified:
- return True, self._format_unverified_warning(unverified)
- return True, None
-
- unverified = self._get_unverified_devices(room_id)
- if not unverified:
- return True, None
-
- if security == "strict":
- return False, (
- "Room has unverified devices — refusing to respond.\n"
- + self._format_unverified_warning(unverified)
- + "\n\nVerify devices or use `!security open` from a fully verified session."
- )
-
- # guarded: block only users with unverified devices
- sender_unverified = unverified.get(sender)
- if sender_unverified:
- dev_str = ", ".join(f"`{d}`" for d in sender_unverified)
- return False, (
- f"You have unverified devices ({dev_str}) — not accepting commands.\n"
- "Verify your devices or ask a verified user to `!security open`."
- )
- return True, None
-
- def _log_interaction(self, room_id: str, user_msg: str, bot_msg: str) -> None:
- log_file = self._room_dir(room_id) / "log.jsonl"
- entry = {
- "ts": datetime.now(timezone.utc).isoformat(),
- "user": user_msg[:1000],
- "bot": bot_msg[:2000],
- }
- with open(log_file, "a") as f:
- f.write(json.dumps(entry, ensure_ascii=False) + "\n")
-
- def _md_to_html(self, text: str) -> str:
- """Convert markdown to Matrix HTML, with tables as monospace blocks."""
- import re
- import markdown
-
- lines = text.split("\n")
- result_lines = []
- table_lines = []
- in_table = False
-
- for line in lines:
- is_table_line = bool(re.match(r"^\s*\|.*\|\s*$", line))
- is_separator = bool(re.match(r"^\s*\|[-:| ]+\|\s*$", line))
-
- if is_table_line:
- if not in_table:
- in_table = True
- table_lines = []
- if not is_separator:
- table_lines.append(line)
- else:
- table_lines.append(line)
- else:
- if in_table:
- result_lines.append("```")
- result_lines.extend(table_lines)
- result_lines.append("```")
- table_lines = []
- in_table = False
- result_lines.append(line)
-
- if in_table:
- result_lines.append("```")
- result_lines.extend(table_lines)
- result_lines.append("```")
-
- text = "\n".join(result_lines)
- html = markdown.markdown(text, extensions=["fenced_code"])
- return html
-
- # --- Avatar management ---
-
- def _avatar_path(self) -> Path | None:
- """Return path to avatar.jpg in workspace, or None."""
- if self.config.workspace_dir:
- p = self.config.workspace_dir / "avatar.jpg"
- if p.exists():
- return p
- return None
-
- async def _set_bot_avatar(self) -> None:
- """Upload avatar.jpg and set as bot profile picture (only if not already set)."""
- path = self._avatar_path()
- if not path:
- return
- try:
- async with httpx.AsyncClient() as http:
- user_id = self.client.user_id
- hs = self.client.homeserver
- # Check if avatar already set
- resp = await http.get(
- f"{hs}/_matrix/client/v3/profile/{user_id}/avatar_url",
- headers={"Authorization": f"Bearer {self.client.access_token}"},
- timeout=10,
- )
- if resp.status_code == 200:
- existing = resp.json().get("avatar_url", "")
- if existing:
- self._avatar_mxc = existing
- logger.info("Bot avatar already set: %s", existing)
- return
- # Upload and set
- data = path.read_bytes()
- mxc = await self._upload_file(data, "image/jpeg", "avatar.jpg")
- if not mxc:
- return
- self._avatar_mxc = mxc
- resp = await http.put(
- f"{hs}/_matrix/client/v3/profile/{user_id}/avatar_url",
- json={"avatar_url": mxc},
- headers={"Authorization": f"Bearer {self.client.access_token}"},
- timeout=15,
- )
- if resp.status_code == 200:
- logger.info("Set bot profile avatar: %s", mxc)
- else:
- logger.warning("Failed to set profile avatar (%d): %s",
- resp.status_code, resp.text[:200])
- except Exception as e:
- logger.warning("Failed to set bot avatar: %s", e)
-
- async def _set_room_avatar(self, room_id: str) -> None:
- """Set room avatar to bot's avatar if not already set. Uses HTTP API directly."""
- if not self._avatar_mxc:
- return
- try:
- from urllib.parse import quote
- hs = self.client.homeserver
- rid = quote(room_id, safe="")
- async with httpx.AsyncClient() as http:
- # Check if avatar already set
- resp = await http.get(
- f"{hs}/_matrix/client/v3/rooms/{rid}/state/m.room.avatar",
- headers={"Authorization": f"Bearer {self.client.access_token}"},
- timeout=10,
- )
- if resp.status_code == 200:
- return # already has avatar
- # Set avatar
- resp = await http.put(
- f"{hs}/_matrix/client/v3/rooms/{rid}/state/m.room.avatar",
- json={"url": self._avatar_mxc},
- headers={"Authorization": f"Bearer {self.client.access_token}"},
- timeout=10,
- )
- if resp.status_code == 200:
- logger.info("Set room avatar for %s", room_id)
- else:
- logger.warning("Failed to set room avatar for %s (%d): %s",
- room_id, resp.status_code, resp.text[:200])
- except Exception as e:
- logger.warning("Failed to set room avatar for %s: %s", room_id, e)
-
- # --- Room management ---
-
- async def _generate_room_label(self, room_id: str, current_label: str = "") -> str | None:
- """Generate a short room label via local LLM based on conversation history.
-
- Returns None if generation fails, or the new label string.
- """
- # Build context from history
- history_file = self._room_dir(room_id) / "history.jsonl"
- chat_lines = []
- if history_file.exists():
- try:
- with open(history_file) as f:
- all_lines = f.readlines()
- for line in all_lines[-15:]:
- line = line.strip()
- if line:
- msg = json.loads(line)
- name = msg.get("name", "?")
- text = msg.get("text", "")[:150]
- chat_lines.append(f"{name}: {text}")
- except Exception:
- pass
- if not chat_lines:
- return None
-
- conversation = "\n".join(chat_lines)
- user_content = conversation
- if current_label:
- user_content = f"Current name: {current_label}\n\n{conversation}"
-
- api_base = os.environ.get("LOCAL_LLM_URL") or os.environ.get("OPENAI_API_BASE", "http://localhost:4000/v1")
- api_key = os.environ.get("OPENAI_API_KEY", "")
- model = os.environ.get("LOCAL_LLM_MODEL", "qwen3.5-122b")
- llm_url = api_base.rstrip("/") + "/chat/completions"
- headers = {}
- if api_key:
- headers["Authorization"] = f"Bearer {api_key}"
- try:
- async with httpx.AsyncClient() as http:
- resp = await http.post(llm_url, json={
- "model": model,
- "messages": [
- {"role": "system", "content": (
- "You generate short chat room titles (3-5 words) based on what the user is asking about. "
- "Rules: output ONLY the title. No quotes, no prefixes. Same language as the user. "
- "Focus on the user's main question or task, ignore bot replies and minor tangents."
- )},
- {"role": "user", "content": user_content},
- ],
- "max_tokens": 20,
- "temperature": 0.3,
- "chat_template_kwargs": {"enable_thinking": False},
- }, headers=headers, timeout=15)
- if resp.status_code == 200:
- data = resp.json()
- label = data["choices"][0]["message"]["content"].strip().strip('"\'')
- return label[:80] if label else None
- except Exception as e:
- logger.warning("Failed to generate room label: %s", e)
- return None
-
- async def _rename_room(self, room_id: str, safe_id: str,
- user_text: str = "", response: str = "") -> None:
- """Rename room if it still has the default 'Bot: ' prefix."""
- room = self.client.rooms.get(room_id)
- if not room:
- return
- current_name = room.name or ""
- if not current_name.startswith(self._default_room_prefix):
- return # user renamed it manually — don't touch
- current_label = current_name[len(self._default_room_prefix):].strip()
- label = await self._generate_room_label(room_id, current_label)
- if not label:
- return
- new_name = f"{self._default_room_prefix}{label}"
- if new_name == current_name:
- return
- try:
- from nio.responses import RoomPutStateError
- resp = await self.client.room_put_state(
- room_id, "m.room.name", {"name": new_name[:255]},
- )
- if isinstance(resp, RoomPutStateError):
- logger.warning("Cannot rename room %s: %s", room_id, resp.status_code)
- return
- logger.info("Renamed room %s to: %s", room_id, new_name)
- await self._set_room_avatar(room_id)
- except Exception as e:
- logger.warning("Failed to rename room: %s", e)
-
- async def _create_conversation_room(self, name: str, for_user: str | None = None) -> str | None:
- """Create a private encrypted room and invite the user."""
- initial_state = [
- {
- "type": "m.room.encryption",
- "state_key": "",
- "content": {"algorithm": "m.megolm.v1.aes-sha2"},
- },
- ]
- if self._avatar_mxc:
- initial_state.append({
- "type": "m.room.avatar",
- "state_key": "",
- "content": {"url": self._avatar_mxc},
- })
- body: dict = {
- "name": name,
- "visibility": "private",
- "preset": "trusted_private_chat",
- "invite": [for_user] if for_user else [],
- }
- # Give the target user admin power (matches Element-created rooms)
- if for_user:
- body["power_level_content_override"] = {
- "users": {
- self.client.user_id: 100,
- for_user: 100,
- },
- }
- if initial_state:
- body["initial_state"] = initial_state
- try:
- async with httpx.AsyncClient() as http:
- resp = await http.post(
- f"{self.client.homeserver}/_matrix/client/v3/createRoom",
- headers={
- "Authorization": f"Bearer {self.client.access_token}",
- "Content-Type": "application/json",
- },
- json=body,
- timeout=15,
- )
- if resp.status_code == 200:
- room_id = resp.json()["room_id"]
- logger.info("Created room %s: %s", room_id, name)
- return room_id
- logger.error("Failed to create room (%d): %s", resp.status_code, resp.text[:200])
- except Exception as e:
- logger.error("Failed to create room: %s", e)
- return None
-
- # --- Sending ---
-
- async def _send_response(self, room_id: str, response: str,
- ignore_unverified_devices: bool = True) -> None:
- """Send response with HTML formatting."""
- html = self._md_to_html(response)
- await self.client.room_send(
- room_id, "m.room.message",
- {
- "msgtype": "m.text",
- "body": response,
- "format": "org.matrix.custom.html",
- "formatted_body": html,
- },
- ignore_unverified_devices=ignore_unverified_devices,
- )
-
- async def _upload_file(self, data: bytes, content_type: str, filename: str) -> str | None:
- """Upload file to Matrix via HTTP API directly."""
- homeserver = self.client.homeserver
- url = f"{homeserver}/_matrix/media/v3/upload?filename={filename}"
- async with httpx.AsyncClient() as http:
- resp = await http.post(
- url, content=data,
- headers={
- "Authorization": f"Bearer {self.client.access_token}",
- "Content-Type": content_type,
- },
- timeout=60,
- )
- if resp.status_code == 200:
- return resp.json().get("content_uri")
- logger.error("Matrix upload failed (%d): %s", resp.status_code, resp.text[:200])
- return None
-
- async def _download_media(self, event) -> bytes | None:
- """Download media from Matrix, decrypting if E2E encrypted."""
- resp = await self.client.download(event.url)
- if not hasattr(resp, "body"):
- logger.error("Failed to download media: %s", resp)
- return None
- data = resp.body
- # Encrypted media (RoomEncryptedImage/Audio/File) has key/hashes/iv
- if hasattr(event, "key") and hasattr(event, "hashes") and hasattr(event, "iv"):
- try:
- data = decrypt_attachment(
- data, event.key["k"], event.hashes["sha256"], event.iv,
- )
- except Exception as e:
- logger.error("Failed to decrypt attachment: %s", e)
- return None
- return data
-
- async def _send_outbox(self, room_id: str, room_dir: Path) -> None:
- """Send files queued in outbox.jsonl by Claude via send-to-user tool."""
- outbox = room_dir / "outbox.jsonl"
- if not outbox.exists():
- return
-
- entries = []
- try:
- with open(outbox) as f:
- for line in f:
- line = line.strip()
- if line:
- entries.append(json.loads(line))
- outbox.unlink()
- except Exception as e:
- logger.error("Failed to read outbox: %s", e)
- return
-
- mime_map = {
- "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
- "webp": "image/webp", "gif": "image/gif", "bmp": "image/bmp",
- "mp4": "video/mp4", "mov": "video/quicktime", "webm": "video/webm",
- "ogg": "audio/ogg", "mp3": "audio/mpeg", "wav": "audio/wav", "m4a": "audio/mp4",
- "pdf": "application/pdf", "doc": "application/msword",
- "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- "html": "text/html", "txt": "text/plain", "csv": "text/csv",
- "zip": "application/zip", "json": "application/json",
- }
-
- for entry in entries:
- fpath = Path(entry.get("path", ""))
- ftype = entry.get("type", "document")
-
- if not fpath.is_file():
- logger.warning("Outbox file not found: %s", fpath)
- continue
-
- try:
- data = fpath.read_bytes()
- ext = fpath.suffix.lstrip(".").lower()
- content_type = mime_map.get(ext, "application/octet-stream")
-
- content_uri = await self._upload_file(data, content_type, fpath.name)
- if not content_uri:
- continue
-
- if ftype == "image":
- msgtype = "m.image"
- elif ftype == "video":
- msgtype = "m.video"
- elif ftype == "audio":
- msgtype = "m.audio"
- else:
- msgtype = "m.file"
-
- await self.client.room_send(
- room_id, "m.room.message",
- {
- "msgtype": msgtype,
- "body": fpath.name,
- "filename": fpath.name,
- "url": content_uri,
- "info": {"mimetype": content_type, "size": len(data)},
- },
- ignore_unverified_devices=True,
- )
- logger.info("Sent %s to Matrix: %s", ftype, fpath.name)
- except Exception as e:
- logger.error("Failed to send %s %s: %s", ftype, fpath.name, e)
-
- def _sender_display_name(self, room: MatrixRoom, sender: str) -> str:
- """Get display name for a sender in a room, fallback to localpart."""
- member = room.users.get(sender)
- if member and member.display_name:
- return member.display_name
- return sender.split(":")[0].lstrip("@")
-
- async def _fetch_recent_messages(self, room_id: str, limit: int = 5) -> list[dict]:
- """Fetch recent messages from a room for context mode."""
- room = self.client.rooms.get(room_id)
- if not room or not room.prev_batch:
- return []
- resp = await self.client.room_messages(room_id, start=room.prev_batch, limit=limit)
- if not hasattr(resp, "chunk"):
- return []
- messages = []
- for event in reversed(resp.chunk): # chronological order
- if event.sender == self.client.user_id:
- continue
- body = getattr(event, "body", None)
- if not body:
- continue
- name = self._sender_display_name(room, event.sender)
- messages.append({"sender": name, "text": body})
- return messages
-
- # --- Thread status messaging ---
-
- async def _send_thread_message(self, room_id: str, thread_root_event_id: str,
- body: str) -> str | None:
- """Send a notice in a thread under the given event."""
- content = {
- "msgtype": "m.notice",
- "body": body,
- "m.relates_to": {
- "rel_type": "m.thread",
- "event_id": thread_root_event_id,
- "is_falling_back": True,
- "m.in_reply_to": {"event_id": thread_root_event_id},
- },
- }
- resp = await self.client.room_send(
- room_id, "m.room.message", content,
- ignore_unverified_devices=True,
- )
- if hasattr(resp, "event_id"):
- return resp.event_id
- return None
-
- async def _edit_message(self, room_id: str, event_id: str, new_body: str) -> None:
- """Edit an existing message using m.replace relation."""
- content = {
- "msgtype": "m.notice",
- "body": f"* {new_body}",
- "m.new_content": {
- "msgtype": "m.notice",
- "body": new_body,
- },
- "m.relates_to": {
- "rel_type": "m.replace",
- "event_id": event_id,
- },
- }
- await self.client.room_send(
- room_id, "m.room.message", content,
- ignore_unverified_devices=True,
- )
-
- async def _run_claude_session(self, room: MatrixRoom, event, message: str,
- security_msg: str | None = None,
- on_question=None,
- on_done=None,
- **extra_kwargs) -> None:
- """Run a Claude session as a background task.
-
- Runs concurrently so the sync loop stays free to process !stop etc.
- on_done(response) is called after session completes (for logging, renaming).
- """
- room_id = room.room_id
- safe_id = room_id.replace(":", "_").replace("!", "")
-
- cancel_event = asyncio.Event()
- idle_timeout_ref = [self.config.claude_idle_timeout]
- session = SessionState(
- cancel_event=cancel_event,
- user_event_id=event.event_id,
- idle_timeout_ref=idle_timeout_ref,
- start_time=time.monotonic(),
- )
- self._active_sessions[room_id] = session
-
- status_event_id = await self._send_thread_message(
- room_id, event.event_id, "Working..."
- )
- session.status_event_id = status_event_id
- on_status = self._make_on_status(room_id, session)
-
- user_profile = self._get_user_profile(event.sender)
- workspace_dir = self._get_user_workspace(event.sender)
-
- # Default on_question: post to room, wait for user reply
- if on_question is None:
- async def on_question(question: str) -> str:
- await self.client.room_send(
- room_id, "m.room.message",
- {"msgtype": "m.text", "body": f"? {question}"},
- ignore_unverified_devices=True,
- )
- future = asyncio.get_event_loop().create_future()
- self._pending_questions[safe_id] = future
- return await future
-
- # Run as background task so sync loop stays free to process !stop etc.
- async def _session_task():
- response = ""
- try:
- response = await self._call_claude(
- room_id, safe_id, message,
- on_status=on_status, cancel_event=cancel_event,
- idle_timeout_ref=idle_timeout_ref,
- on_question=on_question,
- user_profile=user_profile, sender=event.sender,
- workspace_dir=workspace_dir,
- **extra_kwargs,
- )
- display = response + f"\n\n{security_msg}" if security_msg else response
- await self._send_response(room_id, display)
- except RuntimeError as e:
- if cancel_event.is_set():
- await self._send_response(room_id, "Stopped.")
- response = "[cancelled]"
- else:
- logger.error("Claude error in room %s: %s", room.display_name, e)
- await self._send_response(room_id, f"Error: {e}")
- response = f"[error] {e}"
- finally:
- elapsed = int(time.monotonic() - session.start_time)
- mins, secs = divmod(elapsed, 60)
- time_str = f"{mins}m {secs:02d}s" if mins else f"{secs}s"
- tools_used = len(session.status_lines)
- final_status = f"Done ({time_str}, {tools_used} tools)"
- if session.cancel_event.is_set():
- final_status = f"Cancelled ({time_str})"
- try:
- if session.status_event_id:
- await self._edit_message(room_id, session.status_event_id, final_status)
- except Exception:
- pass
-
- await self._send_outbox(room_id, self._topic_dir(safe_id))
-
- # Auto-commit workspace changes
- if workspace_dir:
- asyncio.create_task(self._auto_commit_workspace(workspace_dir, room))
-
- # Post-session callback (logging, renaming, etc.)
- if on_done:
- try:
- await on_done(response)
- except Exception as e:
- logger.warning("on_done callback failed: %s", e)
-
- # Process queued messages — combine all into one prompt.
- # Drain BEFORE popping session so room stays "busy" and new
- # messages don't sneak in between drain and new session start.
- queued, last_eid = self._drain_queue(room_id)
- if queued and last_eid:
- # _process_queued_messages calls _run_claude_session which
- # overwrites _active_sessions[room_id] with a new session.
- await self._process_queued_messages(room, queued, last_eid)
- else:
- self._active_sessions.pop(room_id, None)
-
- asyncio.create_task(_session_task())
-
- async def _auto_commit_workspace(self, workspace_dir: Path, room: MatrixRoom) -> None:
- """Git commit workspace changes after a session, if any."""
- try:
- # Check for uncommitted changes
- proc = await asyncio.create_subprocess_exec(
- "git", "status", "--porcelain",
- cwd=str(workspace_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )
- stdout, _ = await proc.communicate()
- if not stdout.strip():
- return # nothing changed
-
- # Stage all and commit
- await (await asyncio.create_subprocess_exec(
- "git", "add", "-A",
- cwd=str(workspace_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )).communicate()
-
- room_name = room.display_name or room.room_id
- msg = f"auto: {room_name}"
- await (await asyncio.create_subprocess_exec(
- "git", "commit", "-m", msg, "--no-gpg-sign",
- cwd=str(workspace_dir),
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- )).communicate()
- logger.info("Auto-committed workspace changes: %s", workspace_dir)
- except Exception as e:
- logger.warning("Workspace auto-commit failed: %s", e)
-
- def _is_room_busy(self, room_id: str) -> bool:
- return room_id in self._active_sessions
-
- def _enqueue_message(self, room_id: str, event_id: str, sender: str,
- text: str, msg_type: str = "text",
- file_path: str | None = None) -> None:
- """Queue a processed message to queue.jsonl for later delivery."""
- queue_file = self._room_dir(room_id) / "queue.jsonl"
- entry = {
- "ts": datetime.now(timezone.utc).isoformat(),
- "event_id": event_id,
- "sender": sender,
- "type": msg_type,
- "text": text,
- }
- if file_path:
- entry["file"] = file_path
- with open(queue_file, "a") as f:
- f.write(json.dumps(entry, ensure_ascii=False) + "\n")
- count = sum(1 for _ in open(queue_file))
- logger.info("Queued message for room %s (%d pending)", room_id, count)
-
- def _drain_queue(self, room_id: str) -> tuple[list[dict], str | None]:
- """Read and clear queue.jsonl. Returns (messages, last_event_id)."""
- queue_file = self._room_dir(room_id) / "queue.jsonl"
- if not queue_file.exists():
- return [], None
- messages = []
- try:
- with open(queue_file) as f:
- for line in f:
- line = line.strip()
- if line:
- messages.append(json.loads(line))
- queue_file.unlink()
- except Exception as e:
- logger.warning("Failed to drain queue for %s: %s", room_id, e)
- last_event_id = messages[-1]["event_id"] if messages else None
- return messages, last_event_id
-
- async def _process_queued_messages(self, room: MatrixRoom,
- messages: list[dict], last_event_id: str) -> None:
- """Combine queued messages into one prompt and send to Claude."""
- room_id = room.room_id
- safe_id = room_id.replace(":", "_").replace("!", "")
-
- # Build combined prompt
- parts = []
- for msg in messages:
- mtype = msg.get("type", "text")
- text = msg.get("text", "")
- fpath = msg.get("file", "")
- if mtype == "image":
- parts.append(f"[User sent an image: {fpath}]")
- if text:
- parts.append(text)
- elif mtype == "audio":
- parts.append(f"[voice message]: {text}")
- elif mtype == "file":
- parts.append(f"[User sent a file: {fpath}]")
- else:
- parts.append(text)
-
- combined = "\n".join(parts)
- if len(messages) > 1:
- combined = (f"[{len(messages)} messages arrived while you were busy. "
- f"Process them all:]\n\n{combined}")
-
- # Minimal event-like object — covers all attributes accessed by
- # _run_claude_session and downstream code paths
- sender = messages[-1].get("sender", "")
- event = type("QueuedEvent", (), {
- "event_id": last_event_id,
- "sender": sender,
- "body": combined[:100],
- "source": {"content": {}}, # empty — won't match thread checks
- })()
-
- mode = self._get_room_mode(room_id)
-
- async def _on_done(response: str):
- if mode == "full":
- self._save_room_message(room_id, self.client.user_id, "text", response)
- await self._rename_room(room_id, safe_id)
- self._log_interaction(room_id, combined[:200], response)
-
- # Add full context if in full mode
- message_for_claude = combined
- if mode == "full":
- for msg in messages:
- self._save_room_message(room_id, msg.get("sender", ""),
- msg.get("type", "text"), msg.get("text", ""))
- context = self._get_room_context(room_id)
- if context:
- message_for_claude = context + "\n\n---\n\n" + combined
-
- await self._run_claude_session(
- room, event, message_for_claude, on_done=_on_done,
- )
-
- async def _handle_thread_command(self, room_id: str, user_text: str,
- session: SessionState) -> bool:
- """Handle user commands in a session thread. Returns True if handled."""
- cmd = user_text.strip().lower().lstrip("!")
- if cmd in ("stop", "cancel", "abort"):
- session.cancel_event.set()
- await self._send_thread_message(room_id, session.user_event_id, "Stopping...")
- return True
- if cmd in ("more time", "+5m", "+5"):
- session.idle_timeout_ref[0] += 300
- mins = session.idle_timeout_ref[0] // 60
- await self._send_thread_message(
- room_id, session.user_event_id, f"Timeout extended to {mins}m")
- return True
- if cmd in ("+10m", "+10"):
- session.idle_timeout_ref[0] += 600
- mins = session.idle_timeout_ref[0] // 60
- await self._send_thread_message(
- room_id, session.user_event_id, f"Timeout extended to {mins}m")
- return True
- return False
-
- def _make_on_status(self, room_id: str, session: SessionState):
- """Create an on_status callback that posts individual thread messages."""
- async def on_status(status: dict):
- event_type = status.get("event")
- msg = None
-
- if event_type == "tool_start":
- tool = status.get("tool", "?")
- preview = status.get("input_preview", "")
- session.status_lines.append(tool) # count for final summary
- if preview:
- msg = f"`{tool}`: {preview}"
- else:
- msg = f"`{tool}`"
- elif event_type == "tool_end":
- pass # tool_start already posted, no need for end message
- elif event_type == "agent_start":
- desc = status.get("description", "subagent")
- bg = " (bg)" if status.get("background") else ""
- session.status_lines.append("Agent")
- msg = f"`Agent{bg}`: {desc}"
- elif event_type == "thinking":
- text = status.get("text", "").strip()
- if text:
- msg = text
-
- if msg and session.user_event_id:
- try:
- await self._send_thread_message(room_id, session.user_event_id, msg)
- except Exception as e:
- logger.debug("Failed to send thread status: %s", e)
-
- return on_status
-
- # --- Claude call wrapper ---
-
- async def _notify_fallback_used(self, room_id: str, sender: str) -> None:
- """Send notification to admin when fallback provider was used."""
- if not self.admin_mxid or sender == self.admin_mxid:
- return # Don't notify if no admin or admin triggered it
-
- # Find DM room with admin — prefer room named exactly after the bot
- # Priority: exact bot name > "Bot: something" > any 1:1 room
- dm_room_id = None
- named_dm_id = None
- any_dm_id = None
- bot_name = self.client.user_id.split(":")[0].lstrip("@")
- for room in self.client.rooms.values():
- if len(room.users) == 2 and self.admin_mxid in room.users:
- name = (room.name or "").strip()
- if name.lower() == bot_name.lower():
- dm_room_id = room.room_id
- break
- if bot_name.lower() in name.lower() and not named_dm_id:
- named_dm_id = room.room_id
- if not any_dm_id:
- any_dm_id = room.room_id
- if not dm_room_id:
- dm_room_id = named_dm_id or any_dm_id
-
- if not dm_room_id:
- # Create DM room with admin
- resp = await self.client.room_create(
- visibility="private",
- preset="trusted_private_chat",
- invite=[self.admin_mxid],
- )
- if hasattr(resp, "room_id"):
- dm_room_id = resp.room_id
- logger.info("Created DM room with admin: %s", dm_room_id)
-
- if dm_room_id:
- room_link = f"https://matrix.to/#/{room_id}"
- await self.client.room_send(
- dm_room_id, "m.room.message",
- {
- "msgtype": "m.notice",
- "body": f"⚠️ Fallback (z.ai) used for room {room_link} (sender: {sender})",
- },
- ignore_unverified_devices=True,
- )
-
- async def _call_claude(self, room_id: str, safe_id: str, message: str,
- sender: str = "", on_status=None, cancel_event=None,
- idle_timeout_ref=None, **kwargs) -> str:
- """Call Claude CLI with typing indicator and status updates."""
- await self.client.room_typing(room_id, typing_state=True, timeout=30000)
- try:
- response = await claude_send(
- self.config, safe_id, message,
- on_status=on_status, cancel_event=cancel_event,
- idle_timeout_ref=idle_timeout_ref,
- **kwargs,
- )
- # Check if fallback was used and notify owner
- if "(via z.ai fallback)" in response and sender:
- asyncio.create_task(self._notify_fallback_used(room_id, sender))
- return response
- finally:
- await self.client.room_typing(room_id, typing_state=False)
-
- # --- Bot commands ---
-
- async def _handle_status(self, room: MatrixRoom) -> None:
- """Handle !status: show room/session info."""
- safe_id = room.room_id.replace(":", "_").replace("!", "")
- topic_dir = self._topic_dir(safe_id)
- is_busy = room.room_id in self._active_sessions
- lines = [f"**Status: {'working' if is_busy else 'idle'}**", f"Room: `{safe_id}`"]
-
- # Session info
- session_file = topic_dir / "session.txt"
- if session_file.exists():
- sid = session_file.read_text().strip()
- lines.append(f"Session: `{sid[:12]}...`")
- else:
- lines.append("Session: new")
-
- # Topic dir size
- if topic_dir.exists():
- total = sum(f.stat().st_size for f in topic_dir.rglob("*") if f.is_file())
- files = sum(1 for f in topic_dir.rglob("*") if f.is_file())
- if total < 1024:
- size_str = f"{total} B"
- elif total < 1024 * 1024:
- size_str = f"{total // 1024} KB"
- else:
- size_str = f"{total // (1024 * 1024)} MB"
- lines.append(f"Dir: {files} files, {size_str}")
-
- # Interaction count from log
- log_file = self._room_dir(room.room_id) / "log.jsonl"
- if log_file.exists():
- count = sum(1 for _ in open(log_file))
- lines.append(f"Interactions: {count}")
-
- # Auth info
- if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
- lines.append("Auth: `CLAUDE_CODE_OAUTH_TOKEN` (long-lived)")
- else:
- lines.append("Auth: OAuth credentials (short-lived)")
-
- await self._send_response(room.room_id, "\n".join(lines))
-
- async def _handle_help(self, room: MatrixRoom) -> None:
- """Show available commands."""
- room_id = room.room_id
- mode = self._get_room_mode(room_id)
- await self._send_response(room_id,
- f"**Commands:**\n"
- f"`!new [topic]` — new conversation room\n"
- f"`!mode [mode]` — set room mode (current: `{mode}`)\n"
- f" `quiet` — transcribe voice only\n"
- f" `context` — include recent history\n"
- f" `full` — persistent session with full history\n"
- f" `collect` — accumulate notes/images/voice, no replies\n"
- f"`!stop` — stop active Claude session\n"
- f"`!status` — bot status and active sessions\n"
- f"`!security [mode]` — room security level\n"
- f"`!claude-auth` — refresh OAuth token (admin, 1:1 only)\n"
- f"`!help` — this message")
-
- async def _handle_mode_command(self, room: MatrixRoom, args: str) -> None:
- """Handle !mode [quiet|context|full]: set or show room mode."""
- room_id = room.room_id
- mode = args.strip().lower()
- if not mode:
- current = self._get_room_mode(room_id)
- await self._send_response(room_id,
- f"**Mode:** `{current}`\n"
- f"Available: `quiet` (transcribe only), `context` (recent history), "
- f"`full` (persistent session), `collect` (accumulate context, no replies)")
- return
- if mode not in self.ROOM_MODES:
- await self._send_response(room_id,
- f"Unknown mode `{mode}`. Use: quiet, context, full, collect")
- return
- prev_mode = self._get_room_mode(room_id)
- self._set_room_mode(room_id, mode)
-
- # When leaving collect mode, summarize what was accumulated
- if prev_mode == "collect" and mode != "collect":
- summary = self._collect_summary(room_id)
- if summary:
- await self._send_response(room_id,
- f"Mode set to `{mode}`\n\n{summary}")
- # Store preamble for next Claude call
- safe_id = room_id.replace(":", "_").replace("!", "")
- self._collect_preambles[safe_id] = summary
- else:
- await self._send_response(room_id, f"Mode set to `{mode}`")
- else:
- await self._send_response(room_id, f"Mode set to `{mode}`")
-
- def _collect_summary(self, room_id: str) -> str:
- """Summarize what was accumulated in collect mode."""
- history_file = self._room_dir(room_id) / "history.jsonl"
- if not history_file.exists():
- return ""
- images, voice, texts, files = 0, 0, 0, 0
- try:
- with open(history_file) as f:
- for line in f:
- line = line.strip()
- if not line:
- continue
- msg = json.loads(line)
- mtype = msg.get("type", "text")
- sender = msg.get("sender", "")
- if sender == self.client.user_id:
- continue # skip bot messages
- if mtype == "image":
- images += 1
- elif mtype == "audio":
- voice += 1
- elif mtype == "file":
- files += 1
- else:
- texts += 1
- except Exception:
- return ""
- parts = []
- if images:
- parts.append(f"{images} image(s)")
- if voice:
- parts.append(f"{voice} voice note(s)")
- if texts:
- parts.append(f"{texts} text message(s)")
- if files:
- parts.append(f"{files} file(s)")
- if not parts:
- return ""
- return f"Accumulated: {', '.join(parts)}"
-
- async def _handle_security_command(self, room: MatrixRoom, sender: str, args: str) -> None:
- """Handle !security [strict|guarded|open]: set or show room security mode."""
- room_id = room.room_id
- mode = args.strip().lower()
- if not mode:
- current = self._get_security_mode(room_id)
- unverified = self._get_unverified_devices(room_id)
- lines = [
- f"**Security:** `{current}`",
- "Available: `strict` (block all if unverified), "
- "`guarded` (block unverified users), `open` (allow all + warning)",
- ]
- if unverified:
- lines.append(self._format_unverified_warning(unverified))
- else:
- lines.append("All devices in room are verified.")
- await self._send_response(room_id, "\n".join(lines))
- return
- if mode not in self.SECURITY_MODES:
- await self._send_response(room_id,
- f"Unknown security mode `{mode}`. Use: strict, guarded, open")
- return
- # Loosening security requires fully verified sender
- current = self._get_security_mode(room_id)
- mode_rank = {"strict": 2, "guarded": 1, "open": 0}
- if mode_rank[mode] < mode_rank[current]:
- if not self._user_fully_verified(sender):
- await self._send_response(room_id,
- "Only users with all devices verified can loosen security.")
- return
- self._set_security_mode(room_id, mode)
- await self._send_response(room_id, f"Security set to `{mode}`")
-
- async def _handle_claude_auth_command(self, room: MatrixRoom, sender: str, args: str) -> None:
- """Handle !claude-auth command: refresh Claude Code OAuth token.
-
- Restricted to admin (MATRIX_ADMIN_MXID) in 1:1 rooms only.
-
- Flow:
- 1. !claude-auth -> runs `claude setup-token` in tmux, extracts URL
- 2. User opens URL, authenticates, copies token
- 3. User pastes token here -> bot feeds it to tmux via send-keys
- 4. `claude setup-token` finishes and writes credentials itself
- """
- room_id = room.room_id
-
- # Admin-only, 1:1 rooms only (token must not leak to group chat history)
- if not self.admin_mxid or sender != self.admin_mxid:
- await self._send_response(room_id, "This command is admin-only.")
- return
- if self._is_group_room(room):
- await self._send_response(room_id, "This command only works in 1:1 rooms (token security).")
- return
-
- safe_id = room_id.replace(":", "_").replace("!", "")
-
- # Phase 2: user pasted the token — feed it to tmux
- if safe_id in self._auth_flows:
- token = args.strip()
- flow = self._auth_flows.get(safe_id, {})
- tmux_session = flow.get("tmux_session")
-
- if not tmux_session:
- self._auth_flows.pop(safe_id, None)
- await self._send_response(room_id, "Auth flow lost its tmux session. Run `!claude-auth` again.")
- return
-
- try:
- # Feed token to claude setup-token via tmux
- proc = await asyncio.create_subprocess_exec(
- "tmux", "send-keys", "-t", tmux_session, token, "Enter",
- stdout=asyncio.subprocess.DEVNULL,
- stderr=asyncio.subprocess.PIPE
- )
- _, stderr = await proc.communicate()
- if proc.returncode != 0:
- self._auth_flows.pop(safe_id, None)
- await self._send_response(room_id,
- f"Failed to send token to tmux: {stderr.decode().strip()}\nRun `!claude-auth` again.")
- return
-
- # Wait for setup-token to process and exit
- await self._send_response(room_id, "Token sent to `claude setup-token`, waiting for it to finish...")
-
- success = False
- for _ in range(15):
- await asyncio.sleep(1)
- # Check if tmux session still exists
- check = await asyncio.create_subprocess_exec(
- "tmux", "has-session", "-t", tmux_session,
- stdout=asyncio.subprocess.DEVNULL,
- stderr=asyncio.subprocess.DEVNULL
- )
- await check.wait()
- if check.returncode != 0:
- # Session exited — setup-token finished
- success = True
- break
-
- # Also check pane output for success/error messages
- cap = await asyncio.create_subprocess_exec(
- "tmux", "capture-pane", "-t", tmux_session, "-p",
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.DEVNULL
- )
- stdout, _ = await cap.communicate()
- output = stdout.decode('utf-8', errors='replace').lower()
- if 'success' in output or 'saved' in output or 'authenticated' in output:
- success = True
- break
- if 'error' in output or 'invalid' in output or 'failed' in output:
- clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', stdout.decode('utf-8', errors='replace'))
- self._auth_flows.pop(safe_id, None)
- await self._kill_tmux(tmux_session)
- await self._send_response(room_id,
- f"`claude setup-token` reported an error:\n```\n{clean.strip()[-500:]}\n```")
- return
-
- self._auth_flows.pop(safe_id, None)
-
- # Capture pane output BEFORE killing tmux — it contains the long-lived token
- final_output = ""
- if success:
- cap = await asyncio.create_subprocess_exec(
- "tmux", "capture-pane", "-t", tmux_session, "-p", "-S", "-100",
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.DEVNULL
- )
- stdout, _ = await cap.communicate()
- final_output = stdout.decode('utf-8', errors='replace')
-
- await self._kill_tmux(tmux_session)
-
- if success:
- # Extract long-lived token from setup-token output
- clean_output = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', final_output)
- clean_output = re.sub(r'\x1b[^a-zA-Z]*[a-zA-Z]', '', clean_output)
- oauth_token = self._extract_oauth_token(clean_output)
-
- if oauth_token:
- # Try to save to deploy .env
- saved = self._save_oauth_token_to_env(oauth_token)
- if saved:
- msg = "Long-lived token saved to deploy `.env`. Restart bot to apply."
- else:
- msg = (f"Token extracted. Set in deploy `.env` and restart:\n"
- f"`CLAUDE_CODE_OAUTH_TOKEN={oauth_token}`")
- else:
- msg = "Auth completed but could not extract long-lived token from output."
-
- # Also verify with claude auth status
- status_proc = await asyncio.create_subprocess_exec(
- "claude", "auth", "status",
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE
- )
- status_out, _ = await status_proc.communicate()
- status_text = status_out.decode('utf-8', errors='replace').strip()
-
- await self._send_response(room_id,
- f"{msg}\n\n```\n{status_text[:500]}\n```")
- logger.info("Claude auth flow completed for room %s (token saved: %s)",
- room_id, bool(oauth_token))
- else:
- await self._send_response(room_id,
- "`claude setup-token` didn't finish within 15s. "
- "Check manually with `claude auth status`.")
-
- except Exception as e:
- self._auth_flows.pop(safe_id, None)
- await self._kill_tmux(tmux_session)
- logger.error("Error feeding token to tmux: %s", e)
- await self._send_response(room_id, f"Error: {e}")
- return
-
- # Phase 1: start claude setup-token in tmux, extract URL
- await self._send_response(room_id, "Starting Claude Code OAuth flow...")
-
- tmux_session = f"claude-auth-{safe_id[:20]}"
-
- try:
- # Kill any leftover session
- await self._kill_tmux(tmux_session)
- await asyncio.sleep(0.3)
-
- # Start claude setup-token in tmux
- proc = await asyncio.create_subprocess_exec(
- "tmux", "new-session", "-d", "-s", tmux_session,
- "-x", "200", "-y", "50",
- "claude", "setup-token"
- )
- await proc.wait()
-
- # Poll for the OAuth URL to appear
- output = ""
- for _ in range(15):
- await asyncio.sleep(1)
-
- cap = await asyncio.create_subprocess_exec(
- "tmux", "capture-pane", "-t", tmux_session, "-p",
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.DEVNULL
- )
- stdout, _ = await cap.communicate()
- output = stdout.decode('utf-8', errors='replace')
-
- if 'oauth/authorize' in output.lower() or 'console.anthropic.com' in output.lower():
- break
-
- # Strip ANSI escapes
- clean_output = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', output)
- clean_output = re.sub(r'\x1b[^a-zA-Z]*[a-zA-Z]', '', clean_output)
-
- # tmux wraps long URLs across lines — join continuation lines
- # Remove newlines that break mid-URL (lines not starting with whitespace
- # after a line ending with a URL-safe char)
- lines = clean_output.split('\n')
- joined = lines[0] if lines else ''
- for line in lines[1:]:
- stripped = line.strip()
- # If prev line ends with URL-safe char and this line looks like URL continuation
- if stripped and not stripped.startswith(('$', '#', '>', ' ')) and re.match(r'^[a-zA-Z0-9%&=_.~:/?#\[\]@!$\'()*+,;-]', stripped):
- # Check if we're likely in a URL context
- if joined.rstrip().endswith(tuple('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789%&=_.-~:/?#[]@!$\'()*+,;')):
- joined += stripped
- continue
- joined += '\n' + line
- clean_output = joined
-
- # Extract URL
- url_match = re.search(r'(https://[^\s]*(?:oauth/authorize|console\.anthropic\.com)[^\s]*)', clean_output)
-
- if not url_match:
- await self._kill_tmux(tmux_session)
- await self._send_response(room_id,
- "Could not extract auth URL from `claude setup-token`.\n"
- f"```\n{clean_output.strip()[:500]}\n```")
- logger.warning("claude setup-token output: %s", clean_output)
- return
-
- auth_url = url_match.group(1)
-
- # Register auth flow
- self._auth_flows[safe_id] = {
- "tmux_session": tmux_session,
- "started": time.time()
- }
-
- await self._send_response(room_id,
- "**Claude Code Authentication**\n\n"
- f"1. Open: {auth_url}\n\n"
- "2. Authenticate and copy the token from the page\n\n"
- "3. Paste it here\n\n"
- "Flow expires in 5 minutes."
- )
-
- # Timeout cleanup
- async def _auth_cleanup():
- await asyncio.sleep(300)
- if safe_id in self._auth_flows:
- flow = self._auth_flows.pop(safe_id, {})
- await self._kill_tmux(flow.get("tmux_session"))
- await self._send_response(room_id, "Auth flow expired. Run `!claude-auth` to restart.")
-
- asyncio.create_task(_auth_cleanup())
-
- except Exception as e:
- await self._kill_tmux(tmux_session)
- logger.error("Error starting claude setup-token: %s", e)
- await self._send_response(room_id, f"Error: {e}")
-
- async def _kill_tmux(self, session: str | None) -> None:
- """Kill a tmux session if it exists."""
- if not session:
- return
- proc = await asyncio.create_subprocess_exec(
- "tmux", "kill-session", "-t", session,
- stdout=asyncio.subprocess.DEVNULL,
- stderr=asyncio.subprocess.DEVNULL
- )
- await proc.wait()
-
- @staticmethod
- def _extract_oauth_token(text: str) -> str | None:
- """Extract CLAUDE_CODE_OAUTH_TOKEN from setup-token output."""
- # Look for the token after "export CLAUDE_CODE_OAUTH_TOKEN=" or similar
- m = re.search(r'CLAUDE_CODE_OAUTH_TOKEN[=\s]+([a-zA-Z0-9_\-]+)', text)
- if m:
- return m.group(1)
- # Fallback: look for sk-ant-oat pattern (setup-token format)
- m = re.search(r'(sk-ant-oat[a-zA-Z0-9_\-]+)', text)
- if m:
- return m.group(1)
- return None
-
- def _save_oauth_token_to_env(self, token: str) -> bool:
- """Save CLAUDE_CODE_OAUTH_TOKEN to workspace .env file."""
- if not self.config.workspace_dir:
- return False
- env_path = Path(self.config.workspace_dir) / ".env"
- try:
- content = env_path.read_text() if env_path.exists() else ""
- if "CLAUDE_CODE_OAUTH_TOKEN=" in content:
- content = re.sub(
- r'CLAUDE_CODE_OAUTH_TOKEN=.*',
- f'CLAUDE_CODE_OAUTH_TOKEN={token}',
- content
- )
- else:
- content = content.rstrip('\n') + f'\nCLAUDE_CODE_OAUTH_TOKEN={token}\n'
- env_path.write_text(content)
- os.chmod(env_path, 0o600)
- logger.info("Saved CLAUDE_CODE_OAUTH_TOKEN to %s", env_path)
- return True
- except Exception as e:
- logger.error("Failed to save token to %s: %s", env_path, e)
- return False
-
- async def _handle_new_command(self, room: MatrixRoom, event_sender: str, topic: str) -> None:
- """Handle !new command: create a new conversation room and invite user."""
- room_id = room.room_id
- name = topic.strip() if topic.strip() else f"{self._default_room_prefix}Новый чат"
-
- new_room_id = await self._create_conversation_room(name, for_user=event_sender)
- if not new_room_id:
- await self._send_response(room_id, "Failed to create room.")
- return
-
- room_link = f"https://matrix.to/#/{new_room_id}"
- display_name = name.removeprefix(self._default_room_prefix)
- await self.client.room_send(
- room_id, "m.room.message",
- {
- "msgtype": "m.text",
- "body": f"{display_name}: {room_link}",
- "format": "org.matrix.custom.html",
- "formatted_body": f"{display_name}",
- },
- ignore_unverified_devices=True,
- )
- logger.info("Created /new room %s: %s", new_room_id, name)
-
- # --- Message handlers ---
-
- async def _handle_text(self, room: MatrixRoom, event: RoomMessageText) -> None:
- is_group = self._is_group_room(room)
-
- # 1:1 rooms: only owner can use the bot
- # Group rooms: anyone can mention the bot
- if not is_group and not self._is_allowed_user(event.sender):
- return
-
- user_text = event.body
- room_id = room.room_id
- safe_id = room_id.replace(":", "_").replace("!", "")
-
- # Check if this is a session command — thread reply or !command while busy
- session = self._active_sessions.get(room_id)
- if session:
- relates_to = event.source.get("content", {}).get("m.relates_to", {})
- is_thread = relates_to.get("rel_type") == "m.thread"
- is_bang_cmd = user_text.strip().lower().lstrip("!") in (
- "stop", "cancel", "abort", "+5m", "+5", "+10m", "+10",
- )
- if is_thread or is_bang_cmd:
- if await self._handle_thread_command(room_id, user_text, session):
- return
-
- # Strip mention prefix (e.g. "Bot: !status" → "!status")
- command_text = self._strip_mention_prefix(user_text)
-
- # If Claude is waiting for an answer in this room, deliver it
- if safe_id in self._pending_questions:
- future = self._pending_questions.pop(safe_id)
- if not future.done():
- future.set_result(user_text)
- return
-
- # Check if we're in an auth flow for this room
- if safe_id in self._auth_flows:
- # Only intercept if it looks like a token (long, no spaces, no command prefix)
- candidate = user_text.strip()
- if len(candidate) > 20 and ' ' not in candidate and not candidate.startswith('!'):
- # Redact the token message from chat history
- try:
- await self.client.room_redact(room_id, event.event_id, reason="auth token")
- except Exception:
- pass # best-effort, E2E rooms may not support redaction
- await self._handle_claude_auth_command(room, event.sender, user_text)
- return
- # If it looks like a command or normal message, check for !claude-auth cancel
- if candidate.lower() in ('!cancel', '!claude-auth cancel', 'cancel'):
- flow = self._auth_flows.pop(safe_id, {})
- await self._kill_tmux(flow.get("tmux_session"))
- await self._send_response(room_id, "Auth flow cancelled.")
- return
- # Fall through to normal message handling
-
- # Bot commands — only allowed users
- if self._is_allowed_user(event.sender):
- if command_text.strip() in ("!help", "!commands", "!?"):
- await self._handle_help(room)
- return
- if command_text.startswith("!new"):
- topic = command_text[4:].strip()
- await self._handle_new_command(room, event.sender, topic)
- return
- if command_text.strip() == "!status":
- await self._handle_status(room)
- return
- if command_text.startswith("!mode"):
- await self._handle_mode_command(room, command_text[5:])
- return
- if command_text.startswith("!security"):
- await self._handle_security_command(room, event.sender, command_text[9:])
- return
- if command_text.strip() in ("!claude-auth", "!claudeauth"):
- await self._handle_claude_auth_command(room, event.sender, "")
- return
-
- mode = self._get_room_mode(room_id)
-
- # Group rooms: only respond when mentioned (quiet/context modes)
- if is_group and mode not in ("full", "collect"):
- logger.info("Group room %s (members=%d), checking mention", room_id, room.member_count)
- if not self._is_bot_mentioned(event):
- logger.info("Not mentioned in group room, skipping")
- return
-
- # Collect mode: save to history, acknowledge, no Claude
- if mode == "collect":
- self._save_room_message(room_id, event.sender, "text", user_text)
- return
-
- # Check if already processing in this room — queue if busy
- if self._is_room_busy(room_id):
- self._enqueue_message(room_id, event.event_id, event.sender, user_text)
- return
-
- # Security check — after mention check, before Claude interaction
- allowed, security_msg = await self._check_security(room_id, event.sender)
- if not allowed:
- await self._send_response(room_id, security_msg)
- return
-
- # In full mode, save every message to room history
- if mode == "full":
- self._save_room_message(room_id, event.sender, "text", user_text)
-
- # Build message for Claude
- message_for_claude = user_text
- if mode == "context":
- recent = await self._fetch_recent_messages(room_id, limit=10)
- if recent:
- context_lines = [f"{m['sender']}: {m['text']}" for m in recent]
- context_block = "\n".join(context_lines)
- message_for_claude = (
- "[Recent room messages for context]\n"
- f"{context_block}\n\n---\n\n{user_text}"
- )
- elif mode == "full":
- context = self._get_room_context(room_id)
- if context:
- message_for_claude = context + "\n\n---\n\n" + user_text
-
- # Inject collect mode preamble if switching from collect
- preamble = self._collect_preambles.pop(safe_id, "")
- if preamble:
- message_for_claude = (
- "[CONTEXT UPDATE: User just switched from COLLECT mode. "
- "New material was accumulated in this room's history — images, voice notes, "
- "and/or text that you haven't seen yet. Review the conversation history above carefully, "
- "especially entries with [image:] paths (use Read tool to view them) "
- "and voice transcriptions. Process all accumulated material before responding.]\n\n"
- + message_for_claude
- )
-
- async def _on_done(response: str):
- self._pending_questions.pop(safe_id, None)
- if mode == "full":
- self._save_room_message(room_id, self.client.user_id, "text", response)
- await self._rename_room(room_id, safe_id, user_text=user_text, response=response)
- self._log_interaction(room_id, user_text, response)
-
- await self._run_claude_session(
- room, event, message_for_claude,
- security_msg=security_msg, on_done=_on_done,
- )
-
- async def _handle_image(self, room: MatrixRoom, event) -> None:
- if not self._is_allowed_user(event.sender):
- return
- mode = self._get_room_mode(room.room_id)
- if self._is_group_room(room) and mode not in ("full", "collect"):
- return
-
- room_id = room.room_id
- safe_id = room_id.replace(":", "_").replace("!", "")
-
- # Download and save image regardless of mode
- images_dir = self._room_dir(room_id) / "images"
- images_dir.mkdir(exist_ok=True)
-
- data = await self._download_media(event)
- if data is None:
- return
-
- ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
- filename = f"{ts}_{event.body or 'image'}"
- if not any(filename.endswith(ext) for ext in (".jpg", ".jpeg", ".png", ".webp", ".gif")):
- filename += ".jpg"
- filepath = images_dir / filename
- with open(filepath, "wb") as f:
- f.write(data)
-
- caption = event.body if event.body and event.body != "image" else ""
-
- # Collect mode: save to history, no Claude
- if mode == "collect":
- history_text = f"[image: {filepath}]"
- if caption:
- history_text += f" {caption}"
- self._save_room_message(room_id, event.sender, "image", history_text, file_path=str(filepath))
- return
-
- # Security check
- allowed, security_msg = await self._check_security(room_id, event.sender)
- if not allowed:
- await self._send_response(room_id, security_msg)
- return
-
- message = f"User sent an image: {filepath}"
- if caption:
- message += f"\nCaption: {caption}"
-
- if self._is_room_busy(room_id):
- history_text = f"[image: {filepath}]"
- if caption:
- history_text += f" {caption}"
- self._enqueue_message(room_id, event.event_id, event.sender,
- history_text, msg_type="image", file_path=str(filepath))
- return
-
- async def _on_done(response: str):
- await self._rename_room(room_id, safe_id, user_text=message, response=response)
- self._log_interaction(room_id, f"[image] {event.body}", response)
-
- await self._run_claude_session(
- room, event, message, security_msg=security_msg, on_done=_on_done,
- )
-
- async def _handle_audio(self, room: MatrixRoom, event) -> None:
- is_group = self._is_group_room(room)
- if not is_group and not self._is_allowed_user(event.sender):
- return
-
- room_id = room.room_id
- safe_id = room_id.replace(":", "_").replace("!", "")
- mode = self._get_room_mode(room_id)
- voice_dir = self._room_dir(room_id) / "voice"
- voice_dir.mkdir(exist_ok=True)
-
- data = await self._download_media(event)
- if data is None:
- return
-
- ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
- filename = f"{ts}_{event.body or 'voice.ogg'}"
- filepath = voice_dir / filename
- with open(filepath, "wb") as f:
- f.write(data)
-
- # Transcribe
- transcribed_text = None
- engine_tag = ""
- if self.config.stt_url:
- try:
- transcribed_text, engine_tag = await transcribe(
- str(filepath), self.config.stt_url,
- whisper_url=os.environ.get("STT_SHORT_URL"),
- )
- logger.info("Transcribed voice in room %s: %d chars [%s]",
- room.display_name, len(transcribed_text), engine_tag)
- except RuntimeError as e:
- logger.error("ASR failed for room %s: %s", room.display_name, e)
-
- # Post transcription with sender attribution + engine tag
- if transcribed_text:
- sender_name = self._sender_display_name(room, event.sender)
- notice = f"🎙 {sender_name}: {transcribed_text}"
- if engine_tag and os.environ.get("STT_SHORT_URL"):
- notice += f" // {engine_tag}"
- await self.client.room_send(
- room_id, "m.room.message",
- {"msgtype": "m.notice", "body": notice},
- ignore_unverified_devices=True,
- )
-
- # Save to history in full/collect modes
- if mode in ("full", "collect"):
- history_text = transcribed_text or f"[audio: {filepath}]"
- self._save_room_message(room_id, event.sender, "audio", history_text, file_path=str(filepath))
-
- # Collect mode: transcribe and save, no Claude
- if mode == "collect":
- return
-
- # Decide whether to respond via Claude
- should_respond = not is_group # always respond in 1:1
- if is_group and transcribed_text and self._text_mentions_bot(transcribed_text):
- should_respond = True
- if not should_respond:
- return
-
- if self._is_room_busy(room_id):
- queue_text = transcribed_text or f"[audio: {filepath}]"
- self._enqueue_message(room_id, event.event_id, event.sender,
- queue_text, msg_type="audio", file_path=str(filepath))
- return
-
- # Security check — before Claude interaction
- allowed, security_msg = await self._check_security(room_id, event.sender)
- if not allowed:
- await self._send_response(room_id, security_msg)
- return
-
- # Build message for Claude
- if transcribed_text:
- message = f"[voice message transcription]: {transcribed_text}"
- else:
- message = f"User sent a voice message: {filepath}"
-
- if mode == "context":
- recent = await self._fetch_recent_messages(room_id, limit=10)
- if recent:
- context_lines = [f"{m['sender']}: {m['text']}" for m in recent]
- context_block = "\n".join(context_lines)
- message = f"[Recent room messages for context]\n{context_block}\n\n---\n\n{message}"
-
- async def _on_done(response: str):
- if mode == "full":
- self._save_room_message(room_id, self.client.user_id, "text", response)
- await self._rename_room(room_id, safe_id, user_text=message, response=response)
- self._log_interaction(room_id, message, response)
-
- await self._run_claude_session(
- room, event, message, security_msg=security_msg, on_done=_on_done,
- )
-
- async def _handle_file(self, room: MatrixRoom, event) -> None:
- if not self._is_allowed_user(event.sender):
- return
- mode = self._get_room_mode(room.room_id)
- if self._is_group_room(room) and mode not in ("full", "collect"):
- return
-
- room_id = room.room_id
- safe_id = room_id.replace(":", "_").replace("!", "")
-
- # Download and save file regardless of mode
- docs_dir = self._room_dir(room_id) / "documents"
- docs_dir.mkdir(exist_ok=True)
-
- data = await self._download_media(event)
- if data is None:
- return
-
- ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
- orig_name = event.body or "document"
- filename = f"{ts}_{orig_name}"
- filepath = docs_dir / filename
- with open(filepath, "wb") as f:
- f.write(data)
-
- # Collect mode: save to history, no Claude
- if mode == "collect":
- self._save_room_message(room_id, event.sender, "file",
- f"[file: {orig_name}]", file_path=str(filepath))
- return
-
- # Security check
- allowed, security_msg = await self._check_security(room_id, event.sender)
- if not allowed:
- await self._send_response(room_id, security_msg)
- return
-
- message = f"User sent a document: {filepath} (name: {orig_name}, size: {len(data)} bytes)"
-
- if self._is_room_busy(room_id):
- self._enqueue_message(room_id, event.event_id, event.sender,
- f"[file: {orig_name}]", msg_type="file", file_path=str(filepath))
- return
-
- async def _on_done(response: str):
- await self._rename_room(room_id, safe_id, user_text=message, response=response)
- self._log_interaction(room_id, f"[document: {orig_name}]", response)
-
- await self._run_claude_session(
- room, event, message, security_msg=security_msg, on_done=_on_done,
- )
-
- # --- E2E cross-signing & trust ---
-
- async def _setup_cross_signing(self) -> None:
- """Generate cross-signing keys (or load existing) and self-sign device."""
- if not self.client.olm:
- return
- import base64
- import olm as _olm
-
- seeds_path = self.config.data_dir / "crypto_store" / "cross_signing_seeds.json"
-
- # Load or generate seeds
- if seeds_path.exists():
- seeds = json.loads(seeds_path.read_text())
- master_seed = base64.b64decode(seeds["master_seed"])
- self_signing_seed = base64.b64decode(seeds["self_signing_seed"])
- user_signing_seed = base64.b64decode(seeds["user_signing_seed"])
- else:
- master_seed = _olm.PkSigning.generate_seed()
- self_signing_seed = _olm.PkSigning.generate_seed()
- user_signing_seed = _olm.PkSigning.generate_seed()
- seeds_path.parent.mkdir(parents=True, exist_ok=True)
- seeds_path.write_text(json.dumps({
- "master_seed": base64.b64encode(master_seed).decode(),
- "self_signing_seed": base64.b64encode(self_signing_seed).decode(),
- "user_signing_seed": base64.b64encode(user_signing_seed).decode(),
- }))
-
- master = _olm.PkSigning(master_seed)
- self_signing = _olm.PkSigning(self_signing_seed)
- _olm.PkSigning(user_signing_seed) # validate
-
- def _canonical(obj):
- return json.dumps(obj, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
-
- def _sign(obj, key_id, signing_key):
- to_sign = {k: v for k, v in obj.items() if k not in ("signatures", "unsigned")}
- sig = signing_key.sign(_canonical(to_sign))
- obj.setdefault("signatures", {}).setdefault(self.client.user_id, {})[key_id] = sig
-
- user_id = self.client.user_id
- hs = self.client.homeserver
-
- async with httpx.AsyncClient() as http:
- headers = {"Authorization": f"Bearer {self.client.access_token}",
- "Content-Type": "application/json"}
-
- # Check if already uploaded
- resp = await http.post(f"{hs}/_matrix/client/v3/keys/query",
- headers=headers, json={"device_keys": {user_id: []}}, timeout=10)
- existing = resp.json().get("master_keys", {}).get(user_id)
- if existing:
- logger.info("Cross-signing keys already uploaded")
- else:
- # Build and upload cross-signing keys
- master_key = {"user_id": user_id, "usage": ["master"],
- "keys": {f"ed25519:{master.public_key}": master.public_key}}
- self_signing_key = {"user_id": user_id, "usage": ["self_signing"],
- "keys": {f"ed25519:{self_signing.public_key}": self_signing.public_key}}
- user_signing_key_obj = {"user_id": user_id, "usage": ["user_signing"],
- "keys": {f"ed25519:{_olm.PkSigning(user_signing_seed).public_key}":
- _olm.PkSigning(user_signing_seed).public_key}}
- _sign(self_signing_key, f"ed25519:{master.public_key}", master)
- _sign(user_signing_key_obj, f"ed25519:{master.public_key}", master)
- resp = await http.post(f"{hs}/_matrix/client/v3/keys/device_signing/upload",
- headers=headers, timeout=10,
- json={"master_key": master_key,
- "self_signing_key": self_signing_key,
- "user_signing_key": user_signing_key_obj})
- if resp.status_code == 401:
- session = resp.json().get("session", "")
- resp = await http.post(f"{hs}/_matrix/client/v3/keys/device_signing/upload",
- headers=headers, timeout=10,
- json={"master_key": master_key,
- "self_signing_key": self_signing_key,
- "user_signing_key": user_signing_key_obj,
- "auth": {"type": "m.login.dummy", "session": session}})
- if resp.status_code == 200:
- logger.info("Uploaded cross-signing keys")
- else:
- logger.error("Failed to upload cross-signing keys (%d): %s",
- resp.status_code, resp.text[:200])
- return
-
- # Self-sign our device with self-signing key
- resp = await http.post(f"{hs}/_matrix/client/v3/keys/query",
- headers=headers, json={"device_keys": {user_id: []}}, timeout=10)
- device_keys = resp.json()["device_keys"][user_id].get(self.client.device_id)
- if not device_keys:
- logger.error("Own device keys not found on server")
- return
-
- # Check if already signed by self-signing key
- existing_sigs = device_keys.get("signatures", {}).get(user_id, {})
- ss_key_id = f"ed25519:{self_signing.public_key}"
- if ss_key_id in existing_sigs:
- logger.info("Device already self-signed")
- return
-
- to_sign = {k: v for k, v in device_keys.items() if k not in ("signatures", "unsigned")}
- sig = self_signing.sign(_canonical(to_sign))
- sig_body = {user_id: {self.client.device_id: {
- **to_sign,
- "signatures": {user_id: {ss_key_id: sig}},
- }}}
- resp = await http.post(f"{hs}/_matrix/client/v3/keys/signatures/upload",
- headers=headers, json=sig_body, timeout=10)
- if resp.status_code == 200:
- logger.info("Self-signed device %s", self.client.device_id)
- else:
- logger.error("Failed to self-sign device (%d): %s",
- resp.status_code, resp.text[:200])
-
- async def _sync_cross_signing_trust(self) -> None:
- """Query server for cross-signing keys and trust devices signed by self-signing keys.
-
- This bridges the gap between server-side cross-signing verification
- (what Element shows as green/red) and nio's local device trust store.
- A device is considered verified if it's signed by its owner's self-signing key.
- """
- if not self.client.olm:
- return
- hs = self.client.homeserver
- headers = {"Authorization": f"Bearer {self.client.access_token}",
- "Content-Type": "application/json"}
-
- # Collect all user IDs we care about
- user_ids = set(self._users.keys())
- if not user_ids:
- return
-
- try:
- async with httpx.AsyncClient() as http:
- resp = await http.post(
- f"{hs}/_matrix/client/v3/keys/query",
- headers=headers,
- json={"device_keys": {uid: [] for uid in user_ids}},
- timeout=10,
- )
- if resp.status_code != 200:
- logger.warning("Cross-signing trust sync failed (%d)", resp.status_code)
- return
- data = resp.json()
- except Exception as e:
- logger.warning("Cross-signing trust sync error: %s", e)
- return
-
- # For each user, find their self-signing key
- for user_id in user_ids:
- ss_key_obj = data.get("self_signing_keys", {}).get(user_id)
- if not ss_key_obj:
- continue
- # Extract the self-signing public key
- ss_keys = ss_key_obj.get("keys", {})
- ss_pubkey = None
- for key_id, key_val in ss_keys.items():
- if key_id.startswith("ed25519:"):
- ss_pubkey = key_id # e.g. "ed25519:ABCDEF..."
- break
- if not ss_pubkey:
- continue
-
- # Check each device: is it signed by the self-signing key?
- user_devices = data.get("device_keys", {}).get(user_id, {})
- for device_id, dev_keys in user_devices.items():
- sigs = dev_keys.get("signatures", {}).get(user_id, {})
- is_cross_signed = ss_pubkey in sigs
-
- # Find this device in nio's local store
- nio_device = None
- for d in self.client.device_store.active_user_devices(user_id):
- if d.id == device_id:
- nio_device = d
- break
-
- if nio_device is None:
- continue
-
- if is_cross_signed and not nio_device.verified:
- self.client.verify_device(nio_device)
- logger.info("Trusted cross-signed device %s of %s", device_id, user_id)
- elif not is_cross_signed and nio_device.verified:
- # Device lost cross-signing — untrust it
- # (nio has no unverify, but we can note it)
- logger.warning("Device %s of %s no longer cross-signed", device_id, user_id)
-
- logger.info("Cross-signing trust sync complete")
-
- # --- Auto-join and room locking ---
-
- async def _auto_join_invites(self) -> None:
- for room_id in list(self.client.invited_rooms):
- await self.client.join(room_id)
- logger.info("Accepted invite to room %s", room_id)
-
- def _load_sync_token(self) -> str | None:
- if self._sync_token_path.exists():
- token = self._sync_token_path.read_text().strip()
- return token if token else None
- return None
-
- def _save_sync_token(self, token: str) -> None:
- self._sync_token_path.parent.mkdir(parents=True, exist_ok=True)
- self._sync_token_path.write_text(token)
-
- async def run(self) -> None:
- """Start the Matrix bot."""
- # Plain events
- self.client.add_event_callback(self._on_message, RoomMessageText)
- self.client.add_event_callback(self._on_image, RoomMessageImage)
- self.client.add_event_callback(self._on_audio, RoomMessageAudio)
- self.client.add_event_callback(self._on_file, RoomMessageFile)
- self.client.add_event_callback(self._on_member, RoomMemberEvent)
- # Encrypted events (nio auto-decrypts to RoomMessage* types above,
- # but encrypted media comes as RoomEncrypted* types)
- self.client.add_event_callback(self._on_image, RoomEncryptedImage)
- self.client.add_event_callback(self._on_audio, RoomEncryptedAudio)
- self.client.add_event_callback(self._on_file, RoomEncryptedFile)
- # Undecryptable events (missing keys)
- self.client.add_event_callback(self._on_megolm, MegolmEvent)
- # In-room verification events (Element X, FluffyChat)
- self.client.add_event_callback(self._on_room_verify_event, RoomMessageUnknown)
- self.client.add_event_callback(self._on_room_verify_event, UnknownEvent)
- self.client.add_response_callback(self._on_sync, SyncResponse)
- # SAS key verification (to-device events)
- self.client.add_to_device_callback(self._on_verify_start, KeyVerificationStart)
- self.client.add_to_device_callback(self._on_verify_key, KeyVerificationKey)
- self.client.add_to_device_callback(self._on_verify_mac, KeyVerificationMac)
- self.client.add_to_device_callback(self._on_verify_cancel, KeyVerificationCancel)
-
- logger.info("Matrix bot starting as %s", self.client.user_id)
-
- saved_token = self._load_sync_token()
- if saved_token:
- logger.info("Resuming from saved sync token")
-
- resp = await self.client.sync(timeout=10000, since=saved_token, full_state=True)
- if hasattr(resp, "next_batch") and resp.next_batch:
- self._save_sync_token(resp.next_batch)
- await self._auto_join_invites()
- # E2E setup: upload our keys, then fetch and trust other users' devices
- if self.client.olm:
- if self.client.should_upload_keys:
- await self.client.keys_upload()
- logger.info("Uploaded device keys to server")
- try:
- await self.client.keys_query()
- except Exception:
- pass # no keys to query yet (fresh user, no rooms)
- # Note: we intentionally do NOT auto-trust all user devices here.
- # The security model (strict/guarded/open) handles unverified devices
- # per room. Devices are verified via in-room verification or cross-signing.
- await self._sync_cross_signing_trust()
- await self._setup_cross_signing()
- await self._set_bot_avatar()
- self._synced = True
- logger.info("Initial sync complete, E2E=%s, listening for new messages",
- "enabled" if self.client.olm else "disabled")
-
- await self.client.sync_forever(timeout=30000)
-
- def _should_process(self, event, room: MatrixRoom | None = None) -> bool:
- """Check if event should be processed (not own, not old, not duplicate, after sync)."""
- eid = event.event_id
- room_id = room.room_id if room else "?"
- logger.info("_should_process: eid=%s sender=%s room=%s ts=%s body=%s",
- eid, event.sender, room_id, event.server_timestamp,
- getattr(event, 'body', '')[:50])
- if not self._synced:
- return False
- if event.sender == self.client.user_id:
- return False
- if eid in self._processed_events:
- logger.warning("Duplicate event %s, skipping", eid)
- return False
- self._processed_events.add(eid)
- # Keep set bounded
- if len(self._processed_events) > 1000:
- self._processed_events = set(list(self._processed_events)[-500:])
- return True
-
- async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
- if not self._should_process(event, room):
- return
- await self._handle_text(room, event)
-
- async def _on_image(self, room: MatrixRoom, event) -> None:
- if not self._should_process(event, room):
- return
- await self._handle_image(room, event)
-
- async def _on_audio(self, room: MatrixRoom, event) -> None:
- if not self._should_process(event, room):
- return
- await self._handle_audio(room, event)
-
- async def _on_file(self, room: MatrixRoom, event) -> None:
- if not self._should_process(event, room):
- return
- await self._handle_file(room, event)
-
- async def _on_megolm(self, room: MatrixRoom, event: MegolmEvent) -> None:
- """Handle messages we couldn't decrypt."""
- if not self._synced:
- return
- logger.warning("Could not decrypt event %s in %s from %s (session %s)",
- event.event_id, room.room_id, event.sender,
- event.session_id)
-
- # --- SAS key verification (auto-accept for allowed users) ---
-
- async def _on_verify_start(self, event: KeyVerificationStart) -> None:
- """Incoming verification request — auto-accept from allowed users."""
- if not self._is_allowed_user(event.sender):
- logger.warning("Verification from non-allowed user %s, ignoring", event.sender)
- return
- logger.info("Verification request from %s (tx=%s), auto-accepting",
- event.sender, event.transaction_id)
- resp = await self.client.accept_key_verification(event.transaction_id)
- if hasattr(resp, "message"):
- logger.error("Failed to accept verification: %s", resp.message)
-
- async def _on_verify_key(self, event: KeyVerificationKey) -> None:
- """Key exchange done — emojis available. Auto-confirm (bot trusts allowed users)."""
- sas = self.client.key_verifications.get(event.transaction_id)
- if not sas:
- return
- emojis = sas.get_emoji()
- emoji_str = " ".join(f"{e[0]} ({e[1]})" for e in emojis)
- logger.info("Verification emojis for %s: %s", sas.other_olm_device.user_id, emoji_str)
- resp = await self.client.confirm_short_auth_string(event.transaction_id)
- if hasattr(resp, "message"):
- logger.error("Failed to confirm SAS: %s", resp.message)
-
- async def _on_verify_mac(self, event: KeyVerificationMac) -> None:
- """MAC received — verification complete."""
- sas = self.client.key_verifications.get(event.transaction_id)
- if not sas:
- return
- if sas.verified:
- logger.info("Device %s of %s verified via SAS",
- sas.other_olm_device.id, sas.other_olm_device.user_id)
- else:
- logger.warning("SAS verification failed for %s", event.transaction_id)
-
- async def _on_verify_cancel(self, event: KeyVerificationCancel) -> None:
- """Verification canceled."""
- logger.info("Verification %s canceled by %s: %s",
- event.transaction_id, event.sender, event.reason)
-
- # --- In-room verification (used by Element X, FluffyChat) ---
-
- async def _on_room_verify_event(self, room: MatrixRoom, event) -> None:
- """Handle in-room verification events (m.key.verification.*)."""
- if not self._synced:
- return
- source = getattr(event, "source", {})
- content = source.get("content", {})
- event_type = source.get("type", "")
- sender = source.get("sender", "")
- event_id = source.get("event_id", "")
- logger.debug("Room event: type=%s sender=%s eid=%s keys=%s",
- event_type, sender, event_id, list(content.keys()))
-
- # m.room.message with msgtype m.key.verification.request
- if event_type == "m.room.message":
- msgtype = content.get("msgtype", "")
- if msgtype != "m.key.verification.request":
- return
- event_type = "m.key.verification.request"
-
- if not event_type.startswith("m.key.verification."):
- return
-
- if sender == self.client.user_id:
- return
-
- if not self._is_allowed_user(sender):
- return
-
- # Get transaction_id from m.relates_to or from the request event_id
- relates_to = content.get("m.relates_to", {})
- tx_id = relates_to.get("event_id", "")
-
- room_id = room.room_id
- logger.info("In-room verification: %s from %s (tx=%s)", event_type, sender, tx_id or event_id)
-
- if event_type == "m.key.verification.request":
- tx_id = event_id # the request event_id IS the transaction_id
- # Store SAS state
- import olm as _olm
- sas_obj = _olm.Sas()
- self._room_verifications[tx_id] = {
- "sas": sas_obj,
- "room_id": room_id,
- "sender": sender,
- "from_device": content.get("from_device", ""),
- }
- # Send m.key.verification.ready
- await self.client.room_send(room_id, "m.key.verification.ready", {
- "from_device": self.client.device_id,
- "methods": ["m.sas.v1"],
- "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
- }, ignore_unverified_devices=True)
- logger.info("Sent verification ready for tx=%s", tx_id)
- # Send start immediately (bot always initiates SAS after ready)
- try:
- resp = await self.client.room_send(room_id, "m.key.verification.start", {
- "from_device": self.client.device_id,
- "method": "m.sas.v1",
- "key_agreement_protocols": ["curve25519-hkdf-sha256"],
- "hashes": ["sha256"],
- "message_authentication_codes": ["hkdf-hmac-sha256.v2"],
- "short_authentication_string": ["decimal", "emoji"],
- "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
- }, ignore_unverified_devices=True)
- logger.info("Sent verification start for tx=%s", tx_id)
- except Exception as e:
- logger.error("Failed to send verification start: %s", e)
-
- elif event_type == "m.key.verification.accept":
- state = self._room_verifications.get(tx_id)
- if not state:
- return
- state["their_commitment"] = content.get("commitment", "")
- state["mac_method"] = content.get("message_authentication_code", "hkdf-hmac-sha256.v2")
- # Send our public key
- await self.client.room_send(room_id, "m.key.verification.key", {
- "key": state["sas"].pubkey,
- "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
- }, ignore_unverified_devices=True)
- logger.info("Sent verification key for tx=%s", tx_id)
-
- elif event_type == "m.key.verification.start":
- state = self._room_verifications.get(tx_id)
- if not state:
- return
- # Send our key
- await self.client.room_send(room_id, "m.key.verification.key", {
- "key": state["sas"].pubkey,
- "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
- }, ignore_unverified_devices=True)
- logger.info("Sent verification key for tx=%s", tx_id)
-
- elif event_type == "m.key.verification.key":
- state = self._room_verifications.get(tx_id)
- if not state:
- return
- their_key = content.get("key", "")
- state["sas"].set_their_pubkey(their_key)
- # Generate SAS bytes for emoji
- sas_info = (
- "MATRIX_KEY_VERIFICATION_SAS"
- f"{self.client.user_id}{self.client.device_id}"
- f"{state['sas'].pubkey}"
- f"{state['sender']}{state['from_device']}"
- f"{their_key}{tx_id}"
- )
- sas_bytes = state["sas"].generate_bytes(sas_info, 6)
- state["sas_bytes"] = sas_bytes
- emojis = self._sas_to_emojis(sas_bytes)
- logger.info("Verification emojis for %s: %s", state["sender"],
- " ".join(f"{e[0]}({e[1]})" for e in emojis))
- # Auto-confirm: calculate and send MAC for device key + master key
- mac_info_base = (
- "MATRIX_KEY_VERIFICATION_MAC"
- f"{self.client.user_id}{self.client.device_id}"
- f"{state['sender']}{state['from_device']}{tx_id}"
- )
- own_device_key_id = f"ed25519:{self.client.device_id}"
- own_ed25519 = self.client.olm.account.identity_keys["ed25519"]
- mac_dict = {}
- key_ids = []
- # MAC device key
- mac_dict[own_device_key_id] = state["sas"].calculate_mac_fixed_base64(
- own_ed25519, mac_info_base + own_device_key_id)
- key_ids.append(own_device_key_id)
- # MAC master key (so other side can cross-sign our identity)
- seeds_path = self.config.data_dir / "crypto_store" / "cross_signing_seeds.json"
- if seeds_path.exists():
- import base64
- import olm as _olm
- seeds = json.loads(seeds_path.read_text())
- master_pubkey = _olm.PkSigning(base64.b64decode(seeds["master_seed"])).public_key
- master_key_id = f"ed25519:{master_pubkey}"
- mac_dict[master_key_id] = state["sas"].calculate_mac_fixed_base64(
- master_pubkey, mac_info_base + master_key_id)
- key_ids.append(master_key_id)
- # KEY_IDS mac covers sorted comma-separated key ids
- key_ids.sort()
- keys_str = ",".join(key_ids)
- keys_mac = state["sas"].calculate_mac_fixed_base64(
- keys_str, mac_info_base + "KEY_IDS")
- await self.client.room_send(room_id, "m.key.verification.mac", {
- "keys": keys_mac,
- "mac": mac_dict,
- "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
- }, ignore_unverified_devices=True)
- logger.info("Sent verification MAC for tx=%s", tx_id)
-
- elif event_type == "m.key.verification.mac":
- state = self._room_verifications.get(tx_id)
- if not state:
- return
- # Send done
- await self.client.room_send(room_id, "m.key.verification.done", {
- "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
- }, ignore_unverified_devices=True)
- # Cross-sign the user's master key with our user-signing key
- await self._cross_sign_user(state["sender"])
- logger.info("Verification complete for tx=%s with %s", tx_id, state["sender"])
- self._room_verifications.pop(tx_id, None)
-
- elif event_type == "m.key.verification.cancel":
- logger.info("In-room verification %s canceled: %s", tx_id, content.get("reason", ""))
- self._room_verifications.pop(tx_id, None)
-
- elif event_type == "m.key.verification.done":
- logger.info("In-room verification %s done by %s", tx_id, sender)
- self._room_verifications.pop(tx_id, None)
-
- async def _cross_sign_user(self, user_id: str) -> None:
- """Sign user's master key with our user-signing key after successful verification."""
- import base64
- import olm as _olm
-
- seeds_path = self.config.data_dir / "crypto_store" / "cross_signing_seeds.json"
- if not seeds_path.exists():
- logger.warning("No cross-signing seeds, cannot cross-sign user")
- return
-
- seeds = json.loads(seeds_path.read_text())
- user_signing = _olm.PkSigning(base64.b64decode(seeds["user_signing_seed"]))
-
- hs = self.client.homeserver
- headers = {"Authorization": f"Bearer {self.client.access_token}",
- "Content-Type": "application/json"}
-
- async with httpx.AsyncClient() as http:
- # Get user's master key
- resp = await http.post(f"{hs}/_matrix/client/v3/keys/query",
- headers=headers,
- json={"device_keys": {user_id: []}}, timeout=10)
- data = resp.json()
- master_key_obj = data.get("master_keys", {}).get(user_id)
- if not master_key_obj:
- logger.warning("No master key found for %s", user_id)
- return
-
- # Sign the master key with our user-signing key
- to_sign = {k: v for k, v in master_key_obj.items()
- if k not in ("signatures", "unsigned")}
- canonical = json.dumps(to_sign, separators=(",", ":"),
- sort_keys=True, ensure_ascii=False)
- sig = user_signing.sign(canonical)
- us_key_id = f"ed25519:{user_signing.public_key}"
-
- sig_body = {user_id: {
- list(master_key_obj["keys"].keys())[0].split(":")[1]: {
- **to_sign,
- "signatures": {self.client.user_id: {us_key_id: sig}},
- }
- }}
- resp = await http.post(f"{hs}/_matrix/client/v3/keys/signatures/upload",
- headers=headers, json=sig_body, timeout=10)
- if resp.status_code == 200:
- logger.info("Cross-signed master key of %s", user_id)
- else:
- logger.error("Failed to cross-sign %s (%d): %s",
- user_id, resp.status_code, resp.text[:200])
-
- @staticmethod
- def _sas_to_emojis(sas_bytes: bytes) -> list[tuple[str, str]]:
- """Convert 6 SAS bytes to 7 emojis (per Matrix spec)."""
- emoji_list = [
- ("🐶","Dog"),("🐱","Cat"),("🦁","Lion"),("🐴","Horse"),("🦄","Unicorn"),
- ("🐷","Pig"),("🐘","Elephant"),("🐰","Rabbit"),("🐼","Panda"),("🐔","Rooster"),
- ("🐧","Penguin"),("🐢","Turtle"),("🐟","Fish"),("🐙","Octopus"),("🦋","Butterfly"),
- ("🌷","Flower"),("🌳","Tree"),("🌵","Cactus"),("🍄","Mushroom"),("🌏","Globe"),
- ("🌙","Moon"),("☁️","Cloud"),("🔥","Fire"),("🍌","Banana"),("🍎","Apple"),
- ("🍓","Strawberry"),("🌽","Corn"),("🍕","Pizza"),("🎂","Cake"),("❤️","Heart"),
- ("😀","Smiley"),("🤖","Robot"),("🎩","Hat"),("👓","Glasses"),("🔧","Wrench"),
- ("🎅","Santa"),("👍","Thumbs Up"),("☂️","Umbrella"),("⌛","Hourglass"),("⏰","Clock"),
- ("🎁","Gift"),("💡","Light Bulb"),("📕","Book"),("✏️","Pencil"),("📎","Paperclip"),
- ("✂️","Scissors"),("🔒","Lock"),("🔑","Key"),("🔨","Hammer"),("☎️","Telephone"),
- ("🏁","Flag"),("🚂","Train"),("🚲","Bicycle"),("✈️","Airplane"),("🚀","Rocket"),
- ("🏆","Trophy"),("⚽","Ball"),("🎸","Guitar"),("🎺","Trumpet"),("🔔","Bell"),
- ("⚓","Anchor"),("🎧","Headphones"),("📁","Folder"),("📌","Pin"),
- ]
- # 6 bytes → 42 bits → 7 × 6-bit indices
- val = int.from_bytes(sas_bytes, "big")
- result = []
- for i in range(6, -1, -1):
- idx = (val >> (i * 6)) & 0x3F
- result.append(emoji_list[idx])
- return result
-
- async def _on_member(self, room: MatrixRoom, event: RoomMemberEvent) -> None:
- """Handle member events (joins, leaves)."""
- if not self._synced:
- return
- if event.sender == self.client.user_id:
- return
- # Query keys for new members so we know their devices
- if event.membership == "join" and self.client.olm:
- try:
- await self.client.keys_query()
- except Exception:
- pass
-
- async def _on_sync(self, response: SyncResponse) -> None:
- if response.next_batch:
- self._save_sync_token(response.next_batch)
- if self._synced:
- await self._auto_join_invites()
- # Query keys and re-sync cross-signing trust when device lists change
- if self.client.olm and response.device_list.changed:
- try:
- await self.client.keys_query()
- await self._sync_cross_signing_trust()
- except Exception:
- pass
-
- async def close(self) -> None:
- await self.client.close()
diff --git a/bot-examples/matrix_main.py b/bot-examples/matrix_main.py
deleted file mode 100644
index 03e2e7f..0000000
--- a/bot-examples/matrix_main.py
+++ /dev/null
@@ -1,123 +0,0 @@
-"""Entry point for Matrix bot frontend."""
-
-import asyncio
-import logging
-import os
-import sys
-from pathlib import Path
-
-import httpx
-import yaml
-
-from core.config import Config
-from core.matrix_bot import MatrixBot
-
-
-def _load_dotenv(workspace: Path) -> None:
- env_file = workspace / ".env"
- if not env_file.exists():
- return
- for line in env_file.read_text().splitlines():
- line = line.strip()
- if not line or line.startswith("#") or "=" not in line:
- continue
- key, _, value = line.partition("=")
- key = key.strip()
- value = value.strip().strip('"').strip("'")
- if key not in os.environ:
- os.environ[key] = value
-
-
-def _load_users(workspace: Path) -> dict[str, dict]:
- """Load users.yml from workspace. Returns {mxid: {profile: ...}}."""
- users_file = workspace / "users.yml"
- if not users_file.exists():
- return {}
- with open(users_file) as f:
- data = yaml.safe_load(f) or {}
- return data
-
-
-async def main() -> None:
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s %(name)s %(levelname)s %(message)s",
- datefmt="%Y-%m-%d %H:%M:%S",
- )
-
- workspace_dir = os.environ.get("WORKSPACE_DIR")
- if workspace_dir:
- _load_dotenv(Path(workspace_dir))
-
- # MATRIX_DATA_DIR overrides DATA_DIR for Matrix bot
- matrix_data_dir = os.environ.get("MATRIX_DATA_DIR")
- if matrix_data_dir:
- os.environ["DATA_DIR"] = matrix_data_dir
-
- # Matrix-specific env vars
- homeserver = os.environ.get("MATRIX_HOMESERVER")
- user_id = os.environ.get("MATRIX_USER_ID")
- access_token = os.environ.get("MATRIX_ACCESS_TOKEN")
- owner_mxid = os.environ.get("MATRIX_OWNER_MXID", "")
- admin_mxid = os.environ.get("MATRIX_ADMIN_MXID", "") # For admin notifications
-
- if not all([homeserver, user_id, access_token]):
- logging.error(
- "Missing Matrix config. Need: MATRIX_HOMESERVER, MATRIX_USER_ID, "
- "MATRIX_ACCESS_TOKEN"
- )
- sys.exit(1)
-
- # Resolve device_id from server (must match access token)
- async with httpx.AsyncClient() as http:
- resp = await http.get(
- f"{homeserver}/_matrix/client/v3/account/whoami",
- headers={"Authorization": f"Bearer {access_token}"},
- timeout=10,
- )
- if resp.status_code != 200:
- logging.error("whoami failed (%d): %s", resp.status_code, resp.text)
- sys.exit(1)
- device_id = resp.json().get("device_id")
- logging.info("Resolved device_id: %s", device_id)
-
- # Load users map (multi-user mode)
- users = {}
- if workspace_dir:
- users = _load_users(Path(workspace_dir))
- if not users and not owner_mxid:
- logging.error("Need either users.yml in workspace or MATRIX_OWNER_MXID env var")
- sys.exit(1)
-
- try:
- config = Config.from_env()
- except ValueError as e:
- logging.error("Config error: %s", e)
- sys.exit(1)
-
- if config.workspace_dir:
- logging.info("Workspace: %s", config.workspace_dir)
- # Symlink workspace CLAUDE.md into data dir
- claude_md_link = config.data_dir / "CLAUDE.md"
- claude_md_src = config.workspace_dir / "CLAUDE.md"
- if claude_md_src.exists() and not claude_md_link.exists():
- claude_md_link.symlink_to(claude_md_src)
- logging.info("Symlinked CLAUDE.md into data dir")
-
- if users:
- logging.info("Multi-user mode: %d users", len(users))
- logging.info("Data dir: %s", config.data_dir)
-
- bot = MatrixBot(config, homeserver, user_id, access_token,
- owner_mxid=owner_mxid, users=users, device_id=device_id,
- admin_mxid=admin_mxid)
- try:
- await bot.run()
- except KeyboardInterrupt:
- pass
- finally:
- await bot.close()
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/bot-examples/telegram_bot_topics.py b/bot-examples/telegram_bot_topics.py
deleted file mode 100644
index 491c579..0000000
--- a/bot-examples/telegram_bot_topics.py
+++ /dev/null
@@ -1,511 +0,0 @@
-"""Telegram bot engine.
-
-Handles messages (text, photo, voice), topic management, and Claude CLI integration.
-Uses RetryHTTPXRequest for proxy resilience, progressive message editing for streaming.
-"""
-
-import asyncio
-import json
-import logging
-import time
-from datetime import datetime, timezone
-from pathlib import Path
-
-import yaml
-
-from telegram import BotCommand, Update
-from telegram.constants import ChatAction, ParseMode
-from telegram.error import BadRequest, NetworkError
-from telegram.ext import (
- Application,
- CommandHandler,
- ContextTypes,
- MessageHandler,
- filters,
-)
-from telegram.request import HTTPXRequest
-
-from core.asr import transcribe
-from core.claude_session import send_message as claude_send
-from core.config import Config
-
-logger = logging.getLogger(__name__)
-
-# Streaming edit parameters
-EDIT_INTERVAL = 1.5 # seconds between message edits
-EDIT_MIN_DELTA = 150 # minimum new chars before editing
-
-
-class RetryHTTPXRequest(HTTPXRequest):
- """HTTPXRequest with retry on ConnectError (SOCKS5 proxy hiccups)."""
-
- MAX_RETRIES = 3
- RETRY_DELAY = 2
-
- async def do_request(self, *args, **kwargs):
- last_exc = None
- for attempt in range(self.MAX_RETRIES):
- try:
- return await super().do_request(*args, **kwargs)
- except NetworkError as e:
- if "ConnectError" in str(e):
- last_exc = e
- if attempt < self.MAX_RETRIES - 1:
- logger.warning(
- "Telegram ConnectError (attempt %d/%d), retrying in %ds...",
- attempt + 1, self.MAX_RETRIES, self.RETRY_DELAY,
- )
- await asyncio.sleep(self.RETRY_DELAY)
- else:
- raise
- raise last_exc
-
-
-def build_app(config: Config) -> Application:
- """Build and configure the Telegram Application."""
- builder = Application.builder().token(config.bot_token)
-
- # Configure HTTP client with proxy and timeouts
- request_kwargs = {
- "connect_timeout": 30.0,
- "read_timeout": 60.0,
- "write_timeout": 60.0,
- "pool_timeout": 10.0,
- }
- if config.proxy:
- request_kwargs["proxy"] = config.proxy
-
- request = RetryHTTPXRequest(**request_kwargs)
- builder = builder.request(request)
- builder = builder.concurrent_updates(True)
-
- app = builder.build()
-
- # Store config in bot_data for handler access
- app.bot_data["config"] = config
-
- # Register handlers (order matters — more specific first)
- app.add_handler(CommandHandler("start", handle_start))
- app.add_handler(CommandHandler("newtopic", handle_new_topic))
- app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
- app.add_handler(MessageHandler(filters.VOICE | filters.AUDIO, handle_voice))
- app.add_handler(MessageHandler(filters.Document.ALL, handle_document))
- app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
-
- # Post-init: set bot commands
- app.post_init = _post_init
-
- return app
-
-
-async def _post_init(application: Application) -> None:
- """Set bot commands menu after initialization."""
- commands = [
- BotCommand("newtopic", "Create a new topic"),
- BotCommand("start", "Start / help"),
- ]
- await application.bot.set_my_commands(commands)
- logger.info("Bot initialized: @%s", application.bot.username)
-
-
-def _get_config(context: ContextTypes.DEFAULT_TYPE) -> Config:
- return context.bot_data["config"]
-
-
-def _is_owner(update: Update, config: Config) -> bool:
- return update.effective_user and update.effective_user.id == config.owner_id
-
-
-def _topic_id(update: Update) -> str:
- """Get topic ID from message, or 'general' for the default topic."""
- thread_id = update.effective_message.message_thread_id
- return str(thread_id) if thread_id else "general"
-
-
-def _topic_dir(config: Config, topic_id: str) -> Path:
- """Get data directory for a topic."""
- d = config.data_dir / "topics" / topic_id
- d.mkdir(parents=True, exist_ok=True)
- return d
-
-
-def _log_interaction(config: Config, topic_id: str, user_msg: str, bot_msg: str) -> None:
- """Append interaction to topic log."""
- log_file = _topic_dir(config, topic_id) / "log.jsonl"
- entry = {
- "ts": datetime.now(timezone.utc).isoformat(),
- "user": user_msg[:1000],
- "bot": bot_msg[:2000],
- }
- with open(log_file, "a") as f:
- f.write(json.dumps(entry, ensure_ascii=False) + "\n")
-
-
-def _md_to_html(text: str) -> str:
- """Convert common Markdown to Telegram HTML."""
- import re
- # Escape HTML entities first (but preserve our conversions)
- text = text.replace("&", "&").replace("<", "<").replace(">", ">")
-
- # Code blocks: ```lang\n...\n```
- text = re.sub(
- r"```\w*\n(.*?)```",
- lambda m: f"{m.group(1)}",
- text, flags=re.DOTALL,
- )
- # Inline code: `...`
- text = re.sub(r"`([^`]+)`", r"\1", text)
- # Bold: **...**
- text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
- # Italic: *...*
- text = re.sub(r"\*(.+?)\*", r"\1", text)
- # Headers: ## ... → bold line
- text = re.sub(r"^#{1,6}\s+(.+)$", r"\1", text, flags=re.MULTILINE)
- # Bullet lists: - item → bullet
- text = re.sub(r"^- ", "• ", text, flags=re.MULTILINE)
-
- return text
-
-
-async def _edit_text_md(message, text: str) -> None:
- """Edit message with HTML formatting, falling back to plain text."""
- try:
- html = _md_to_html(text)
- await message.edit_text(html, parse_mode=ParseMode.HTML)
- except BadRequest:
- try:
- await message.edit_text(text)
- except BadRequest:
- pass
-
-
-# Cache of topic labels we've already applied: {topic_id: label}
-_applied_labels: dict[str, str] = {}
-
-# Pending questions from Claude: {topic_id: asyncio.Future}
-_pending_questions: dict[str, asyncio.Future] = {}
-
-
-async def _sync_topic_name(update: Update, config: Config, topic_id: str) -> None:
- """Rename Telegram topic if topic-map.yml has a new/changed label."""
- if topic_id == "general":
- return
- topic_map_path = config.data_dir / "topic-map.yml"
- if not topic_map_path.exists():
- return
- try:
- with open(topic_map_path) as f:
- topic_map = yaml.safe_load(f) or {}
- entry = topic_map.get(topic_id) or topic_map.get(int(topic_id))
- if not entry or not isinstance(entry, dict):
- return
- label = entry.get("label")
- if not label or _applied_labels.get(topic_id) == label:
- return
- await update.get_bot().edit_forum_topic(
- chat_id=update.effective_chat.id,
- message_thread_id=int(topic_id),
- name=label[:128],
- )
- _applied_labels[topic_id] = label
- logger.info("Renamed topic %s to: %s", topic_id, label)
- except BadRequest as e:
- if "not modified" not in str(e).lower():
- logger.warning("Failed to rename topic %s: %s", topic_id, e)
- _applied_labels[topic_id] = label # don't retry
- except Exception as e:
- logger.warning("Error reading topic-map.yml: %s", e)
-
-
-async def handle_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle /start command."""
- config = _get_config(context)
- if not _is_owner(update, config):
- return
- await update.effective_message.reply_text(
- "Ready. Send me a message or use /newtopic to create a topic."
- )
-
-
-async def handle_new_topic(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle /newtopic — create a forum topic."""
- config = _get_config(context)
- if not _is_owner(update, config):
- return
-
- name = " ".join(context.args) if context.args else None
- if not name:
- await update.effective_message.reply_text("Usage: /newtopic Topic Name")
- return
-
- try:
- topic = await context.bot.create_forum_topic(
- chat_id=update.effective_chat.id,
- name=name,
- )
- tid = str(topic.message_thread_id)
- _topic_dir(config, tid)
- await context.bot.send_message(
- chat_id=update.effective_chat.id,
- message_thread_id=topic.message_thread_id,
- text=f"Topic created. Send me anything here.",
- )
- logger.info("Created topic: %s (id=%s)", name, tid)
- except BadRequest as e:
- logger.error("Failed to create topic: %s", e)
- await update.effective_message.reply_text(f"Failed to create topic: {e}")
-
-
-async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle text messages — send to Claude CLI."""
- config = _get_config(context)
- if not _is_owner(update, config):
- return
-
- tid = _topic_id(update)
- user_text = update.effective_message.text
-
- # If Claude is waiting for an answer in this topic, deliver it
- if tid in _pending_questions:
- future = _pending_questions.pop(tid)
- if not future.done():
- future.set_result(user_text)
- return
-
- # Send typing indicator and placeholder
- await context.bot.send_chat_action(
- chat_id=update.effective_chat.id,
- action=ChatAction.TYPING,
- message_thread_id=update.effective_message.message_thread_id,
- )
- placeholder = await update.effective_message.reply_text("thinking...")
-
- # Streaming state
- last_edit_time = 0.0
- last_edit_len = 0
-
- async def on_chunk(text_so_far: str):
- nonlocal last_edit_time, last_edit_len
- now = time.monotonic()
- delta = len(text_so_far) - last_edit_len
-
- if delta >= EDIT_MIN_DELTA and (now - last_edit_time) >= EDIT_INTERVAL:
- try:
- display = _truncate_for_telegram(text_so_far)
- await placeholder.edit_text(display)
- last_edit_time = now
- last_edit_len = len(text_so_far)
- except BadRequest:
- pass # message not modified or too long
-
- async def on_question(question: str) -> str:
- """Claude asks user a question — send it and wait for reply."""
- await update.effective_message.reply_text(f"❓ {question}")
- loop = asyncio.get_event_loop()
- future = loop.create_future()
- _pending_questions[tid] = future
- return await future
-
- topic_dir = _topic_dir(config, tid)
-
- try:
- response = await claude_send(
- config, tid, user_text, on_chunk=on_chunk, on_question=on_question,
- )
- display = _truncate_for_telegram(response)
- await _edit_text_md(placeholder, display)
- except RuntimeError as e:
- logger.error("Claude error for topic %s: %s", tid, e)
- await placeholder.edit_text(f"Error: {e}")
- response = f"[error] {e}"
- finally:
- _pending_questions.pop(tid, None)
-
- await _send_outbox(update, topic_dir)
- _log_interaction(config, tid, user_text, response)
- await _sync_topic_name(update, config, tid)
-
-
-async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle photo messages — save image, send path to Claude."""
- config = _get_config(context)
- if not _is_owner(update, config):
- return
-
- tid = _topic_id(update)
- images_dir = _topic_dir(config, tid) / "images"
- images_dir.mkdir(exist_ok=True)
-
- # Download the largest photo
- photo = update.effective_message.photo[-1]
- file = await context.bot.get_file(photo.file_id)
- ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
- filename = f"{ts}_{photo.file_unique_id}.jpg"
- filepath = images_dir / filename
- await file.download_to_drive(str(filepath))
-
- caption = update.effective_message.caption or ""
- message = f"User sent an image: {filepath}"
- if caption:
- message += f"\nCaption: {caption}"
-
- # Send typing and placeholder
- placeholder = await update.effective_message.reply_text("looking at image...")
-
- try:
- response = await claude_send(config, tid, message)
- display = _truncate_for_telegram(response)
- await _edit_text_md(placeholder, display)
- except RuntimeError as e:
- logger.error("Claude error for photo in topic %s: %s", tid, e)
- await placeholder.edit_text(f"Error: {e}")
- response = f"[error] {e}"
-
- _log_interaction(config, tid, f"[photo] {caption}", response)
- await _sync_topic_name(update, config, tid)
-
-
-async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle document messages — save file, send path to Claude."""
- config = _get_config(context)
- if not _is_owner(update, config):
- return
-
- tid = _topic_id(update)
- docs_dir = _topic_dir(config, tid) / "documents"
- docs_dir.mkdir(exist_ok=True)
-
- doc = update.effective_message.document
- file = await context.bot.get_file(doc.file_id)
- # Use original filename if available, otherwise generate one
- orig_name = doc.file_name or f"{doc.file_unique_id}"
- ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
- filename = f"{ts}_{orig_name}"
- filepath = docs_dir / filename
- await file.download_to_drive(str(filepath))
-
- caption = update.effective_message.caption or ""
- message = f"User sent a document: {filepath} (name: {orig_name}, size: {doc.file_size} bytes)"
- if caption:
- message += f"\nCaption: {caption}"
-
- topic_dir = _topic_dir(config, tid)
- placeholder = await update.effective_message.reply_text("reading document...")
-
- try:
- response = await claude_send(config, tid, message)
- display = _truncate_for_telegram(response)
- await _edit_text_md(placeholder, display)
- except RuntimeError as e:
- logger.error("Claude error for document in topic %s: %s", tid, e)
- await placeholder.edit_text(f"Error: {e}")
- response = f"[error] {e}"
-
- await _send_outbox(update, topic_dir)
- _log_interaction(config, tid, f"[document: {orig_name}] {caption}", response)
- await _sync_topic_name(update, config, tid)
-
-
-async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle voice/audio messages — save file, send path to Claude."""
- config = _get_config(context)
- if not _is_owner(update, config):
- return
-
- tid = _topic_id(update)
- voice_dir = _topic_dir(config, tid) / "voice"
- voice_dir.mkdir(exist_ok=True)
-
- # Download voice file
- voice = update.effective_message.voice or update.effective_message.audio
- file = await context.bot.get_file(voice.file_id)
- ext = "ogg" if update.effective_message.voice else "mp3"
- ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
- filename = f"{ts}_{voice.file_unique_id}.{ext}"
- filepath = voice_dir / filename
- await file.download_to_drive(str(filepath))
-
- topic_dir = _topic_dir(config, tid)
-
- # Transcribe via Whisper if available, otherwise send file path
- if config.whisper_url:
- placeholder = await update.effective_message.reply_text("transcribing voice...")
- try:
- text = await transcribe(str(filepath), config.whisper_url)
- message = f"[voice message transcription]: {text}"
- logger.info("Transcribed voice in topic %s: %d chars", tid, len(text))
- # Show transcription to user, then send to Claude
- try:
- await placeholder.edit_text(f"🎤 {text}")
- except BadRequest:
- pass
- placeholder = await update.effective_message.reply_text("thinking...")
- except RuntimeError as e:
- logger.error("ASR failed for topic %s: %s", tid, e)
- message = f"User sent a voice message: {filepath} (duration: {voice.duration}s)\n(transcription failed: {e})"
- else:
- message = f"User sent a voice message: {filepath} (duration: {voice.duration}s)"
- placeholder = await update.effective_message.reply_text("processing voice...")
-
- try:
- response = await claude_send(config, tid, message)
- display = _truncate_for_telegram(response)
- await _edit_text_md(placeholder, display)
- except RuntimeError as e:
- logger.error("Claude error for voice in topic %s: %s", tid, e)
- await placeholder.edit_text(f"Error: {e}")
- response = f"[error] {e}"
-
- await _send_outbox(update, topic_dir)
- _log_interaction(config, tid, message, response)
- await _sync_topic_name(update, config, tid)
-
-
-async def _send_outbox(update: Update, topic_dir: Path) -> None:
- """Send files queued in outbox.jsonl by Claude via send-to-user tool."""
- outbox = topic_dir / "outbox.jsonl"
- if not outbox.exists():
- return
-
- entries = []
- try:
- with open(outbox) as f:
- for line in f:
- line = line.strip()
- if line:
- entries.append(json.loads(line))
- # Clear outbox
- outbox.unlink()
- except Exception as e:
- logger.error("Failed to read outbox: %s", e)
- return
-
- for entry in entries:
- fpath = Path(entry.get("path", ""))
- ftype = entry.get("type", "document")
- caption = entry.get("caption", "") or fpath.name
-
- if not fpath.is_file():
- logger.warning("Outbox file not found: %s", fpath)
- continue
-
- try:
- with open(fpath, "rb") as f:
- if ftype == "image":
- await update.effective_message.reply_photo(photo=f, caption=caption)
- elif ftype == "video":
- await update.effective_message.reply_video(video=f, caption=caption)
- elif ftype == "audio":
- await update.effective_message.reply_voice(voice=f, caption=caption)
- else:
- await update.effective_message.reply_document(document=f, caption=caption)
- logger.info("Sent %s: %s", ftype, fpath.name)
- except Exception as e:
- logger.error("Failed to send %s %s: %s", ftype, fpath.name, e)
-
-
-def _truncate_for_telegram(text: str, max_len: int = 4096) -> str:
- """Truncate text to Telegram message limit."""
- if len(text) <= max_len:
- return text
- return text[: max_len - 20] + "\n\n[truncated]"
diff --git a/bot-examples/telegram_main.py b/bot-examples/telegram_main.py
deleted file mode 100644
index cf5d13e..0000000
--- a/bot-examples/telegram_main.py
+++ /dev/null
@@ -1,75 +0,0 @@
-"""Entry point for agent-core bot.
-
-Loads config from environment, optionally reads .env from workspace,
-builds and runs the Telegram bot.
-"""
-
-import logging
-import sys
-from pathlib import Path
-
-from core.bot import build_app
-from core.config import Config
-
-
-def _load_dotenv(workspace_dir: Path | None) -> None:
- """Load .env file from workspace directory if it exists."""
- if not workspace_dir:
- return
- env_file = workspace_dir / ".env"
- if not env_file.exists():
- return
-
- import os
- for line in env_file.read_text().splitlines():
- line = line.strip()
- if not line or line.startswith("#"):
- continue
- if "=" not in line:
- continue
- key, _, value = line.partition("=")
- key = key.strip()
- value = value.strip().strip('"').strip("'")
- # Don't override existing env vars
- if key not in os.environ:
- os.environ[key] = value
-
-
-def main() -> None:
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s %(name)s %(levelname)s %(message)s",
- datefmt="%Y-%m-%d %H:%M:%S",
- )
-
- import os
- workspace_dir = os.environ.get("WORKSPACE_DIR")
- if workspace_dir:
- _load_dotenv(Path(workspace_dir))
-
- try:
- config = Config.from_env()
- except ValueError as e:
- logging.error("Config error: %s", e)
- sys.exit(1)
-
- if config.workspace_dir:
- logging.info("Workspace: %s", config.workspace_dir)
- # Symlink workspace CLAUDE.md into data dir so Claude CLI finds it
- # when running in topic subdirectories
- claude_md_link = config.data_dir / "CLAUDE.md"
- claude_md_src = config.workspace_dir / "CLAUDE.md"
- if claude_md_src.exists() and not claude_md_link.exists():
- claude_md_link.symlink_to(claude_md_src)
- logging.info("Symlinked CLAUDE.md into data dir")
- logging.info("Data dir: %s", config.data_dir)
-
- app = build_app(config)
- app.run_polling(
- allowed_updates=["message", "edited_message"],
- stop_signals=None,
- )
-
-
-if __name__ == "__main__":
- main()
diff --git a/config/matrix-agents.example.yaml b/config/matrix-agents.example.yaml
deleted file mode 100644
index 84221eb..0000000
--- a/config/matrix-agents.example.yaml
+++ /dev/null
@@ -1,44 +0,0 @@
-# Agent registry for the Matrix bot.
-# Production target: one surface bot routes to 25-30 externally managed agents.
-# Keep adding entries with the same base_url/workspace_path pattern.
-#
-# user_agents: maps a Matrix user ID to an agent ID.
-# If a user is not listed, the bot uses the first agent from the list below.
-# Omit this section entirely for a single-agent setup.
-#
-# agents: list of available agents.
-# id — must match the agent ID known to the platform
-# label — human-readable name (shown in logs)
-# base_url — HTTP/WS URL of this agent's endpoint
-# (overrides the global AGENT_BASE_URL env var for this agent)
-# workspace_path — absolute path to this agent's workspace directory inside the bot container
-# (the bot saves incoming files directly here and reads outgoing files from here)
-# Example: /agents/0 means the bot mounts the shared volume at /agents/
-# and this agent's files live under /agents/0/
-
-user_agents:
- "@user0:matrix.example.org": agent-0
- "@user1:matrix.example.org": agent-1
- "@user2:matrix.example.org": agent-2
-
-agents:
- - id: agent-0
- label: "Agent 0"
- base_url: "http://lambda.coredump.ru:7000/agent_0/"
- workspace_path: "/agents/0"
-
- - id: agent-1
- label: "Agent 1"
- base_url: "http://lambda.coredump.ru:7000/agent_1/"
- workspace_path: "/agents/1"
-
- - id: agent-2
- label: "Agent 2"
- base_url: "http://lambda.coredump.ru:7000/agent_2/"
- workspace_path: "/agents/2"
-
- # Continue the same pattern through agent-29 for a 25-30 agent deployment:
- # - id: agent-29
- # label: "Agent 29"
- # base_url: "http://lambda.coredump.ru:7000/agent_29/"
- # workspace_path: "/agents/29"
diff --git a/config/matrix-agents.smoke.yaml b/config/matrix-agents.smoke.yaml
deleted file mode 100644
index 9b357fe..0000000
--- a/config/matrix-agents.smoke.yaml
+++ /dev/null
@@ -1,10 +0,0 @@
-agents:
- - id: agent-0
- label: "Smoke Agent 0"
- base_url: "http://agent-proxy:7000/agent_0/"
- workspace_path: "/agents/0"
-
- - id: agent-1
- label: "Smoke Agent 1"
- base_url: "http://agent-proxy:7000/agent_1/"
- workspace_path: "/agents/1"
diff --git a/config/matrix-agents.yaml b/config/matrix-agents.yaml
deleted file mode 100644
index 3ab9366..0000000
--- a/config/matrix-agents.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-# Single-agent configuration for MVP deployment.
-# For multi-agent setup with per-user routing, see config/matrix-agents.example.yaml.
-
-agents:
- - id: agent-1
- label: Surface
- base_url: "http://lambda.coredump.ru:7000/agent_1/"
- workspace_path: "/agents/1"
diff --git a/core/handlers/chat.py b/core/handlers/chat.py
index a7140b5..8e32468 100644
--- a/core/handlers/chat.py
+++ b/core/handlers/chat.py
@@ -4,19 +4,9 @@ from __future__ import annotations
from core.protocol import IncomingCommand, OutgoingMessage
-def _command(platform: str, name: str) -> str:
- prefix = "!" if platform == "matrix" else "/"
- return f"{prefix}{name}"
-
-
async def handle_new_chat(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not await auth_mgr.is_authenticated(event.user_id):
- return [
- OutgoingMessage(
- chat_id=event.chat_id,
- text=f"Введите {_command(event.platform, 'start')} чтобы начать.",
- )
- ]
+ return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")]
name = " ".join(event.args) if event.args else None
ctx = await chat_mgr.get_or_create(
user_id=event.user_id,
@@ -30,12 +20,7 @@ async def handle_new_chat(event: IncomingCommand, auth_mgr, platform, chat_mgr,
async def handle_rename(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not event.args:
- return [
- OutgoingMessage(
- chat_id=event.chat_id,
- text=f"Укажите название: {_command(event.platform, 'rename')} Название",
- )
- ]
+ return [OutgoingMessage(chat_id=event.chat_id, text="Укажите название: /rename Название")]
ctx = await chat_mgr.rename(event.chat_id, " ".join(event.args))
return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")]
diff --git a/core/handlers/message.py b/core/handlers/message.py
index 876754c..e1475ef 100644
--- a/core/handlers/message.py
+++ b/core/handlers/message.py
@@ -1,49 +1,12 @@
# core/handlers/message.py
from __future__ import annotations
-from core.protocol import Attachment, IncomingMessage, OutgoingMessage, OutgoingTyping
-
-
-def _infer_attachment_type(mime_type: str | None) -> str:
- if not mime_type:
- return "document"
- if mime_type.startswith("image/"):
- return "image"
- if mime_type.startswith("audio/"):
- return "audio"
- if mime_type.startswith("video/"):
- return "video"
- return "document"
-
-
-def _to_core_attachments(raw: list) -> list[Attachment]:
- result = []
- for a in raw:
- if isinstance(a, Attachment):
- result.append(a)
- else:
- result.append(Attachment(
- type=getattr(a, "type", None) or _infer_attachment_type(getattr(a, "mime_type", None)),
- url=getattr(a, "url", None),
- filename=getattr(a, "filename", None),
- mime_type=getattr(a, "mime_type", None),
- workspace_path=getattr(a, "workspace_path", None),
- ))
- return result
-
-
-def _start_command(platform: str) -> str:
- return "!start" if platform == "matrix" else "/start"
+from core.protocol import IncomingMessage, OutgoingMessage, OutgoingTyping
async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not await auth_mgr.is_authenticated(event.user_id):
- return [
- OutgoingMessage(
- chat_id=event.chat_id,
- text=f"Введите {_start_command(event.platform)} чтобы начать.",
- )
- ]
+ return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")]
# Voice slot fallback: audio attachment without registered voice_handler
if event.attachments and event.attachments[0].type == "audio":
@@ -57,15 +20,10 @@ async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, s
user_id=event.user_id,
chat_id=event.chat_id,
text=event.text,
- attachments=event.attachments,
+ attachments=[],
)
return [
OutgoingTyping(chat_id=event.chat_id, is_typing=False),
- OutgoingMessage(
- chat_id=event.chat_id,
- text=response.response,
- parse_mode="markdown",
- attachments=_to_core_attachments(getattr(response, "attachments", [])),
- ),
+ OutgoingMessage(chat_id=event.chat_id, text=response.response, parse_mode="markdown"),
]
diff --git a/core/protocol.py b/core/protocol.py
index 7d6e25f..02a9f4a 100644
--- a/core/protocol.py
+++ b/core/protocol.py
@@ -12,7 +12,6 @@ class Attachment:
content: bytes | None = None
filename: str | None = None
mime_type: str | None = None
- workspace_path: str | None = None
@dataclass
diff --git a/docker-compose.fullstack.yml b/docker-compose.fullstack.yml
deleted file mode 100644
index 88ff37b..0000000
--- a/docker-compose.fullstack.yml
+++ /dev/null
@@ -1,61 +0,0 @@
-services:
- matrix-bot:
- extends:
- file: docker-compose.prod.yml
- service: matrix-bot
- build:
- context: .
- dockerfile: Dockerfile
- target: development
- args:
- LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master}
- additional_contexts:
- agent_api: ./external/platform-agent_api
- tags:
- - ${SURFACES_BOT_DEV_IMAGE:-surfaces-bot:dev}
- environment:
- AGENT_BASE_URL: http://platform-agent:8000
- depends_on:
- platform-agent:
- condition: service_healthy
-
- platform-agent:
- build:
- context: ./external/platform-agent
- target: development
- additional_contexts:
- agent_api: ./external/platform-agent_api
- environment:
- PYTHONUNBUFFERED: "1"
- AGENT_ID: ${AGENT_ID:-matrix-dev}
- PROVIDER_MODEL: ${PROVIDER_MODEL:-openai/gpt-4o-mini}
- PROVIDER_URL: ${PROVIDER_URL:-}
- PROVIDER_API_KEY: ${PROVIDER_API_KEY:-}
- COMPOSIO_API_KEY: ${COMPOSIO_API_KEY:-}
- volumes:
- - ./external/platform-agent/src:/app/src
- - ./external/platform-agent_api:/agent_api
- - agents:/workspace
- command: >
- sh -lc "
- mkdir -p /workspace &&
- chown -R agent:agent /workspace &&
- exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log
- "
- ports:
- - "8000:8000"
- healthcheck:
- test:
- - CMD-SHELL
- - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
- interval: 60s
- timeout: 5s
- retries: 5
- start_period: 15s
- restart: unless-stopped
-
-volumes:
- agents:
- name: ${SURFACES_SHARED_VOLUME:-surfaces-agents}
- bot-state:
- name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state}
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
deleted file mode 100644
index 2c7e942..0000000
--- a/docker-compose.prod.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-services:
- matrix-bot:
- image: "${SURFACES_BOT_IMAGE:?Set SURFACES_BOT_IMAGE to the pushed image, e.g. mput1/surfaces-bot:latest}"
- environment:
- MATRIX_HOMESERVER: ${MATRIX_HOMESERVER:-}
- MATRIX_USER_ID: ${MATRIX_USER_ID:-}
- MATRIX_PASSWORD: ${MATRIX_PASSWORD:-}
- MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-}
- MATRIX_PLATFORM_BACKEND: ${MATRIX_PLATFORM_BACKEND:-real}
- MATRIX_AGENT_REGISTRY_PATH: ${MATRIX_AGENT_REGISTRY_PATH:-/app/config/matrix-agents.yaml}
- AGENT_BASE_URL: ${AGENT_BASE_URL:-}
- SURFACES_WORKSPACE_DIR: ${SURFACES_WORKSPACE_DIR:-/agents}
- MATRIX_DB_PATH: /app/state/lambda_matrix.db
- MATRIX_STORE_PATH: /app/state/matrix_store
- PYTHONUNBUFFERED: "1"
- volumes:
- - agents:/agents
- - bot-state:/app/state
- - ./config:/app/config:ro
- restart: unless-stopped
-
-volumes:
- agents:
- name: ${SURFACES_SHARED_VOLUME:-surfaces-agents}
- bot-state:
- name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state}
diff --git a/docker-compose.smoke.timeout.yml b/docker-compose.smoke.timeout.yml
deleted file mode 100644
index c8f4ba3..0000000
--- a/docker-compose.smoke.timeout.yml
+++ /dev/null
@@ -1,18 +0,0 @@
-services:
- agent-proxy:
- volumes:
- - ./docker/nginx/smoke-agents-timeout.conf:/etc/nginx/nginx.conf:ro
- depends_on:
- agent-no-status:
- condition: service_started
-
- agent-no-status:
- build:
- context: .
- dockerfile: Dockerfile
- target: production
- args:
- LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master}
- environment:
- PYTHONUNBUFFERED: "1"
- command: ["python", "-m", "tools.no_status_agent", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/docker-compose.smoke.yml b/docker-compose.smoke.yml
deleted file mode 100644
index ed4e8b8..0000000
--- a/docker-compose.smoke.yml
+++ /dev/null
@@ -1,109 +0,0 @@
-services:
- surface-smoke:
- build:
- context: .
- dockerfile: Dockerfile
- target: production
- args:
- LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master}
- environment:
- PYTHONUNBUFFERED: "1"
- SMOKE_TIMEOUT: ${SMOKE_TIMEOUT:-5}
- volumes:
- - agents:/agents
- - ./config:/app/config:ro
- depends_on:
- agent-proxy:
- condition: service_healthy
- command: >
- sh -lc "
- python -m tools.check_matrix_agents --config /app/config/matrix-agents.smoke.yaml --timeout ${SMOKE_TIMEOUT:-5}
- "
-
- agent-proxy:
- image: nginx:1.27-alpine
- volumes:
- - ./docker/nginx/smoke-agents.conf:/etc/nginx/nginx.conf:ro
- healthcheck:
- test:
- - CMD-SHELL
- - nc -z 127.0.0.1 7000
- interval: 2s
- timeout: 2s
- retries: 15
- start_period: 2s
- depends_on:
- agent-0:
- condition: service_healthy
- agent-1:
- condition: service_healthy
- ports:
- - "${SMOKE_PROXY_PORT:-7000}:7000"
-
- agent-0:
- build:
- context: ./external/platform-agent
- target: development
- additional_contexts:
- agent_api: ./external/platform-agent_api
- environment:
- PYTHONUNBUFFERED: "1"
- AGENT_ID: ${AGENT_0_ID:-agent-0}
- PROVIDER_MODEL: ${PROVIDER_MODEL:-debug-model}
- PROVIDER_URL: ${PROVIDER_URL:-http://provider.invalid/v1}
- PROVIDER_API_KEY: ${PROVIDER_API_KEY:-debug-key}
- volumes:
- - ./external/platform-agent/src:/app/src
- - ./external/platform-agent_api:/agent_api
- - agents:/shared-agents
- healthcheck:
- test:
- - CMD-SHELL
- - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
- interval: 5s
- timeout: 3s
- retries: 12
- start_period: 5s
- command: >
- sh -lc "
- mkdir -p /shared-agents/0 &&
- rm -rf /workspace &&
- ln -s /shared-agents/0 /workspace &&
- exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log
- "
-
- agent-1:
- build:
- context: ./external/platform-agent
- target: development
- additional_contexts:
- agent_api: ./external/platform-agent_api
- environment:
- PYTHONUNBUFFERED: "1"
- AGENT_ID: ${AGENT_1_ID:-agent-1}
- PROVIDER_MODEL: ${PROVIDER_MODEL:-debug-model}
- PROVIDER_URL: ${PROVIDER_URL:-http://provider.invalid/v1}
- PROVIDER_API_KEY: ${PROVIDER_API_KEY:-debug-key}
- volumes:
- - ./external/platform-agent/src:/app/src
- - ./external/platform-agent_api:/agent_api
- - agents:/shared-agents
- healthcheck:
- test:
- - CMD-SHELL
- - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()"
- interval: 5s
- timeout: 3s
- retries: 12
- start_period: 5s
- command: >
- sh -lc "
- mkdir -p /shared-agents/1 &&
- rm -rf /workspace &&
- ln -s /shared-agents/1 /workspace &&
- exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log
- "
-
-volumes:
- agents:
- name: ${SURFACES_SMOKE_VOLUME:-surfaces-smoke-agents}
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index c7323d0..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,39 +0,0 @@
-services:
- platform-agent:
- build:
- context: ./external/platform-agent
- target: development
- additional_contexts:
- agent_api: ./external/platform-agent_api
- env_file: .env
- environment:
- PYTHONUNBUFFERED: "1"
- volumes:
- - ./external/platform-agent/src:/app/src
- - ./external/platform-agent_api:/agent_api
- - workspace:/workspace
- command: >
- sh -lc "
- mkdir -p /workspace &&
- chown -R agent:agent /workspace &&
- exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000
- "
- ports:
- - "8000:8000"
- restart: unless-stopped
-
- matrix-bot:
- build: .
- env_file: .env
- environment:
- AGENT_BASE_URL: http://platform-agent:8000
- SURFACES_WORKSPACE_DIR: /workspace
- depends_on:
- - platform-agent
- volumes:
- - workspace:/workspace
- - ./config:/app/config:ro
- restart: unless-stopped
-
-volumes:
- workspace:
diff --git a/docker/nginx/smoke-agents-timeout.conf b/docker/nginx/smoke-agents-timeout.conf
deleted file mode 100644
index 03c7e79..0000000
--- a/docker/nginx/smoke-agents-timeout.conf
+++ /dev/null
@@ -1,28 +0,0 @@
-events {}
-
-http {
- map $http_upgrade $connection_upgrade {
- default upgrade;
- '' close;
- }
-
- server {
- listen 7000;
-
- location /agent_0/ {
- proxy_pass http://agent-0:8000/;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection $connection_upgrade;
- }
-
- location /agent_1/ {
- proxy_pass http://agent-no-status:8000/;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection $connection_upgrade;
- }
- }
-}
diff --git a/docker/nginx/smoke-agents.conf b/docker/nginx/smoke-agents.conf
deleted file mode 100644
index e3bcaab..0000000
--- a/docker/nginx/smoke-agents.conf
+++ /dev/null
@@ -1,28 +0,0 @@
-events {}
-
-http {
- map $http_upgrade $connection_upgrade {
- default upgrade;
- '' close;
- }
-
- server {
- listen 7000;
-
- location /agent_0/ {
- proxy_pass http://agent-0:8000/;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection $connection_upgrade;
- }
-
- location /agent_1/ {
- proxy_pass http://agent-1:8000/;
- proxy_http_version 1.1;
- proxy_set_header Host $host;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection $connection_upgrade;
- }
- }
-}
diff --git a/docs/api-contract.md b/docs/api-contract.md
new file mode 100644
index 0000000..10fd899
--- /dev/null
+++ b/docs/api-contract.md
@@ -0,0 +1,143 @@
+# API Contract — Lambda Platform
+
+> **Статус:** ЧЕРНОВИК — проектируем сами, уточняем с Азаматом когда SDK будет готов
+> **Последнее обновление:** 2026-03-29
+
+---
+
+## Архитектурный контекст
+
+Каждому пользователю выделяется **один LXC-контейнер** с workspace 10 ГБ.
+Workspace содержит директории чатов: `C1/`, `C2/`, `C3/` — файлы + `history.db` в каждом.
+
+**Master** управляет lifecycle контейнера (запуск, заморозка, пробуждение).
+Бот **не управляет lifecycle** — он передаёт `user_id` + `chat_id` + сообщение.
+Master сам решает: нужно ли поднять контейнер, смонтировать нужный чат, запустить агента.
+
+---
+
+## Base URL
+
+```
+https://api.lambda-platform.io/v1
+```
+
+## Аутентификация
+
+```
+Authorization: Bearer {SERVICE_TOKEN}
+```
+
+Сервисный токен выдаётся команде поверхностей. Не путать с токеном пользователя.
+
+---
+
+## Users
+
+### GET /users/{external_id}?platform={platform}
+
+Получает или создаёт пользователя.
+
+**Query params:**
+- `platform` — `telegram` | `matrix`
+
+**Response 200:**
+```json
+{
+ "user_id": "usr_abc123",
+ "external_id": "12345678",
+ "platform": "telegram",
+ "display_name": "Иван Иванов",
+ "created_at": "2025-01-15T10:30:00Z",
+ "is_new": false
+}
+```
+
+---
+
+## Messages
+
+Бот не управляет сессиями явно. Отправка сообщения — единственная операция.
+Master решает: нужен ли новый контейнер, или разбудить существующий.
+
+### POST /users/{user_id}/chats/{chat_id}/messages
+
+Отправляет сообщение пользователя агенту. Master поднимает/размораживает контейнер,
+монтирует нужный чат (`C1/`, `C2/`...), запускает агента.
+
+**Request:**
+```json
+{
+ "text": "Привет, что ты умеешь?",
+ "attachments": []
+}
+```
+
+**Response 200:**
+```json
+{
+ "message_id": "msg_qwe012",
+ "response": "Я AI-агент Lambda...",
+ "tokens_used": 142,
+ "finished": true
+}
+```
+
+---
+
+## Settings
+
+### GET /users/{user_id}/settings
+
+Настройки пользователя: скиллы, коннекторы, SOUL, безопасность, план.
+
+**Response 200:**
+```json
+{
+ "skills": {"web-search": true, "browser": false},
+ "connectors": {"gmail": {"connected": true, "email": "user@gmail.com"}},
+ "soul": {"name": "Лямбда", "style": "friendly"},
+ "safety": {"email-send": true, "file-delete": true},
+ "plan": {"name": "Beta", "tokens_used": 800, "tokens_limit": 1000}
+}
+```
+
+### POST /users/{user_id}/settings
+
+Применяет действие над настройками.
+
+**Request:**
+```json
+{
+ "action": "toggle_skill",
+ "payload": {"skill": "browser", "enabled": true}
+}
+```
+
+**Response 200:**
+```json
+{"ok": true}
+```
+
+---
+
+## Error format
+
+```json
+{
+ "error": "ERROR_CODE",
+ "message": "Human readable description",
+ "details": {}
+}
+```
+
+Коды ошибок: `USER_NOT_FOUND`, `RATE_LIMITED`, `PLATFORM_ERROR`, `CONTAINER_UNAVAILABLE`
+
+---
+
+## Открытые вопросы к команде платфрмы (SDK)
+
+- [ ] Точный формат эндпоинта отправки сообщения — URL, поля
+- [ ] Как передавать вложения (файлы, изображения)? Через S3 pre-signed URL или напрямую?
+- [ ] Стриминговый ответ (SSE / WebSocket) или только sync?
+- [ ] Формат `SettingsAction` — совпадает с нашим или другой?
diff --git a/docs/deploy-architecture.md b/docs/deploy-architecture.md
deleted file mode 100644
index e838611..0000000
--- a/docs/deploy-architecture.md
+++ /dev/null
@@ -1,197 +0,0 @@
-# Deployment Architecture — Matrix Bot + Agents
-
-> Сформировано 2026-04-27 по итогам обсуждения с платформой.
-
----
-
-## Compose Artifacts
-
-- **Production deploy:** `docker-compose.prod.yml`
- Bot-only handoff через published image (`SURFACES_BOT_IMAGE`). Поднимает только `matrix-bot`, монтирует shared volume в `/agents`.
- Платформа предоставляет 25-30 agent containers/services отдельно; бот подключается к ним через `base_url` из `matrix-agents.yaml`.
-- **Internal full-stack E2E:** `docker-compose.fullstack.yml`
- Внутренний harness для тестирования. Локально собирает `matrix-bot` через Dockerfile target `development`, поднимает один `platform-agent`, health-gated startup.
-
-Production operators: `docker-compose.prod.yml`. Internal E2E: `docker-compose.fullstack.yml`.
-
----
-
-## Топология
-
-```
-lambda.coredump.ru
-├── :7000 (reverse proxy, path-based routing)
-│ ├── /agent_0/ → agent_0 container
-│ ├── /agent_1/ → agent_1 container
-│ └── /agent_N/ → agent_N container
-│
-└── Matrix bot instance (один инстанс на всех)
- └── volume /agents/ (shared с агентами)
- ├── /agents/0/ ← workspace agent_0
- ├── /agents/1/ ← workspace agent_1
- └── /agents/N/
-```
-
-- **Один инстанс Matrix-бота** обслуживает всех пользователей.
-- **Один агент-контейнер на пользователя.** Production scale target: 25-30 внешних агентов. Изоляция по `agent_id`, не через один общий agent instance.
-- **Shared volume** `/agents/` смонтирован в Matrix-бот. В internal full-stack harness тот же volume mounted as `/workspace` inside `platform-agent`, чтобы bot-side absolute paths и agent workspace относились к одному и тому же хранилищу.
-
----
-
-## Конфиг (два словаря)
-
-```yaml
-# config/matrix-agents.yaml
-
-user_agents:
- "@user0:matrix.lambda.coredump.ru": agent-0
- "@user1:matrix.lambda.coredump.ru": agent-1
- "@user2:matrix.lambda.coredump.ru": agent-2
-
-agents:
- - id: agent-0
- label: "Agent 0"
- base_url: "http://lambda.coredump.ru:7000/agent_0/"
- workspace_path: "/agents/0"
-
- - id: agent-1
- label: "Agent 1"
- base_url: "http://lambda.coredump.ru:7000/agent_1/"
- workspace_path: "/agents/1"
-
- - id: agent-2
- label: "Agent 2"
- base_url: "http://lambda.coredump.ru:7000/agent_2/"
- workspace_path: "/agents/2"
-```
-
-- `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент из списка.
-- `agents[].base_url` — HTTP URL агент-эндпоинта. Бот подключается через AgentApi.
-- `agents[].workspace_path` — абсолютный путь к воркспейсу агента **внутри контейнера бота** (т.е. на shared volume).
- Бот сохраняет входящие файлы прямо в `{workspace_path}/`, читает исходящие из `{workspace_path}/`.
-- Для 25-30 агентов продолжайте тот же паттерн до нужного номера: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`.
-
-## Surface Image Build Contract
-
-Production image содержит только Matrix surface. Он не содержит `platform-agent` и не требует локального `external/` build context.
-
-```bash
-docker login
-export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest
-
-docker build --target production \
- --build-arg LAMBDA_AGENT_API_REF=master \
- -t "$SURFACES_BOT_IMAGE" .
-docker push "$SURFACES_BOT_IMAGE"
-```
-
-Published image:
-
-```text
-mput1/surfaces-bot:latest
-sha256:2f135f3535f7765d4377b440cdabe41195ad2efbc3e175def159ae4689ef90bd
-```
-
-`SURFACES_BOT_IMAGE` должен указывать на registry namespace, куда текущий Docker account может пушить. Ошибка `insufficient_scope` означает, что пользователь не залогинен в этот namespace, repository не создан, или у аккаунта нет push-доступа.
-
-Production Dockerfile ставит `platform/agent_api` из Git по тому же принципу, что и `platform-agent` production image:
-
-```bash
-git+https://git.lambda.coredump.ru/platform/agent_api.git
-```
-
-Локальный `docker-compose.fullstack.yml` остаётся dev/E2E harness: он использует target `development` и `additional_contexts.agent_api=./external/platform-agent_api`, чтобы можно было тестировать surface вместе с локальным checkout SDK.
-
----
-
-## Agent API (используем master ветку `platform/agent_api`)
-
-```python
-from lambda_agent_api.agent_api import AgentApi
-
-connected_agents: dict[tuple[str, int], AgentApi] = {}
-
-def on_agent_disconnect(agent: AgentApi):
- connected_agents.pop((agent.id, agent.chat_id), None)
-
-async def on_message(matrix_user_id: str, matrix_room_id: str, text: str):
- agent_id = get_agent_id_by_user(matrix_user_id) # из user_agents конфига
- platform_chat_id = get_room_platform_chat_id(matrix_room_id)
-
- agent = connected_agents.get((agent_id, platform_chat_id))
- if not agent:
- agent = AgentApi(
- agent_id,
- get_agent_base_url(agent_id), # ws://lambda.coredump.ru:7000/agent_0/
- on_disconnect=on_agent_disconnect,
- chat_id=platform_chat_id, # отдельный thread на Matrix room
- )
- await agent.connect()
- connected_agents[(agent_id, platform_chat_id)] = agent
-
- async for event in agent.send_message(text):
- ...
-```
-
-**Параметры конструктора (master):**
-```python
-AgentApi(
- agent_id: str,
- base_url: str, # ws://host:port/agent_N/
- chat_id: int = 0, # surfaces must supply per-room platform_chat_id
- on_disconnect: callable,
-)
-```
-
-**Lifecycle:** агент автоматически отключается после нескольких минут бездействия.
-`on_disconnect` удаляет из пула → следующее сообщение создаёт новое соединение.
-
----
-
-## Передача файлов
-
-### Пользователь → Агент (входящий файл)
-
-1. Matrix-бот получает файл от пользователя
-2. Сохраняет в workspace агента: `/agents/{N}/{filename}`
-3. Если файл уже существует, выбирает следующее имя: `filename (1).ext`, `filename (2).ext`
-4. Вызывает `agent.send_message(text, attachments=["filename"])`
- — путь относительно `/workspace` агента
-
-### Агент → Пользователь (исходящий файл)
-
-1. Агент эмитит `MsgEventSendFile(path="report.pdf")`
-2. Matrix-бот читает файл: `/agents/{N}/report.pdf`
-3. Отправляет как Matrix file message пользователю
-
-**Ключевое:** production handoff через `docker-compose.prod.yml` и internal E2E через `docker-compose.fullstack.yml` используют один и тот же `/agents` contract на стороне поверхности. Прямой HTTP-доступ к файлам не нужен.
-
----
-
-## Текущее состояние platform-agent (main)
-
-- Composio интегрирован в main (`#9-интеграция-composIO`)
-- Агент требует в `.env`: `AGENT_ID`, `COMPOSIO_API_KEY`
-- Backend: `IsolatedShellBackend` (main) / `CompositeBackend` (ветка `#19`, не merged)
-- Memory: `MemorySaver` — история слетает при рестарте контейнера (known limitation)
-
----
-
-## platform-master (будущее, пока не используем)
-
-Ветка `feat/storage` реализует реальный Master-сервис:
-- `POST /api/v1/create {chat_id}` → поднимает/переиспользует sandbox-контейнер
-- TTL-based lifecycle (300с default, конфигурируемо)
-- `ChatStorage` — API для upload/download файлов через Master
-- Auth + p2p lease — вне текущего scope MVP
-
-**Для деплоя MVP используем статический конфиг без Master.**
-При готовности Master: `get_agent_url()` будет вызывать `POST /api/v1/create`, URL возвращается в ответе.
-
----
-
-## Открытые вопросы
-
-- `platform-agent_api #9-clientside-tool-call` убирает `attachments` и `MsgEventSendFile` — пока используем master. Уточнить у платформы сроки мержа перед деплоем.
-- История при рестарте агента теряется — `platform-agent` использует `MemorySaver` (in-memory). Ограничение платформы.
-- `AGENT_ID` и `COMPOSIO_API_KEY` для каждого агент-контейнера — значения предоставляет платформа.
diff --git a/docs/known-limitations.md b/docs/known-limitations.md
deleted file mode 100644
index e98f0ba..0000000
--- a/docs/known-limitations.md
+++ /dev/null
@@ -1,51 +0,0 @@
-# Known Limitations
-
-## Telegram — Threaded Mode (Bot API 9.3+)
-
-Threaded Mode — относительно новая фича Bot API. Ряд ограничений связан с незрелостью клиентов Telegram, а не с нашим кодом.
-
-### Telegram Mac клиент
-
-- Новые топики, созданные ботом через `/new`, не появляются в сайдбаре сразу.
- Топики существуют на сервере и доступны на мобильном клиенте — это баг Mac клиента.
-
-### Bot API — управление топиками
-
-- `closeForumTopic` и аналогичные методы работают только для supergroup-форумов.
- В Threaded Mode личного чата эти вызовы возвращают `"the chat is not a supergroup forum"`.
-- `deleteForumTopic` работает на мобильных клиентах, поведение на Mac непоследовательно.
-- Топики, созданные ботом через API (`/new`), пользователь не может удалить через Mac UI
- (только через мобильный клиент). Бот пытается удалить топик сам при `/archive`.
-
-### После удаления топика
-
-- Когда все топики удалены, Telegram показывает кнопку Start как при первом запуске.
- Это стандартное поведение Telegram, не баг бота.
-
-### История чатов
-
-- При пересоздании базы данных (`lambda_bot.db`) старые топики в Telegram остаются.
- История сообщений в Telegram не удаляется при сбросе БД бота.
-
----
-
-*Все перечисленные ограничения — на стороне платформы Telegram. Решение: принято, движемся дальше.*
-
-## Matrix
-
-### Незашифрованные комнаты только
-
-- Текущая Matrix-реализация в этом репозитории тестируется только в незашифрованных комнатах.
- Encrypted DM и encrypted rooms пока не поддержаны.
-
-### Зависимость от локального состояния
-
-- Бот хранит локальный маппинг `chat_id ↔ room_id`.
- Если удалить `lambda_matrix.db` или `matrix_store/`, старые комнаты в Matrix останутся,
- но `!rename` и `!archive` для них больше не смогут отработать как для зарегистрированных чатов.
-
-### Поведение после рестарта
-
-- При старте бот делает bootstrap sync и продолжает `sync_forever()` с `since`.
- Это снижает риск повторной обработки старой timeline, но означает, что рестарт не предназначен
- для ретро-обработки уже существующих исторических сообщений.
diff --git a/docs/matrix-direct-agent-prototype-ru.md b/docs/matrix-direct-agent-prototype-ru.md
deleted file mode 100644
index 2367dc5..0000000
--- a/docs/matrix-direct-agent-prototype-ru.md
+++ /dev/null
@@ -1,301 +0,0 @@
-# Matrix Direct-Agent Prototype
-
-> **ВНИМАНИЕ: Это исторический документ.**
-> Описанный здесь прототип был интегрирован в `main` и стал основой для Matrix MVP, но архитектура претерпела значительные изменения. Для актуальной информации по деплою смотрите `docs/deploy-architecture.md`, а для создания новой поверхности — `docs/max-surface-guide.md`.
-
-Русскоязычная заметка по прототипу Matrix surface, который ходит не в `MockPlatformClient`, а напрямую в живой agent backend по WebSocket.
-
-## Что сделали
-
-В этой ветке собран рабочий Matrix-only прототип с минимальным вмешательством в существующую архитектуру.
-
-Ключевая идея:
-- Matrix-адаптер и `core/` остаются на старом контракте `PlatformClient`
-- вместо `sdk/mock.py` можно включить `sdk/real.py`
-- `sdk/real.py` внутри разделяет две ответственности:
- - `sdk/agent_session.py` — прямое общение с agent по WebSocket
- - `sdk/prototype_state.py` — локальный user/settings state для прототипа
-
-Это позволило не переписывать Matrix-логику под нестабильный `platform/master` и при этом подключить живого агента вместо мока.
-
-## Что поменялось в `surfaces-bot`
-
-Добавлено:
-- `sdk/agent_session.py`
-- `sdk/prototype_state.py`
-- `sdk/real.py`
-- тесты для transport/state/real backend
-
-Изменено:
-- `adapter/matrix/bot.py`
-- `adapter/matrix/handlers/auth.py`
-- `README.md`
-- интеграционные и Matrix dispatcher тесты
-
-Функционально это дало:
-- переключение Matrix backend через env:
- - `MATRIX_PLATFORM_BACKEND=mock`
- - `MATRIX_PLATFORM_BACKEND=real`
-- прямую отправку текста в live agent через `AGENT_BASE_URL`
-- локальное хранение settings и user mapping
-- изоляцию backend memory по `thread_id`
-- исправление повторных invite: бот теперь сначала `join()`, а уже потом решает, нужно ли пере-провиженить Space/chat tree
-
-## Что поменяли в `platform-agent`
-
-Для прототипа потребовался минимальный локальный патч в клонированном `external/platform-agent`.
-
-Изменения:
-- `src/api/external.py`
-- `src/agent/service.py`
-
-Смысл патча:
-- agent больше не использует один общий hardcoded `thread_id="default"`
-- `thread_id` читается из query parameter WebSocket-соединения
-- дальше этот `thread_id` передаётся в config memory/checkpointer
-
-Локальный commit в clone:
-- `1dca2c1` — `feat: support websocket thread ids`
-
-Важно:
-- этот commit живёт в `external/platform-agent`
-- он не входит в git-историю `surfaces-bot`
-- если прототип должен запускаться у других людей без ручных патчей, этот commit надо отдельно запушить или повторить в platform repo
-
-## Текущая архитектура прототипа
-
-Поток сообщения сейчас такой:
-
-1. Matrix room event попадает в `adapter/matrix`
-2. адаптер переводит его в `IncomingMessage` / `IncomingCommand`
-3. `EventDispatcher` вызывает handler из `core/`
-4. handler вызывает `PlatformClient`
-5. при real backend это `RealPlatformClient`
-6. `RealPlatformClient` строит `thread_key`
-7. `AgentSessionClient` открывает WebSocket на `agent_ws/?thread_id=...`
-8. ответ агента возвращается обратно в Matrix
-
-Что остаётся локальным в v1:
-- `!settings`
-- `!skills`
-- `!soul`
-- `!safety`
-- user registration mapping
-
-Что реально идёт в живого агента:
-- обычные текстовые сообщения
-- память по чатам через `thread_id`
-
-## Ограничения прототипа
-
-Сейчас это не полный platform integration, а рабочий direct-agent prototype.
-
-Ограничения:
-- только текстовый чат
-- без attachments в agent
-- без async task callbacks/webhooks
-- без реального control-plane из `platform/master`
-- encrypted Matrix rooms пока не поддержаны
-- repeat invite не создаёт новую Space-структуру, если user уже был провиженен локально
-- backend/provider ошибки пока не везде деградируют в user-facing reply; часть ошибок всё ещё может уронить процесс surface
-
-## Как запускать
-
-Нужно поднять два процесса:
-- patched `platform-agent`
-- Matrix bot из `surfaces-bot`
-
-### 1. Подготовить `platform-agent`
-
-Локальный clone:
-- [external/platform-agent](/Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent)
-
-И связанный SDK clone:
-- [external/platform-agent_api](/Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent_api)
-
-Первичная подготовка:
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent
-uv sync
-uv pip install --python .venv/bin/python -e ../platform-agent_api
-```
-
-Если у вас был активирован чужой venv, сначала сделайте:
-
-```bash
-deactivate
-```
-
-Иначе `uv pip install` может поставить пакет не в тот interpreter.
-
-### 2. Запустить agent backend
-
-Пример с OpenRouter:
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent
-
-export PROVIDER_URL=https://openrouter.ai/api/v1
-export PROVIDER_API_KEY='YOUR_OPENROUTER_KEY'
-export PROVIDER_MODEL='qwen/qwen3.5-122b-a10b'
-
-uv run uvicorn src.main:app --host 0.0.0.0 --port 8000
-```
-
-После этого WebSocket endpoint должен быть доступен по:
-
-```text
-ws://127.0.0.1:8000/agent_ws/
-```
-
-### 3. Запустить Matrix bot
-
-В отдельном терминале:
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot
-
-export MATRIX_PLATFORM_BACKEND=real
-export AGENT_BASE_URL=http://127.0.0.1:8000
-export MATRIX_HOMESERVER=https://matrix.lambda.coredump.ru
-export MATRIX_USER_ID=@lambda_surface_test_bot:matrix.lambda.coredump.ru
-export MATRIX_PASSWORD='YOUR_PASSWORD'
-
-PYTHONPATH=. uv run python -m adapter.matrix.bot
-```
-
-Если всё ок, в логах будет что-то вроде:
-
-```text
-Matrix bot starting ...
-```
-
-## Точные команды
-
-Ниже команды в том виде, в котором реально поднимался рабочий прототип.
-
-### Platform / agent backend
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent
-deactivate 2>/dev/null || true
-uv sync
-uv pip install --python .venv/bin/python -e ../platform-agent_api
-
-export PROVIDER_URL=https://openrouter.ai/api/v1
-export PROVIDER_API_KEY='YOUR_OPENROUTER_KEY'
-export PROVIDER_MODEL='qwen/qwen3.5-122b-a10b'
-
-uv run uvicorn src.main:app --host 0.0.0.0 --port 8000
-```
-
-### Matrix bot
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot
-
-export MATRIX_PLATFORM_BACKEND=real
-export AGENT_BASE_URL=http://127.0.0.1:8000
-export MATRIX_HOMESERVER=https://matrix.lambda.coredump.ru
-export MATRIX_USER_ID=@lambda_surface_test_bot:matrix.lambda.coredump.ru
-export MATRIX_PASSWORD='YOUR_PASSWORD'
-
-PYTHONPATH=. uv run python -m adapter.matrix.bot
-```
-
-### Перезапуск Matrix state с нуля
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot
-rm -f lambda_matrix.db
-rm -rf matrix_store
-PYTHONPATH=. uv run python -m adapter.matrix.bot
-```
-
-## Smoke test
-
-Рекомендуемый сценарий ручной проверки:
-
-1. Пригласить бота в fresh unencrypted room
-2. Дождаться join
-3. Если это первый invite для данного локального state:
- - бот создаст private Space
- - бот создаст room `Чат 1`
-4. Открыть `Чат 1`
-5. Отправить `!start`
-6. Отправить обычное текстовое сообщение
-7. Проверить, что ответ пришёл от live backend, а не от `[MOCK]`
-8. Проверить `!new`
-9. Проверить, что память разделяется между чатами
-
-Если бот уже был однажды провиженен и локальный state не очищался:
-- повторный invite не создаст новую Space-структуру
-- бот просто зайдёт в room и будет отвечать там
-
-Это нормальное поведение текущей реализации.
-
-## Сброс локального Matrix state
-
-Если нужно повторно проверить именно first-invite provisioning:
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot
-rm -f lambda_matrix.db
-rm -rf matrix_store
-PYTHONPATH=. uv run python -m adapter.matrix.bot
-```
-
-После этого можно снова приглашать бота как "с нуля".
-
-## Частые проблемы
-
-### 1. `ModuleNotFoundError: lambda_agent_api`
-
-Значит `platform-agent_api` не установлен в `.venv` агента.
-
-Исправление:
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent
-uv pip install --python .venv/bin/python -e ../platform-agent_api
-```
-
-### 2. `CERTIFICATE_VERIFY_FAILED` при запуске Matrix bot
-
-Это не ошибка surface logic. Это TLS trust problem до Matrix homeserver.
-
-Нужно:
-- либо установить системные/Python certificates
-- либо передать корпоративный CA через `SSL_CERT_FILE`
-
-### 3. Бот заходит в room, но не создаёт новую Space
-
-Скорее всего user уже есть в локальном state.
-
-Варианты:
-- это ожидаемо для repeat invite
-- либо очистить `lambda_matrix.db` и `matrix_store`
-
-### 4. Бот падает после message send
-
-Значит backend/provider вернул ошибку, которая ещё не была деградирована в user-facing ответ.
-
-Пример уже встречавшегося кейса:
-- неверный model id
-- key не имеет доступа к model
-
-Сначала проверяйте:
-- `PROVIDER_URL`
-- `PROVIDER_MODEL`
-- `PROVIDER_API_KEY`
-
-## Полезные ссылки внутри repo
-
-- [README.md](/Users/a/MAI/sem2/lambda/surfaces-bot/README.md)
-- [adapter/matrix/bot.py](/Users/a/MAI/sem2/lambda/surfaces-bot/adapter/matrix/bot.py)
-- [sdk/agent_session.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_session.py)
-- [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py)
-- [sdk/prototype_state.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/prototype_state.py)
-- [2026-04-08-matrix-direct-agent-prototype-design.md](/Users/a/MAI/sem2/lambda/surfaces-bot/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md)
-- [2026-04-08-matrix-direct-agent-prototype.md](/Users/a/MAI/sem2/lambda/surfaces-bot/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md)
diff --git a/docs/matrix-prototype.md b/docs/matrix-prototype.md
index d79ff83..5e57c88 100644
--- a/docs/matrix-prototype.md
+++ b/docs/matrix-prototype.md
@@ -2,103 +2,247 @@
## Концепция
-Один бот, каждый чат — отдельная комната, все комнаты собраны в personal Space.
+Один бот, каждый чат — отдельная комната, все комнаты собраны в Space.
-При первом invite бот создаёт для пользователя личное пространство (Space) и первую рабочую комнату.
-История хранится нативно в Matrix. UX прагматичный: явные `!`-команды, локальный state-store, нативные Matrix rooms.
+При первом входе бот создаёт для пользователя личное пространство (Space) —
+это как папка в Element. Внутри Space бот создаёт комнату для каждого нового
+чата с агентом. Пользователь видит аккуратную структуру: одно пространство,
+внутри — список чатов. История хранится нативно в Matrix — это часть протокола,
+ничего дополнительно делать не нужно.
-Matrix — внутренняя поверхность: команда лаборатории, тестировщики, разработчики скиллов.
+Matrix выбран как внутренняя поверхность: команды лаборатории, тестировщики,
+разработчики скиллов. Поэтому UX здесь — про удобство работы, а не онбординг.
---
-## Онбординг
+## Аутентификация
-1. Пользователь приглашает бота в личные сообщения (DM) на Matrix-сервере
-2. Бот принимает invite, создаёт Space `Lambda — {display_name}` и первую комнату `Чат 1`
-3. Приглашает пользователя в `Чат 1` и пишет приветствие
-4. Дальнейшее общение ведётся в рабочих комнатах, не в DM
+### Флоу
+1. Пользователь приглашает бота в личные сообщения или пишет в общей комнате
+2. Бот проверяет `@user:matrix.org` — есть ли аккаунт на платформе
+3. Если нет — бот отправляет одноразовый код или ссылку
+4. Пользователь подтверждает, платформа возвращает токен
+5. Бот сохраняет привязку `matrix_user_id → platform_user_id`
+### В моке
+- Любой пользователь проходит аутентификацию автоматически
+- Бот отвечает: «Добро пожаловать, {display_name}. Создаю ваше пространство...»
+- Демонстрирует флоу без реальной платформы
+
+---
+
+## Чаты через Space + комнаты (вариант Б)
+
+### Структура
```
Space: «Lambda — {display_name}»
- ├── 💬 Чат 1 ← создаётся автоматически при invite
+ ├── 📌 Настройки ← специальная комната для команд управления
+ ├── 💬 Чат 1 ← первый чат, создаётся автоматически
├── 💬 Чат 2
- └── 💬 Исследование рынка ← пользователь называет сам через !new
+ └── 💬 Исследование рынка ← пользователь сам называет
```
-**Требование:** незашифрованные комнаты. E2EE не поддержан (инфраструктурное ограничение).
-
----
-
-## Работающие команды
+### Создание Space
+При первом входе бот:
+1. Создаёт Space `Lambda — {display_name}`
+2. Создаёт комнату `Настройки` (закреплена вверху)
+3. Создаёт первую комнату-чат `Чат 1`
+4. Приглашает пользователя во все комнаты
+5. Пишет в `Чат 1` приветствие
### Управление чатами
+Команды работают в любой комнате Space:
| Команда | Действие |
|---|---|
| `!new` | Создать новый чат (новую комнату в Space) |
| `!new Название` | Создать чат с именем |
-| `!chats` | Список активных чатов |
-| `!rename <название>` | Переименовать текущую комнату |
-| `!archive` | Архивировать чат |
-| `!help` | Справка |
+| `!rename Название` | Переименовать текущую комнату |
+| `!archive` | Вывести комнату из Space (не удалять) |
+| `!chats` | Показать список чатов |
-### Контекст
+### Создание нового чата
+1. Пользователь пишет `!new` или `!new Анализ конкурентов`
+2. Бот создаёт новую комнату в Space
+3. Приглашает пользователя
+4. Пишет приветствие; при первом сообщении платформа автоматически поднимает контейнер
+5. Пользователь переходит в новую комнату — начинает диалог
-| Команда | Действие |
-|---|---|
-| `!clear` | Сбросить контекст текущего чата (создаёт новый thread у агента) |
-| `!reset` | Псевдоним для `!clear` |
-
-### Подтверждения
-
-| Команда | Действие |
-|---|---|
-| `!yes` | Подтвердить действие агента |
-| `!no` | Отменить действие агента |
-
-### Вложения (файловая очередь)
-
-Matrix-клиенты отправляют файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь, а не уходит агенту сразу.
-
-| Команда | Действие |
-|---|---|
-| `!list` | Показать файлы в очереди |
-| `!remove ` | Удалить файл из очереди по номеру |
-| `!remove all` | Очистить всю очередь |
-
-Как отправить файлы агенту:
-1. Отправь один или несколько файлов в рабочую комнату
-2. Напиши текстовое сообщение с инструкцией, например: `что на изображении?`
-3. Бот отправит агенту текст вместе со всеми файлами из очереди
+### В моке
+- Space и комнаты создаются реально через matrix-nio
+- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...)
+- История хранится в Matrix нативно
---
-## Диалог
+## Основной диалог
-- Любое текстовое сообщение уходит агенту, бот показывает typing-индикатор
-- Ответ стримится по WebSocket и выводится в ту же комнату
-- Каждая комната имеет свой `platform_chat_id` — контексты изолированы между комнатами
+### Флоу сообщения
+1. Пользователь пишет текст в комнату-чат
+2. Бот показывает typing (m.typing event)
+3. Запрос уходит в платформу (MockPlatformClient)
+4. Бот отвечает в той же комнате
+
+### Вложения
+- Файлы, изображения отправляются как Matrix media events
+- Бот принимает `m.file`, `m.image`, `m.audio`
+- Передаёт в платформу как `attachments` через `IncomingMessage`
+- В моке: подтверждение получения + заглушка-ответ
+
+### Реакции как действия
+Matrix поддерживает реакции на сообщения (`m.reaction`).
+Используем это для подтверждения действий агента:
+
+```
+Агент: Хочу отправить письмо на vasya@mail.ru
+ Тема: «Отчёт за неделю»
+
+ 👍 — подтвердить ❌ — отменить
+```
+
+Пользователь ставит реакцию — бот обрабатывает. Нативно и удобно.
+
+### Треды для длинных задач
+Если агент выполняет долгую задачу (deep research, генерация документа),
+бот создаёт тред от своего первого ответа и пишет промежуточные статусы туда.
+Основной чат не засоряется.
+
+```
+Бот: Начинаю исследование по теме «AI агенты 2025» [→ в треде]
+ └── Ищу источники... (1/4)
+ └── Анализирую статьи... (2/4)
+ └── Формирую отчёт... (3/4)
+ └── Готово. Отчёт: [...]
+```
---
-## Передача файлов
+## Комната «Настройки»
-### Пользователь → Агент
-Бот сохраняет файл в shared volume: `{workspace_path}/{filename}`
-и передаёт агенту относительный путь как `workspace_path`.
+Специальная комната для управления агентом. Закреплена вверху Space.
+Команды работают только здесь — не мешают диалогу в чатах.
-### Агент → Пользователь
-Агент эмитит путь к файлу в своём workspace. Бот читает файл из `/agents/...`
-и отправляет пользователю как Matrix file message.
+### Коннекторы
+```
+!connectors — показать список
+!connect gmail — подключить Gmail (OAuth ссылка)
+!connect github — подключить GitHub
+!connect calendar — подключить Google Calendar
+!connect notion — подключить Notion
+!disconnect gmail — отключить
+```
+
+Статус:
+```
+Коннекторы:
+ ✅ Gmail — подключён (user@gmail.com)
+ ❌ GitHub — не подключён → !connect github
+ ❌ Google Calendar — не подключён
+ ❌ Notion — не подключён
+```
+
+В моке: OAuth ссылка-заглушка → «Подключено ✓»
+
+### Скиллы
+```
+!skills — показать список
+!skill on browser — включить Browser Use
+!skill off browser — выключить
+```
+
+Статус:
+```
+Скиллы:
+ ✅ web-search — поиск в интернете
+ ✅ fetch-url — чтение веб-страниц
+ ✅ email — чтение почты (требует Gmail)
+ ❌ browser — управление браузером
+ ❌ image-gen — генерация изображений
+ ❌ video-gen — генерация видео
+ ✅ files — работа с файлами
+ ❌ calendar — календарь (требует Google Calendar)
+```
+
+В моке: состояние хранится локально.
+
+### Личность агента
+```
+!soul — показать текущий SOUL.md
+!soul name Лямбда — задать имя агента
+!soul style brief — стиль: brief | friendly | formal
+!soul priority «разбирать почту утром» — приоритетная задача
+!soul reset — сбросить к дефолту
+```
+
+В моке: SOUL.md генерируется и хранится локально, агент обращается по имени.
+
+### Безопасность
+```
+!safety — показать настройки
+!safety on email-send — требовать подтверждение перед отправкой письма
+!safety off calendar-create — не спрашивать для создания событий
+```
+
+Статус:
+```
+Подтверждение требуется для:
+ ✅ отправка письма
+ ✅ удаление файлов
+ ✅ публикация в соцсетях
+ ❌ создание события в календаре
+ ❌ поиск в интернете
+```
+
+### Подписка
+```
+!plan — показать текущий план
+```
+
+```
+Подписка: Beta (бесплатно)
+Токены этот месяц: 800 / 1000
+━━━━━━━━░░ 80%
+```
+
+Заглушка, реализует другая команда.
+
+### Статус и диагностика
+```
+!status — состояние платформы и чатов
+!whoami — текущий аккаунт платформы
+```
+
+```
+Статус:
+ Платформа: ✅ доступна
+ Аккаунт: user@lambda.lab
+ Активных чатов: 3
+```
---
-## Известные ограничения
+## FSM состояния
-| Проблема | Причина |
-|---|---|
-| `!save` / `!load` / `!context` | Нестабильны: `!save` зависит от агента (пишет файл в workspace), сессии хранятся in-memory и теряются при рестарте |
-| Первый чанк ответа иногда пропадает после tool/file flow | Баг в upstream `platform-agent`; подробности: `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md` |
-| Персистентность истории между рестартами агента | `platform-agent` использует `MemorySaver` (in-memory) |
-| E2EE комнаты | `python-olm` не собирается на macOS/ARM |
-| `!settings` и настройки скиллов/SOUL/безопасности | Заглушки MVP, требуют готового SDK платформы |
+```
+[Invite] → AuthPending → AuthConfirmed
+ ↓
+ SpaceSetup → Idle (в комнате Настройки)
+ ↓
+ [новая комната] → ChatCreated → Idle (в чате)
+ ↓
+ ReceivingMessage → WaitingResponse → Idle
+ ↓
+ WaitingReaction (confirm) → [✅/❌] → Idle
+ ↓
+ LongTask → [тред со статусами] → Done → Idle
+```
+
+---
+
+## Стек
+
+- Python 3.11+
+- matrix-nio (async) — Matrix клиент
+- MockPlatformClient → `platform/interface.py`
+- structlog для логирования
+- SQLite для хранения `matrix_user_id → platform_user_id`, состояния скиллов, маппинга `chat_id → room_id`
diff --git a/docs/new-surface-guide.md b/docs/new-surface-guide.md
deleted file mode 100644
index 7ebdc2a..0000000
--- a/docs/new-surface-guide.md
+++ /dev/null
@@ -1,313 +0,0 @@
-# Руководство по созданию новой поверхности
-
-Этот документ описывает, как написать новую новую поверхность (например, Discord, Slack или Custom Web) по образцу текущей Matrix-поверхности в ветке `main`.
-
-Он основан на актуальной реализации Matrix surface в репозитории и отражает текущую продакшн-логику, а не устаревший легаси.
-
----
-
-## 1. Общая архитектура
-
-### 1.1. Что такое поверхность
-
-Поверхность — это тонкий адаптер между конкретной платформой (Платформа) и общим ядром бота.
-
-В репозитории есть разделение:
-
-- `core/` — общее ядро и бизнес-логика
-- `adapter//` — реализация конкретной поверхности
-- `sdk/real.py` — работа с реальной платформой / агентом
-- `config/` — статическая конфигурация агентов
-- `docs/surface-protocol.md` — общий контракт поверхностей
-
-### 1.2. Как это работает
-
-Поверхность должна:
-
-- принимать нативные события от Платформа
-- преобразовывать их в единый внутренний контракт (`IncomingMessage`, `IncomingCommand`, `IncomingCallback`)
-- передавать их в `core`
-- получать ответы из `core` (`OutgoingMessage`, `OutgoingUI`, `OutgoingTyping`, `OutgoingNotification`)
-- преобразовывать ответы обратно в нативные нативные сообщения
-
-Поверхность не должна:
-
-- управлять жизненным циклом агентских контейнеров
-- хранить долгую историю бесед вне `core`/платформы
-- аутентифицировать пользователей сама (если это не часть Платформа API)
-
----
-
-## 2. Структура новой поверхности
-
-### 2.1. Основные каталоги
-
-Рекомендуемая структура для новой платформы:
-
-```
-adapter//
- bot.py
- converter.py
- agent_registry.py
- files.py
- handlers/
- store.py
-```
-
-### 2.2. Принцип reuse
-
-По примеру Matrix surface, New surface должен переиспользовать общий `core` и общий `sdk`.
-
-Не дублируйте бизнес-логику, а реализуйте только адаптер:
-
-- `adapter//converter.py` — конвертация событий платформы ⇄ внутренние структуры
-- `adapter//bot.py` — основной runtime, старт Платформа client, loop, отправка/прием
-- `adapter//agent_registry.py` — загрузка `config/-agents.yaml`
-- `adapter//files.py` — хранение входящих/исходящих вложений
-
----
-
-## 3. Контракт входящих/исходящих событий
-
-### 3.1. Внутренний формат
-
-Смотрите `core/protocol.py`. Основные типы:
-
-- `IncomingMessage` — обычное текстовое сообщение + вложения
-- `IncomingCommand` — управляющая команда
-- `IncomingCallback` — подтверждение / интерактивные действия
-- `OutgoingMessage` — ответ пользователю
-- `OutgoingUI` — интерфейсные элементы (кнопки и т.п.)
-- `OutgoingTyping` — индикатор печати
-- `OutgoingNotification` — системное уведомление
-
-### 3.2. Пример конверсии Matrix
-
-В Matrix-реализации `adapter/matrix/converter.py`:
-
-- текст `!yes` / `!no` превращается в `IncomingCallback` с `action: confirm/cancel`
-- `!list`/`!remove` говорят не агенту, а surface-процессу
-- вложения `m.file`, `m.image`, `m.audio`, `m.video` нормализуются в `Attachment`
-
-Для Платформа реализуйте аналогичную логику для native команд вашего клиента.
-
----
-
-## 4. Реестр агентов и маршрутизация
-
-### 4.1. Что хранит реестр
-
-В текущей Matrix реализации есть `config/matrix-agents.yaml` и `adapter/matrix/agent_registry.py`.
-
-Структура:
-
-```yaml
-user_agents:
- "@user0:matrix.example.org": agent-0
- "@user1:matrix.example.org": agent-1
-
-agents:
- - id: agent-0
- label: "Agent 0"
- base_url: "http://lambda.coredump.ru:7000/agent_0/"
- workspace_path: "/agents/0"
-```
-
-### 4.2. Логика выбора агента
-
-- `user_agents` маппит конкретного пользователя на `agent_id`
-- если user_id не найден, используется первый агент из списка
-- `agents[].base_url` определяет URL агента
-- `agents[].workspace_path` определяет путь внутри surface-контейнера для этого агента
-
-Это важно: именно на этом контракте строится разделение агентов по рабочим каталогам.
-
-### 4.3. Рекомендуемая Версия для новой платформы
-
-Создайте `config/-agents.yaml` с тем же смыслом.
-
-- `user_agents` — маппинг external user_id → agent_id
-- `agents` — список агентов
-- `workspace_path` для каждого агента должен быть абсолютным путем внутри surface-контейнера, например `/agents/0`
-
----
-
-## 5. Файловый контракт
-
-### 5.1. Shared volume
-
-Текущее Matrix-решение использует shared volume:
-
-- surface монтирует общий том как `/agents`
-- каждый агент видит свою поддиректорию как `/workspace`
-
-Топология:
-
-```
-Bot (/agents) Agent (/workspace = /agents/N/)
- /agents/0/report.pdf ←──→ /workspace/report.pdf
-```
-
-### 5.2. Правила записи файлов
-
-В `adapter/matrix/files.py` реализовано:
-
-- входящий файл сохраняется прямо в `{workspace_root}/{filename}`
-- возвращается путь `workspace_path` относительный внутри рабочего каталога агента
-- при коллизии имен создаётся `file (1).ext`, `file (2).ext`
-- `Attachment.workspace_path` передаётся агенту
-
-Для исходящих файлов:
-
-- surface читает файл из `workspace_root / workspace_path`
-- загружает его в платформу
-
-### 5.3. Пример поведения
-
-- Пользователь отправляет файл → surface скачивает файл и кладёт его в agent workspace
-- Агент получает `attachments=["report.pdf"]` и работает с относительным `workspace_path`
-- Агент пишет результат в `/workspace/result.txt`
-- surface читает `/agents/{N}/result.txt` и отправляет файл пользователю
-
----
-
-## 6. Чат-менеджмент и контекст
-
-### 6.1. `platform_chat_id`
-
-Matrix-реализация использует `platform_chat_id` как стабильный идентификатор чата на стороне агента.
-
-- `room_meta.platform_chat_id` определяется и сохраняется в `adapter/matrix/store.py`
-- `reconcile_startup_state()` восстанавливает отсутствующие `platform_chat_id` при рестарте
-- `RoutedPlatformClient` перенаправляет запросы агенту по `agent_id` + `platform_chat_id`
-
-Для New surface тот же принцип:
-
-- каждая внешняя беседа должна привязываться к одному внутреннему `chat_id`
-- этот `chat_id` используется для вызовов агента
-- если в Платформа есть несколько комнат/топиков, каждая должна иметь свой `surface_ref`
-
-### 6.2. Команды управления чатами
-
-Matrix поддерживает следующие команды, которые нужно сохранить в Платформа:
-
-- `!new [название]` — создать новый чат
-- `!chats` — список активных чатов
-- `!rename <название>` — переименовать текущий чат
-- `!archive` — архивировать чат
-- `!clear` / `!reset` — сбросить контекст текущего чата
-- `!yes` / `!no` — подтвердить или отменить действие агента
-- `!list` — показать очередь вложений
-- `!remove ` / `!remove all` — удалить вложение из очереди
-- `!help` — справка
-
-Эти команды реализованы в Matrix через `adapter/matrix/handlers/`.
-
-### 6.3. Очередь вложений
-
-Matrix surface поддерживает staged attachments:
-
-- файл может быть отправлен без текста
-- surface сохраняет файл в `staged_attachments` для конкретного room_id + user_id
-- следующий текст отправляется агенту вместе со всеми файлами из очереди
-
-В Платформа можно реализовать ту же модель:
-
-- `!list` показывает текущую очередь
-- `!remove` удаляет файл из очереди
-- команда-индикатор или следующее текстовое сообщение отправляет queued attachments агенту
-
----
-
-## 7. Runtime и окружение
-
-### 7.1. Переменные среды
-
-Для Matrix surface текущий runtime ожидает:
-
-- `MATRIX_HOMESERVER` — URL Matrix-сервера
-- `MATRIX_USER_ID` — `@bot:example.org`
-- `MATRIX_PASSWORD` или `MATRIX_ACCESS_TOKEN`
-- `MATRIX_PLATFORM_BACKEND` — должно быть `real` для продакшна
-- `MATRIX_AGENT_REGISTRY_PATH` — путь к `config/matrix-agents.yaml`
-- `AGENT_BASE_URL` — fallback URL агента
-- `SURFACES_WORKSPACE_DIR` — путь к shared volume внутри контейнера (по умолчанию `/workspace` в коде, но в docs рекомендуют `/agents`)
-
-Для New surface используйте аналогичные переменные:
-
-- `PLATFORM_PLATFORM_BACKEND=real`
-- `PLATFORM_AGENT_REGISTRY_PATH=/app/config/-agents.yaml`
-- `SURFACES_WORKSPACE_DIR=/agents`
-- `AGENT_BASE_URL` — если хотите общий fallback
-
-### 7.2. Environment contract
-
-В коде `adapter/matrix/bot.py`:
-
-- `_agent_base_url_from_env()` читает `AGENT_BASE_URL` или `AGENT_WS_URL`
-- `_load_agent_registry_from_env()` читает `MATRIX_AGENT_REGISTRY_PATH`
-- `_build_platform_from_env()` выбирает `RealPlatformClient` при `MATRIX_PLATFORM_BACKEND=real`
-
-В New surface реализуйте ту же логику, заменив префиксы на `PLATFORM_`.
-
----
-
-## 8. Локальное тестирование
-
-Для тестирования новой поверхности вместе с одним локальным агентом используйте паттерн `docker-compose.fullstack.yml`.
-В этом режиме:
-- Запускается 1 контейнер вашей поверхности
-- Запускается 1 контейнер `platform-agent`
-- Поднимается локальный shared volume (`surfaces-agents`)
-- Поверхность настроена маршрутизировать запросы на `http://platform-agent:8000` (через `AGENT_BASE_URL`)
-- Пользователь общается с ботом, а бот напрямую общается с локальным агентом, разделяя с ним общую папку для файлов.
-
-Это самый быстрый способ проверить интеграцию новой платформы без внешнего бэкенда.
-
----
-
-## 9. Реализация шаг за шагом
-
-1. Скопировать `adapter/matrix/` как шаблон для `adapter//`.
-2. Сделать `adapter//converter.py`:
- - превратить native нативные сообщения в `IncomingMessage`
- - превратить команды в `IncomingCommand`
- - превратить yes/no-подтверждения в `IncomingCallback`
-3. Сделать `adapter//agent_registry.py` на основе `adapter/matrix/agent_registry.py`.
-4. Сделать `adapter//files.py` на основе `adapter/matrix/files.py`.
-5. Сделать `adapter//bot.py`:
- - инстанцировать runtime
- - читать env vars `PLATFORM_*`
- - загружать реестр агентов
- - обрабатывать входящие события
- - отправлять `Outgoing*` обратно в Платформа
-6. Реализовать команды управления чатами и очередь вложений.
-7. Прописать `config/-agents.yaml`.
-8. Прописать `docker-compose.platform.yml` или аналог, чтобы surface монтировал `/agents`.
-9. Написать тесты по аналогии с `tests/adapter/matrix/`.
-10. Проверить, что все env vars читаются из окружения и не зависят от устаревших Matrix-переменных.
-
----
-
-## 10. Важные замечания
-
-- Текущий Matrix surface на ветке `main` — активная реализация, а не устаревший легаси.
-- Документация и код согласованы: `agent_registry`, `files`, `routed_platform`, `reconciliation` работают вместе.
-- Обязательно явно задавайте `SURFACES_WORKSPACE_DIR=/agents` в production, если `workspace_path` в реестре указывает на `/agents/*`.
-- Для New surface сохраните ту же архитектуру: surface = thin adapter, агенты = внешние сервисы.
-- Не пытайтесь в surface реализовывать логику запуска/стопа агент-контейнеров.
-
----
-
-## 11. Полезные ссылки внутри репозитория
-
-- `README.md`
-- `docs/deploy-architecture.md`
-- `docs/surface-protocol.md`
-- `adapter/matrix/bot.py`
-- `adapter/matrix/converter.py`
-- `adapter/matrix/agent_registry.py`
-- `adapter/matrix/files.py`
-- `adapter/matrix/routed_platform.py`
-- `adapter/matrix/reconciliation.py`
-- `tests/adapter/matrix/`
diff --git a/docs/reports/2026-04-01-final-report.md b/docs/reports/2026-04-01-final-report.md
deleted file mode 100644
index 8298931..0000000
--- a/docs/reports/2026-04-01-final-report.md
+++ /dev/null
@@ -1,280 +0,0 @@
-# Отчёт о проделанной работе — Surfaces Team
-
-**Проект:** Lambda Lab 3.0 — Surfaces
-**Дата:** 2026-04-01
-**Период:** 2026-03-28 — 2026-04-01
-
----
-
-## 1. Цель этапа
-
-Собрать работоспособный прототип двух поверхностей для взаимодействия пользователя с AI-агентом Lambda:
-
-- **Telegram-бот** — основная пользовательская поверхность
-- **Matrix-бот** — альтернативная децентрализованная поверхность
-
-Ключевое требование: не ждать готовности платформенного SDK, а двигаться вперёд через собственный контракт и мок-реализацию. Это позволило вести параллельную разработку UX, архитектуры и интеграции без блокировки на внешние зависимости.
-
----
-
-## 2. Архитектура
-
-### 2.1. Общее ядро (`core/`)
-
-Выделен независимый от транспорта слой, используемый обеими поверхностями:
-
-| Компонент | Файл | Назначение |
-|-----------|------|-----------|
-| Протокол событий | `core/protocol.py` | `IncomingMessage`, `OutgoingMessage`, `OutgoingUI` и др. |
-| Диспетчер | `core/handler.py` | `EventDispatcher`: маршрутизация событий → обработчики |
-| Обработчики | `core/handlers/` | `start`, `message`, `chat`, `settings`, `callback` |
-| Хранилище состояний | `core/store.py` | `InMemoryStore`, `SQLiteStore` |
-| Менеджмент чатов | `core/chat.py` | `ChatManager` |
-| Аутентификация | `core/auth.py` | `AuthManager` |
-| Настройки | `core/settings.py` | `SettingsManager` |
-
-Telegram и Matrix — тонкие адаптеры: принимают транспортные события, конвертируют в формат ядра, передают в `core`, рендерят ответ обратно.
-
-### 2.2. Платформенный контракт (`sdk/`)
-
-Вместо ожидания SDK Lambda зафиксирован собственный контракт:
-
-- `sdk/interface.py` — Protocol: `PlatformClient`, `WebhookReceiver`
-- `sdk/mock.py` — `MockPlatformClient` (заглушка с симулируемой латентностью)
-
-При подключении реального SDK заменяется только `sdk/mock.py` — core и адаптеры не трогаются.
-
-> **Примечание:** в процессе работы директория `platform/` была переименована в `sdk/` для устранения конфликта имён со стандартной библиотекой Python (`platform.python_implementation`). Все импорты обновлены.
-
-### 2.3. Структура репозитория
-
-```
-surfaces-bot/
- core/ — общее ядро
- sdk/ — платформенный контракт и мок
- adapter/
- telegram/ — Telegram-адаптер (worktree: feat/telegram-adapter)
- matrix/ — Matrix-адаптер (в main)
- docs/
- superpowers/
- specs/ — утверждённые спецификации
- plans/ — планы реализации
- research/ — исследования API и архитектурных вариантов
- reports/ — отчёты
- tests/ — pytest (70 тестов)
-```
-
----
-
-## 3. Telegram: итоги
-
-### 3.1. Что реализовано
-
-**Базовый DM-режим (полностью работает):**
-
-| Функция | Команда/механизм |
-|---------|-----------------|
-| Онбординг | `/start` — создание первого чата, восстановление сессии |
-| Создание чатов | `/new [название]` |
-| Список чатов | `/chats` — инлайн-кнопки с переключением |
-| Диалог | Любое сообщение → мок-ответ `[MOCK] Ответ на: «...»` |
-| Typing indicator | `send_chat_action("typing")` + обновление каждые 4 сек |
-| Настройки | `/settings` → меню: скиллы, личность агента, безопасность, подписка |
-| Подтверждения | `confirm:yes/` / `confirm:no/` через `InlineKeyboard` |
-| Список команд | Зарегистрирован через `set_my_commands()` |
-| Вложения | Конвертируются в `Attachment` (фото, документ, голос) |
-
-**Forum Topics режим (реализован поверх DM):**
-
-| Функция | Описание |
-|---------|----------|
-| Подключение группы | `/forum` → FSM онбординг → пересылка сообщения из супергруппы |
-| Проверка прав | Бот должен быть администратором с `can_manage_topics` |
-| Синхронизация | При подключении группы создаются темы для всех DM-чатов |
-| Регистрация темы | `/new` в forum-теме регистрирует её как чат |
-| Создание с синхронизацией | `/new` в DM + подключённая группа → создаёт и DM-чат, и forum-тему |
-| Маршрутизация | Пришло из DM → ответ в DM с тегом `[Чат #N]`; из темы → ответ в тему без тега |
-
-**Ключевые принятые решения:**
-- Основной режим — виртуальные чаты в DM (нулевое friction)
-- Forum Topics — opt-in advanced mode, не обязательный
-- Бот не создаёт группы сам (Telegram Bot API не позволяет)
-- Один контекст (`chat_id` = UUID) для обеих поверхностей
-
-### 3.2. Техническая реализация
-
-```
-adapter/telegram/
- bot.py — Dispatcher, DispatcherMiddleware, регистрация роутеров
- states.py — ChatState, SettingsState, ForumSetupState
- db.py — SQLite: tg_users + chats (включая forum_group_id, forum_thread_id)
- converter.py — from_message(), is_forum_message(), resolve_forum_chat_id()
- handlers/
- auth.py — /start
- chat.py — сообщения, /new, /chats, forum-маршрутизация
- settings.py — /settings, скиллы, личность, безопасность, подписка
- confirm.py — подтверждение действий агента
- forum.py — /forum, онбординг, регистрация группы
- keyboards/
- chat.py — список чатов
- settings.py — меню настроек, скиллы, безопасность
- confirm.py — кнопки ✅/❌
-```
-
-**Исправленные баги:**
-- Команды (`/new`, `/settings` и др.) обрабатывались как обычные сообщения — исправлено фильтром `~F.text.startswith("/")`
-- Конфликт `platform/` с stdlib Python — устранён переименованием в `sdk/`
-
-### 3.3. Документация
-
-- Спецификация DM-режима: `docs/superpowers/specs/2026-03-31-telegram-adapter-design.md`
-- Спецификация Forum Topics: `docs/superpowers/specs/2026-03-31-forum-topics-design.md`
-- План реализации Forum Topics: `docs/superpowers/plans/2026-03-31-forum-topics.md`
-- Исследования: `docs/research/telegram-chat-alternatives.md`, `docs/research/telegram-forum-topics.md`
-
-### 3.4. Открытые задачи
-
-- Edge-cases forum synchronization (частично закрыты агентами после лимита)
-- Ручной QA форум-сценариев
-- Слияние `feat/telegram-adapter` → `main`
-
----
-
-## 4. Matrix: итоги
-
-### 4.1. Что реализовано
-
-- Matrix bot entrypoint (`adapter/matrix/bot.py`)
-- Converter layer (Matrix events → `IncomingEvent`)
-- Room metadata store
-- Маршрутизация входящих событий
-- Обработка реакций
-- Обработка приглашений (invite → DM onboarding)
-- Platform-aware command hints (`/start` для Telegram, `!start` для Matrix)
-- Модель room-per-chat: команда `!new` создаёт **реальную Matrix room**
-
-### 4.2. Архитектурный сдвиг: Space-first → DM-first
-
-Изначально рассматривалась модель Space-first (персональный Space + settings-room + отдельные комнаты внутри Space). По ходу реализации выбран более прагматичный первый этап:
-
-- **DM-first onboarding**: пользователь приглашает бота → бот приветствует → первый контекст привязывается к C1
-- **Room-per-chat**: `!new` создаёт реальную Matrix room, бот приглашает пользователя
-
-Это соответствует принципу: каждый чат — отдельная сущность транспорта, не только внутренняя запись.
-
-### 4.3. Критические баги, исправленные в ходе работы
-
-| Баг | Причина | Исправление |
-|-----|---------|-------------|
-| Бот не принимал invite | Подписка только на `RoomMemberEvent` | Добавлена поддержка `InviteMemberEvent` |
-| Бот отвечал сам себе (цикл) | Нет фильтра собственных сообщений | События от `self.client.user_id` игнорируются |
-| Дублирование приветствия | Неидемпотентный invite flow | Room onboarding сделан идемпотентным |
-| Агрессивные timeout/retry | Настройки sync по умолчанию | Настроен `AsyncClientConfig` |
-| Telegram-ориентированные команды | Тексты в ядре не учитывали платформу | Platform-aware hints в core |
-
-### 4.4. Тесты Matrix
-
-Собран и проходит набор тестов:
-- converter tests
-- dispatcher tests
-- reactions tests
-- store tests
-- интеграционные тесты core-сценариев
-
-Покрытые сценарии: разбор команд `!new`, `!skills`, `!yes`, `!no`; invite onboarding; защита от self-loop; создание реальной Matrix room; mapping `room_id → chat_id`.
-
-### 4.5. Ограничение: Matrix E2EE
-
-Шифрование (E2EE) в текущей реализации не поддержано. Причина — внешняя:
-
-- `matrix-nio` требует `python-olm` для E2EE
-- сборка `python-olm` не воспроизводится на текущей macOS/ARM среде
-
-Текущий рабочий сценарий: **только незашифрованные комнаты**. E2EE — отдельная инфраструктурная задача.
-
-### 4.6. Документация
-
-- Спецификация: `docs/superpowers/specs/2026-03-31-matrix-adapter-design.md`
-- План реализации: `docs/superpowers/plans/2026-03-31-matrix-adapter.md`
-
----
-
-## 5. Тесты
-
-```
-tests/
- core/ — 46 тестов (EventDispatcher, ChatManager, AuthManager, SettingsManager, stores)
- platform/ — 5 тестов (MockPlatformClient)
- adapter/ — 3 теста (forum DB functions) [в процессе]
-
-Итого: 70 passed, 3 errors (ошибки — проблема пути импорта в CI, не логики)
-```
-
----
-
-## 6. Отклонения от исходного плана
-
-| Аспект | Исходный план | Фактическое решение | Причина |
-|--------|--------------|-------------------|---------|
-| Telegram Forum | Бот создаёт группу сам | Пользователь создаёт, бот подключается | Telegram Bot API не позволяет создавать группы |
-| Matrix UX | Space-first | DM-first + room-per-chat | Быстрее работает, проще в отладке |
-| Платформенный слой | `platform/` | `sdk/` | Конфликт имён с stdlib Python |
-| Matrix E2EE | В области применения | Вынесено как отдельная задача | Инфраструктурный блокер (python-olm) |
-
-Все изменения — корректная инженерная адаптация, не регресс.
-
----
-
-## 7. Текущий статус по направлениям
-
-| Направление | Статус | Примечание |
-|-------------|--------|-----------|
-| `core/` | ✅ Готово | Полное покрытие тестами |
-| `sdk/` (mock) | ✅ Готово | Замена на реальный SDK — замена одного файла |
-| Telegram DM-режим | ✅ Готово | Можно тестировать руками |
-| Telegram Forum Topics | ✅ Реализовано | Требует ручного QA |
-| Matrix adapter | ✅ Готово | В `main` |
-| Matrix E2EE | ⏸ Заблокировано | Инфраструктурный блокер |
-| Слияние Telegram ветки | 🔄 В процессе | `feat/telegram-adapter` → `main` |
-
----
-
-## 8. Риски
-
-| Риск | Уровень | Митигация |
-|------|---------|-----------|
-| Matrix E2EE | Средний | Работаем с незашифрованными комнатами, E2EE — отдельный тикет |
-| Forum sync edge-cases | Низкий | Базовый сценарий работает, edge-cases в backlog |
-| Реальный SDK vs мок | Низкий | Контракт зафиксирован, замена изолирована в `sdk/mock.py` |
-
----
-
-## 9. Следующие шаги
-
-**Ближайшие:**
-1. Ручной QA Telegram Forum Topics
-2. Слияние `feat/telegram-adapter` → `main`
-3. Ручной QA Matrix-бота (issue `#14`)
-
-**Среднесрочные:**
-1. Расширить покрытие тестами (adapter-level)
-2. Довести Matrix settings workflow
-3. Актуализировать `docs/api-contract.md`
-
-**Стратегические:**
-1. Подготовить замену `MockPlatformClient` → реальный SDK Lambda
-2. Довести обе поверхности до demo-ready состояния
-3. Отдельно решить Matrix E2EE (инфраструктура)
-
----
-
-## 10. Вывод
-
-За текущий этап команда собрала работающий каркас двух поверхностей вокруг единого ядра и собственного платформенного контракта.
-
-**Практический итог:**
-- Telegram в стадии реального UX-прототипа — можно демонстрировать
-- Matrix имеет рабочий transport-слой и модель комнат
-- Архитектура устойчива и готова к замене мока на реальный SDK
-
-Проект движется по инженерной логике: исследование ограничений → адаптация архитектуры → фиксация решений → реализация. Не по формальному чеклисту.
diff --git a/docs/reports/2026-04-01-surfaces-progress-report.md b/docs/reports/2026-04-01-surfaces-progress-report.md
deleted file mode 100644
index 2c2e408..0000000
--- a/docs/reports/2026-04-01-surfaces-progress-report.md
+++ /dev/null
@@ -1,601 +0,0 @@
-# Отчёт о проделанной работе
-
-**Проект:** Lambda Lab 3.0 — Surfaces
-**Команда:** Surfaces Team
-**Дата:** 2026-04-01
-**Период отчёта:** текущий этап разработки прототипов Telegram и Matrix
-
----
-
-## 1. Цель этапа
-
-Целью текущего этапа было собрать работоспособный прототип двух поверхностей для взаимодействия пользователя с AI-агентом Lambda:
-
-- Telegram-бота
-- Matrix-бота
-
-При этом важным требованием было не ждать готовности платформенного SDK, а сразу строить систему вокруг собственного контракта и мок-реализации платформы. Это позволило параллельно двигаться по UX, архитектуре и интеграционным сценариям, не блокируясь внешними зависимостями.
-
----
-
-## 2. Что было сделано на уровне архитектуры
-
-### 2.1. Сформировано общее ядро
-
-В репозитории выделено общее `core/`, которое не зависит от конкретного транспорта и используется обеими поверхностями.
-
-Реализованы:
-
-- единый протокол событий и ответов
-- диспетчеризация входящих событий через `EventDispatcher`
-- менеджмент чатов
-- менеджмент аутентификации
-- менеджмент настроек
-- общее state-хранилище (`InMemoryStore`, `SQLiteStore`)
-
-Это позволило построить Telegram и Matrix как тонкие адаптеры, которые:
-
-- принимают события транспорта
-- конвертируют их в единый формат ядра
-- передают в `core`
-- рендерят результат обратно в транспорт
-
-### 2.2. Зафиксирован платформенный контракт
-
-Вместо ожидания готового SDK был введён собственный контракт через:
-
-- [`sdk/interface.py`](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/interface.py)
-- [`sdk/mock.py`](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/mock.py)
-
-За счёт этого:
-
-- UX и интеграционный слой можно развивать уже сейчас
-- реальные платформенные вызовы можно позже подключить заменой одной реализации
-- транспортные адаптеры и `core` не придётся переписывать
-
-### 2.3. Уточнена текущая архитектурная стратегия
-
-По ходу работы часть исходных планов была пересмотрена и адаптирована под реальные ограничения платформ и API.
-
-Ключевые изменения:
-
-- `platform/` был переименован в `sdk/` для устранения конфликта имён и более точного смысла слоя
-- Telegram ушёл от идеи автоматического создания групп ботом: Bot API этого не позволяет
-- Matrix ушёл от Space-first реализации к DM-first / room-first модели как к более реалистичному первому рабочему этапу
-
----
-
-## 3. Telegram: текущее состояние
-
-### 3.1. Организация разработки
-
-Telegram-часть выделена в отдельный worktree:
-
-- ветка: `feat/telegram-adapter`
-
-Это позволило вести Telegram независимо от Matrix и не смешивать контексты разработки.
-
-### 3.2. Что реализовано
-
-В Telegram-адаптере уже собран рабочий базовый UX:
-
-- стартовый onboarding через `/start`
-- основной диалог в DM
-- создание новых чатов
-- список чатов и переключение между ними
-- меню настроек
-- подтверждение действий через inline-кнопки
-- базовая работа с вложениями
-
-Отдельно реализован **Forum Topics mode** как расширение поверх DM-сценария:
-
-- команда `/forum`
-- подключение уже существующей forum-group через пересланное сообщение
-- проверка, что бот является администратором с правом управления темами
-- синхронизация существующих локальных чатов с forum topics
-- routing сообщений из topic обратно в нужный chat context
-- routing confirm callbacks внутри topic
-
-### 3.3. Принятые продуктовые решения
-
-Во время разработки были приняты важные решения по UX Telegram:
-
-- основным пользовательским сценарием остаётся DM-first
-- Forum Topics не являются обязательным режимом, а выступают как advanced mode
-- контекст чатов должен синхронизироваться между DM и topic-представлением
-- пользователь не должен сталкиваться с невозможной автоматизацией создания групп со стороны бота
-
-### 3.4. Что ещё не закрыто
-
-Для Telegram остаются открытые задачи, в первую очередь в области polish и согласованности UX:
-
-- не все сценарии forum synchronization доведены до конца
-- есть оставшиеся вопросы по командам в topic-контексте
-- нужен дополнительный проход по UX-деталям и ручному QA
-
-Актуальный follow-up зафиксирован в issue:
-
-- `#15` Telegram forum topics: remaining UX and synchronization gaps
-
----
-
-## 4. Matrix: текущее состояние
-
-### 4.1. Что реализовано
-
-В `main` уже добавлен Matrix-адаптер, включающий:
-
-- Matrix bot entrypoint
-- converter layer
-- room metadata store
-- routing входящих событий
-- обработку реакций
-- обработку приглашения в DM
-- базовый onboarding
-- platform-aware command hints
-- набор adapter-level тестов
-
-### 4.2. Главный архитектурный сдвиг
-
-Изначально Matrix рассматривался через модель:
-
-- персональный Space
-- settings-room
-- отдельные room-чаты внутри Space
-
-Однако по ходу реализации был выбран более прагматичный маршрут первого этапа:
-
-- **DM-first onboarding**
-- затем **room-per-chat**
-
-Текущее поведение:
-
-- пользователь приглашает бота в комнату
-- бот приветствует пользователя
-- первый контекст привязывается к `C1`
-- команда `!new` создаёт **реальную новую Matrix room**
-- бот приглашает пользователя в эту новую комнату
-
-Это уже соответствует целевому принципу:
-
-> новый чат пользователя должен быть отдельной сущностью транспорта, а не только внутренней записью в `core`
-
-### 4.3. Критические баги, которые были обнаружены и исправлены
-
-Во время ручной проверки Matrix были найдены и устранены несколько важных проблем:
-
-1. **бот не принимал invite корректно**
- - причина: подписка только на `RoomMemberEvent`
- - исправление: добавлена поддержка `InviteMemberEvent`
-
-2. **бот отвечал сам себе и уходил в цикл**
- - симптом: спам приветствиями и сообщениями типа `Введите !start`
- - причина: отсутствие фильтра собственных сообщений
- - исправление: события от `self.client.user_id` теперь игнорируются
-
-3. **дублировалось стартовое приветствие**
- - причина: invite-flow был неидемпотентным
- - исправление: room onboarding сделан идемпотентным
-
-4. **слишком агрессивные timeout/retry при sync**
- - исправление: настроен более мягкий transport config через `AsyncClientConfig`
-
-5. **команды и подсказки были Telegram-ориентированными**
- - исправление: тексты в ядре стали platform-aware (`/start` для Telegram, `!start` для Matrix)
-
-### 4.4. Что подтверждено тестами
-
-Для Matrix собран и пройден набор тестов:
-
-- converter tests
-- dispatcher tests
-- reactions tests
-- store tests
-- интеграционные тесты core-сценариев
-
-Примеры покрытых сценариев:
-
-- разбор команд `!new`, `!skills`, `!yes`, `!no`
-- invite onboarding
-- защита от self-loop
-- создание реальной Matrix room на `!new`
-- mapping `room_id -> chat_id`
-
-### 4.5. Ограничение текущей реализации
-
-Главное незакрытое ограничение Matrix на текущий момент:
-
-## encrypted DM пока не поддержан
-
-Причина не в логике бота, а во внешнем crypto-stack:
-
-- для E2EE в `matrix-nio` нужен `python-olm`
-- на текущей macOS/ARM среде сборка `python-olm` не воспроизводится корректно
-- поэтому в рабочем сценарии Matrix пока используется **только незашифрованный room flow**
-
-Это означает:
-
-- незашифрованные комнаты и room-per-chat можно развивать и тестировать уже сейчас
-- encrypted DM нужно рассматривать как отдельную инфраструктурную подзадачу
-
-### 4.6. Что ещё остаётся по Matrix
-
-Открытые направления:
-
-- ручной QA текущего Matrix-бота
-- доработка UX и edge-cases room-per-chat
-- дальнейшее развитие settings-команд
-- возможное возвращение к Space lifecycle как следующему этапу
-- отдельный infrastructure task по E2EE / `python-olm`
-
-Для ручного тестирования создан issue:
-
-- `#14` Manual QA: test Matrix bot and record issues / gaps
-
----
-
-## 5. Что было сделано с точки зрения git и процесса
-
-### 5.1. Основные изменения были оформлены коммитами
-
-На текущем этапе были сделаны и запушены в репозиторий следующие ключевые коммиты:
-
-- `82eb711` — базовый Matrix adapter + platform-aware command hints
-- `14c091b` — реальное создание новых Matrix rooms на `!new`
-- `6a843e8` — transport timeout tuning для Matrix sync
-- `27f3da8` — обновление README под фактическую архитектуру проекта
-
-### 5.2. Проведён аудит backlog
-
-По открытым issue был выполнен аудит:
-
-- закрыты уже выполненные задачи
-- устаревшие issue переписаны под текущую архитектуру
-- не выполненные и актуальные задачи оставлены открытыми
-
-В частности:
-
-- закрыт issue `#13` по Matrix research
-- актуализированы старые Telegram и Matrix issue под текущие реальные пути, ограничения и UX-модель
-
----
-
-## 6. Что изменилось по сравнению с изначальным планом
-
-Это важный блок для руководителя: проект движется не просто по “чеклисту задач”, а по реальным ограничениям платформ.
-
-### 6.1. Telegram
-
-Изначально планировался сценарий, где бот создаёт Forum-группу сам.
-
-Фактический результат исследования и реализации показал:
-
-- Telegram Bot API этого не позволяет
-- группа создаётся пользователем вручную
-- бот подключается к уже существующей группе
-
-Это не регресс, а корректная адаптация архитектуры под реальные ограничения API.
-
-### 6.2. Matrix
-
-Изначально планировался Space-first UX.
-
-Фактически первым рабочим этапом стала модель:
-
-- DM-first onboarding
-- затем room-per-chat
-
-Причина:
-
-- так можно получить работающий transport flow раньше
-- это проще в отладке
-- это не блокирует дальнейший переход к Space lifecycle
-
-### 6.3. Платформенный слой
-
-Изначально существовали старые пути и слои, которые затем были пересобраны в более понятную форму.
-
-Итоговое направление:
-
-- `sdk/interface.py`
-- `sdk/mock.py`
-- `core/` как единый уровень бизнес-логики
-- transport adapters отдельно
-
-Это повысило устойчивость архитектуры и упростило дальнейшую замену mock на реальный SDK.
-
----
-
-## 7. Основные результаты этапа
-
-К концу текущего этапа проект достиг следующих результатов:
-
-### Telegram
-
-- есть рабочий Telegram adapter
-- реализован основной DM flow
-- реализован Forum Topics mode
-- собрана отдельная ветка/worktree под Telegram
-- основные пользовательские сценарии уже можно проверять руками
-
-### Matrix
-
-- есть рабочий Matrix adapter
-- invite/onboarding flow уже функционирует
-- реализована модель room-per-chat
-- устранены основные критические баги цикла и self-processing
-- собран базовый test suite
-
-### Общий уровень проекта
-
-- ядро и контракты унифицированы
-- backlog приведён в соответствие с реальной архитектурой
-- README актуализирован под текущее состояние
-- ручной QA Matrix вынесен в отдельную управляемую задачу
-
----
-
-## 8. Текущие риски и ограничения
-
-### Технические риски
-
-1. **Matrix E2EE**
- - blocked внешним crypto-stack
- - не решается только правками Python-кода в проекте
-
-2. **Telegram forum synchronization**
- - функциональность уже есть, но остаются edge-cases и UX-недоработки
-
-3. **Расхождение старых документов и новых решений**
- - backlog уже частично синхронизирован
- - но часть старых design assumptions всё ещё может встречаться в документации
-
-### Процессные риски
-
-1. требуется более строгий feature-branch workflow для следующих этапов Matrix
-2. для Telegram и Matrix желательно продолжать раздельную работу по веткам/worktree
-3. ручной QA остаётся критичным, особенно для Matrix transport behavior
-
----
-
-## 9. Следующие шаги
-
-### Ближайшие
-
-1. Провести ручной QA Matrix-бота по issue `#14`
-2. Зафиксировать воспроизводимые проблемы Matrix
-3. Продолжить Telegram в worktree `feat/telegram-adapter`
-4. Довести Telegram forum synchronization gaps по issue `#15`
-
-### Среднесрочные
-
-1. Расширить покрытие тестами
-2. Довести Matrix settings workflow
-3. Уточнить и обновить `docs/api-contract.md`
-4. Отдельно решить вопрос Matrix E2EE support
-
-### Стратегические
-
-1. Подготовить замену `MockPlatformClient` на реальный SDK
-2. Довести обе поверхности до более стабильного demo-ready состояния
-3. Выровнять UX Telegram и Matrix вокруг общих принципов surface protocol
-
----
-
-## 10. Краткий вывод для руководителя
-
-На текущем этапе команда не просто написала часть кода, а уже собрала работающий каркас двух поверхностей вокруг общего ядра и собственного платформенного контракта.
-
-Главный практический результат:
-
-- Telegram уже находится в стадии реального UX-прототипа
-- Matrix уже имеет рабочий transport-слой и модель отдельных комнат для чатов
-- архитектура проекта стала значительно устойчивее и ближе к реальной интеграции с платформой
-
-При этом команда корректно адаптировала исходные планы под реальные ограничения Telegram Bot API и Matrix ecosystem, не пытаясь “продавить” заведомо неверные решения.
-
-То есть проект движется не по формальному чеклисту, а по зрелой инженерной логике:
-
-- исследование
-- фиксация архитектурных решений
-- рабочая реализация
-- ручной QA
-- корректировка backlog под фактическое состояние системы
-
-Это хороший признак для дальнейшего перехода от прототипа к более устойчивой демонстрационной версии.
-
-
-## 8. Дополнение: итоги отдельной Telegram-сессии по Forum Topics
-
-В рамках отдельной рабочей сессии в Telegram worktree `feat/telegram-adapter` был проведён focused pass по качеству и устойчивости **Forum Topics mode**. Целью этой работы было не просто добавить функциональность, а довести forum-сценарии до состояния, в котором их можно стабильно демонстрировать, вручную тестировать и развивать дальше без постоянных расхождений между UX, кодом и документацией.
-
-### 8.1. Что было выявлено в начале сессии
-
-При аудите Telegram-ветки подтвердилось, что базовая реализация уже существует:
-
-- Telegram adapter реализован
-- Forum Topics mode уже добавлен
-- `/forum` onboarding присутствует
-- forum thread routing реализован
-- confirm callbacks внутри forum thread уже работают
-
-Однако вместе с этим были обнаружены существенные проблемы двух типов.
-
-**Первый тип — расхождение документации и фактической реализации.**
-Часть документов всё ещё описывала старую DM-only или forum-only модель, тогда как код фактически уже работал как hybrid `DM + Forum Topics`.
-
-**Второй тип — реальные поведенческие баги forum mode.**
-Наиболее заметные проблемы:
-
-- нестабильный onboarding подключения forum group
-- слабая диагностика ошибок подключения
-- возможность сломать соответствие `topic -> chat` через команды управления чатами внутри topic
-- неполная согласованность UX внутри forum topics
-
-### 8.2. Исправление документации
-
-Были актуализированы Telegram-документы, чтобы они соответствовали реальному состоянию ветки:
-
-- `docs/telegram-prototype.md`
-- `docs/superpowers/specs/2026-03-31-telegram-adapter-design.md`
-
-Что было отражено в документации:
-
-- Telegram работает как hybrid-модель `DM + Forum Topics`
-- DM остаётся базовой поверхностью
-- Forum Topics — расширенный режим поверх того же chat context
-- `/forum` подключает уже существующую forum-group пользователя
-- один и тот же `chat_id` может быть доступен как из DM, так и из forum topic
-- forum thread routing и confirm callbacks уже входят в реализованную модель адаптера
-
-Практический результат: документация перестала вводить в заблуждение разработчиков и reviewers и теперь описывает не гипотетическую, а фактическую архитектуру Telegram-ветки.
-
-### 8.3. Разбор и исправление проблемного onboarding `/forum`
-
-Изначально `/forum` опирался на пересланное сообщение из супергруппы и ожидал, что Telegram отдаст боту `forward_from_chat`.
-
-В реальном запуске было установлено, что этот сценарий ненадёжен:
-
-- Telegram/aiogram может присылать не `forward_from_chat`, а `forward_origin`
-- в ряде случаев бот видит только `forward_origin_type=user`
-- из такого payload невозможно надёжно восстановить `group_id`
-
-То есть даже при визуально «правильной» пересылке сообщение не обязательно содержит необходимые данные о группе.
-
-Для диагностики в onboarding были добавлены stage-level логи. Теперь логируются:
-
-- запуск `/forum`
-- получение onboarding message
-- тип forward metadata
-- наличие или отсутствие данных о группе
-- тип найденного chat
-- проверка forum-enabled supergroup
-- права бота (`administrator` / `can_manage_topics`)
-- успешная привязка forum group
-- создание и привязка topics
-- завершение onboarding
-
-Это позволило быстро локализовать проблему и убедиться, что узкое место было именно в механике получения `group_id`.
-
-### 8.4. Перевод onboarding на Telegram-native `request_chat`
-
-Вместо ненадёжного forwarding-only flow основной путь подключения forum group был переведён на **Telegram-native выбор чата** через `request_chat`.
-
-Было сделано следующее:
-
-- добавлена новая клавиатура выбора forum-group
-- `/forum` теперь предлагает пользователю выбрать подходящую group кнопкой
-- бот получает `chat_shared.chat_id` напрямую
-- после выбора выполняется проверка реальных прав бота в группе
-- старый forwarding path оставлен как fallback
-
-Это решение даёт несколько преимуществ:
-
-- не зависит от нестабильных forwarded metadata
-- даёт детерминированный `chat_id`
-- лучше соответствует реальному Telegram API
-- делает onboarding заметно понятнее для пользователя
-
-### 8.5. Исправление ошибки `USER_RIGHTS_MISSING`
-
-После внедрения `request_chat` на реальном запуске проявилась новая ошибка:
-
-- `TelegramBadRequest: USER_RIGHTS_MISSING`
-
-Ошибка возникала ещё на этапе отправки кнопки выбора forum-group.
-
-Причина: в `KeyboardButtonRequestChat` был указан слишком жёсткий набор `bot_administrator_rights`, из-за чего Telegram отклонял сам запрос на показ кнопки.
-
-Исправление:
-
-- из `request_chat` были убраны жёсткие `bot_administrator_rights`
-- фактическая проверка нужных прав оставлена на следующем шаге через `get_chat_member`
-
-В результате onboarding сохранил строгую проверку прав, но перестал ломаться на этапе отправки UI.
-
-### 8.6. Исправление опасного поведения внутри forum topics
-
-После успешного onboarding был отдельно проверен UX внутри уже созданных topics. Здесь обнаружился критичный баг: пользователь мог использовать `/chats` в topic-контексте и переключать активный чат через inline callbacks.
-
-Это приводило к рассинхронизации:
-
-- Telegram topic визуально оставался темой одного чата
-- FSM и routing переключались на другой чат
-- пользователь начинал фактически разговаривать «в чате 4 внутри темы чата 2»
-
-Чтобы устранить этот класс ошибок, были введены ограничения для topic-контекста.
-
-Теперь внутри forum topic:
-
-- `/chats` не открывает механизм переключения и сообщает, что эта функция доступна только в DM
-- callback `switch::` запрещён
-- callback `new_chat` из списка чатов запрещён
-
-Это устранило основной сценарий, которым пользователь мог руками сломать привязку `topic -> chat`.
-
-### 8.7. Что покрыто тестами
-
-В рамках этой же сессии были расширены Telegram-specific тесты. Покрыты сценарии:
-
-- forum routing helpers
-- `/forum` переводит FSM в setup state
-- подключение группы через `forward_from_chat`
-- подключение группы через `forward_origin`
-- подключение группы через `chat_shared`
-- негативные сценарии без метаданных группы
-- негативный сценарий для supergroup без Topics
-- routing сообщений в forum thread
-- создание forum topic при `/new` в DM
-- регистрация чата в текущем topic
-- confirm callback внутри forum thread
-- запрет `/chats` внутри topic
-- запрет `switch` callback внутри topic
-- запрет `new_chat` callback внутри topic
-
-Проверка выполнялась командами:
-
-- `pytest tests/adapter/telegram/test_forum.py -q`
-- `pytest tests/core/test_dispatcher.py tests/core/test_integration.py -q`
-
-Результат: ключевые улучшения forum mode закреплены тестами, а не остались только на уровне ручной отладки.
-
-### 8.8. Что ещё осталось как follow-up
-
-Во время сессии были зафиксированы проблемы, которые разумно вынести в отдельную follow-up задачу, а не смешивать с текущими исправлениями.
-
-Оставшиеся gap'ы:
-
-- глобальные команды Telegram всё ещё видны и в topic-контексте, хотя часть из них логически там отключена
-- `/new ` внутри уже связанного topic может переименовать локальный чат, но не переименовывает сам Telegram topic
-- callback `new_chat` из DM-списка пока не синхронизирован с forum topic creation так же, как `/new` в DM
-
-Эти пункты были вынесены в отдельный issue:
-
-- `#15` — `Telegram forum topics: remaining UX and synchronization gaps`
-
-### 8.9. Git-результат Telegram-сессии
-
-По итогам сессии изменения были оформлены отдельным коммитом и опубликованы в удалённую ветку.
-
-**Commit:**
-
-- `a1b7a14` — `Improve Telegram forum onboarding and topic safety`
-
-**Push:**
-
-- `origin/feat/telegram-adapter`
-
-### 8.10. Практический результат этой Telegram-сессии
-
-На выходе Telegram Forum Topics mode стал существенно устойчивее и пригоднее для демонстрации и дальнейшей разработки.
-
-Главные практические улучшения:
-
-- forum onboarding стал надёжнее за счёт `request_chat`
-- диагностика ошибок onboarding стала прозрачной
-- пользователю стало сложнее случайно сломать topic-context
-- документация приведена в соответствие с кодом
-- изменения закреплены тестами
-- остаточные проблемы не потеряны и вынесены в issue tracker
-
-Итог: Telegram forum mode из состояния «уже работает, но легко ломается и плохо диагностируется» был переведён в состояние «работает заметно устойчивее, ограничивает опасные сценарии и имеет понятный backlog дальнейших улучшений».
diff --git a/docs/reports/2026-04-21-platform-streaming-bug-report-ru.md b/docs/reports/2026-04-21-platform-streaming-bug-report-ru.md
deleted file mode 100644
index f183ede..0000000
--- a/docs/reports/2026-04-21-platform-streaming-bug-report-ru.md
+++ /dev/null
@@ -1,245 +0,0 @@
-# Баг-репорт: регрессия стриминга платформы после file/tool flow
-
-## Кратко
-
-После обновления до текущих upstream-версий платформы стриминг ответов стал нестабильным в сценариях с вложениями и tool/file flow.
-
-Наблюдаемые симптомы:
-
-- первый текстовый chunk ответа может приходить уже обрезанным
-- соседние ответы могут "протекать" друг в друга
-- после некоторых запросов бот перестаёт присылать финальный ответ
-- платформа присылает дублирующий `END`
-
-До обновления платформы этот класс ошибок у нас не воспроизводился.
-
-## Версии платформы
-
-В рантайме используются upstream-репозитории без локальных правок:
-
-- `platform-agent`: `5e7c2df954cc3cd2f5bf8ae688e10a20038dde61`
-- `platform-agent_api`: `aa480bbec5bbf8e006284dd03aed1c2754e9bbee`
-
-## Контекст интеграции
-
-- поверхность: Matrix
-- транспорт к платформе: websocket через `platform-agent_api`
-- `chat_id` на платформу отправляется как стабильный числовой surrogate id
-- shared workspace: `/workspace`
-
-Важно: vendored platform repos чистые, совпадают с upstream. Проблема воспроизводится без локальных patch'ей в платформу.
-
-## Пользовательские симптомы
-
-Примеры из живого диалога:
-
-- ожидалось: `Моя ошибка: ...`
-- фактически пришло: `оя ошибка: ...`
-
-- ожидалось начало ответа вида `По фото IMG_3183.png ...`
-- фактически пришло: `IMG_3183.png**) — это ...`
-
-Также наблюдалось:
-
-- после вопросов по изображениям бот иногда вообще перестаёт отвечать
-- в том же чате, до attachment/tool flow, ответы приходят корректно
-
-## Шаги воспроизведения
-
-1. Поднять `platform-agent` и Matrix surface на версиях выше.
-2. Отправить несколько обычных текстовых сообщений.
-3. Убедиться, что начальные ответы стримятся корректно.
-4. Отправить изображения/файлы и задать вопросы вида:
- - `что изображено на фото`
- - уточняющие follow-up вопросы по тем же вложениям
-5. Затем отправить ещё одно обычное текстовое сообщение.
-6. Наблюдать один или несколько симптомов:
- - первый chunk начинается с середины слова
- - ответ начинается с середины фразы
- - хвост прошлого ответа загрязняет следующий
- - видимого финального ответа нет вообще
-
-## Что удалось доказать
-
-По debug-логам Matrix surface видно, что обрезание присутствует уже в первом chunk, полученном от платформы.
-
-Корректные первые chunk'и до attachment/tool flow:
-
-- `Hey! How`
-- `Я`
-- `Первый файл не найден — возможно, ...`
-
-Некорректные первые chunk'и после attachment/tool flow:
-
-- `IMG_3183.png**) — это ю...`
-- `оя ошибка: в первом запросе...`
-
-Это логируется сразу после десериализации websocket event на клиентской стороне, до Matrix rendering и до финальной сборки текста ответа. Из этого следует, что повреждение происходит на стороне платформенного стриминга, а не в Matrix sender.
-
-## Дополнительное наблюдение по протоколу
-
-Платформа сейчас отправляет дублирующий `END`.
-
-Релевантные места в upstream:
-
-- `external/platform-agent/src/agent/service.py`
- - уже `yield MsgEventEnd(...)`
-- `external/platform-agent/src/api/external.py`
- - после завершения цикла дополнительно отправляет ещё один `MsgEventEnd(...)`
-
-В живых логах это видно как:
-
-- первый `END`
-- второй `END`
-- клиентская suppression логика, которая гасит дубликат
-
-Это само по себе делает границы ответа неоднозначными и повышает риск "протекания" поздних событий в следующий запрос.
-
-## Предполагаемая первопричина
-
-Похоже, что на стороне платформы одновременно есть две проблемы.
-
-### 1. Двойной сигнал завершения стрима
-
-Для одного ответа генерируется два `END`.
-
-Вероятные последствия:
-
-- нечёткая граница ответа
-- поздние события могут относиться не к тому запросу
-- соседние ответы могут смешиваться
-
-### 2. Некорректное извлечение текстового chunk'а
-
-В текущем upstream `platform-agent/src/agent/service.py` текст форвардится из `chunk.content` внутри `on_chat_model_stream`.
-
-Однако в документации LangChain для `astream_events()` примеры используют `event["data"]["chunk"].text`, а в Deep Agents stream дополнительно есть `ns`/`source`, которые важны для отделения main-agent output от tool/subagent/model-internal stream.
-
-Потенциальные последствия:
-
-- первый видимый chunk может быть неполным
-- во внешний клиент может попадать не только финальный пользовательский текст
-- attachment/tool flow сильнее деградирует поведение стрима
-
-## Почему проблема считается платформенной
-
-С нашей стороны были проверены и исключены базовые причины:
-
-- вложения корректно сохраняются в `/workspace`
-- контейнер `platform-agent` видит эти файлы
-- Matrix surface получает уже обрезанный первый chunk от платформы
-- обрезание происходит до сборки финального ответа
-- эксперимент с reconnect на каждый запрос не исправил проблему
-- платформенные vendored repos сейчас совпадают с upstream
-
-## Ожидаемое поведение
-
-Для каждого пользовательского запроса:
-
-- текстовые chunk'и должны начинаться с реального начала ответа модели
-- должен приходить ровно один terminal `END`
-- границы ответов должны быть однозначными
-- file/tool flow не должен ломать следующий ответ
-
-## Фактическое поведение
-
-После attachment/tool flow:
-
-- первый text chunk может быть уже обрезан
-- `END` приходит дважды
-- следующий ответ может начаться с середины слова или фразы
-- отдельные запросы могут не завершаться видимым ответом
-
-## Дополнительный failure mode: большие изображения
-
-В отдельном воспроизведении текстовые запросы до файлового сценария работали корректно, а сбой возникал только после попытки анализа изображений.
-
-По логам видно уже не только stream corruption, но и конкретный image-path failure:
-
-- `platform-agent` рвёт websocket с `1009 (message too big)`
-- провайдер возвращает `400` с причиной:
- - `Exceeded limit on max bytes per data-uri item : 10485760`
-
-Характерный фрагмент:
-
-```text
-websockets.exceptions.ConnectionClosedError: received 1009 (message too big); then sent 1009 (message too big)
-...
-Agent error (INTERNAL_ERROR): Error code: 400 - {
- 'error': {
- 'message': 'Provider returned error',
- 'metadata': {
- 'raw': '{"error":{"message":"Exceeded limit on max bytes per data-uri item : 10485760"...}}'
- }
- }
-}
-```
-
-Из этого следует:
-
-- текстовый path сам по себе работоспособен
-- image-analysis path в платформе сейчас передаёт изображение как data URI
-- для достаточно больших изображений это утыкается в provider limit `10,485,760` байт на один data-uri item
-- параллельно websocket path между surface и platform-agent неустойчив к этому failure scenario и закрывается с `1009`
-
-То есть в платформе есть как минимум ещё одна отдельная проблема помимо некорректного стриминга:
-
-- отсутствует безопасная обработка больших изображений до отправки в provider
-- отсутствует аккуратная деградация без разрыва websocket-сессии
-
-## Что стоит исправить в платформе
-
-1. Отправлять ровно один `MsgEventEnd` на один ответ.
-2. Перепроверить extraction текста из `on_chat_model_stream`:
- - вероятно, должен использоваться `chunk.text`, а не `chunk.content`.
-3. Учитывать `ns`/`source` и форвардить наружу только main assistant output.
-4. Перед отправкой изображений в provider проверять размер payload и не пытаться слать oversized data-uri.
-5. Для больших изображений:
- - либо делать resize/compression,
- - либо возвращать контролируемую user-facing ошибку без разрыва websocket.
-6. В идеале добавить более жёсткую request boundary в websocket protocol, чтобы late events не могли прилипать к следующему запросу.
-
-## Наши временные mitigation'ы на стороне surface
-
-Они не исправляют корень, только снижают ущерб:
-
-- suppression duplicate `END`
-- короткий post-`END` drain window
-- idle timeout для зависшего стрима
-- transport failures нормализуются в `PlatformError`, чтобы Matrix bot не падал процессом
-
-Эти меры понадобились только потому, что в текущем сценарии platform stream contract нестабилен.
-
-## Приложение: характерный фрагмент логов
-
-```text
-[matrix-bot] text chunk queue=True text='Первый файл не найден — возможно,'
-[matrix-bot] ...
-[matrix-bot] end event queue=True tokens=0
-[matrix-bot] end event queue=True tokens=0
-[matrix-bot] dropped duplicate END tokens=0
-[matrix-bot] text chunk queue=True text='IMG_3183.png**) — это ю'
-[matrix-bot] ...
-[matrix-bot] end event queue=True tokens=0
-[matrix-bot] end event queue=True tokens=0
-[matrix-bot] dropped duplicate END tokens=0
-[matrix-bot] text chunk queue=True text='оя ошибка: в первом запросе я случайно постав'
-```
-
-Этот фрагмент показывает две вещи:
-
-- duplicate `END` действительно приходит от платформы
-- следующий первый chunk уже приходит в клиента обрезанным
-
-## Приложение: характерный фрагмент логов для больших изображений
-
-```text
-platform-agent-1 | websockets.exceptions.ConnectionClosedError: received 1009 (message too big); then sent 1009 (message too big)
-...
-matrix-bot-1 | Agent error (INTERNAL_ERROR): Error code: 400 - {'error': {'message': 'Provider returned error', 'code': 400, 'metadata': {'raw': '{"error":{"message":"Exceeded limit on max bytes per data-uri item : 10485760","type":"invalid_request_error"...}}}}
-```
-
-Этот фрагмент показывает ещё две вещи:
-
-- image path в платформе реально упирается в лимит провайдера на размер data URI
-- при этом ошибка не изолируется корректно и сопровождается разрывом websocket-соединения
diff --git a/docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md b/docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md
deleted file mode 100644
index d03adc6..0000000
--- a/docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md
+++ /dev/null
@@ -1,294 +0,0 @@
-# Финальный баг-репорт: потеря начала ответа и сбои streaming/image path в `platform-agent`
-
-## Статус
-
-Это финальный отчёт после полного аудита интеграции `surfaces -> platform-agent_api -> platform-agent`.
-
-Итог:
-
-- текущая реализация `surfaces` рабочая, но проблемная из-за upstream-дефектов платформы
-- баг с пропадающим началом ответа на текущем состоянии **не локализуется в `surfaces`**
-- в воспроизведённом сценарии повреждённый первый текстовый chunk рождается уже внутри `platform-agent`
-- помимо этого подтверждены ещё два независимых platform-side дефекта:
- - duplicate `END`
- - некорректная обработка больших изображений (`data-uri > 10 MB`, `WS 1009`)
-
-## Версии и состояние кода
-
-Рантайм воспроизводился на vendored upstream-репозиториях без локальных platform patch’ей:
-
-- `platform-agent`: `5e7c2df954cc3cd2f5bf8ae688e10a20038dde61`
-- `platform-agent_api`: `aa480bbec5bbf8e006284dd03aed1c2754e9bbee`
-
-Со стороны `surfaces` transport layer был предварительно очищен:
-
-- убрана локальная stream-semantics из `sdk/agent_api_wrapper.py`
-- `sdk/real.py` переведён на pinned upstream `platform-agent_api.AgentApi`
-- больше нет локального post-END drain, custom listener и wrapper-owned reclassification events
-
-Это важно: баг воспроизводился **после** удаления наших транспортных костылей.
-
-## Контекст интеграции
-
-- поверхность: Matrix
-- транспорт к платформе: WebSocket через upstream `platform-agent_api.AgentApi`
-- `chat_id` на платформу: стабильный числовой surrogate id, выдаваемый со стороны `surfaces`
-- файловый контракт: shared `/workspace`, вложения передаются как относительные пути в `attachments`
-
-## Пользовательские симптомы
-
-Наблюдались несколько классов сбоев:
-
-1. Начало ответа может пропасть
-- ожидалось: `Моя ошибка: ...`
-- фактически: `оя ошибка: ...`
-
-- ожидалось: `На двух изображениях: ...`
-- фактически: ` двух изображениях: ...`
-
-2. После tool/file flow ответы могут вести себя нестабильно
-- следующий ответ стартует с середины фразы
-- в некоторых сценариях после image/tool path платформа отвечает ошибкой или замолкает
-
-3. На больших изображениях image path падает совсем
-- provider error `Exceeded limit on max bytes per data-uri item : 10485760`
-- websocket закрывается с `1009 (message too big)`
-
-## Что было проверено на стороне `surfaces`
-
-Ниже перечислено, что именно было перепроверено в нашем коде и почему это не выглядит корнем проблемы.
-
-### 1. Мы больше не режем и не переклассифицируем stream локально
-
-В текущем `surfaces`:
-
-- `sdk/agent_api_wrapper.py` — thin construction/factory shim над upstream `AgentApi`
-- `sdk/real.py` — просто итерирует upstream events и склеивает `MsgEventTextChunk.text`
-- `adapter/matrix/bot.py` — отправляет `OutgoingMessage.text` в Matrix без `strip/lstrip`
-
-Наблюдение:
-
-- в текущем коде не осталось места, где строка могла бы превратиться из `Моя ошибка` в `оя ошибка` через локальный slicing
-
-### 2. Сборка ответа у нас линейная и тупая
-
-`sdk/real.py` делает только следующее:
-
-- если пришёл `MsgEventTextChunk` — добавляет `event.text` в `response_parts`
-- если пришёл `MsgEventSendFile` — превращает его в `Attachment`
-- не пытается “восстанавливать” поток после `END`
-
-Следствие:
-
-- если начало уже отсутствует в `event.text`, мы его не можем ни потерять, ни вернуть
-
-### 3. Matrix sender не модифицирует текст
-
-`adapter/matrix/bot.py` передаёт текст дальше как есть.
-
-Следствие:
-
-- Matrix renderer не является объяснением пропажи первого куска
-
-## Что было проверено в `platform-agent_api`
-
-Upstream client всё ещё имеет спорную queue-архитектуру:
-
-- одна активная `_current_queue`
-- `MsgEventEnd` съедается внутри `send_message()`
-- в `finally` очередь отвязывается и дренится orphan messages
-
-Это архитектурно хрупко и может быть источником других boundary bugs.
-
-Но в конкретном воспроизведении этот слой не был точкой порчи текста.
-
-Почему:
-
-- в raw logs клиент получил **ровно тот же** первый text chunk, который сервер уже отправил
-- queue/dequeue не изменили его содержимое
-
-## Что удалось доказать по raw logs
-
-Для финальной проверки была временно добавлена точечная диагностика в:
-
-- `external/platform-agent/src/agent/service.py`
-- `external/platform-agent/src/api/external.py`
-- `external/platform-agent_api/lambda_agent_api/agent_api.py`
-
-Эта диагностика **не входила** в рабочую реализацию и использовалась только для локализации бага.
-
-### Ключевое наблюдение
-
-На проблемном запросе после tool/file flow сервер сам yield’ил уже обрезанный первый chunk:
-
-```text
-platform-agent-1 | [raw-stream][server-yield] chat=1 event=TEXT text=' двух изображениях:\n\n**Первое изображение'
-platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение' path=None
-matrix-bot-1 | [raw-stream][client-listen] agent=matrix-bot chat=1 queue_active=True AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение'
-matrix-bot-1 | [raw-stream][client-dequeue] agent=matrix-bot chat=1 request=2 AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение'
-```
-
-Это означает:
-
-- порча произошла **до** websocket-клиента
-- `surfaces` transport layer не является источником именно этого дефекта
-- `platform-agent_api` не исказил этот конкретный chunk по дороге
-
-Дополнительно тот же паттерн виден и вне image-сценария:
-
-```text
-platform-agent-1 | [raw-stream][server-yield] chat=1 event=TEXT text='сё работает напрямую'
-...
-matrix-bot-1 | [raw-stream][client-dequeue] ... text='сё работает напрямую'
-```
-
-То есть сервер уже выдаёт `сё`, а не `Всё`.
-
-## Наиболее вероятный root cause
-
-Главный подозреваемый — `external/platform-agent/src/agent/service.py`.
-
-Сейчас он делает следующее:
-
-- читает `self._agent.astream_events(...)`
-- обрабатывает только `kind == "on_chat_model_stream"`
-- берёт `chunk = event["data"]["chunk"]`
-- если `chunk.content`, форвардит `MsgEventTextChunk(text=chunk.content)`
-
-Проблема в том, что это очень грубое преобразование raw event stream в пользовательский текст.
-
-### Почему именно это место выглядит корнем
-
-1. Первый битый chunk уже рождается на server-side
-- это подтверждено логами выше
-
-2. Код берёт только `chunk.content`
-- если начало ответа приходит в другой форме, поле или raw event, оно просто теряется
-
-3. Код не учитывает `ns` / `source`
-- после tool/vision flow у Deep Agents / LangChain может быть более сложная структура потока
-- текущий adapter flatten’ит её слишком агрессивно
-
-4. Код никак не валидирует, что наружу уходит именно main assistant output
-
-Итоговая гипотеза:
-
-> После tool/file/vision flow `platform-agent` неправильно адаптирует `astream_events()` в `MsgEventTextChunk`. Начало итогового пользовательского ответа может находиться не в том raw event, который сейчас читается через `chunk.content`, либо теряться из-за упрощённой фильтрации потока.
-
-## Подтверждённый отдельный баг: duplicate `END`
-
-Это отдельный platform-side дефект.
-
-Сейчас:
-
-- `external/platform-agent/src/agent/service.py` уже делает `yield MsgEventEnd(...)`
-- `external/platform-agent/src/api/external.py` после завершения цикла дополнительно отправляет ещё один `MsgEventEnd(...)`
-
-По логам это выглядит так:
-
-```text
-platform-agent-1 | [raw-stream][server-yield] chat=1 event=END
-platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_END text=None path=None
-platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_END duplicate_end=true
-matrix-bot-1 | ... AGENT_EVENT_END tokens_used=0
-matrix-bot-1 | ... AGENT_EVENT_END tokens_used=0
-```
-
-Независимая оценка:
-
-- duplicate `END` — реальный баг платформы
-- он делает границу ответа менее надёжной
-- но в текущем воспроизведении он **не объясняет** сам факт потери первого text chunk
-
-То есть это важный, но вторичный дефект.
-
-## Подтверждённый отдельный баг: большие изображения ломают image path
-
-В отдельном воспроизведении платформа падала на анализе изображений с provider error:
-
-```text
-Exceeded limit on max bytes per data-uri item : 10485760
-```
-
-И параллельно websocket рвался с:
-
-```text
-received 1009 (message too big); then sent 1009 (message too big)
-```
-
-Это означает:
-
-- image path отправляет в provider oversized `data:` URI
-- безопасной предвалидации / деградации нет
-- failure scenario сопровождается разрывом websocket-соединения
-
-Независимая оценка:
-
-- это отдельный platform-side bug
-- он не объясняет потерю первого чанка в текстовом сценарии напрямую
-- но подтверждает, что path `tool/file/image -> platform stream` в целом сейчас нестабилен
-
-## Что мы считаем исключённым
-
-С достаточной уверенностью можно исключить:
-
-1. Локальный slicing текста в `surfaces`
-2. Локальную “умную” реконструкцию потока, потому что она была удалена
-3. Matrix sender как источник потери первого чанка
-4. `platform-agent_api` queue/dequeue как primary root cause именно в этом воспроизведении
-
-## Финальная независимая оценка
-
-Текущая оценка вероятностей:
-
-- `75%` — ошибка в `platform-agent`, в адаптере `astream_events() -> MsgEventTextChunk`
-- `15%` — provider/model stream приносит начало ответа в другой raw event/field, а `platform-agent` его некорректно интерпретирует
-- `10%` — вторичные platform-side boundary defects (`duplicate END`, queue semantics и т.д.)
-- `~0-5%` — ошибка в `surfaces`
-
-Итоговый вывод:
-
-> На текущем состоянии кода баг с пропадающим началом ответа следует считать platform-side дефектом. Воспроизведение после cleanup transport layer показывает, что первый повреждённый text chunk формируется уже внутри `platform-agent` до отправки в websocket.
-
-## Что нужно исправить в платформе
-
-### Обязательно
-
-1. Убрать duplicate `END`
-- один ответ должен завершаться ровно одним `MsgEventEnd`
-
-2. Перепроверить адаптацию `astream_events()` в `service.py`
-- логировать и проанализировать raw `event["event"]`
-- проверить `event.get("name")`
-- смотреть `event.get("ns")`
-- сравнить `chunk.content` с тем, что реально лежит в `chunk.text` / raw chunk repr
-
-3. Форвардить наружу только финальный main assistant output
-- не flatten’ить весь поток без учёта `ns/source`
-
-### Желательно
-
-4. Сделать image path устойчивым к oversized payload
-- preflight check размера
-- resize/compress или controlled error без разрыва WS
-
-5. Улучшить client/server protocol boundary
-- более строгая корреляция запроса и ответа
-- более однозначная semantics конца ответа
-
-## Что мы сделали со своей стороны
-
-Со стороны `surfaces` уже выполнено следующее:
-
-- transport layer очищен до thin adapter над upstream `AgentApi`
-- локальные stream-workaround’ы удалены
-- рабочая интеграция сохранена
-- known issue задокументирован
-
-То есть текущая интеграция не “идеальна”, но её поведение теперь достаточно чистое, чтобы platform bug было можно локализовать без смешения ответственности.
-
-## Приложение: короткий диагноз
-
-Если нужна самая короткая формулировка для issue tracker:
-
-> После cleanup transport layer в `surfaces` и воспроизведения на clean upstream platform repos видно, что `platform-agent` иногда сам порождает первый `MsgEventTextChunk` уже обрезанным, особенно после tool/file flow. Дополнительно подтверждены duplicate `END` и отдельный image-path failure на больших `data:` URI.
diff --git a/docs/research/aiogram-architecture-review.md b/docs/research/aiogram-architecture-review.md
deleted file mode 100644
index c0a7946..0000000
--- a/docs/research/aiogram-architecture-review.md
+++ /dev/null
@@ -1,172 +0,0 @@
-# Ресёрч: aiogram 3.x Architecture Review
-
-> **Дата:** 2026-03-30
-> **Вердикт:** APPROVED с двумя уточнениями
-
----
-
-## 1. Структура проекта
-
-**Официальный пример multi_file_bot:**
-```
-multi_file_bot/
- bot.py
- handlers/
- common.py
- ...
-```
-
-**Best practice для средних проектов (наш случай):**
-```
-adapter/telegram/
- bot.py ← Dispatcher + include_routers + polling/webhook
- converter.py ← граница aiogram ↔ core/
- states.py ← все StatesGroup
- handlers/ ← по одному Router на модуль
- keyboards/ ← InlineKeyboardBuilder фабрики
- middleware.py ← DI + logging + rate limit
-```
-
-**Оценка:** наша структура соответствует стандарту. ✓
-
----
-
-## 2. Middleware vs Converter
-
-В aiogram 3.x эти два паттерна решают **разные задачи** и должны использоваться вместе.
-
-| | Middleware | Converter |
-|---|---|---|
-| Назначение | Infrastructure | Бизнес-логика |
-| Что делает | Логирование, DI, rate limit, сессия БД | aiogram Event → IncomingEvent |
-| Когда вызывается | До и после хендлера | Внутри хендлера |
-
-**Правильная комбинация:**
-```python
-# middleware.py — только infrastructure
-class DependencyMiddleware(BaseMiddleware):
- def __init__(self, platform, store):
- self.platform = platform
- self.store = store
-
- async def __call__(self, handler, event, data):
- data["platform"] = self.platform
- data["store"] = self.store
- return await handler(event, data)
-
-# handler — converter вызывается внутри
-async def handle_message(message: Message, platform, store):
- event = to_incoming_message(message) # converter
- results = await dispatcher.dispatch(event, platform, store)
- await send_results(message, results) # converter обратно
-```
-
-**Оценка:** наш converter.py — правильный паттерн. Добавить `middleware.py` для DI. ✓+
-
----
-
-## 3. Dependency Injection
-
-Стандарт aiogram 3.x — **через middleware + data dict**:
-
-```python
-# Регистрация в bot.py
-dp.message.middleware(DependencyMiddleware(platform=platform_client, store=store))
-
-# Получение в handler (через type hint на имя ключа)
-async def handle_message(message: Message, platform: PlatformClient, store: StateStore):
- ...
-```
-
-Альтернатива — через `dp["key"] = value` (Dispatcher workflow data):
-```python
-dp["platform"] = platform_client # в bot.py
-
-async def handler(message: Message, platform: PlatformClient): # aiogram сам находит по типу
- ...
-```
-
-**Оценка:** нужно явно добавить один из этих механизмов, иначе хендлеры не получат platform/store. ⚠️
-
----
-
-## 4. InlineKeyboardBuilder
-
-`InlineKeyboardBuilder` — рекомендуемый подход в aiogram 3.x. `InlineKeyboardMarkup` с вложенными списками считается устаревшим стилем.
-
-```python
-# keyboards/chat.py
-from aiogram.utils.keyboard import InlineKeyboardBuilder
-
-def chats_keyboard(chats: list[ChatContext]) -> InlineKeyboardMarkup:
- builder = InlineKeyboardBuilder()
- for chat in chats:
- builder.button(text=f"💬 {chat.name}", callback_data=f"chat:{chat.chat_id}")
- builder.button(text="➕ Новый чат", callback_data="new_chat")
- builder.adjust(1) # одна кнопка в строку
- return builder.as_markup()
-```
-
-**Оценка:** использовать `InlineKeyboardBuilder` везде. ✓
-
----
-
-## 5. F-фильтры (MagicFilter)
-
-aiogram 3.x MagicFilter (`F`) — стандарт вместо ручных проверок в хендлерах:
-
-```python
-from aiogram import F
-
-# Вместо if message.text == "/start" внутри хендлера
-router.message.register(start_handler, Command("start"))
-
-# Фильтр по типу вложения
-router.message.register(voice_handler, F.voice)
-router.message.register(photo_handler, F.photo)
-
-# Фильтр по состоянию
-router.message.register(handle_name_input, OnboardingState.waiting_for_name)
-
-# Callback фильтр
-router.callback_query.register(confirm_handler, F.data.startswith("confirm:"))
-```
-
-**Оценка:** использовать F-фильтры при регистрации роутеров — чище, чем if/else в хендлерах. ✓
-
----
-
-## 6. Сцены (Scenes) — новинка aiogram 3.x
-
-aiogram 3.4+ ввёл `Scene` как улучшенный FSM для сложных диалогов:
-
-```python
-from aiogram.fsm.scene import Scene, on
-
-class OnboardingScene(Scene, state="onboarding"):
- @on.message.enter()
- async def on_enter(self, message: Message):
- await message.answer("Как зовут твоего агента?")
-
- @on.message()
- async def on_name(self, message: Message, state: FSMContext):
- await state.update_data(agent_name=message.text)
- await self.wizard.goto(OnboardingScene2)
-```
-
-**Оценка:** Scenes — опциональное улучшение для онбординга. Классический FSM через StatesGroup тоже корректен и проще для понимания. Использовать StatesGroup для прототипа, Scenes — в будущем. ✓
-
----
-
-## Итог
-
-| Решение | Статус |
-|---|---|
-| Router-based архитектура, один Router на модуль | ✅ Стандарт |
-| converter.py как граница aiogram ↔ core/ | ✅ Правильный паттерн |
-| InlineKeyboardBuilder в keyboards/ | ✅ Рекомендуется |
-| SQLiteStorage для FSM | ✅ Стандарт для MVP |
-| **Нужно добавить: DependencyMiddleware** | ⚠️ DI без него не работает |
-| **Нужно добавить: F-фильтры при регистрации** | ⚠️ Иначе проверки в хендлерах |
-
-**Архитектура одобрена.** Два уточнения (middleware.py и F-фильтры) небольшие и органично вписываются в текущую структуру.
diff --git a/docs/superpowers/plans/2026-03-31-forum-topics.md b/docs/superpowers/plans/2026-03-31-forum-topics.md
deleted file mode 100644
index 87a92b2..0000000
--- a/docs/superpowers/plans/2026-03-31-forum-topics.md
+++ /dev/null
@@ -1,704 +0,0 @@
-# Forum Topics Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Добавить опциональный Forum Topics режим — пользователь подключает Telegram-супергруппу, его DM-чаты синхронизируются с нативными темами форума.
-
-**Architecture:** Каждый `chat` в БД получает опциональный `forum_thread_id`. Адаптер маршрутизирует: пришло из DM → отвечает в DM с тегом, пришло из Forum-темы → отвечает в ту же тему без тега. Core не меняется — `chat_id` (UUID) одинаковый для обеих поверхностей.
-
-**Tech Stack:** aiogram 3.x, SQLite (sqlite3), Python 3.11+
-
-**Working directory:** `/Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram`
-
----
-
-## File Map
-
-| Файл | Действие | Что меняется |
-|------|----------|--------------|
-| `adapter/telegram/db.py` | Modify | Миграция схемы + 4 новых функции |
-| `adapter/telegram/states.py` | Modify | Добавить `ForumSetupState` |
-| `adapter/telegram/converter.py` | Modify | Добавить `is_forum_message`, `resolve_chat_id` |
-| `adapter/telegram/handlers/forum.py` | Create | `/forum` команда + онбординг |
-| `adapter/telegram/handlers/chat.py` | Modify | `cmd_new_chat` + `handle_message` с Forum-маршрутизацией |
-| `adapter/telegram/bot.py` | Modify | Зарегистрировать `forum.router` |
-| `tests/adapter/test_forum_db.py` | Create | Тесты новых функций БД |
-
----
-
-## Task 1: DB migration + новые функции
-
-**Files:**
-- Modify: `adapter/telegram/db.py`
-- Create: `tests/adapter/__init__.py`
-- Create: `tests/adapter/test_forum_db.py`
-
-- [ ] **Step 1: Создать тест-файл и написать падающие тесты**
-
-```python
-# tests/adapter/__init__.py
-# (пустой файл)
-```
-
-```python
-# tests/adapter/test_forum_db.py
-from __future__ import annotations
-
-import os
-import tempfile
-import pytest
-
-os.environ["DB_PATH"] = ":memory:"
-
-from adapter.telegram.db import (
- init_db,
- get_or_create_tg_user,
- create_chat,
- set_forum_group,
- get_forum_group,
- set_forum_thread,
- get_chat_by_thread,
-)
-
-
-@pytest.fixture(autouse=True)
-def fresh_db(tmp_path, monkeypatch):
- db_file = str(tmp_path / "test.db")
- monkeypatch.setenv("DB_PATH", db_file)
- # reload module so DB_PATH is picked up
- import importlib
- import adapter.telegram.db as db_mod
- importlib.reload(db_mod)
- db_mod.init_db()
- return db_mod
-
-
-def test_set_and_get_forum_group(fresh_db):
- db = fresh_db
- db.get_or_create_tg_user(111, "usr-111", "Alice")
- assert db.get_forum_group(111) is None
- db.set_forum_group(111, 999888)
- assert db.get_forum_group(111) == 999888
-
-
-def test_set_forum_thread_and_get_by_thread(fresh_db):
- db = fresh_db
- db.get_or_create_tg_user(222, "usr-222", "Bob")
- chat_id = db.create_chat(222, "Чат #1")
- assert db.get_chat_by_thread(222, 42) is None
- db.set_forum_thread(chat_id, 42)
- chat = db.get_chat_by_thread(222, 42)
- assert chat is not None
- assert chat["chat_id"] == chat_id
- assert chat["forum_thread_id"] == 42
-
-
-def test_get_chat_by_thread_wrong_user(fresh_db):
- db = fresh_db
- db.get_or_create_tg_user(333, "usr-333", "Carol")
- chat_id = db.create_chat(333, "Чат #1")
- db.set_forum_thread(chat_id, 77)
- assert db.get_chat_by_thread(999, 77) is None
-```
-
-- [ ] **Step 2: Запустить тесты — убедиться что падают**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot
-PYTHONPATH=.worktrees/telegram pytest tests/adapter/test_forum_db.py -v
-```
-
-Ожидаем: `ImportError` — функции ещё не существуют.
-
-- [ ] **Step 3: Добавить миграцию и новые функции в `db.py`**
-
-В `init_db()` добавить после `CREATE TABLE IF NOT EXISTS chats`:
-
-```python
-def init_db() -> None:
- with _conn() as con:
- con.executescript("""
- CREATE TABLE IF NOT EXISTS tg_users (
- tg_user_id INTEGER PRIMARY KEY,
- platform_user_id TEXT NOT NULL,
- display_name TEXT,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- forum_group_id INTEGER
- );
-
- CREATE TABLE IF NOT EXISTS chats (
- chat_id TEXT PRIMARY KEY,
- tg_user_id INTEGER NOT NULL,
- name TEXT NOT NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- archived_at TIMESTAMP,
- forum_thread_id INTEGER,
- FOREIGN KEY(tg_user_id) REFERENCES tg_users(tg_user_id)
- );
- """)
- # Миграция для существующих БД
- try:
- con.execute("ALTER TABLE tg_users ADD COLUMN forum_group_id INTEGER")
- except Exception:
- pass
- try:
- con.execute("ALTER TABLE chats ADD COLUMN forum_thread_id INTEGER")
- except Exception:
- pass
-```
-
-Добавить в конец файла:
-
-```python
-def set_forum_group(tg_user_id: int, group_id: int) -> None:
- with _conn() as con:
- con.execute(
- "UPDATE tg_users SET forum_group_id = ? WHERE tg_user_id = ?",
- (group_id, tg_user_id),
- )
-
-
-def get_forum_group(tg_user_id: int) -> int | None:
- with _conn() as con:
- row = con.execute(
- "SELECT forum_group_id FROM tg_users WHERE tg_user_id = ?",
- (tg_user_id,),
- ).fetchone()
- return row["forum_group_id"] if row else None
-
-
-def set_forum_thread(chat_id: str, thread_id: int) -> None:
- with _conn() as con:
- con.execute(
- "UPDATE chats SET forum_thread_id = ? WHERE chat_id = ?",
- (thread_id, chat_id),
- )
-
-
-def get_chat_by_thread(tg_user_id: int, thread_id: int) -> dict | None:
- with _conn() as con:
- row = con.execute(
- "SELECT * FROM chats WHERE tg_user_id = ? AND forum_thread_id = ? "
- "AND archived_at IS NULL",
- (tg_user_id, thread_id),
- ).fetchone()
- return dict(row) if row else None
-```
-
-- [ ] **Step 4: Запустить тесты — убедиться что проходят**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot
-PYTHONPATH=.worktrees/telegram pytest tests/adapter/test_forum_db.py -v
-```
-
-Ожидаем: `3 passed`.
-
-- [ ] **Step 5: Убедиться что все тесты проекта не сломались**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot
-PYTHONPATH=.worktrees/telegram pytest tests/ -v
-```
-
-Ожидаем: все тесты `passed`.
-
-- [ ] **Step 6: Commit**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
-git add adapter/telegram/db.py ../../tests/adapter/
-git commit -m "feat: db migration + forum_group_id/forum_thread_id functions"
-```
-
----
-
-## Task 2: ForumSetupState в states.py
-
-**Files:**
-- Modify: `adapter/telegram/states.py`
-
-- [ ] **Step 1: Добавить ForumSetupState**
-
-```python
-# adapter/telegram/states.py
-from aiogram.fsm.state import State, StatesGroup
-
-
-class ChatState(StatesGroup):
- idle = State()
- waiting_response = State()
-
-
-class SettingsState(StatesGroup):
- menu = State()
- soul_editing = State()
- confirm_action = State()
-
-
-class ForumSetupState(StatesGroup):
- waiting_for_group = State() # ждём пересылку из группы
-```
-
-- [ ] **Step 2: Проверить синтаксис**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot
-uv run python -m py_compile .worktrees/telegram/adapter/telegram/states.py && echo OK
-```
-
-Ожидаем: `OK`.
-
-- [ ] **Step 3: Commit**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
-git add adapter/telegram/states.py
-git commit -m "feat: add ForumSetupState"
-```
-
----
-
-## Task 3: converter.py — is_forum_message и resolve_chat_id
-
-**Files:**
-- Modify: `adapter/telegram/converter.py`
-
-- [ ] **Step 1: Добавить функции в converter.py**
-
-Добавить в конец файла (после `format_outgoing`):
-
-```python
-def is_forum_message(message: Message) -> bool:
- """Сообщение пришло из Forum-темы (не из General и не из DM)."""
- return (
- message.message_thread_id is not None
- and message.chat.type in ("supergroup", "group")
- )
-
-
-def resolve_forum_chat_id(message: Message) -> str | None:
- """
- Для Forum-сообщения ищет chat_id (UUID) по forum_thread_id в БД.
- Возвращает None если тема не зарегистрирована.
- """
- from adapter.telegram import db
- tg_user_id = message.from_user.id
- thread_id = message.message_thread_id
- chat = db.get_chat_by_thread(tg_user_id, thread_id)
- return chat["chat_id"] if chat else None
-```
-
-- [ ] **Step 2: Проверить синтаксис**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot
-uv run python -m py_compile .worktrees/telegram/adapter/telegram/converter.py && echo OK
-```
-
-Ожидаем: `OK`.
-
-- [ ] **Step 3: Commit**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
-git add adapter/telegram/converter.py
-git commit -m "feat: add is_forum_message and resolve_forum_chat_id to converter"
-```
-
----
-
-## Task 4: handlers/forum.py — /forum и онбординг
-
-**Files:**
-- Create: `adapter/telegram/handlers/forum.py`
-
-- [ ] **Step 1: Создать handlers/forum.py**
-
-```python
-# adapter/telegram/handlers/forum.py
-from __future__ import annotations
-
-from aiogram import Bot, F, Router
-from aiogram.filters import Command
-from aiogram.fsm.context import FSMContext
-from aiogram.types import Message
-
-from adapter.telegram import db
-from adapter.telegram.states import ChatState, ForumSetupState
-
-router = Router(name="forum")
-
-
-async def _check_forum_admin(bot: Bot, group_id: int) -> bool:
- """Проверяет что бот — администратор с правом управления темами."""
- try:
- me = await bot.get_me()
- member = await bot.get_chat_member(group_id, me.id)
- return (
- member.status in ("administrator", "creator")
- and getattr(member, "can_manage_topics", False)
- )
- except Exception:
- return False
-
-
-@router.message(Command("forum"))
-async def cmd_forum(message: Message, state: FSMContext) -> None:
- await state.set_state(ForumSetupState.waiting_for_group)
- await message.answer(
- "📋 Подключение Forum-группы\n\n"
- "1. Создай супергруппу в Telegram\n"
- "2. Включи Topics: настройки группы → Topics\n"
- "3. Добавь меня как администратора с правом управления темами\n"
- "4. Перешли мне любое сообщение из этой группы\n\n"
- "Или /cancel чтобы отменить."
- )
-
-
-@router.message(ForumSetupState.waiting_for_group, Command("cancel"))
-async def cmd_cancel_forum(message: Message, state: FSMContext) -> None:
- await state.set_state(ChatState.idle)
- await message.answer("❌ Настройка форума отменена.")
-
-
-@router.message(ForumSetupState.waiting_for_group, F.forward_from_chat)
-async def handle_group_forward(
- message: Message,
- state: FSMContext,
-) -> None:
- group = message.forward_from_chat
-
- if group.type != "supergroup":
- await message.answer(
- "⚠️ Это не супергруппа. Нужна именно супергруппа с включёнными Topics."
- )
- return
-
- group_id = group.id
-
- if not await _check_forum_admin(message.bot, group_id):
- await message.answer(
- "⚠️ Не могу управлять темами в этой группе.\n\n"
- "Убедись что:\n"
- "• Я добавлен как администратор\n"
- "• У меня есть право «Управление темами»"
- )
- return
-
- tg_id = message.from_user.id
- db.set_forum_group(tg_id, group_id)
-
- # Создать Forum-темы для всех существующих активных DM-чатов
- chats = db.get_user_chats(tg_id)
- created = 0
- for chat in chats:
- if chat.get("forum_thread_id"):
- continue # уже есть тема
- try:
- topic = await message.bot.create_forum_topic(
- chat_id=group_id,
- name=chat["name"],
- )
- db.set_forum_thread(chat["chat_id"], topic.message_thread_id)
- created += 1
- except Exception:
- pass # не страшно — тему можно создать позже через /new
-
- await state.set_state(ChatState.idle)
- await message.answer(
- f"✅ Группа «{group.title}» подключена!\n"
- f"Создано тем в форуме: {created} из {len(chats)}.\n\n"
- "Теперь можешь писать как в DM, так и в темах форума."
- )
-
-
-@router.message(ForumSetupState.waiting_for_group)
-async def handle_forward_wrong(message: Message) -> None:
- await message.answer(
- "Жду пересланное сообщение из группы. "
- "Перешли любое сообщение из своей супергруппы."
- )
-```
-
-- [ ] **Step 2: Проверить синтаксис**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot
-uv run python -m py_compile .worktrees/telegram/adapter/telegram/handlers/forum.py && echo OK
-```
-
-Ожидаем: `OK`.
-
-- [ ] **Step 3: Commit**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
-git add adapter/telegram/handlers/forum.py
-git commit -m "feat: add handlers/forum.py — /forum onboarding flow"
-```
-
----
-
-## Task 5: handlers/chat.py — Forum-маршрутизация
-
-**Files:**
-- Modify: `adapter/telegram/handlers/chat.py`
-
-- [ ] **Step 1: Обновить импорты в chat.py**
-
-Заменить блок импортов целиком:
-
-```python
-# adapter/telegram/handlers/chat.py
-from __future__ import annotations
-
-import asyncio
-
-from aiogram import F, Router
-from aiogram.filters import Command
-from aiogram.fsm.context import FSMContext
-from aiogram.types import CallbackQuery, Message
-
-from adapter.telegram import db
-from adapter.telegram.converter import (
- format_outgoing,
- from_message,
- is_forum_message,
- resolve_forum_chat_id,
-)
-from adapter.telegram.keyboards.chat import chats_list_keyboard
-from adapter.telegram.keyboards.confirm import confirm_keyboard
-from adapter.telegram.states import ChatState
-from core.handler import EventDispatcher
-from core.protocol import OutgoingMessage, OutgoingUI
-
-router = Router(name="chat")
-```
-
-- [ ] **Step 2: Обновить `_send_outgoing` — добавить Forum-вариант**
-
-Заменить функцию `_send_outgoing`:
-
-```python
-async def _send_outgoing(
- message: Message,
- chat_name: str,
- events: list,
- forum_group_id: int | None = None,
- forum_thread_id: int | None = None,
-) -> None:
- for event in events:
- if forum_group_id and forum_thread_id:
- # Ответ в Forum-тему (без тега)
- text = event.text if isinstance(event, (OutgoingMessage, OutgoingUI)) else str(event)
- if isinstance(event, OutgoingUI) and event.buttons:
- action_id = event.buttons[0].payload.get("action_id", "unknown")
- kb = confirm_keyboard(action_id)
- await message.bot.send_message(
- forum_group_id, text,
- message_thread_id=forum_thread_id,
- reply_markup=kb,
- )
- else:
- await message.bot.send_message(
- forum_group_id, text,
- message_thread_id=forum_thread_id,
- )
- else:
- # Ответ в DM с тегом
- if isinstance(event, OutgoingUI) and event.buttons:
- action_id = event.buttons[0].payload.get("action_id", "unknown")
- kb = confirm_keyboard(action_id)
- await message.answer(format_outgoing(chat_name, event), reply_markup=kb)
- elif isinstance(event, (OutgoingMessage, OutgoingUI)):
- await message.answer(format_outgoing(chat_name, event))
-```
-
-- [ ] **Step 3: Обновить `handle_message` — Forum-маршрутизация**
-
-Заменить функцию `handle_message`:
-
-```python
-@router.message(ChatState.idle, (F.text | F.photo | F.document | F.voice) & ~F.text.startswith("/"))
-async def handle_message(
- message: Message,
- state: FSMContext,
- dispatcher: EventDispatcher,
-) -> None:
- tg_id = message.from_user.id
-
- # Определяем chat_id и канал ответа
- if is_forum_message(message):
- chat_id = resolve_forum_chat_id(message)
- if not chat_id:
- await message.reply(
- "Эта тема не зарегистрирована как чат. "
- "Введи /new в этой теме чтобы создать чат."
- )
- return
- chat = db.get_chat_by_thread(tg_id, message.message_thread_id)
- chat_name = chat["name"]
- forum_group_id = message.chat.id
- forum_thread_id = message.message_thread_id
- else:
- data = await state.get_data()
- chat_id = data.get("active_chat_id")
- chat_name = data.get("active_chat_name", "Чат")
- forum_group_id = None
- forum_thread_id = None
-
- if not chat_id:
- await message.answer("Нет активного чата. Введите /start")
- return
-
- await state.set_state(ChatState.waiting_response)
-
- async def _typing_loop():
- while True:
- await message.bot.send_chat_action(message.chat.id, "typing")
- await asyncio.sleep(4)
-
- task = asyncio.create_task(_typing_loop())
- try:
- tg_user = db.get_or_create_tg_user(tg_id, str(tg_id), message.from_user.full_name)
- platform_user_id = tg_user.get("platform_user_id", str(tg_id))
-
- incoming = from_message(message, chat_id)
- incoming.user_id = platform_user_id
- events = await dispatcher.dispatch(incoming)
- finally:
- task.cancel()
- try:
- await task
- except asyncio.CancelledError:
- pass
-
- await state.set_state(ChatState.idle)
- await _send_outgoing(message, chat_name, events, forum_group_id, forum_thread_id)
-```
-
-- [ ] **Step 4: Обновить `cmd_new_chat` — ветвление DM vs Forum**
-
-Заменить функцию `cmd_new_chat`:
-
-```python
-@router.message(Command("new"))
-async def cmd_new_chat(message: Message, state: FSMContext) -> None:
- tg_id = message.from_user.id
- args = message.text.split(maxsplit=1)
- name = args[1].strip() if len(args) > 1 else None
-
- if is_forum_message(message):
- # /new в Forum-теме — регистрируем эту тему как чат
- thread_id = message.message_thread_id
- existing = db.get_chat_by_thread(tg_id, thread_id)
- if existing:
- await message.reply(f"Эта тема уже зарегистрирована как [{existing['name']}].")
- return
-
- count = db.count_chats(tg_id)
- chat_name = name or f"Чат #{count + 1}"
- chat_id = db.create_chat(tg_id, chat_name)
- db.set_forum_thread(chat_id, thread_id)
-
- await message.reply(f"✅ [{chat_name}] зарегистрирован. Пиши здесь!")
- else:
- # /new в DM
- count = db.count_chats(tg_id)
- chat_name = name or f"Чат #{count + 1}"
- chat_id = db.create_chat(tg_id, chat_name)
-
- # Если есть форум-группа — создать тему и там
- group_id = db.get_forum_group(tg_id)
- if group_id:
- try:
- topic = await message.bot.create_forum_topic(
- chat_id=group_id,
- name=chat_name,
- )
- db.set_forum_thread(chat_id, topic.message_thread_id)
- except Exception:
- pass # не блокирует создание DM-чата
-
- await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
- await state.set_state(ChatState.idle)
- await message.answer(f"✅ [{chat_name}] создан. Пиши!")
-```
-
-- [ ] **Step 5: Проверить синтаксис**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot
-uv run python -m py_compile .worktrees/telegram/adapter/telegram/handlers/chat.py && echo OK
-```
-
-Ожидаем: `OK`.
-
-- [ ] **Step 6: Запустить все тесты**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot
-PYTHONPATH=.worktrees/telegram pytest tests/ -v
-```
-
-Ожидаем: все тесты `passed`.
-
-- [ ] **Step 7: Commit**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
-git add adapter/telegram/handlers/chat.py
-git commit -m "feat: forum routing in handle_message and cmd_new_chat"
-```
-
----
-
-## Task 6: bot.py — регистрация forum.router
-
-**Files:**
-- Modify: `adapter/telegram/bot.py`
-
-- [ ] **Step 1: Добавить импорт и регистрацию router**
-
-В блоке импортов добавить:
-
-```python
-from adapter.telegram.handlers import auth, chat, confirm, forum, settings
-```
-
-В `main()` после `dp.include_router(auth.router)`:
-
-```python
- dp.include_router(auth.router)
- dp.include_router(forum.router) # ← добавить
- dp.include_router(chat.router)
- dp.include_router(settings.router)
- dp.include_router(confirm.router)
-```
-
-- [ ] **Step 2: Проверить синтаксис**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot
-uv run python -m py_compile .worktrees/telegram/adapter/telegram/bot.py && echo OK
-```
-
-Ожидаем: `OK`.
-
-- [ ] **Step 3: Финальный прогон всех тестов**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot
-PYTHONPATH=.worktrees/telegram pytest tests/ -v
-```
-
-Ожидаем: все тесты `passed`.
-
-- [ ] **Step 4: Commit**
-
-```bash
-cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram
-git add adapter/telegram/bot.py
-git commit -m "feat: register forum router in bot.py"
-```
diff --git a/docs/superpowers/plans/2026-03-31-matrix-adapter.md b/docs/superpowers/plans/2026-03-31-matrix-adapter.md
deleted file mode 100644
index 7f3ea28..0000000
--- a/docs/superpowers/plans/2026-03-31-matrix-adapter.md
+++ /dev/null
@@ -1,1681 +0,0 @@
-# Matrix Adapter Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Implement `adapter/matrix/` — Matrix bot using matrix-nio that connects to the Lambda platform via `EventDispatcher` and `MockPlatformClient`.
-
-**Architecture:** Room-type routing — each incoming event is classified by room type (chat/settings) then dispatched. DM room = C1 (first chat). Space and Settings room created lazily on first `!new`. Core business logic lives in `EventDispatcher`; the adapter converts nio events ↔ protocol events.
-
-**Tech Stack:** matrix-nio 0.21+, Python 3.11+, `SQLiteStore` (key-value), `MockPlatformClient`, pytest-asyncio
-
----
-
-## File map
-
-| File | Responsibility |
-|------|---------------|
-| `adapter/matrix/store.py` | Key-prefix helpers for room/user metadata in `StateStore` |
-| `adapter/matrix/converter.py` | nio event → `IncomingEvent`, `extract_attachments` |
-| `adapter/matrix/reactions.py` | `add_reaction`, `edit_message`, `build_skills_text` |
-| `adapter/matrix/handlers/auth.py` | Invite → join + register room + welcome message |
-| `adapter/matrix/handlers/chat.py` | Text messages, `!new`, `!chats` |
-| `adapter/matrix/handlers/confirm.py` | 👍/❌ reactions + `!yes`/`!no` |
-| `adapter/matrix/handlers/settings.py` | `!skills` (m.replace), `!soul`, `!safety`, `!plan`, `!status`, `!whoami`, `!connectors` |
-| `adapter/matrix/bot.py` | `AsyncClient`, sync loop, event routing |
-
-Store key conventions (all via `StateStore` KV):
-- `matrix_room:{room_id}` → `{room_type, chat_id, display_name, matrix_user_id}`
-- `matrix_user:{matrix_user_id}` → `{platform_user_id, display_name, space_id, settings_room_id, next_chat_index}`
-- `matrix_state:{room_id}` → `{state}` — one of `idle | waiting_response | confirm_pending | settings_active`
-- `matrix_skills_msg:{room_id}` → `{event_id}` — event_id of the last `!skills` message (for m.replace)
-
----
-
-### Task 1: Store helpers
-
-**Files:**
-- Create: `adapter/matrix/__init__.py`
-- Create: `adapter/matrix/store.py`
-- Create: `tests/adapter/__init__.py`
-- Create: `tests/adapter/matrix/__init__.py`
-- Create: `tests/adapter/matrix/test_store.py`
-
-- [ ] **Step 1: Write failing test**
-
-```python
-# tests/adapter/matrix/test_store.py
-import pytest
-from core.store import InMemoryStore
-from adapter.matrix.store import (
- get_room_meta, set_room_meta,
- get_user_meta, set_user_meta,
- get_room_state, set_room_state,
- next_chat_id,
-)
-
-
-@pytest.fixture
-def store():
- return InMemoryStore()
-
-
-async def test_room_meta_roundtrip(store):
- meta = {"room_type": "chat", "chat_id": "C1", "display_name": "Чат 1", "matrix_user_id": "@alice:m.org"}
- await set_room_meta(store, "!r:m.org", meta)
- assert await get_room_meta(store, "!r:m.org") == meta
-
-
-async def test_room_meta_missing(store):
- assert await get_room_meta(store, "!nonexistent:m.org") is None
-
-
-async def test_user_meta_roundtrip(store):
- meta = {"platform_user_id": "usr-1", "display_name": "Alice",
- "space_id": None, "settings_room_id": None, "next_chat_index": 1}
- await set_user_meta(store, "@alice:m.org", meta)
- assert await get_user_meta(store, "@alice:m.org") == meta
-
-
-async def test_room_state_roundtrip(store):
- await set_room_state(store, "!r:m.org", "idle")
- assert await get_room_state(store, "!r:m.org") == "idle"
- await set_room_state(store, "!r:m.org", "waiting_response")
- assert await get_room_state(store, "!r:m.org") == "waiting_response"
-
-
-async def test_room_state_default_idle(store):
- assert await get_room_state(store, "!unknown:m.org") == "idle"
-
-
-async def test_next_chat_id_increments(store):
- uid = "@alice:m.org"
- await set_user_meta(store, uid, {"next_chat_index": 1})
- assert await next_chat_id(store, uid) == "C1"
- assert await next_chat_id(store, uid) == "C2"
- assert await next_chat_id(store, uid) == "C3"
-```
-
-- [ ] **Step 2: Run — expect ImportError**
-
-```bash
-cd /path/to/surfaces-bot && pytest tests/adapter/matrix/test_store.py -v
-```
-
-- [ ] **Step 3: Create `__init__.py` files**
-
-```bash
-touch adapter/__init__.py adapter/matrix/__init__.py tests/adapter/__init__.py tests/adapter/matrix/__init__.py
-```
-
-- [ ] **Step 4: Implement store.py**
-
-```python
-# adapter/matrix/store.py
-from __future__ import annotations
-from core.store import StateStore
-
-
-async def get_room_meta(store: StateStore, room_id: str) -> dict | None:
- return await store.get(f"matrix_room:{room_id}")
-
-
-async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None:
- await store.set(f"matrix_room:{room_id}", meta)
-
-
-async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None:
- return await store.get(f"matrix_user:{matrix_user_id}")
-
-
-async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None:
- await store.set(f"matrix_user:{matrix_user_id}", meta)
-
-
-async def get_room_state(store: StateStore, room_id: str) -> str:
- data = await store.get(f"matrix_state:{room_id}")
- return data["state"] if data else "idle"
-
-
-async def set_room_state(store: StateStore, room_id: str, state: str) -> None:
- await store.set(f"matrix_state:{room_id}", {"state": state})
-
-
-async def next_chat_id(store: StateStore, matrix_user_id: str) -> str:
- """Allocate next chat_id (C1, C2, ...) and increment counter in user meta."""
- meta = await get_user_meta(store, matrix_user_id) or {}
- index = meta.get("next_chat_index", 1)
- meta["next_chat_index"] = index + 1
- await set_user_meta(store, matrix_user_id, meta)
- return f"C{index}"
-```
-
-- [ ] **Step 5: Run — expect all PASS**
-
-```bash
-pytest tests/adapter/matrix/test_store.py -v
-```
-Expected: 6 tests PASS.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add adapter/__init__.py adapter/matrix/__init__.py adapter/matrix/store.py \
- tests/adapter/__init__.py tests/adapter/matrix/__init__.py tests/adapter/matrix/test_store.py
-git commit -m "feat(matrix): room/user store helpers"
-```
-
----
-
-### Task 2: Converter
-
-**Files:**
-- Create: `adapter/matrix/converter.py`
-- Create: `tests/adapter/matrix/test_converter.py`
-
-- [ ] **Step 1: Write failing tests**
-
-```python
-# tests/adapter/matrix/test_converter.py
-from types import SimpleNamespace
-from core.protocol import Attachment, IncomingCallback, IncomingCommand, IncomingMessage
-from adapter.matrix.converter import from_room_event
-
-
-def text_event(body, sender="@a:m.org", event_id="$e1"):
- return SimpleNamespace(sender=sender, body=body, event_id=event_id,
- msgtype="m.text", replyto_event_id=None)
-
-
-def file_event(url="mxc://x/y", filename="doc.pdf", mime="application/pdf"):
- return SimpleNamespace(sender="@a:m.org", body=filename, event_id="$e2",
- msgtype="m.file", replyto_event_id=None,
- url=url, mimetype=mime)
-
-
-def image_event(url="mxc://x/img", mime="image/jpeg"):
- return SimpleNamespace(sender="@a:m.org", body="img.jpg", event_id="$e3",
- msgtype="m.image", replyto_event_id=None,
- url=url, mimetype=mime)
-
-
-def audio_event(url="mxc://x/audio", mime="audio/ogg"):
- return SimpleNamespace(sender="@a:m.org", body="voice.ogg", event_id="$e4",
- msgtype="m.audio", replyto_event_id=None,
- url=url, mimetype=mime)
-
-
-def reaction_event(key, reacted_to="$orig"):
- return SimpleNamespace(sender="@a:m.org", key=key, reacted_to_id=reacted_to, event_id="$r1")
-
-
-async def test_plain_text_to_incoming_message():
- result = from_room_event(text_event("Hello"), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingMessage)
- assert result.text == "Hello"
- assert result.platform == "matrix"
- assert result.chat_id == "C1"
- assert result.attachments == []
-
-
-async def test_bang_command_to_incoming_command():
- result = from_room_event(text_event("!new Analysis"), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingCommand)
- assert result.command == "new"
- assert result.args == ["Analysis"]
-
-
-async def test_bang_command_no_args():
- result = from_room_event(text_event("!skills"), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingCommand)
- assert result.command == "skills"
- assert result.args == []
-
-
-async def test_yes_to_callback():
- result = from_room_event(text_event("!yes"), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingCallback)
- assert result.action == "confirm"
-
-
-async def test_no_to_callback():
- result = from_room_event(text_event("!no"), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingCallback)
- assert result.action == "cancel"
-
-
-async def test_file_attachment():
- result = from_room_event(file_event(), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingMessage)
- assert len(result.attachments) == 1
- a = result.attachments[0]
- assert a.type == "document"
- assert a.url == "mxc://x/y"
- assert a.filename == "doc.pdf"
- assert a.mime_type == "application/pdf"
-
-
-async def test_image_attachment():
- result = from_room_event(image_event(), room_id="!r:m.org", chat_id="C1")
- assert result.attachments[0].type == "image"
- assert result.attachments[0].mime_type == "image/jpeg"
-
-
-async def test_audio_attachment():
- result = from_room_event(audio_event(), room_id="!r:m.org", chat_id="C1")
- assert result.attachments[0].type == "audio"
-
-
-async def test_confirm_reaction():
- result = from_room_event(reaction_event("👍"), room_id="!r:m.org", chat_id="C1", is_reaction=True)
- assert isinstance(result, IncomingCallback)
- assert result.action == "confirm"
-
-
-async def test_cancel_reaction():
- result = from_room_event(reaction_event("❌"), room_id="!r:m.org", chat_id="C1", is_reaction=True)
- assert isinstance(result, IncomingCallback)
- assert result.action == "cancel"
-
-
-async def test_skill_reaction_index():
- result = from_room_event(reaction_event("4️⃣"), room_id="!r:m.org", chat_id="C1", is_reaction=True)
- assert isinstance(result, IncomingCallback)
- assert result.action == "toggle_skill"
- assert result.payload["skill_index"] == 3 # 0-based
-
-
-async def test_unknown_reaction_returns_none():
- result = from_room_event(reaction_event("🎉"), room_id="!r:m.org", chat_id="C1", is_reaction=True)
- assert result is None
-```
-
-- [ ] **Step 2: Run — expect ImportError**
-
-```bash
-pytest tests/adapter/matrix/test_converter.py -v
-```
-
-- [ ] **Step 3: Implement converter.py**
-
-```python
-# adapter/matrix/converter.py
-from __future__ import annotations
-from core.protocol import Attachment, IncomingCallback, IncomingCommand, IncomingEvent, IncomingMessage
-
-SKILL_REACTIONS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"]
-CONFIRM_REACTIONS = {"👍": "confirm", "❌": "cancel"}
-_CALLBACK_COMMANDS = {"yes": "confirm", "no": "cancel"}
-
-
-def from_room_event(
- event,
- room_id: str,
- chat_id: str,
- is_reaction: bool = False,
-) -> IncomingEvent | None:
- """Convert a nio event object to an IncomingEvent. Returns None if unrecognised."""
- if is_reaction:
- return _from_reaction(event, chat_id)
-
- body: str = event.body
-
- if body.startswith("!"):
- parts = body[1:].split(maxsplit=1)
- cmd = parts[0].lower()
- args = parts[1].split() if len(parts) > 1 else []
-
- if cmd in _CALLBACK_COMMANDS:
- return IncomingCallback(
- user_id=event.sender, platform="matrix", chat_id=chat_id,
- action=_CALLBACK_COMMANDS[cmd], payload={},
- )
- return IncomingCommand(
- user_id=event.sender, platform="matrix", chat_id=chat_id,
- command=cmd, args=args,
- )
-
- return IncomingMessage(
- user_id=event.sender, platform="matrix", chat_id=chat_id,
- text=body if event.msgtype == "m.text" else "",
- attachments=extract_attachments(event),
- reply_to=getattr(event, "replyto_event_id", None),
- )
-
-
-def extract_attachments(event) -> list[Attachment]:
- msgtype = getattr(event, "msgtype", "m.text")
- url = getattr(event, "url", None)
- mime = getattr(event, "mimetype", None)
-
- if msgtype == "m.image":
- return [Attachment(type="image", url=url, mime_type=mime)]
- if msgtype == "m.file":
- return [Attachment(type="document", url=url, filename=event.body, mime_type=mime)]
- if msgtype == "m.audio":
- return [Attachment(type="audio", url=url, mime_type=mime)]
- return []
-
-
-def _from_reaction(event, chat_id: str) -> IncomingCallback | None:
- key = event.key
- if key in CONFIRM_REACTIONS:
- return IncomingCallback(
- user_id=event.sender, platform="matrix", chat_id=chat_id,
- action=CONFIRM_REACTIONS[key],
- payload={"reacted_to_id": event.reacted_to_id},
- )
- if key in SKILL_REACTIONS:
- return IncomingCallback(
- user_id=event.sender, platform="matrix", chat_id=chat_id,
- action="toggle_skill",
- payload={"skill_index": SKILL_REACTIONS.index(key), "reacted_to_id": event.reacted_to_id},
- )
- return None
-```
-
-- [ ] **Step 4: Run — expect all PASS**
-
-```bash
-pytest tests/adapter/matrix/test_converter.py -v
-```
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/converter.py tests/adapter/matrix/test_converter.py
-git commit -m "feat(matrix): event converter"
-```
-
----
-
-### Task 3: Reactions helpers
-
-**Files:**
-- Create: `adapter/matrix/reactions.py`
-- Create: `tests/adapter/matrix/test_reactions.py`
-
-- [ ] **Step 1: Write failing tests**
-
-```python
-# tests/adapter/matrix/test_reactions.py
-from unittest.mock import AsyncMock
-from adapter.matrix.reactions import add_reaction, edit_message, build_skills_text
-from sdk.interface import UserSettings
-
-
-async def test_add_reaction():
- client = AsyncMock()
- await add_reaction(client, "!r:m.org", "$evt", "👍")
- client.room_send.assert_called_once_with(
- "!r:m.org", "m.reaction",
- {"m.relates_to": {"rel_type": "m.annotation", "event_id": "$evt", "key": "👍"}},
- )
-
-
-async def test_edit_message():
- client = AsyncMock()
- await edit_message(client, "!r:m.org", "$orig", "new text")
- client.room_send.assert_called_once_with(
- "!r:m.org", "m.room.message",
- {
- "msgtype": "m.text",
- "body": "* new text",
- "m.new_content": {"msgtype": "m.text", "body": "new text"},
- "m.relates_to": {"rel_type": "m.replace", "event_id": "$orig"},
- },
- )
-
-
-def test_build_skills_text_shows_status():
- settings = UserSettings(skills={"web-search": True, "browser": False})
- text = build_skills_text(settings)
- assert "✅ 1 web-search" in text
- assert "❌ 2 browser" in text
-
-
-def test_build_skills_text_has_reaction_hint():
- settings = UserSettings(skills={"web-search": True, "browser": False})
- text = build_skills_text(settings)
- assert "1️⃣" in text
- assert "Реакция" in text
-```
-
-- [ ] **Step 2: Run — expect ImportError**
-
-```bash
-pytest tests/adapter/matrix/test_reactions.py -v
-```
-
-- [ ] **Step 3: Implement reactions.py**
-
-```python
-# adapter/matrix/reactions.py
-from __future__ import annotations
-from adapter.matrix.converter import SKILL_REACTIONS
-from sdk.interface import UserSettings
-
-_SKILL_DESCRIPTIONS: dict[str, str] = {
- "web-search": "поиск в интернете",
- "fetch-url": "чтение веб-страниц",
- "email": "чтение почты",
- "browser": "управление браузером",
- "image-gen": "генерация изображений",
- "video-gen": "генерация видео",
- "files": "работа с файлами",
- "calendar": "календарь",
-}
-
-
-async def add_reaction(client, room_id: str, event_id: str, key: str) -> None:
- await client.room_send(
- room_id, "m.reaction",
- {"m.relates_to": {"rel_type": "m.annotation", "event_id": event_id, "key": key}},
- )
-
-
-async def edit_message(client, room_id: str, original_event_id: str, new_body: str) -> None:
- await client.room_send(
- room_id, "m.room.message",
- {
- "msgtype": "m.text",
- "body": f"* {new_body}",
- "m.new_content": {"msgtype": "m.text", "body": new_body},
- "m.relates_to": {"rel_type": "m.replace", "event_id": original_event_id},
- },
- )
-
-
-def build_skills_text(settings: UserSettings) -> str:
- skill_names = list(settings.skills.keys())
- lines = []
- for i, name in enumerate(skill_names):
- enabled = settings.skills[name]
- emoji = "✅" if enabled else "❌"
- desc = _SKILL_DESCRIPTIONS.get(name, name)
- lines.append(f"{emoji} {i + 1} {name} — {desc}")
-
- hint = " ".join(SKILL_REACTIONS[i] for i in range(min(len(skill_names), len(SKILL_REACTIONS))))
- lines += ["", f"Реакция {hint} = переключить скилл"]
- return "\n".join(lines)
-```
-
-- [ ] **Step 4: Run — expect all PASS**
-
-```bash
-pytest tests/adapter/matrix/test_reactions.py -v
-```
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/reactions.py tests/adapter/matrix/test_reactions.py
-git commit -m "feat(matrix): reactions and edit helpers"
-```
-
----
-
-### Task 4: Auth handler — invite → onboarding
-
-**Files:**
-- Create: `adapter/matrix/handlers/__init__.py`
-- Create: `adapter/matrix/handlers/auth.py`
-- Create: `tests/adapter/matrix/test_auth.py`
-
-- [ ] **Step 1: Write failing tests**
-
-```python
-# tests/adapter/matrix/test_auth.py
-import pytest
-from unittest.mock import AsyncMock
-from core.store import InMemoryStore
-from core.auth import AuthManager
-from sdk.mock import MockPlatformClient
-from adapter.matrix.handlers.auth import handle_invite
-from adapter.matrix.store import get_room_meta, get_room_state, get_user_meta
-
-
-@pytest.fixture
-def store():
- return InMemoryStore()
-
-
-@pytest.fixture
-def platform():
- return MockPlatformClient()
-
-
-@pytest.fixture
-def client():
- c = AsyncMock()
- c.join = AsyncMock()
- c.room_send = AsyncMock()
- return c
-
-
-async def test_invite_joins_room(client, store, platform):
- await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice")
- client.join.assert_called_once_with("!dm:m.org")
-
-
-async def test_invite_sends_welcome_with_name(client, store, platform):
- await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice")
- body = client.room_send.call_args[0][2]["body"]
- assert "Alice" in body
- assert "!new" in body
-
-
-async def test_invite_registers_room_as_c1(client, store, platform):
- await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform)
- meta = await get_room_meta(store, "!dm:m.org")
- assert meta["room_type"] == "chat"
- assert meta["chat_id"] == "C1"
- assert meta["matrix_user_id"] == "@alice:m.org"
-
-
-async def test_invite_creates_platform_user(client, store, platform):
- await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice")
- user_meta = await get_user_meta(store, "@alice:m.org")
- assert user_meta is not None
- assert "platform_user_id" in user_meta
-
-
-async def test_invite_authenticates_user(client, store, platform):
- await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform)
- auth_mgr = AuthManager(platform, store)
- assert await auth_mgr.is_authenticated("@alice:m.org")
-
-
-async def test_invite_room_state_idle(client, store, platform):
- await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform)
- assert await get_room_state(store, "!dm:m.org") == "idle"
-
-
-async def test_second_invite_gets_c2(client, store, platform):
- await handle_invite(client, "!dm1:m.org", "@alice:m.org", store, platform)
- await handle_invite(client, "!dm2:m.org", "@alice:m.org", store, platform)
- meta = await get_room_meta(store, "!dm2:m.org")
- assert meta["chat_id"] == "C2"
-```
-
-- [ ] **Step 2: Run — expect ImportError**
-
-```bash
-pytest tests/adapter/matrix/test_auth.py -v
-```
-
-- [ ] **Step 3: Create `__init__.py` and implement auth.py**
-
-```python
-# adapter/matrix/handlers/__init__.py
-# (empty)
-```
-
-```python
-# adapter/matrix/handlers/auth.py
-from __future__ import annotations
-import structlog
-from adapter.matrix.store import (
- get_user_meta, next_chat_id,
- set_room_meta, set_room_state, set_user_meta,
-)
-from core.auth import AuthManager
-from sdk.interface import PlatformClient
-
-logger = structlog.get_logger(__name__)
-
-
-async def handle_invite(
- client,
- room_id: str,
- matrix_user_id: str,
- store,
- platform: PlatformClient,
- display_name: str | None = None,
-) -> None:
- """Accept invite, register DM room as first chat, authenticate user, send welcome."""
- await client.join(room_id)
- logger.info("Joined room", room_id=room_id, user=matrix_user_id)
-
- user = await platform.get_or_create_user(matrix_user_id, "matrix", display_name)
-
- user_meta = await get_user_meta(store, matrix_user_id)
- if user_meta is None:
- user_meta = {
- "platform_user_id": user.user_id,
- "display_name": display_name,
- "space_id": None,
- "settings_room_id": None,
- "next_chat_index": 1,
- }
- await set_user_meta(store, matrix_user_id, user_meta)
-
- auth_mgr = AuthManager(platform, store)
- await auth_mgr.confirm(matrix_user_id)
-
- chat_id = await next_chat_id(store, matrix_user_id)
- chat_num = chat_id[1:]
- await set_room_meta(store, room_id, {
- "room_type": "chat",
- "chat_id": chat_id,
- "display_name": f"Чат {chat_num}",
- "matrix_user_id": matrix_user_id,
- })
- await set_room_state(store, room_id, "idle")
-
- name = display_name or matrix_user_id.split(":")[0].lstrip("@")
- welcome = (
- f"Привет, {name}! Пиши — я здесь.\n\n"
- "Команды: !new · !chats · !rename · !archive · !skills"
- )
- await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": welcome})
-```
-
-- [ ] **Step 4: Run — expect all PASS**
-
-```bash
-pytest tests/adapter/matrix/test_auth.py -v
-```
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/handlers/__init__.py adapter/matrix/handlers/auth.py tests/adapter/matrix/test_auth.py
-git commit -m "feat(matrix): invite handler + onboarding"
-```
-
----
-
-### Task 5: Chat handler — messages + !new + !chats
-
-**Files:**
-- Create: `adapter/matrix/handlers/chat.py`
-- Create: `tests/adapter/matrix/test_chat_handler.py`
-
-- [ ] **Step 1: Write failing tests**
-
-```python
-# tests/adapter/matrix/test_chat_handler.py
-import pytest
-from types import SimpleNamespace
-from unittest.mock import AsyncMock
-from core.store import InMemoryStore
-from core.auth import AuthManager
-from core.chat import ChatManager
-from core.settings import SettingsManager
-from core.handler import EventDispatcher
-from core.handlers import register_all
-from sdk.mock import MockPlatformClient
-from adapter.matrix.store import get_room_meta, set_room_meta, set_room_state, set_user_meta
-from adapter.matrix.handlers.chat import handle_message, handle_new_chat, handle_list_chats
-
-
-@pytest.fixture
-def store():
- return InMemoryStore()
-
-
-@pytest.fixture
-def platform():
- return MockPlatformClient()
-
-
-@pytest.fixture
-def dispatcher(platform, store):
- d = EventDispatcher(
- platform=platform,
- chat_mgr=ChatManager(platform, store),
- auth_mgr=AuthManager(platform, store),
- settings_mgr=SettingsManager(platform, store),
- )
- register_all(d)
- return d
-
-
-@pytest.fixture
-def client():
- c = AsyncMock()
- c.room_send = AsyncMock()
- c.room_typing = AsyncMock()
- c.room_create = AsyncMock(return_value=AsyncMock(room_id="!new:m.org"))
- c.room_invite = AsyncMock()
- c.room_put_state = AsyncMock()
- return c
-
-
-async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"):
- user = await platform.get_or_create_user(uid, "matrix", "Alice")
- await set_user_meta(store, uid, {
- "platform_user_id": user.user_id,
- "display_name": "Alice",
- "space_id": None,
- "settings_room_id": None,
- "next_chat_index": 2,
- })
- await set_room_meta(store, room_id, {
- "room_type": "chat", "chat_id": "C1",
- "display_name": "Чат 1", "matrix_user_id": uid,
- })
- await set_room_state(store, room_id, "idle")
- auth = AuthManager(platform, store)
- await auth.confirm(uid)
-
-
-def _text_event(body, sender="@alice:m.org"):
- return SimpleNamespace(sender=sender, body=body, event_id="$e1",
- msgtype="m.text", replyto_event_id=None)
-
-
-async def test_message_gets_response(client, store, platform, dispatcher):
- await _setup(store, platform)
- await handle_message(client, "!dm:m.org", _text_event("Hello"), store, platform, dispatcher)
- texts = [str(c) for c in client.room_send.call_args_list]
- assert any("[MOCK]" in t for t in texts)
-
-
-async def test_message_sends_typing(client, store, platform, dispatcher):
- await _setup(store, platform)
- await handle_message(client, "!dm:m.org", _text_event("Hello"), store, platform, dispatcher)
- client.room_typing.assert_called()
-
-
-async def test_new_creates_matrix_room(client, store, platform, dispatcher):
- await _setup(store, platform)
- await handle_new_chat(client, "!dm:m.org", _text_event("!new Analysis"), store, platform, dispatcher)
- client.room_create.assert_called()
- client.room_invite.assert_called()
-
-
-async def test_new_registers_room_meta(client, store, platform, dispatcher):
- await _setup(store, platform)
- await handle_new_chat(client, "!dm:m.org", _text_event("!new Analysis"), store, platform, dispatcher)
- meta = await get_room_meta(store, "!new:m.org")
- assert meta is not None
- assert meta["room_type"] == "chat"
- assert meta["display_name"] == "Analysis"
-
-
-async def test_list_chats_includes_room_name(client, store, platform, dispatcher):
- await _setup(store, platform)
- await handle_list_chats(client, "!dm:m.org", "@alice:m.org", store)
- body = client.room_send.call_args[0][2]["body"]
- assert "Чат 1" in body
-```
-
-- [ ] **Step 2: Run — expect ImportError**
-
-```bash
-pytest tests/adapter/matrix/test_chat_handler.py -v
-```
-
-- [ ] **Step 3: Implement handlers/chat.py**
-
-```python
-# adapter/matrix/handlers/chat.py
-from __future__ import annotations
-import asyncio
-import structlog
-from adapter.matrix.converter import from_room_event
-from adapter.matrix.store import (
- get_room_meta, get_user_meta,
- next_chat_id, set_room_meta, set_room_state, set_user_meta,
-)
-from core.protocol import OutgoingMessage, OutgoingTyping
-from sdk.interface import PlatformClient
-
-logger = structlog.get_logger(__name__)
-_TYPING_INTERVAL = 25 # nio typing expires ~30s
-
-
-async def handle_message(client, room_id: str, event, store, platform: PlatformClient, dispatcher) -> None:
- room_meta = await get_room_meta(store, room_id)
- if room_meta is None:
- return
-
- incoming = from_room_event(event, room_id=room_id, chat_id=room_meta["chat_id"])
- if incoming is None:
- return
-
- await set_room_state(store, room_id, "waiting_response")
- await client.room_typing(room_id, True, timeout=_TYPING_INTERVAL * 1000)
-
- typing_task = asyncio.create_task(_keep_typing(client, room_id, _TYPING_INTERVAL))
- try:
- outgoing_events = await dispatcher.dispatch(incoming)
- finally:
- typing_task.cancel()
- await client.room_typing(room_id, False, timeout=0)
-
- await set_room_state(store, room_id, "idle")
- for out in outgoing_events:
- await _send(client, room_id, out)
-
-
-async def handle_new_chat(client, room_id: str, event, store, platform: PlatformClient, dispatcher) -> None:
- room_meta = await get_room_meta(store, room_id)
- if room_meta is None:
- return
-
- matrix_user_id = room_meta["matrix_user_id"]
- parts = event.body[1:].split(maxsplit=1) # "!new Analysis" → ["new", "Analysis"]
- display_name_arg = parts[1] if len(parts) > 1 else None
-
- chat_id = await next_chat_id(store, matrix_user_id)
- chat_num = chat_id[1:]
- display_name = display_name_arg or f"Чат {chat_num}"
-
- response = await client.room_create(name=display_name)
- new_room_id = response.room_id
- await client.room_invite(new_room_id, matrix_user_id)
-
- user_meta = await get_user_meta(store, matrix_user_id) or {}
- space_id = user_meta.get("space_id")
- if space_id is None:
- space_id = await _create_space(client, store, matrix_user_id, user_meta)
-
- await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=new_room_id)
- await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=room_id)
-
- await set_room_meta(store, new_room_id, {
- "room_type": "chat", "chat_id": chat_id,
- "display_name": display_name, "matrix_user_id": matrix_user_id,
- })
- await set_room_state(store, new_room_id, "idle")
-
- await client.room_send(
- room_id, "m.room.message",
- {"msgtype": "m.text", "body": f"✅ [{display_name}] создан. Перейди в комнату."},
- )
-
-
-async def handle_list_chats(client, room_id: str, matrix_user_id: str, store) -> None:
- all_keys = await store.keys("matrix_room:")
- chats = []
- for key in all_keys:
- meta = await store.get(key)
- if (meta and meta.get("matrix_user_id") == matrix_user_id
- and meta.get("room_type") == "chat"):
- chats.append(meta)
-
- if not chats:
- body = "Нет активных чатов. Напиши !new чтобы создать."
- else:
- lines = ["Твои чаты:"]
- for chat in chats:
- lines.append(f" {chat['display_name']} ({chat['chat_id']})")
- body = "\n".join(lines)
-
- await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
-
-
-async def _create_space(client, store, matrix_user_id: str, user_meta: dict) -> str:
- name = user_meta.get("display_name") or matrix_user_id.split(":")[0].lstrip("@")
- space_resp = await client.room_create(
- name=f"Lambda — {name}",
- initial_state=[{"type": "m.room.create", "content": {"type": "m.space"}}],
- )
- space_id = space_resp.room_id
- await client.room_invite(space_id, matrix_user_id)
-
- settings_resp = await client.room_create(name="⚙️ Настройки")
- settings_room_id = settings_resp.room_id
- await client.room_invite(settings_room_id, matrix_user_id)
- await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=settings_room_id)
-
- await set_room_meta(store, settings_room_id, {
- "room_type": "settings", "chat_id": None,
- "display_name": "Настройки", "matrix_user_id": matrix_user_id,
- })
- await set_room_state(store, settings_room_id, "settings_active")
-
- user_meta["space_id"] = space_id
- user_meta["settings_room_id"] = settings_room_id
- await set_user_meta(store, matrix_user_id, user_meta)
- return space_id
-
-
-async def _keep_typing(client, room_id: str, interval: int) -> None:
- try:
- while True:
- await asyncio.sleep(interval)
- await client.room_typing(room_id, True, timeout=interval * 1000)
- except asyncio.CancelledError:
- pass
-
-
-async def _send(client, room_id: str, event) -> None:
- if isinstance(event, OutgoingMessage):
- await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text})
- elif isinstance(event, OutgoingTyping):
- await client.room_typing(room_id, event.is_typing, timeout=0)
-```
-
-- [ ] **Step 4: Run — expect all PASS**
-
-```bash
-pytest tests/adapter/matrix/test_chat_handler.py -v
-```
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/handlers/chat.py tests/adapter/matrix/test_chat_handler.py
-git commit -m "feat(matrix): chat handler — messages, !new, !chats"
-```
-
----
-
-### Task 6: Confirm handler — 👍/❌ + !yes/!no
-
-**Files:**
-- Create: `adapter/matrix/handlers/confirm.py`
-- Create: `tests/adapter/matrix/test_confirm.py`
-
-- [ ] **Step 1: Write failing tests**
-
-```python
-# tests/adapter/matrix/test_confirm.py
-import pytest
-from types import SimpleNamespace
-from unittest.mock import AsyncMock
-from core.store import InMemoryStore
-from core.auth import AuthManager
-from core.chat import ChatManager
-from core.settings import SettingsManager
-from core.handler import EventDispatcher
-from core.handlers import register_all
-from sdk.mock import MockPlatformClient
-from adapter.matrix.store import get_room_state, set_room_meta, set_room_state
-from adapter.matrix.handlers.confirm import handle_confirm_callback
-
-
-@pytest.fixture
-def store():
- return InMemoryStore()
-
-
-@pytest.fixture
-def platform():
- return MockPlatformClient()
-
-
-@pytest.fixture
-def dispatcher(platform, store):
- d = EventDispatcher(
- platform=platform,
- chat_mgr=ChatManager(platform, store),
- auth_mgr=AuthManager(platform, store),
- settings_mgr=SettingsManager(platform, store),
- )
- register_all(d)
- return d
-
-
-@pytest.fixture
-def client():
- return AsyncMock()
-
-
-async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"):
- await platform.get_or_create_user(uid, "matrix", "Alice")
- await set_room_meta(store, room_id, {
- "room_type": "chat", "chat_id": "C1",
- "display_name": "Чат 1", "matrix_user_id": uid,
- })
- await set_room_state(store, room_id, "confirm_pending")
- await AuthManager(platform, store).confirm(uid)
-
-
-async def test_yes_command_transitions_to_idle(client, store, platform, dispatcher):
- await _setup(store, platform)
- event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1",
- msgtype="m.text", replyto_event_id=None)
- await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False)
- assert await get_room_state(store, "!dm:m.org") == "idle"
-
-
-async def test_no_command_transitions_to_idle(client, store, platform, dispatcher):
- await _setup(store, platform)
- event = SimpleNamespace(sender="@alice:m.org", body="!no", event_id="$e1",
- msgtype="m.text", replyto_event_id=None)
- await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False)
- assert await get_room_state(store, "!dm:m.org") == "idle"
-
-
-async def test_thumbs_up_reaction_transitions_to_idle(client, store, platform, dispatcher):
- await _setup(store, platform)
- event = SimpleNamespace(sender="@alice:m.org", key="👍",
- reacted_to_id="$orig", event_id="$r1")
- await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=True)
- assert await get_room_state(store, "!dm:m.org") == "idle"
-
-
-async def test_confirm_sends_response(client, store, platform, dispatcher):
- await _setup(store, platform)
- event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1",
- msgtype="m.text", replyto_event_id=None)
- await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False)
- client.room_send.assert_called()
-
-
-async def test_noop_when_state_not_confirm_pending(client, store, platform, dispatcher):
- await _setup(store, platform)
- await set_room_state(store, "!dm:m.org", "idle") # wrong state
- event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1",
- msgtype="m.text", replyto_event_id=None)
- await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False)
- client.room_send.assert_not_called()
-```
-
-- [ ] **Step 2: Run — expect ImportError**
-
-```bash
-pytest tests/adapter/matrix/test_confirm.py -v
-```
-
-- [ ] **Step 3: Implement handlers/confirm.py**
-
-```python
-# adapter/matrix/handlers/confirm.py
-from __future__ import annotations
-import structlog
-from adapter.matrix.converter import from_room_event
-from adapter.matrix.store import get_room_meta, get_room_state, set_room_state
-from core.protocol import OutgoingMessage
-from sdk.interface import PlatformClient
-
-logger = structlog.get_logger(__name__)
-
-
-async def handle_confirm_callback(
- client,
- room_id: str,
- event,
- store,
- platform: PlatformClient,
- dispatcher,
- is_reaction: bool = False,
-) -> None:
- if await get_room_state(store, room_id) != "confirm_pending":
- return
-
- room_meta = await get_room_meta(store, room_id)
- if room_meta is None:
- return
-
- incoming = from_room_event(event, room_id=room_id,
- chat_id=room_meta["chat_id"], is_reaction=is_reaction)
- if incoming is None or getattr(incoming, "action", None) not in ("confirm", "cancel"):
- return
-
- await set_room_state(store, room_id, "idle")
- outgoing_events = await dispatcher.dispatch(incoming)
-
- for out in outgoing_events:
- if isinstance(out, OutgoingMessage):
- await client.room_send(room_id, "m.room.message",
- {"msgtype": "m.text", "body": out.text})
-```
-
-- [ ] **Step 4: Run — expect all PASS**
-
-```bash
-pytest tests/adapter/matrix/test_confirm.py -v
-```
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/handlers/confirm.py tests/adapter/matrix/test_confirm.py
-git commit -m "feat(matrix): confirm handler — reactions and !yes/!no"
-```
-
----
-
-### Task 7: Settings handler — !skills (m.replace) + other commands
-
-**Files:**
-- Create: `adapter/matrix/handlers/settings.py`
-- Create: `tests/adapter/matrix/test_settings_handler.py`
-
-- [ ] **Step 1: Write failing tests**
-
-```python
-# tests/adapter/matrix/test_settings_handler.py
-import pytest
-from unittest.mock import AsyncMock
-from core.store import InMemoryStore
-from core.auth import AuthManager
-from core.chat import ChatManager
-from core.settings import SettingsManager
-from core.handler import EventDispatcher
-from core.handlers import register_all
-from sdk.mock import MockPlatformClient
-from adapter.matrix.store import set_room_meta, set_room_state, set_user_meta
-from adapter.matrix.handlers.settings import handle_skills, handle_skill_toggle, handle_text_setting
-
-
-@pytest.fixture
-def store():
- return InMemoryStore()
-
-
-@pytest.fixture
-def platform():
- return MockPlatformClient()
-
-
-@pytest.fixture
-def dispatcher(platform, store):
- d = EventDispatcher(
- platform=platform,
- chat_mgr=ChatManager(platform, store),
- auth_mgr=AuthManager(platform, store),
- settings_mgr=SettingsManager(platform, store),
- )
- register_all(d)
- return d
-
-
-@pytest.fixture
-def client():
- c = AsyncMock()
- c.room_send = AsyncMock(return_value=AsyncMock(event_id="$skills_msg"))
- return c
-
-
-async def _setup(store, platform, uid="@alice:m.org", room_id="!s:m.org"):
- user = await platform.get_or_create_user(uid, "matrix", "Alice")
- await set_user_meta(store, uid, {
- "platform_user_id": user.user_id, "display_name": "Alice",
- "space_id": None, "settings_room_id": room_id, "next_chat_index": 2,
- })
- await set_room_meta(store, room_id, {
- "room_type": "settings", "chat_id": None,
- "display_name": "Настройки", "matrix_user_id": uid,
- })
- await set_room_state(store, room_id, "settings_active")
- await AuthManager(platform, store).confirm(uid)
-
-
-async def test_skills_sends_list(client, store, platform, dispatcher):
- await _setup(store, platform)
- await handle_skills(client, "!s:m.org", "@alice:m.org", store, platform, dispatcher)
- body = client.room_send.call_args[0][2]["body"]
- assert "web-search" in body
- assert "Реакция" in body
-
-
-async def test_skills_stores_event_id(client, store, platform, dispatcher):
- await _setup(store, platform)
- await handle_skills(client, "!s:m.org", "@alice:m.org", store, platform, dispatcher)
- stored = await store.get("matrix_skills_msg:!s:m.org")
- assert stored is not None
- assert stored["event_id"] == "$skills_msg"
-
-
-async def test_skill_toggle_edits_message(client, store, platform, dispatcher):
- await _setup(store, platform)
- await store.set("matrix_skills_msg:!s:m.org", {"event_id": "$skills_msg"})
- from types import SimpleNamespace
- reaction = SimpleNamespace(sender="@alice:m.org", key="1️⃣",
- reacted_to_id="$skills_msg", event_id="$r1")
- await handle_skill_toggle(client, "!s:m.org", reaction, store, platform, dispatcher)
- content = client.room_send.call_args[0][2]
- assert content.get("m.relates_to", {}).get("rel_type") == "m.replace"
-
-
-async def test_whoami_contains_user_id(client, store, platform, dispatcher):
- await _setup(store, platform)
- await handle_text_setting(client, "!s:m.org", "@alice:m.org", "whoami", [], store, platform)
- body = client.room_send.call_args[0][2]["body"]
- assert "@alice:m.org" in body
-
-
-async def test_status_response(client, store, platform, dispatcher):
- await _setup(store, platform)
- await handle_text_setting(client, "!s:m.org", "@alice:m.org", "status", [], store, platform)
- body = client.room_send.call_args[0][2]["body"]
- assert "Статус" in body
-
-
-async def test_plan_shows_tokens(client, store, platform, dispatcher):
- await _setup(store, platform)
- await handle_text_setting(client, "!s:m.org", "@alice:m.org", "plan", [], store, platform)
- body = client.room_send.call_args[0][2]["body"]
- assert "Beta" in body
- assert "/" in body # "0 / 1000"
-```
-
-- [ ] **Step 2: Run — expect ImportError**
-
-```bash
-pytest tests/adapter/matrix/test_settings_handler.py -v
-```
-
-- [ ] **Step 3: Implement handlers/settings.py**
-
-```python
-# adapter/matrix/handlers/settings.py
-from __future__ import annotations
-import structlog
-from adapter.matrix.converter import SKILL_REACTIONS
-from adapter.matrix.reactions import build_skills_text, edit_message
-from adapter.matrix.store import get_room_meta, get_user_meta
-from core.protocol import SettingsAction
-from sdk.interface import PlatformClient
-
-logger = structlog.get_logger(__name__)
-
-_SKILL_NAMES_ORDER = ["web-search", "fetch-url", "email", "browser",
- "image-gen", "video-gen", "files", "calendar"]
-
-
-async def handle_skills(
- client, room_id: str, matrix_user_id: str, store, platform: PlatformClient, dispatcher,
-) -> None:
- """Send skills list and store its event_id for later m.replace edits."""
- user_meta = await get_user_meta(store, matrix_user_id) or {}
- platform_user_id = user_meta.get("platform_user_id", matrix_user_id)
- settings = await platform.get_settings(platform_user_id)
- body = build_skills_text(settings)
- response = await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
- event_id = getattr(response, "event_id", None)
- if event_id:
- await store.set(f"matrix_skills_msg:{room_id}", {"event_id": event_id})
-
-
-async def handle_skill_toggle(
- client, room_id: str, reaction_event, store, platform: PlatformClient, dispatcher,
-) -> None:
- """Toggle a skill based on numbered reaction, then edit the skills message."""
- key = reaction_event.key
- if key not in SKILL_REACTIONS:
- return
- skill_index = SKILL_REACTIONS.index(key)
- if skill_index >= len(_SKILL_NAMES_ORDER):
- return
-
- skill_name = _SKILL_NAMES_ORDER[skill_index]
- room_meta = await get_room_meta(store, room_id)
- if room_meta is None:
- return
-
- matrix_user_id = room_meta["matrix_user_id"]
- user_meta = await get_user_meta(store, matrix_user_id) or {}
- platform_user_id = user_meta.get("platform_user_id", matrix_user_id)
-
- settings = await platform.get_settings(platform_user_id)
- current = settings.skills.get(skill_name, False)
- action = SettingsAction(action="toggle_skill",
- payload={"skill": skill_name, "enabled": not current})
- await platform.update_settings(platform_user_id, action)
-
- updated = await platform.get_settings(platform_user_id)
- new_body = build_skills_text(updated)
-
- msg_data = await store.get(f"matrix_skills_msg:{room_id}")
- if msg_data:
- await edit_message(client, room_id, msg_data["event_id"], new_body)
- else:
- await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": new_body})
-
-
-async def handle_text_setting(
- client, room_id: str, matrix_user_id: str,
- command: str, args: list[str], store, platform: PlatformClient,
-) -> None:
- """Handle !connectors, !soul, !safety, !plan, !status, !whoami."""
- user_meta = await get_user_meta(store, matrix_user_id) or {}
- platform_user_id = user_meta.get("platform_user_id", matrix_user_id)
-
- if command == "whoami":
- name = user_meta.get("display_name") or matrix_user_id
- body = f"Аккаунт: {matrix_user_id}\nПлатформа: {platform_user_id}\nИмя: {name}"
-
- elif command == "status":
- body = f"Статус платформы: ✅ доступна\nАккаунт: {matrix_user_id}"
-
- elif command == "plan":
- settings = await platform.get_settings(platform_user_id)
- plan = settings.plan
- name_plan = plan.get("name", "Beta")
- used = plan.get("tokens_used", 0)
- limit = plan.get("tokens_limit", 1000)
- pct = used * 10 // limit if limit else 0
- bar = "━" * pct + "░" * (10 - pct)
- body = f"Подписка: {name_plan}\nТокены: {used} / {limit}\n{bar} {used * 100 // limit if limit else 0}%"
-
- elif command == "soul":
- if len(args) >= 2:
- field, value = args[0], " ".join(args[1:])
- await platform.update_settings(platform_user_id,
- SettingsAction(action="set_soul",
- payload={"field": field, "value": value}))
- body = f"✅ soul.{field} = «{value}»"
- else:
- settings = await platform.get_settings(platform_user_id)
- lines = [f"{k}: {v}" for k, v in settings.soul.items()] if settings.soul else ["(пусто)"]
- body = "Soul:\n" + "\n".join(lines)
-
- elif command == "safety":
- if args and args[0] in ("on", "off"):
- enabled = args[0] == "on"
- trigger = " ".join(args[1:])
- await platform.update_settings(platform_user_id,
- SettingsAction(action="set_safety",
- payload={"trigger": trigger, "enabled": enabled}))
- body = f"✅ safety.{trigger} = {'включено' if enabled else 'выключено'}"
- else:
- settings = await platform.get_settings(platform_user_id)
- lines = [f"{'✅' if v else '❌'} {k}" for k, v in settings.safety.items()]
- body = "Безопасность:\n" + ("\n".join(lines) if lines else "(пусто)")
-
- elif command == "connectors":
- settings = await platform.get_settings(platform_user_id)
- if settings.connectors:
- lines = [f"✅ {k}" for k in settings.connectors]
- body = "Коннекторы:\n" + "\n".join(lines)
- else:
- body = "Коннекторы:\n❌ Нет подключённых сервисов\n\n!connect gmail — подключить Gmail"
-
- else:
- body = f"Неизвестная команда: !{command}"
-
- await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
-```
-
-- [ ] **Step 4: Run — expect all PASS**
-
-```bash
-pytest tests/adapter/matrix/test_settings_handler.py -v
-```
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/handlers/settings.py tests/adapter/matrix/test_settings_handler.py
-git commit -m "feat(matrix): settings handler — !skills m.replace + commands"
-```
-
----
-
-### Task 8: Bot entry point — sync loop + event routing
-
-**Files:**
-- Create: `adapter/matrix/bot.py`
-- Create: `tests/adapter/matrix/test_bot.py`
-
-- [ ] **Step 1: Write failing tests**
-
-```python
-# tests/adapter/matrix/test_bot.py
-import pytest
-from types import SimpleNamespace
-from unittest.mock import AsyncMock
-from core.store import InMemoryStore
-from sdk.mock import MockPlatformClient
-from adapter.matrix.bot import create_dispatcher, route_message_event, route_reaction_event
-from adapter.matrix.store import set_room_meta, set_room_state, set_user_meta
-from core.auth import AuthManager
-from core.handler import EventDispatcher
-
-
-@pytest.fixture
-def store():
- return InMemoryStore()
-
-
-@pytest.fixture
-def platform():
- return MockPlatformClient()
-
-
-@pytest.fixture
-def dispatcher(platform, store):
- return create_dispatcher(platform, store)
-
-
-@pytest.fixture
-def client():
- c = AsyncMock()
- c.user_id = "@bot:m.org"
- c.room_create = AsyncMock(return_value=AsyncMock(room_id="!new:m.org"))
- c.room_invite = AsyncMock()
- c.room_put_state = AsyncMock()
- return c
-
-
-async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"):
- user = await platform.get_or_create_user(uid, "matrix", "Alice")
- await set_user_meta(store, uid, {
- "platform_user_id": user.user_id, "display_name": "Alice",
- "space_id": None, "settings_room_id": None, "next_chat_index": 2,
- })
- await set_room_meta(store, room_id, {
- "room_type": "chat", "chat_id": "C1",
- "display_name": "Чат 1", "matrix_user_id": uid,
- })
- await set_room_state(store, room_id, "idle")
- await AuthManager(platform, store).confirm(uid)
-
-
-async def test_create_dispatcher_returns_event_dispatcher(platform, store):
- d = create_dispatcher(platform, store)
- assert isinstance(d, EventDispatcher)
-
-
-async def test_route_text_message(client, store, platform, dispatcher):
- await _setup(store, platform)
- event = SimpleNamespace(sender="@alice:m.org", body="Hello", event_id="$e1",
- msgtype="m.text", replyto_event_id=None)
- room = SimpleNamespace(room_id="!dm:m.org")
- await route_message_event(client, room, event, store, platform, dispatcher)
- client.room_send.assert_called()
- body_calls = [str(c) for c in client.room_send.call_args_list]
- assert any("[MOCK]" in c for c in body_calls)
-
-
-async def test_route_new_command(client, store, platform, dispatcher):
- await _setup(store, platform)
- event = SimpleNamespace(sender="@alice:m.org", body="!new Test", event_id="$e2",
- msgtype="m.text", replyto_event_id=None)
- room = SimpleNamespace(room_id="!dm:m.org")
- await route_message_event(client, room, event, store, platform, dispatcher)
- client.room_create.assert_called()
-
-
-async def test_route_skills_command(client, store, platform, dispatcher):
- await _setup(store, platform)
- event = SimpleNamespace(sender="@alice:m.org", body="!skills", event_id="$e3",
- msgtype="m.text", replyto_event_id=None)
- room = SimpleNamespace(room_id="!dm:m.org")
- await route_message_event(client, room, event, store, platform, dispatcher)
- body = client.room_send.call_args[0][2]["body"]
- assert "web-search" in body
-
-
-async def test_bot_ignores_own_messages(client, store, platform, dispatcher):
- await _setup(store, platform)
- event = SimpleNamespace(sender="@bot:m.org", body="Hello", event_id="$e4",
- msgtype="m.text", replyto_event_id=None)
- room = SimpleNamespace(room_id="!dm:m.org")
- await route_message_event(client, room, event, store, platform, dispatcher)
- client.room_send.assert_not_called()
-
-
-async def test_route_confirm_reaction(client, store, platform, dispatcher):
- await _setup(store, platform)
- await set_room_state(store, "!dm:m.org", "confirm_pending")
- event = SimpleNamespace(sender="@alice:m.org", key="👍",
- reacted_to_id="$orig", event_id="$r1",
- source={"content": {"m.relates_to": {"key": "👍", "event_id": "$orig"}}})
- room = SimpleNamespace(room_id="!dm:m.org")
- await route_reaction_event(client, room, event, store, platform, dispatcher)
- client.room_send.assert_called()
-```
-
-- [ ] **Step 2: Run — expect ImportError**
-
-```bash
-pytest tests/adapter/matrix/test_bot.py -v
-```
-
-- [ ] **Step 3: Implement bot.py**
-
-```python
-# adapter/matrix/bot.py
-from __future__ import annotations
-import os
-import structlog
-from nio import AsyncClient, InviteMemberEvent, RoomMessageText, UnknownEvent
-from adapter.matrix.converter import CONFIRM_REACTIONS, SKILL_REACTIONS
-from adapter.matrix.handlers.auth import handle_invite
-from adapter.matrix.handlers.chat import handle_list_chats, handle_message, handle_new_chat
-from adapter.matrix.handlers.confirm import handle_confirm_callback
-from adapter.matrix.handlers.settings import handle_skill_toggle, handle_skills, handle_text_setting
-from adapter.matrix.store import get_room_meta, get_room_state
-from core.auth import AuthManager
-from core.chat import ChatManager
-from core.handler import EventDispatcher
-from core.handlers import register_all
-from core.settings import SettingsManager
-from core.store import SQLiteStore
-from sdk.interface import PlatformClient
-from sdk.mock import MockPlatformClient
-
-logger = structlog.get_logger(__name__)
-
-_SETTINGS_COMMANDS = {"connectors", "soul", "safety", "plan", "status", "whoami"}
-
-
-def create_dispatcher(platform: PlatformClient, store) -> EventDispatcher:
- d = EventDispatcher(
- platform=platform,
- chat_mgr=ChatManager(platform, store),
- auth_mgr=AuthManager(platform, store),
- settings_mgr=SettingsManager(platform, store),
- )
- register_all(d)
- return d
-
-
-async def route_message_event(client, room, event, store, platform, dispatcher) -> None:
- room_id = room.room_id
- sender = event.sender
- if sender == client.user_id:
- return
-
- room_meta = await get_room_meta(store, room_id)
- if room_meta is None:
- return
-
- body: str = event.body or ""
- state = await get_room_state(store, room_id)
-
- if state == "confirm_pending" and body.startswith("!") and body[1:].split()[0] in ("yes", "no"):
- await handle_confirm_callback(client, room_id, event, store, platform, dispatcher, is_reaction=False)
- return
-
- if body.startswith("!"):
- parts = body[1:].split(maxsplit=1)
- cmd = parts[0].lower()
- args = parts[1].split() if len(parts) > 1 else []
-
- if cmd == "new":
- await handle_new_chat(client, room_id, event, store, platform, dispatcher)
- elif cmd == "chats":
- await handle_list_chats(client, room_id, sender, store)
- elif cmd == "skills":
- await handle_skills(client, room_id, sender, store, platform, dispatcher)
- elif cmd in _SETTINGS_COMMANDS:
- await handle_text_setting(client, room_id, sender, cmd, args, store, platform)
- else:
- # Unknown command — treat as regular message
- await handle_message(client, room_id, event, store, platform, dispatcher)
- else:
- await handle_message(client, room_id, event, store, platform, dispatcher)
-
-
-async def route_reaction_event(client, room, event, store, platform, dispatcher) -> None:
- room_id = room.room_id
- sender = getattr(event, "sender", None)
- if sender == client.user_id:
- return
-
- # nio may give us a ReactionEvent or UnknownEvent; normalise key access
- key = getattr(event, "key", None)
- reacted_to_id = getattr(event, "reacted_to_id", None)
- if key is None:
- relates = event.source.get("content", {}).get("m.relates_to", {})
- key = relates.get("key", "")
- reacted_to_id = relates.get("event_id", "")
-
- from types import SimpleNamespace
- norm = SimpleNamespace(sender=sender, key=key, reacted_to_id=reacted_to_id,
- event_id=event.event_id)
-
- state = await get_room_state(store, room_id)
- if state == "confirm_pending" and key in CONFIRM_REACTIONS:
- await handle_confirm_callback(client, room_id, norm, store, platform, dispatcher, is_reaction=True)
- elif key in SKILL_REACTIONS:
- await handle_skill_toggle(client, room_id, norm, store, platform, dispatcher)
-
-
-async def main() -> None:
- homeserver = os.getenv("MATRIX_HOMESERVER", "https://matrix.org")
- user_id = os.getenv("MATRIX_USER_ID", "@lambda-bot:matrix.org")
- password = os.getenv("MATRIX_PASSWORD", "")
-
- store = SQLiteStore("matrix_bot.db")
- platform = MockPlatformClient()
- dispatcher = create_dispatcher(platform, store)
-
- client = AsyncClient(homeserver, user_id)
- await client.login(password)
- logger.info("Logged in", user_id=user_id)
-
- async def on_message(room, event: RoomMessageText) -> None:
- await route_message_event(client, room, event, store, platform, dispatcher)
-
- async def on_invite(room, event: InviteMemberEvent) -> None:
- if event.membership == "invite" and event.state_key == client.user_id:
- display_name = getattr(event, "display_name", None)
- await handle_invite(client, room.room_id, event.sender, store, platform, display_name)
-
- async def on_unknown(room, event: UnknownEvent) -> None:
- if event.type == "m.reaction":
- await route_reaction_event(client, room, event, store, platform, dispatcher)
-
- client.add_event_callback(on_message, RoomMessageText)
- client.add_event_callback(on_invite, InviteMemberEvent)
- client.add_event_callback(on_unknown, UnknownEvent)
-
- logger.info("Starting sync loop")
- await client.sync_forever(timeout=30000)
-
-
-if __name__ == "__main__":
- import asyncio
- asyncio.run(main())
-```
-
-- [ ] **Step 4: Run matrix tests**
-
-```bash
-pytest tests/adapter/matrix/ -v
-```
-Expected: all PASS.
-
-- [ ] **Step 5: Run full suite — verify no regressions**
-
-```bash
-pytest tests/ -v
-```
-Expected: all tests PASS including pre-existing `tests/core/` and `tests/platform/`.
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add adapter/matrix/bot.py tests/adapter/matrix/test_bot.py
-git commit -m "feat(matrix): bot entry point — sync loop and event routing"
-```
diff --git a/docs/superpowers/plans/2026-04-01-telegram-forum-redesign.md b/docs/superpowers/plans/2026-04-01-telegram-forum-redesign.md
deleted file mode 100644
index 3592485..0000000
--- a/docs/superpowers/plans/2026-04-01-telegram-forum-redesign.md
+++ /dev/null
@@ -1,1308 +0,0 @@
-# Telegram Forum Redesign Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Rewrite the Telegram adapter to use Bot API 9.3 Threaded Mode — private chat becomes a forum, each topic is an isolated agent context, no supergroup required.
-
-**Architecture:** New branch `feat/telegram-forum` from `main`. Cherry-pick `keyboards/settings.py` and `keyboards/confirm.py` from `feat/telegram-adapter`. Everything else is written from scratch using `(user_id, thread_id)` as the context key, `core/store.py` for state (no aiogram FSM for topic routing), and `sdk/interface.py`'s `stream_message()` for streaming responses.
-
-**Tech Stack:** Python 3.11+, aiogram 3.4+, SQLite (via stdlib `sqlite3`), pytest + pytest-asyncio (asyncio_mode=auto), `sdk.mock.MockPlatformClient` as platform stub.
-
-**Spec:** `docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md`
-
----
-
-## File Map
-
-| File | Action | Notes |
-|------|--------|-------|
-| `adapter/telegram/db.py` | Rewrite | New schema: `chats(user_id, thread_id PK, ...)` |
-| `adapter/telegram/converter.py` | Rewrite | context_key = `(user_id, thread_id)`, keep `_extract_attachments` |
-| `adapter/telegram/handlers/start.py` | New | `/start` — create first topic, health-check existing ones |
-| `adapter/telegram/handlers/topic_events.py` | New | `forum_topic_created / edited / closed` |
-| `adapter/telegram/handlers/commands.py` | New | `/new`, `/archive`, `/rename`, `/settings` |
-| `adapter/telegram/handlers/message.py` | New | Incoming messages with streaming |
-| `adapter/telegram/handlers/settings.py` | Cherry-pick + adapt | Drop FSM state dependency for topic routing; keep SettingsState for soul modal |
-| `adapter/telegram/keyboards/settings.py` | Cherry-pick | No changes needed |
-| `adapter/telegram/keyboards/confirm.py` | Cherry-pick | No changes needed |
-| `adapter/telegram/states.py` | Minimal | Only `SettingsState` (soul editing modal), no topic FSM |
-| `adapter/telegram/bot.py` | Rewrite | New router list, same middleware pattern |
-| `adapter/telegram/__init__.py` | Keep | No changes |
-| `tests/adapter/test_forum_db.py` | Rewrite | Tests for new schema |
-| `tests/adapter/telegram/test_converter.py` | New | |
-| `tests/adapter/telegram/test_topic_events.py` | New | |
-| `tests/adapter/telegram/test_commands.py` | New | |
-
-**Delete from `feat/telegram-adapter` (do not carry over):**
-- `adapter/telegram/handlers/forum.py` — supergroup onboarding
-- `adapter/telegram/handlers/chat.py` — chat switching
-- `adapter/telegram/handlers/auth.py` — auth flow
-- `adapter/telegram/handlers/confirm.py` — confirm modal
-- `adapter/telegram/keyboards/chat.py`
-- `adapter/telegram/keyboards/forum.py`
-
----
-
-## Task 0: Create Branch and Cherry-Pick Keyboards
-
-**Files:**
-- Create branch: `feat/telegram-forum`
-- Cherry-pick: `adapter/telegram/keyboards/settings.py`
-- Cherry-pick: `adapter/telegram/keyboards/confirm.py`
-
-- [ ] **Step 1: Create new branch from main**
-
-```bash
-git checkout main
-git checkout -b feat/telegram-forum
-```
-
-- [ ] **Step 2: Copy keyboards from feat/telegram-adapter**
-
-```bash
-mkdir -p adapter/telegram/keyboards
-git show feat/telegram-adapter:adapter/telegram/keyboards/__init__.py > adapter/telegram/keyboards/__init__.py
-git show feat/telegram-adapter:adapter/telegram/keyboards/settings.py > adapter/telegram/keyboards/settings.py
-git show feat/telegram-adapter:adapter/telegram/keyboards/confirm.py > adapter/telegram/keyboards/confirm.py
-```
-
-- [ ] **Step 3: Create package stubs**
-
-```bash
-mkdir -p adapter/telegram/handlers
-touch adapter/__init__.py
-touch adapter/telegram/__init__.py
-touch adapter/telegram/handlers/__init__.py
-```
-
-- [ ] **Step 4: Verify keyboards import cleanly**
-
-```bash
-python -c "from adapter.telegram.keyboards.settings import settings_main_keyboard; print('ok')"
-```
-
-Expected: `ok`
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/
-git commit -m "chore: init feat/telegram-forum, cherry-pick keyboards"
-```
-
----
-
-## Task 1: Database Layer
-
-**Files:**
-- Create: `adapter/telegram/db.py`
-- Rewrite: `tests/adapter/test_forum_db.py`
-
-- [ ] **Step 1: Write failing tests**
-
-Write `tests/adapter/test_forum_db.py`:
-
-```python
-from __future__ import annotations
-
-import importlib
-import pytest
-
-
-@pytest.fixture(autouse=True)
-def fresh_db(tmp_path, monkeypatch):
- monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
- import adapter.telegram.db as db_mod
- importlib.reload(db_mod)
- db_mod.init_db()
- return db_mod
-
-
-def test_create_and_get_chat(fresh_db):
- db = fresh_db
- db.create_chat(user_id=1, thread_id=100, chat_name="Чат #1")
- chat = db.get_chat(user_id=1, thread_id=100)
- assert chat is not None
- assert chat["chat_name"] == "Чат #1"
- assert chat["archived_at"] is None
-
-
-def test_get_chat_missing(fresh_db):
- assert fresh_db.get_chat(user_id=1, thread_id=999) is None
-
-
-def test_archive_chat(fresh_db):
- db = fresh_db
- db.create_chat(1, 100, "Чат #1")
- db.archive_chat(1, 100)
- chat = db.get_chat(1, 100)
- assert chat["archived_at"] is not None
-
-
-def test_rename_chat(fresh_db):
- db = fresh_db
- db.create_chat(1, 100, "Чат #1")
- db.rename_chat(1, 100, "Новое имя")
- assert db.get_chat(1, 100)["chat_name"] == "Новое имя"
-
-
-def test_get_active_chats(fresh_db):
- db = fresh_db
- db.create_chat(1, 100, "Чат #1")
- db.create_chat(1, 200, "Чат #2")
- db.archive_chat(1, 100)
- chats = db.get_active_chats(1)
- assert len(chats) == 1
- assert chats[0]["thread_id"] == 200
-
-
-def test_display_number(fresh_db):
- db = fresh_db
- db.create_chat(1, 100, "Чат #1")
- db.create_chat(1, 200, "Чат #2")
- db.create_chat(1, 300, "Чат #3")
- assert db.get_display_number(1, 100) == 1
- assert db.get_display_number(1, 200) == 2
- assert db.get_display_number(1, 300) == 3
-
-
-def test_count_active_chats(fresh_db):
- db = fresh_db
- db.create_chat(1, 100, "Чат #1")
- db.create_chat(1, 200, "Чат #2")
- db.archive_chat(1, 100)
- assert db.count_active_chats(1) == 1
-
-
-def test_different_users_isolated(fresh_db):
- db = fresh_db
- db.create_chat(1, 100, "Чат #1")
- db.create_chat(2, 100, "Чат #1") # same thread_id, different user
- assert db.get_chat(1, 100)["chat_name"] == "Чат #1"
- assert db.get_chat(2, 100)["chat_name"] == "Чат #1"
- db.archive_chat(1, 100)
- assert db.get_chat(1, 100)["archived_at"] is not None
- assert db.get_chat(2, 100)["archived_at"] is None
-```
-
-- [ ] **Step 2: Run tests — verify they fail**
-
-```bash
-pytest tests/adapter/test_forum_db.py -v
-```
-
-Expected: `ModuleNotFoundError` or `AttributeError` (db.py doesn't exist yet)
-
-- [ ] **Step 3: Implement db.py**
-
-Create `adapter/telegram/db.py`:
-
-```python
-from __future__ import annotations
-
-import os
-import sqlite3
-from contextlib import contextmanager
-
-DB_PATH = os.environ.get("DB_PATH", "lambda_bot.db")
-
-
-@contextmanager
-def _conn():
- con = sqlite3.connect(DB_PATH)
- con.row_factory = sqlite3.Row
- try:
- yield con
- con.commit()
- finally:
- con.close()
-
-
-def init_db() -> None:
- with _conn() as con:
- con.executescript("""
- CREATE TABLE IF NOT EXISTS chats (
- user_id INTEGER NOT NULL,
- thread_id INTEGER NOT NULL,
- chat_name TEXT NOT NULL DEFAULT 'Чат #1',
- archived_at DATETIME,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (user_id, thread_id)
- );
- """)
-
-
-def create_chat(user_id: int, thread_id: int, chat_name: str) -> None:
- with _conn() as con:
- con.execute(
- "INSERT OR IGNORE INTO chats (user_id, thread_id, chat_name) VALUES (?, ?, ?)",
- (user_id, thread_id, chat_name),
- )
-
-
-def get_chat(user_id: int, thread_id: int) -> dict | None:
- with _conn() as con:
- row = con.execute(
- "SELECT * FROM chats WHERE user_id = ? AND thread_id = ?",
- (user_id, thread_id),
- ).fetchone()
- return dict(row) if row else None
-
-
-def get_active_chats(user_id: int) -> list[dict]:
- with _conn() as con:
- rows = con.execute(
- "SELECT * FROM chats WHERE user_id = ? AND archived_at IS NULL "
- "ORDER BY created_at ASC",
- (user_id,),
- ).fetchall()
- return [dict(r) for r in rows]
-
-
-def count_active_chats(user_id: int) -> int:
- with _conn() as con:
- row = con.execute(
- "SELECT COUNT(*) FROM chats WHERE user_id = ? AND archived_at IS NULL",
- (user_id,),
- ).fetchone()
- return row[0]
-
-
-def archive_chat(user_id: int, thread_id: int) -> None:
- with _conn() as con:
- con.execute(
- "UPDATE chats SET archived_at = CURRENT_TIMESTAMP "
- "WHERE user_id = ? AND thread_id = ?",
- (user_id, thread_id),
- )
-
-
-def rename_chat(user_id: int, thread_id: int, new_name: str) -> None:
- with _conn() as con:
- con.execute(
- "UPDATE chats SET chat_name = ? WHERE user_id = ? AND thread_id = ?",
- (new_name, user_id, thread_id),
- )
-
-
-def get_display_number(user_id: int, thread_id: int) -> int:
- """Return 1-based display number for a chat (by creation order)."""
- with _conn() as con:
- row = con.execute(
- """
- SELECT rn FROM (
- SELECT thread_id,
- ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn
- FROM chats
- WHERE user_id = ?
- ) WHERE thread_id = ?
- """,
- (user_id, thread_id),
- ).fetchone()
- return row[0] if row else 1
-```
-
-- [ ] **Step 4: Run tests — verify they pass**
-
-```bash
-pytest tests/adapter/test_forum_db.py -v
-```
-
-Expected: all 8 tests pass
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/telegram/db.py tests/adapter/test_forum_db.py
-git commit -m "feat(tg): new db schema — (user_id, thread_id) PK"
-```
-
----
-
-## Task 2: Converter
-
-**Files:**
-- Create: `adapter/telegram/converter.py`
-- Create: `tests/adapter/telegram/test_converter.py`
-
-- [ ] **Step 1: Write failing tests**
-
-Create `tests/adapter/telegram/test_converter.py`:
-
-```python
-from __future__ import annotations
-
-from types import SimpleNamespace
-
-from adapter.telegram.converter import from_message, format_outgoing
-from core.protocol import OutgoingMessage, OutgoingUI
-
-
-def make_message(*, text="hello", thread_id=42, user_id=1):
- m = SimpleNamespace()
- m.text = text
- m.caption = None
- m.photo = None
- m.document = None
- m.voice = None
- m.message_thread_id = thread_id
- m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
- return m
-
-
-def test_from_message_in_topic():
- msg = make_message(thread_id=42, user_id=7)
- result = from_message(msg)
- assert result is not None
- assert result.user_id == "7"
- assert result.chat_id == "42"
- assert result.text == "hello"
- assert result.platform == "telegram"
-
-
-def test_from_message_in_general_returns_none():
- msg = make_message(thread_id=None)
- assert from_message(msg) is None
-
-
-def test_from_message_uses_caption_if_no_text():
- msg = make_message(text=None, thread_id=10)
- msg.caption = "caption text"
- result = from_message(msg)
- assert result.text == "caption text"
-
-
-def test_format_outgoing_message():
- event = OutgoingMessage(chat_id="42", text="response")
- assert format_outgoing(event) == "response"
-
-
-def test_format_outgoing_ui():
- event = OutgoingUI(chat_id="42", text="choose")
- assert format_outgoing(event) == "choose"
-```
-
-- [ ] **Step 2: Run tests — verify they fail**
-
-```bash
-pytest tests/adapter/telegram/test_converter.py -v
-```
-
-Expected: `ModuleNotFoundError`
-
-- [ ] **Step 3: Implement converter.py**
-
-Create `adapter/telegram/converter.py`:
-
-```python
-from __future__ import annotations
-
-from aiogram.types import Message
-
-from core.protocol import Attachment, IncomingMessage, OutgoingEvent, OutgoingMessage, OutgoingUI
-
-
-def from_message(message: Message) -> IncomingMessage | None:
- """Convert aiogram Message to IncomingMessage. Returns None for General topic."""
- thread_id = message.message_thread_id
- if thread_id is None:
- return None
- return IncomingMessage(
- user_id=str(message.from_user.id),
- chat_id=str(thread_id),
- text=message.text or message.caption or "",
- attachments=_extract_attachments(message),
- platform="telegram",
- )
-
-
-def _extract_attachments(message: Message) -> list[Attachment]:
- attachments: list[Attachment] = []
- if message.photo:
- file = message.photo[-1]
- attachments.append(Attachment(
- type="image",
- url=f"tg://file/{file.file_id}",
- mime_type="image/jpeg",
- ))
- if message.document:
- attachments.append(Attachment(
- type="document",
- url=f"tg://file/{message.document.file_id}",
- mime_type=message.document.mime_type or "application/octet-stream",
- filename=message.document.file_name,
- ))
- if message.voice:
- attachments.append(Attachment(
- type="audio",
- url=f"tg://file/{message.voice.file_id}",
- mime_type="audio/ogg",
- ))
- return attachments
-
-
-def format_outgoing(event: OutgoingEvent) -> str:
- """Extract text from an outgoing event for sending to Telegram."""
- if isinstance(event, (OutgoingMessage, OutgoingUI)):
- return event.text
- return str(event)
-```
-
-- [ ] **Step 4: Run tests — verify they pass**
-
-```bash
-pytest tests/adapter/telegram/test_converter.py -v
-```
-
-Expected: all 5 tests pass
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/telegram/converter.py tests/adapter/telegram/test_converter.py
-git commit -m "feat(tg): converter — context_key=(user_id, thread_id)"
-```
-
----
-
-## Task 3: Topic Event Handlers
-
-**Files:**
-- Create: `adapter/telegram/handlers/topic_events.py`
-- Create: `tests/adapter/telegram/test_topic_events.py`
-
-- [ ] **Step 1: Write failing tests**
-
-Create `tests/adapter/telegram/test_topic_events.py`:
-
-```python
-from __future__ import annotations
-
-import importlib
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, patch
-
-import pytest
-
-
-@pytest.fixture(autouse=True)
-def fresh_db(tmp_path, monkeypatch):
- monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
- import adapter.telegram.db as db_mod
- importlib.reload(db_mod)
- db_mod.init_db()
- return db_mod
-
-
-def make_service_message(*, user_id=1, thread_id=42, chat_id=1):
- m = SimpleNamespace()
- m.message_thread_id = thread_id
- m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
- m.chat = SimpleNamespace(id=chat_id)
- m.forum_topic_created = SimpleNamespace(name="Мой чат")
- m.forum_topic_edited = SimpleNamespace(name="Новое имя")
- m.forum_topic_closed = SimpleNamespace()
- m.answer = AsyncMock()
- return m
-
-
-async def test_on_topic_created_registers_chat(fresh_db, monkeypatch):
- from adapter.telegram.handlers.topic_events import on_topic_created
- msg = make_service_message(user_id=5, thread_id=99)
- await on_topic_created(msg)
- chat = fresh_db.get_chat(5, 99)
- assert chat is not None
- assert chat["chat_name"] == "Мой чат"
-
-
-async def test_on_topic_edited_renames_chat(fresh_db, monkeypatch):
- from adapter.telegram.handlers.topic_events import on_topic_edited
- fresh_db.create_chat(5, 99, "Старое имя")
- msg = make_service_message(user_id=5, thread_id=99)
- await on_topic_edited(msg)
- assert fresh_db.get_chat(5, 99)["chat_name"] == "Новое имя"
-
-
-async def test_on_topic_edited_unknown_chat_is_noop(fresh_db):
- from adapter.telegram.handlers.topic_events import on_topic_edited
- msg = make_service_message(user_id=5, thread_id=999)
- await on_topic_edited(msg) # should not raise
-
-
-async def test_on_topic_closed_archives_chat(fresh_db):
- from adapter.telegram.handlers.topic_events import on_topic_closed
- fresh_db.create_chat(5, 99, "Чат #1")
- msg = make_service_message(user_id=5, thread_id=99)
- await on_topic_closed(msg)
- assert fresh_db.get_chat(5, 99)["archived_at"] is not None
-
-
-async def test_on_topic_closed_unknown_chat_is_noop(fresh_db):
- from adapter.telegram.handlers.topic_events import on_topic_closed
- msg = make_service_message(user_id=5, thread_id=999)
- await on_topic_closed(msg) # should not raise
-```
-
-- [ ] **Step 2: Run tests — verify they fail**
-
-```bash
-pytest tests/adapter/telegram/test_topic_events.py -v
-```
-
-Expected: `ModuleNotFoundError`
-
-- [ ] **Step 3: Implement topic_events.py**
-
-Create `adapter/telegram/handlers/topic_events.py`:
-
-```python
-from __future__ import annotations
-
-import structlog
-from aiogram import F, Router
-from aiogram.types import Message
-
-from adapter.telegram import db
-
-logger = structlog.get_logger(__name__)
-
-router = Router(name="topic_events")
-
-
-@router.message(F.forum_topic_created)
-async def on_topic_created(message: Message) -> None:
- """User created a topic via Telegram UI — register it as a new chat."""
- user_id = message.from_user.id
- thread_id = message.message_thread_id
- name = message.forum_topic_created.name
- db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=name)
- logger.info("topic_created", user_id=user_id, thread_id=thread_id, name=name)
-
-
-@router.message(F.forum_topic_edited)
-async def on_topic_edited(message: Message) -> None:
- """User renamed a topic via Telegram UI — sync chat_name in DB."""
- user_id = message.from_user.id
- thread_id = message.message_thread_id
- new_name = message.forum_topic_edited.name
- existing = db.get_chat(user_id=user_id, thread_id=thread_id)
- if existing is None:
- return
- db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name)
- logger.info("topic_renamed", user_id=user_id, thread_id=thread_id, new_name=new_name)
-
-
-@router.message(F.forum_topic_closed)
-async def on_topic_closed(message: Message) -> None:
- """User closed a topic via Telegram UI — auto-archive the chat."""
- user_id = message.from_user.id
- thread_id = message.message_thread_id
- existing = db.get_chat(user_id=user_id, thread_id=thread_id)
- if existing is None:
- return
- db.archive_chat(user_id=user_id, thread_id=thread_id)
- logger.info("topic_closed_archived", user_id=user_id, thread_id=thread_id)
-```
-
-- [ ] **Step 4: Run tests — verify they pass**
-
-```bash
-pytest tests/adapter/telegram/test_topic_events.py -v
-```
-
-Expected: all 5 tests pass
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/telegram/handlers/topic_events.py tests/adapter/telegram/test_topic_events.py
-git commit -m "feat(tg): handle forum_topic_created/edited/closed events"
-```
-
----
-
-## Task 4: Command Handlers
-
-**Files:**
-- Create: `adapter/telegram/handlers/commands.py`
-- Create: `tests/adapter/telegram/test_commands.py`
-
-- [ ] **Step 1: Write failing tests**
-
-Create `tests/adapter/telegram/test_commands.py`:
-
-```python
-from __future__ import annotations
-
-import importlib
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, MagicMock
-
-import pytest
-
-
-@pytest.fixture(autouse=True)
-def fresh_db(tmp_path, monkeypatch):
- monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
- import adapter.telegram.db as db_mod
- importlib.reload(db_mod)
- db_mod.init_db()
- return db_mod
-
-
-def make_message(*, user_id=1, thread_id=42, chat_id=1, args=None):
- m = SimpleNamespace()
- m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
- m.message_thread_id = thread_id
- m.chat = SimpleNamespace(id=chat_id)
- m.answer = AsyncMock()
- m.reply = AsyncMock()
- m.bot = MagicMock()
- m.bot.create_forum_topic = AsyncMock(
- return_value=SimpleNamespace(message_thread_id=200)
- )
- m.bot.close_forum_topic = AsyncMock()
- m.bot.edit_forum_topic = AsyncMock()
- m.bot.send_message = AsyncMock()
- return m
-
-
-async def test_cmd_new_creates_topic(fresh_db):
- from adapter.telegram.handlers.commands import cmd_new
- msg = make_message(user_id=1, thread_id=42, chat_id=100)
- fresh_db.create_chat(1, 42, "Чат #1") # 1 existing chat
- await cmd_new(msg)
- msg.bot.create_forum_topic.assert_called_once()
- call_kwargs = msg.bot.create_forum_topic.call_args
- assert "Чат #2" in str(call_kwargs)
- new_chat = fresh_db.get_chat(1, 200)
- assert new_chat is not None
- assert new_chat["chat_name"] == "Чат #2"
-
-
-async def test_cmd_archive_closes_and_archives(fresh_db):
- from adapter.telegram.handlers.commands import cmd_archive
- fresh_db.create_chat(1, 42, "Чат #1")
- msg = make_message(user_id=1, thread_id=42, chat_id=100)
- await cmd_archive(msg)
- msg.bot.close_forum_topic.assert_called_once_with(
- chat_id=100, message_thread_id=42
- )
- assert fresh_db.get_chat(1, 42)["archived_at"] is not None
-
-
-async def test_cmd_archive_unknown_topic_replies_error(fresh_db):
- from adapter.telegram.handlers.commands import cmd_archive
- msg = make_message(user_id=1, thread_id=999, chat_id=100)
- await cmd_archive(msg)
- msg.answer.assert_called_once()
- assert "не найден" in msg.answer.call_args[0][0].lower() or \
- "not found" in msg.answer.call_args[0][0].lower() or \
- len(msg.answer.call_args[0][0]) > 0 # some error message
-
-
-async def test_cmd_rename_updates_db_and_topic(fresh_db):
- from adapter.telegram.handlers.commands import cmd_rename
- fresh_db.create_chat(1, 42, "Чат #1")
- msg = make_message(user_id=1, thread_id=42, chat_id=100)
- await cmd_rename(msg, new_name="Работа")
- msg.bot.edit_forum_topic.assert_called_once_with(
- chat_id=100, message_thread_id=42, name="Работа"
- )
- assert fresh_db.get_chat(1, 42)["chat_name"] == "Работа"
-```
-
-- [ ] **Step 2: Run tests — verify they fail**
-
-```bash
-pytest tests/adapter/telegram/test_commands.py -v
-```
-
-Expected: `ModuleNotFoundError`
-
-- [ ] **Step 3: Implement commands.py**
-
-Create `adapter/telegram/handlers/commands.py`:
-
-```python
-from __future__ import annotations
-
-import structlog
-from aiogram import Router
-from aiogram.filters import Command
-from aiogram.types import Message
-
-from adapter.telegram import db
-from adapter.telegram.keyboards.settings import settings_main_keyboard
-
-logger = structlog.get_logger(__name__)
-
-router = Router(name="commands")
-
-
-@router.message(Command("new"))
-async def cmd_new(message: Message) -> None:
- """Create a new topic and register it as a new chat."""
- user_id = message.from_user.id
- chat_id = message.chat.id
- n = db.count_active_chats(user_id) + 1
- new_name = f"Чат #{n}"
- topic = await message.bot.create_forum_topic(chat_id=chat_id, name=new_name)
- thread_id = topic.message_thread_id
- db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=new_name)
- await message.bot.send_message(
- chat_id=chat_id,
- message_thread_id=thread_id,
- text=f"Создан {new_name}. Напиши что-нибудь.",
- )
- logger.info("cmd_new", user_id=user_id, thread_id=thread_id, name=new_name)
-
-
-@router.message(Command("archive"))
-async def cmd_archive(message: Message) -> None:
- """Archive the current topic."""
- user_id = message.from_user.id
- thread_id = message.message_thread_id
- chat = db.get_chat(user_id=user_id, thread_id=thread_id)
- if chat is None or chat["archived_at"] is not None:
- await message.answer("Этот чат не найден или уже архивирован.")
- return
- await message.bot.close_forum_topic(
- chat_id=message.chat.id, message_thread_id=thread_id
- )
- db.archive_chat(user_id=user_id, thread_id=thread_id)
- logger.info("cmd_archive", user_id=user_id, thread_id=thread_id)
-
-
-@router.message(Command("rename"))
-async def cmd_rename(message: Message, new_name: str = "") -> None:
- """Rename the current topic. Usage: /rename New Name"""
- user_id = message.from_user.id
- thread_id = message.message_thread_id
- if not new_name:
- # Parse from message text: /rename New Name
- parts = (message.text or "").split(maxsplit=1)
- new_name = parts[1].strip() if len(parts) > 1 else ""
- if not new_name:
- await message.answer("Использование: /rename Новое название")
- return
- chat = db.get_chat(user_id=user_id, thread_id=thread_id)
- if chat is None:
- await message.answer("Этот чат не найден.")
- return
- await message.bot.edit_forum_topic(
- chat_id=message.chat.id,
- message_thread_id=thread_id,
- name=new_name[:128],
- )
- db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name[:128])
- logger.info("cmd_rename", user_id=user_id, thread_id=thread_id, new_name=new_name)
-
-
-@router.message(Command("settings"))
-async def cmd_settings(message: Message) -> None:
- """Open settings menu."""
- await message.answer("⚙️ Настройки", reply_markup=settings_main_keyboard())
-```
-
-- [ ] **Step 4: Run tests — verify they pass**
-
-```bash
-pytest tests/adapter/telegram/test_commands.py -v
-```
-
-Expected: all 4 tests pass
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/telegram/handlers/commands.py tests/adapter/telegram/test_commands.py
-git commit -m "feat(tg): command handlers — /new /archive /rename /settings"
-```
-
----
-
-## Task 5: /start Handler
-
-**Files:**
-- Create: `adapter/telegram/handlers/start.py`
-
-No separate test file — behaviour is verified via integration in Task 7. Unit testing `/start` requires heavy bot mocking; the key logic (stale topic detection) is thin enough to verify manually.
-
-- [ ] **Step 1: Implement start.py**
-
-Create `adapter/telegram/handlers/start.py`:
-
-```python
-from __future__ import annotations
-
-import structlog
-from aiogram import Router
-from aiogram.exceptions import TelegramBadRequest
-from aiogram.filters import Command, CommandStart
-from aiogram.types import Message
-
-from adapter.telegram import db
-
-logger = structlog.get_logger(__name__)
-
-router = Router(name="start")
-
-
-@router.message(CommandStart())
-async def cmd_start(message: Message) -> None:
- """
- Bootstrap the user's forum.
-
- First visit: create Чат #1, hide General topic.
- Returning visit: health-check all active topics, archive stale ones.
- """
- user_id = message.from_user.id
- chat_id = message.chat.id
-
- # Health-check existing topics — archive any that Telegram no longer knows about
- await _check_and_prune_stale_topics(message, user_id, chat_id)
-
- active = db.get_active_chats(user_id)
-
- if not active:
- # First visit or all topics were pruned — create the first one
- try:
- topic = await message.bot.create_forum_topic(
- chat_id=chat_id, name="Чат #1"
- )
- thread_id = topic.message_thread_id
- db.create_chat(user_id=user_id, thread_id=thread_id, chat_name="Чат #1")
- logger.info("start_created_first_topic", user_id=user_id, thread_id=thread_id)
- except TelegramBadRequest as e:
- if "not modified" not in str(e).lower():
- logger.warning("start_create_topic_failed", error=str(e))
- await message.answer(
- "Не удалось создать топик. Убедись, что в @BotFather включён "
- "Threaded Mode для этого бота."
- )
- return
-
- # Hide General topic so it doesn't distract
- try:
- await message.bot.hide_general_forum_topic(chat_id=chat_id)
- except TelegramBadRequest:
- pass # Not critical — may not be available in all API versions
-
- await message.answer(
- "Привет! Это твоё личное пространство с AI-агентом Lambda. "
- "Каждый топик — отдельный контекст. Напиши что-нибудь."
- )
- else:
- await message.answer(
- f"Снова привет! У тебя {len(active)} активных чатов. "
- "Напиши /new чтобы создать новый."
- )
-
-
-async def _check_and_prune_stale_topics(
- message: Message, user_id: int, chat_id: int
-) -> None:
- """
- Send typing action to each active topic.
- If Telegram returns an error — the topic was deleted; archive it.
- """
- active = db.get_active_chats(user_id)
- for chat in active:
- thread_id = chat["thread_id"]
- try:
- await message.bot.send_chat_action(
- chat_id=chat_id,
- action="typing",
- message_thread_id=thread_id,
- )
- except TelegramBadRequest:
- db.archive_chat(user_id=user_id, thread_id=thread_id)
- logger.info("pruned_stale_topic", user_id=user_id, thread_id=thread_id)
-```
-
-- [ ] **Step 2: Verify it imports cleanly**
-
-```bash
-python -c "from adapter.telegram.handlers.start import router; print('ok')"
-```
-
-Expected: `ok`
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add adapter/telegram/handlers/start.py
-git commit -m "feat(tg): /start handler with topic bootstrap and stale-topic pruning"
-```
-
----
-
-## Task 6: Message Handler with Streaming
-
-**Files:**
-- Create: `adapter/telegram/handlers/message.py`
-
-- [ ] **Step 1: Implement message.py**
-
-Create `adapter/telegram/handlers/message.py`:
-
-```python
-from __future__ import annotations
-
-import asyncio
-import time
-
-import structlog
-from aiogram import F, Router
-from aiogram.exceptions import TelegramBadRequest
-from aiogram.types import Message
-
-from adapter.telegram import converter, db
-from core.handler import EventDispatcher
-
-logger = structlog.get_logger(__name__)
-
-router = Router(name="message")
-
-STREAM_EDIT_INTERVAL = 1.5 # seconds between edit_text calls
-STREAM_MIN_DELTA = 100 # minimum new chars before editing
-TELEGRAM_MAX_LEN = 4096
-
-
-@router.message(F.text & F.message_thread_id)
-async def handle_topic_message(message: Message, dispatcher: EventDispatcher) -> None:
- """Route a text message in a topic to the platform and stream the response."""
- user_id = message.from_user.id
- thread_id = message.message_thread_id
-
- chat = db.get_chat(user_id=user_id, thread_id=thread_id)
- if chat is None or chat["archived_at"] is not None:
- # Unregistered or archived topic — silently ignore
- return
-
- incoming = converter.from_message(message)
- if incoming is None:
- return
-
- platform_user = await dispatcher._platform.get_or_create_user(
- external_id=str(user_id),
- platform="telegram",
- display_name=message.from_user.full_name,
- )
-
- placeholder = await message.reply("...")
-
- accumulated = ""
- last_edit_time = 0.0
- last_edit_len = 0
-
- try:
- async for chunk in dispatcher._platform.stream_message(
- user_id=platform_user.user_id,
- chat_id=str(thread_id),
- text=incoming.text,
- attachments=None,
- ):
- accumulated += chunk.delta
- now = time.monotonic()
- delta = len(accumulated) - last_edit_len
- if delta >= STREAM_MIN_DELTA and (now - last_edit_time) >= STREAM_EDIT_INTERVAL:
- await _safe_edit(placeholder, accumulated)
- last_edit_time = now
- last_edit_len = len(accumulated)
-
- # Final edit with complete response
- await _safe_edit(placeholder, accumulated or "...")
-
- except TelegramBadRequest as e:
- if "thread not found" in str(e).lower():
- db.archive_chat(user_id=user_id, thread_id=thread_id)
- logger.warning("topic_deleted_during_message", thread_id=thread_id)
- else:
- logger.error("telegram_error", error=str(e))
- except Exception:
- logger.exception("platform_error", user_id=user_id, thread_id=thread_id)
- await _safe_edit(placeholder, "Сервис временно недоступен, попробуй позже")
-
-
-async def _safe_edit(message: Message, text: str) -> None:
- """Edit message text, truncating to Telegram limit. Swallows 'not modified'."""
- truncated = text[:TELEGRAM_MAX_LEN]
- try:
- await message.edit_text(truncated)
- except TelegramBadRequest as e:
- if "not modified" not in str(e).lower():
- raise
-```
-
-- [ ] **Step 2: Verify it imports cleanly**
-
-```bash
-python -c "from adapter.telegram.handlers.message import router; print('ok')"
-```
-
-Expected: `ok`
-
-- [ ] **Step 3: Commit**
-
-```bash
-git add adapter/telegram/handlers/message.py
-git commit -m "feat(tg): message handler with streaming via sdk.stream_message"
-```
-
----
-
-## Task 7: Settings Handler (Cherry-Pick + Adapt)
-
-**Files:**
-- Create: `adapter/telegram/states.py`
-- Create: `adapter/telegram/handlers/settings.py`
-
-The settings handler from `feat/telegram-adapter` already works well. We adapt it to drop `db.get_or_create_tg_user` (no longer needed — platform resolves users by `str(tg_id)`) and remove topic-FSM dependency.
-
-- [ ] **Step 1: Create states.py (SettingsState only)**
-
-Create `adapter/telegram/states.py`:
-
-```python
-from __future__ import annotations
-
-from aiogram.fsm.state import State, StatesGroup
-
-
-class SettingsState(StatesGroup):
- menu = State()
- soul_editing = State()
-```
-
-- [ ] **Step 2: Cherry-pick settings handler**
-
-```bash
-git show feat/telegram-adapter:adapter/telegram/handlers/settings.py > adapter/telegram/handlers/settings.py
-```
-
-- [ ] **Step 3: Patch settings handler — remove get_or_create_tg_user calls**
-
-In `adapter/telegram/handlers/settings.py`, replace all blocks that call `db.get_or_create_tg_user` with a direct string cast. Find every occurrence of:
-
-```python
-from adapter.telegram import db as tgdb
-tg_id = callback.from_user.id
-tg_user = tgdb.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name)
-platform_user_id = tg_user.get("platform_user_id", str(tg_id))
-```
-
-Replace with:
-
-```python
-platform_user_id = str(callback.from_user.id)
-```
-
-And for message handlers (soul editing), replace the analogous block with:
-
-```python
-platform_user_id = str(message.from_user.id)
-```
-
-Also remove the import of `ChatState` from `adapter.telegram.states` — it no longer exists:
-Find: `from adapter.telegram.states import ChatState, SettingsState`
-Replace: `from adapter.telegram.states import SettingsState`
-
-- [ ] **Step 4: Verify settings handler imports cleanly**
-
-```bash
-python -c "from adapter.telegram.handlers.settings import router; print('ok')"
-```
-
-Expected: `ok`
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/telegram/states.py adapter/telegram/handlers/settings.py
-git commit -m "feat(tg): cherry-pick settings handler, drop get_or_create_tg_user"
-```
-
----
-
-## Task 8: Wire Everything in bot.py
-
-**Files:**
-- Create: `adapter/telegram/bot.py`
-
-- [ ] **Step 1: Implement bot.py**
-
-Create `adapter/telegram/bot.py`:
-
-```python
-from __future__ import annotations
-
-import asyncio
-import os
-
-import structlog
-from aiogram import Bot, Dispatcher
-from aiogram.fsm.storage.memory import MemoryStorage
-from aiogram.types import BotCommand
-
-from adapter.telegram import db
-from adapter.telegram.handlers import commands, message, settings, start, topic_events
-from core.auth import AuthManager
-from core.chat import ChatManager
-from core.handler import EventDispatcher
-from core.settings import SettingsManager
-from core.store import InMemoryStore
-from sdk.mock import MockPlatformClient
-
-logger = structlog.get_logger(__name__)
-
-
-class PlatformMiddleware:
- """Injects EventDispatcher (with platform inside) into every handler."""
-
- def __init__(self, dispatcher: EventDispatcher) -> None:
- self._dispatcher = dispatcher
-
- async def __call__(self, handler, event, data):
- data["dispatcher"] = self._dispatcher
- return await handler(event, data)
-
-
-def build_event_dispatcher() -> EventDispatcher:
- platform = MockPlatformClient()
- store = InMemoryStore()
- chat_mgr = ChatManager(platform, store)
- auth_mgr = AuthManager(platform, store)
- settings_mgr = SettingsManager(platform, store)
- return EventDispatcher(
- platform=platform,
- chat_mgr=chat_mgr,
- auth_mgr=auth_mgr,
- settings_mgr=settings_mgr,
- )
-
-
-async def main() -> None:
- token = os.environ.get("BOT_TOKEN")
- if not token:
- raise RuntimeError("BOT_TOKEN env variable is not set")
-
- db.init_db()
-
- bot = Bot(token=token)
- storage = MemoryStorage()
- dp = Dispatcher(storage=storage)
-
- event_dispatcher = build_event_dispatcher()
-
- dp.message.middleware(PlatformMiddleware(event_dispatcher))
- dp.callback_query.middleware(PlatformMiddleware(event_dispatcher))
-
- # Register routers — order matters (most specific first)
- dp.include_router(topic_events.router) # service messages
- dp.include_router(start.router) # /start
- dp.include_router(commands.router) # /new /archive /rename /settings
- dp.include_router(settings.router) # settings callbacks + soul FSM
- dp.include_router(message.router) # text messages in topics (last)
-
- await bot.set_my_commands([
- BotCommand(command="start", description="Начать / восстановить сессию"),
- BotCommand(command="new", description="Создать новый чат"),
- BotCommand(command="archive", description="Архивировать текущий чат"),
- BotCommand(command="rename", description="Переименовать текущий чат"),
- BotCommand(command="settings", description="Настройки"),
- ])
-
- logger.info("bot_starting")
- await dp.start_polling(
- bot,
- allowed_updates=[
- "message",
- "callback_query",
- ],
- )
-
-
-if __name__ == "__main__":
- asyncio.run(main())
-```
-
-- [ ] **Step 2: Verify full import chain**
-
-```bash
-python -c "from adapter.telegram.bot import main; print('ok')"
-```
-
-Expected: `ok`
-
-- [ ] **Step 3: Run all tests**
-
-```bash
-pytest tests/adapter/ -v
-```
-
-Expected: all tests pass, no import errors
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add adapter/telegram/bot.py
-git commit -m "feat(tg): wire forum-first adapter in bot.py"
-```
-
----
-
-## Task 9: Final Cleanup and Module Entry Point
-
-**Files:**
-- Verify: `adapter/telegram/__init__.py`
-
-- [ ] **Step 1: Ensure `python -m adapter.telegram.bot` works**
-
-```bash
-python -m adapter.telegram.bot --help 2>&1 | head -5 || echo "needs BOT_TOKEN"
-```
-
-Expected: either `needs BOT_TOKEN` or a clean import error (not `ModuleNotFoundError`)
-
-- [ ] **Step 2: Run full test suite**
-
-```bash
-pytest tests/ -v --tb=short
-```
-
-Expected: all tests pass (including core/ and matrix/ tests from main)
-
-- [ ] **Step 3: Final commit**
-
-```bash
-git add -A
-git status # verify no unintended files
-git commit -m "feat(tg): forum-first adapter complete — threaded mode, (user_id, thread_id) context"
-```
-
----
-
-## Self-Review Checklist
-
-Spec requirements vs tasks:
-
-| Spec requirement | Task |
-|-----------------|------|
-| `(user_id, thread_id)` PK | Task 1 |
-| `forum_topic_created` → register | Task 3 |
-| `forum_topic_edited` → sync name | Task 3 |
-| `forum_topic_closed` → auto-archive | Task 3 |
-| `/new` creates topic | Task 4 |
-| `/archive` closes + archives | Task 4 |
-| `/rename` edits topic + DB | Task 4 |
-| `/settings` global keyboard | Task 4 + Task 7 |
-| `/start` bootstrap + health-check | Task 5 |
-| Hide General topic | Task 5 |
-| Threaded Mode not enabled → explain | Task 5 |
-| Streaming via `stream_message` | Task 6 |
-| General topic messages ignored | Task 6 (thread_id None guard in converter) |
-| Stale topic auto-archive on send | Task 6 |
-| `core/store.py` for state, no FSM | All tasks (no FSMContext in message/topic handlers) |
-| platform resolves workspace | Implicit — adapter passes `str(thread_id)` as `chat_id` |
diff --git a/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md b/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md
deleted file mode 100644
index e9a9921..0000000
--- a/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md
+++ /dev/null
@@ -1,515 +0,0 @@
-# Matrix Direct-Agent Prototype Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Replace `MockPlatformClient` with a real Matrix-only direct-agent prototype backend while keeping Matrix adapter logic stable and preserving a clean future migration path.
-
-**Architecture:** Introduce a thin compatibility layer under `sdk/` that splits direct WebSocket agent messaging from local prototype-only control-plane state. Keep `core/` and Matrix handlers on the existing `PlatformClient` contract, and limit surface changes to runtime wiring and tests.
-
-**Tech Stack:** Python 3.11, `aiohttp` WebSocket client, `matrix-nio`, `pydantic`, `pytest`, `pytest-asyncio`
-
----
-
-## File Structure
-
-- Create: `sdk/agent_session.py`
- Purpose: Minimal direct WebSocket client for the real agent, including thread-key derivation, request/response parsing, and sync/stream helpers.
-
-- Create: `sdk/prototype_state.py`
- Purpose: Local prototype-only user mapping and settings store kept behind a small API.
-
-- Create: `sdk/real.py`
- Purpose: `PlatformClient` implementation that composes `AgentSessionClient` and `PrototypeStateStore`.
-
-- Modify: `sdk/__init__.py`
- Purpose: export `RealPlatformClient` if useful for runtime imports.
-
-- Modify: `adapter/matrix/bot.py`
- Purpose: runtime/backend selection and env-based configuration for mock vs real backend.
-
-- Create: `tests/platform/test_agent_session.py`
- Purpose: transport-level tests for direct agent communication.
-
-- Create: `tests/platform/test_prototype_state.py`
- Purpose: unit tests for local user/settings behavior.
-
-- Create: `tests/platform/test_real.py`
- Purpose: contract tests for `RealPlatformClient`.
-
-- Modify: `tests/core/test_integration.py`
- Purpose: prove the new platform implementation preserves core behavior.
-
-- Modify: `README.md`
- Purpose: document backend selection and prototype limitations after code is working.
-
----
-
-### Task 1: Add Direct Agent Session Transport
-
-**Files:**
-- Create: `sdk/agent_session.py`
-- Test: `tests/platform/test_agent_session.py`
-
-- [ ] **Step 1: Write the failing transport tests**
-
-```python
-import pytest
-
-from sdk.agent_session import AgentSessionClient, build_thread_key
-
-
-def test_build_thread_key_uses_surface_user_and_chat_id():
- assert build_thread_key("matrix", "@alice:example.org", "C1") == "matrix:@alice:example.org:C1"
-
-
-@pytest.mark.asyncio
-async def test_send_message_collects_text_chunks_and_tokens(aiohttp_server):
- ...
-
-
-@pytest.mark.asyncio
-async def test_stream_message_yields_incremental_chunks(aiohttp_server):
- ...
-```
-
-- [ ] **Step 2: Run test to verify it fails**
-
-Run: `pytest tests/platform/test_agent_session.py -q`
-Expected: FAIL with `ModuleNotFoundError: No module named 'sdk.agent_session'`
-
-- [ ] **Step 3: Write minimal transport implementation**
-
-```python
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import AsyncIterator
-
-import aiohttp
-
-from sdk.interface import MessageChunk, MessageResponse, PlatformError
-
-
-def build_thread_key(platform: str, user_id: str, chat_id: str) -> str:
- return f"{platform}:{user_id}:{chat_id}"
-
-
-@dataclass
-class AgentSessionConfig:
- base_ws_url: str
- timeout_seconds: float = 30.0
-
-
-class AgentSessionClient:
- def __init__(self, config: AgentSessionConfig) -> None:
- self._config = config
-
- async def send_message(self, *, thread_key: str, text: str) -> MessageResponse:
- chunks = []
- tokens_used = 0
- async for chunk in self.stream_message(thread_key=thread_key, text=text):
- chunks.append(chunk.delta)
- tokens_used = chunk.tokens_used or tokens_used
- return MessageResponse(
- message_id=thread_key,
- response="".join(chunks),
- tokens_used=tokens_used,
- finished=True,
- )
-
- async def stream_message(self, *, thread_key: str, text: str) -> AsyncIterator[MessageChunk]:
- url = f"{self._config.base_ws_url}?thread_id={thread_key}"
- async with aiohttp.ClientSession() as session:
- async with session.ws_connect(url, heartbeat=30) as ws:
- status_msg = await ws.receive_json(timeout=self._config.timeout_seconds)
- if status_msg.get("type") != "STATUS":
- raise PlatformError("Agent did not send STATUS", code="AGENT_PROTOCOL_ERROR")
-
- await ws.send_json({"type": "USER_MESSAGE", "text": text})
-
- while True:
- payload = await ws.receive_json(timeout=self._config.timeout_seconds)
- msg_type = payload.get("type")
- if msg_type == "AGENT_EVENT_TEXT_CHUNK":
- yield MessageChunk(message_id=thread_key, delta=payload["text"], finished=False)
- elif msg_type == "AGENT_EVENT_END":
- yield MessageChunk(
- message_id=thread_key,
- delta="",
- finished=True,
- tokens_used=payload.get("tokens_used", 0),
- )
- return
- elif msg_type == "ERROR":
- raise PlatformError(payload.get("details", "Agent error"), code=payload.get("code", "AGENT_ERROR"))
- else:
- raise PlatformError(f"Unexpected agent message: {payload}", code="AGENT_PROTOCOL_ERROR")
-```
-
-- [ ] **Step 4: Run test to verify it passes**
-
-Run: `pytest tests/platform/test_agent_session.py -q`
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add sdk/agent_session.py tests/platform/test_agent_session.py
-git commit -m "feat: add direct agent session transport"
-```
-
----
-
-### Task 2: Add Local Prototype State For Users And Settings
-
-**Files:**
-- Create: `sdk/prototype_state.py`
-- Test: `tests/platform/test_prototype_state.py`
-
-- [ ] **Step 1: Write the failing state tests**
-
-```python
-import pytest
-
-from core.protocol import SettingsAction
-from sdk.prototype_state import PrototypeStateStore
-
-
-@pytest.mark.asyncio
-async def test_get_or_create_user_is_stable_per_surface_identity():
- ...
-
-
-@pytest.mark.asyncio
-async def test_settings_defaults_match_existing_mock_shape():
- ...
-
-
-@pytest.mark.asyncio
-async def test_update_settings_supports_toggle_skill_and_setters():
- ...
-```
-
-- [ ] **Step 2: Run test to verify it fails**
-
-Run: `pytest tests/platform/test_prototype_state.py -q`
-Expected: FAIL with `ModuleNotFoundError: No module named 'sdk.prototype_state'`
-
-- [ ] **Step 3: Write minimal state implementation**
-
-```python
-from __future__ import annotations
-
-from datetime import UTC, datetime
-
-from sdk.interface import User, UserSettings
-
-# Defaults are defined here, not imported from sdk.mock, to keep real backend
-# isolated from the mock. Copy-paste intentional.
-DEFAULT_SKILLS: dict[str, bool] = {
- "web-search": True,
- "fetch-url": True,
- "email": False,
- "browser": False,
- "image-gen": False,
- "files": True,
-}
-DEFAULT_SAFETY: dict[str, bool] = {"email-send": True, "file-delete": True, "social-post": True}
-DEFAULT_SOUL: dict[str, str] = {"name": "Лямбда", "instructions": ""}
-DEFAULT_PLAN: dict = {"name": "Beta", "tokens_used": 0, "tokens_limit": 1000}
-
-
-class PrototypeStateStore:
- def __init__(self) -> None:
- self._users: dict[str, User] = {}
- self._settings: dict[str, dict] = {}
-
- async def get_or_create_user(
- self,
- *,
- external_id: str,
- platform: str,
- display_name: str | None = None,
- ) -> User:
- key = f"{platform}:{external_id}"
- existing = self._users.get(key)
- if existing is not None:
- return existing.model_copy(update={"is_new": False})
-
- user = User(
- user_id=f"usr-{platform}-{external_id}",
- external_id=external_id,
- platform=platform,
- display_name=display_name,
- created_at=datetime.now(UTC),
- is_new=True,
- )
- self._users[key] = user.model_copy(update={"is_new": False})
- return user
-
- async def get_settings(self, user_id: str) -> UserSettings:
- stored = self._settings.get(user_id, {})
- return UserSettings(
- skills={**DEFAULT_SKILLS, **stored.get("skills", {})},
- connectors=stored.get("connectors", {}),
- soul={**DEFAULT_SOUL, **stored.get("soul", {})},
- safety={**DEFAULT_SAFETY, **stored.get("safety", {})},
- plan={**DEFAULT_PLAN, **stored.get("plan", {})},
- )
-
- async def update_settings(self, user_id: str, action) -> None:
- settings = self._settings.setdefault(user_id, {})
- if action.action == "toggle_skill":
- skills = settings.setdefault("skills", DEFAULT_SKILLS.copy())
- skills[action.payload["skill"]] = action.payload.get("enabled", True)
- elif action.action == "set_soul":
- soul = settings.setdefault("soul", DEFAULT_SOUL.copy())
- soul[action.payload["field"]] = action.payload["value"]
- elif action.action == "set_safety":
- safety = settings.setdefault("safety", DEFAULT_SAFETY.copy())
- safety[action.payload["trigger"]] = action.payload.get("enabled", True)
-```
-
-- [ ] **Step 4: Run test to verify it passes**
-
-Run: `pytest tests/platform/test_prototype_state.py -q`
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add sdk/prototype_state.py tests/platform/test_prototype_state.py
-git commit -m "feat: add prototype local state store"
-```
-
----
-
-### Task 3: Implement RealPlatformClient Compatibility Layer
-
-**Files:**
-- Create: `sdk/real.py`
-- Modify: `sdk/__init__.py`
-- Test: `tests/platform/test_real.py`
-- Test: `tests/core/test_integration.py`
-
-- [ ] **Step 1: Write the failing compatibility tests**
-
-```python
-import pytest
-
-from core.protocol import SettingsAction
-from sdk.real import RealPlatformClient
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_get_or_create_user_uses_local_state():
- ...
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_send_message_uses_thread_key():
- ...
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_settings_are_local():
- ...
-```
-
-- [ ] **Step 2: Run test to verify it fails**
-
-Run: `pytest tests/platform/test_real.py -q`
-Expected: FAIL with `ModuleNotFoundError: No module named 'sdk.real'`
-
-- [ ] **Step 3: Write minimal compatibility wrapper**
-
-```python
-from __future__ import annotations
-
-from typing import AsyncIterator
-
-from sdk.agent_session import AgentSessionClient, build_thread_key
-from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformClient, User, UserSettings
-from sdk.prototype_state import PrototypeStateStore
-
-
-class RealPlatformClient(PlatformClient):
- def __init__(
- self,
- agent_sessions: AgentSessionClient,
- prototype_state: PrototypeStateStore,
- platform: str = "matrix",
- ) -> None:
- self._agent_sessions = agent_sessions
- self._prototype_state = prototype_state
- self._platform = platform # surface name used in thread key; pass explicitly for future surfaces
-
- async def get_or_create_user(
- self,
- external_id: str,
- platform: str,
- display_name: str | None = None,
- ) -> User:
- return await self._prototype_state.get_or_create_user(
- external_id=external_id,
- platform=platform,
- display_name=display_name,
- )
-
- async def send_message(
- self,
- user_id: str,
- chat_id: str,
- text: str,
- attachments: list[Attachment] | None = None,
- ) -> MessageResponse:
- # user_id here is the internal id (e.g. "usr-matrix-@alice:example.org"), which is
- # unique per user and stable — acceptable as thread identity for v1 prototype.
- thread_key = build_thread_key(self._platform, user_id, chat_id)
- return await self._agent_sessions.send_message(thread_key=thread_key, text=text)
-
- async def stream_message(
- self,
- user_id: str,
- chat_id: str,
- text: str,
- attachments: list[Attachment] | None = None,
- ) -> AsyncIterator[MessageChunk]:
- thread_key = build_thread_key(self._platform, user_id, chat_id)
- async for chunk in self._agent_sessions.stream_message(thread_key=thread_key, text=text):
- yield chunk
-
- async def get_settings(self, user_id: str) -> UserSettings:
- return await self._prototype_state.get_settings(user_id)
-
- async def update_settings(self, user_id: str, action) -> None:
- await self._prototype_state.update_settings(user_id, action)
-```
-
-- [ ] **Step 4: Run tests to verify the contract holds**
-
-Run: `pytest tests/platform/test_real.py tests/core/test_integration.py -q`
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add sdk/real.py sdk/__init__.py tests/platform/test_real.py tests/core/test_integration.py
-git commit -m "feat: add real platform compatibility layer"
-```
-
----
-
-### Task 4: Wire Matrix Runtime To Real Backend And Document Usage
-
-**Files:**
-- Modify: `adapter/matrix/bot.py`
-- Modify: `README.md`
-- Modify: `tests/adapter/matrix/test_dispatcher.py`
-
-- [ ] **Step 1: Write the failing runtime wiring tests**
-
-```python
-import os
-
-from adapter.matrix.bot import build_runtime
-from sdk.real import RealPlatformClient
-
-
-def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monkeypatch):
- monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
- monkeypatch.setenv("AGENT_WS_URL", "ws://agent.example/agent_ws/")
- runtime = build_runtime()
- assert isinstance(runtime.platform, RealPlatformClient)
-```
-
-- [ ] **Step 2: Run test to verify it fails**
-
-Run: `pytest tests/adapter/matrix/test_dispatcher.py -q`
-Expected: FAIL because runtime still always constructs `MockPlatformClient`
-
-- [ ] **Step 3: Implement backend selection and docs**
-
-```python
-# adapter/matrix/bot.py — add these imports at the top
-from sdk.agent_session import AgentSessionClient, AgentSessionConfig
-from sdk.interface import PlatformClient
-from sdk.prototype_state import PrototypeStateStore
-from sdk.real import RealPlatformClient
-
-
-def _build_platform_from_env() -> PlatformClient:
- backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock")
- if backend == "real":
- ws_url = os.environ["AGENT_WS_URL"]
- return RealPlatformClient(
- agent_sessions=AgentSessionClient(AgentSessionConfig(base_ws_url=ws_url)),
- prototype_state=PrototypeStateStore(),
- platform="matrix",
- )
- return MockPlatformClient()
-
-
-# Update build_runtime to use env-based selection when no platform is injected:
-def build_runtime(
- platform: PlatformClient | None = None, # was MockPlatformClient | None
- store: StateStore | None = None,
- client: AsyncClient | None = None,
-) -> MatrixRuntime:
- platform = platform or _build_platform_from_env()
- ... # rest unchanged
-```
-
-Also update the `MatrixRuntime` dataclass field type from `MockPlatformClient` to `PlatformClient` so the type annotation matches the runtime behavior.
-
-```markdown
-# README.md
-
-Matrix prototype backend selection:
-
-- `MATRIX_PLATFORM_BACKEND=mock` uses `sdk/mock.py`
-- `MATRIX_PLATFORM_BACKEND=real` uses direct agent WebSocket integration
-- `AGENT_WS_URL=ws://host:port/agent_ws/` is required for the real backend
-
-Current real-backend limitations:
-- text chat only
-- local settings storage
-- no attachments or async task callbacks yet
-```
-
-- [ ] **Step 4: Run targeted verification**
-
-Run: `pytest tests/adapter/matrix/test_dispatcher.py tests/platform/test_agent_session.py tests/platform/test_prototype_state.py tests/platform/test_real.py -q`
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/bot.py README.md tests/adapter/matrix/test_dispatcher.py
-git commit -m "feat: wire matrix runtime to real backend"
-```
-
----
-
-## Self-Review
-
-- Spec coverage:
- - direct-agent transport: Task 1
- - local settings/user state: Task 2
- - stable `PlatformClient` wrapper: Task 3
- - Matrix runtime wiring and docs: Task 4
-- Placeholder scan: no `TBD`, `TODO`, or “implement later” steps remain in the plan.
-- Type consistency:
- - `build_thread_key(platform, user_id, chat_id)` is used consistently.
- - `RealPlatformClient` remains the only bot-facing implementation.
- - local settings stay in `PrototypeStateStore`.
-
-## Execution Handoff
-
-Plan complete and saved to `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`. Two execution options:
-
-**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
-
-**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
-
-**Which approach?**
diff --git a/docs/superpowers/plans/2026-04-19-matrix-per-chat-context.md b/docs/superpowers/plans/2026-04-19-matrix-per-chat-context.md
deleted file mode 100644
index ed4b80e..0000000
--- a/docs/superpowers/plans/2026-04-19-matrix-per-chat-context.md
+++ /dev/null
@@ -1,480 +0,0 @@
-# Matrix Per-Chat Context Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Move the Matrix surface from a shared agent context to true per-room platform contexts mapped through `platform_chat_id`, including `!new`, `!branch`, lazy migration, and per-room context commands.
-
-**Architecture:** Matrix keeps owning UX chats (`C1`, `C2`, rooms, spaces). Each working room stores a `platform_chat_id` in `room_meta`, and platform-facing operations use that mapping. Legacy rooms without a mapping lazily create one on first context-aware use.
-
-**Tech Stack:** Python 3.11, Matrix nio adapter, local state store, `lambda_agent_api`, pytest
-
----
-
-### Task 1: Add `platform_chat_id` to Matrix metadata and tests
-
-**Files:**
-- Modify: `adapter/matrix/store.py`
-- Test: `tests/adapter/matrix/test_store.py`
-
-- [ ] **Step 1: Write the failing test**
-
-```python
-async def test_room_meta_roundtrip_with_platform_chat_id(store: InMemoryStore):
- meta = {
- "chat_id": "C1",
- "matrix_user_id": "@alice:example.org",
- "platform_chat_id": "chat-platform-1",
- }
- await set_room_meta(store, "!r:m.org", meta)
- saved = await get_room_meta(store, "!r:m.org")
- assert saved is not None
- assert saved["platform_chat_id"] == "chat-platform-1"
-```
-
-- [ ] **Step 2: Run test to verify it fails or proves missing coverage**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q`
-Expected: either FAIL on missing assertion coverage or PASS after adding the new test with current generic storage behavior
-
-- [ ] **Step 3: Write minimal implementation**
-
-```python
-# adapter/matrix/store.py
-# No schema gate is required because room metadata is already stored as a dict.
-# Keep helpers unchanged, but add focused helper functions if they reduce repeated logic:
-
-async def get_platform_chat_id(store: StateStore, room_id: str) -> str | None:
- meta = await get_room_meta(store, room_id)
- return meta.get("platform_chat_id") if meta else None
-
-
-async def set_platform_chat_id(store: StateStore, room_id: str, platform_chat_id: str) -> None:
- meta = await get_room_meta(store, room_id) or {}
- meta["platform_chat_id"] = platform_chat_id
- await set_room_meta(store, room_id, meta)
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q`
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/store.py tests/adapter/matrix/test_store.py
-git commit -m "feat: add platform chat id room metadata helpers"
-```
-
-### Task 2: Extend the platform wrapper to support context-aware API calls
-
-**Files:**
-- Modify: `sdk/agent_api_wrapper.py`
-- Modify: `sdk/real.py`
-- Test: `tests/platform/test_real.py`
-
-- [ ] **Step 1: Write the failing tests**
-
-```python
-@pytest.mark.asyncio
-async def test_real_client_send_message_uses_platform_chat_id():
- api = FakeAgentApi()
- client = RealPlatformClient(agent_api=api, prototype_state=PrototypeStateStore())
-
- await client.send_message("@alice:example.org", "chat-platform-1", "hello")
-
- assert api.sent == [("chat-platform-1", "hello")]
-
-
-@pytest.mark.asyncio
-async def test_real_client_create_and_branch_context_delegate_to_agent_api():
- api = FakeAgentApi(create_ids=["chat-new", "chat-branch"])
- client = RealPlatformClient(agent_api=api, prototype_state=PrototypeStateStore())
-
- created = await client.create_chat_context("@alice:example.org")
- branched = await client.branch_chat_context("@alice:example.org", "chat-source")
-
- assert created == "chat-new"
- assert branched == "chat-branch"
- assert api.branch_calls == ["chat-source"]
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q`
-Expected: FAIL because `RealPlatformClient` does not yet expose context-aware methods or pass a platform context id through
-
-- [ ] **Step 3: Write minimal implementation**
-
-```python
-# sdk/agent_api_wrapper.py
-class AgentApiWrapper(AgentApi):
- async def create_chat(self) -> str:
- ...
-
- async def branch_chat(self, chat_id: str) -> str:
- ...
-
- async def send_message(self, chat_id: str, text: str):
- ...
-
- async def save_context(self, chat_id: str, name: str) -> None:
- ...
-
- async def load_context(self, chat_id: str, name: str) -> None:
- ...
-
-
-# sdk/real.py
-class RealPlatformClient(PlatformClient):
- async def create_chat_context(self, user_id: str) -> str:
- return await self._agent_api.create_chat()
-
- async def branch_chat_context(self, user_id: str, from_chat_id: str) -> str:
- return await self._agent_api.branch_chat(from_chat_id)
-
- async def save_chat_context(self, user_id: str, chat_id: str, name: str) -> None:
- await self._agent_api.save_context(chat_id, name)
-
- async def load_chat_context(self, user_id: str, chat_id: str, name: str) -> None:
- await self._agent_api.load_context(chat_id, name)
-
- async def stream_message(...):
- async for event in self._agent_api.send_message(chat_id, text):
- ...
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q`
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add sdk/agent_api_wrapper.py sdk/real.py tests/platform/test_real.py
-git commit -m "feat: add context-aware real platform client methods"
-```
-
-### Task 3: Create Matrix-side resolver for mapped or lazy-created platform contexts
-
-**Files:**
-- Modify: `adapter/matrix/bot.py`
-- Modify: `adapter/matrix/store.py`
-- Test: `tests/adapter/matrix/test_dispatcher.py`
-
-- [ ] **Step 1: Write the failing tests**
-
-```python
-async def test_existing_room_without_platform_chat_id_gets_lazy_mapping_on_message():
- runtime = build_runtime(platform=FakeRealPlatformClient(create_ids=["chat-platform-1"]))
- await set_room_meta(runtime.store, "!room:example.org", {
- "chat_id": "C1",
- "matrix_user_id": "@alice:example.org",
- })
- client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
- bot = MatrixBot(client, runtime)
- room = SimpleNamespace(room_id="!room:example.org")
- event = SimpleNamespace(sender="@alice:example.org", body="hello")
-
- await bot.on_room_message(room, event)
-
- meta = await get_room_meta(runtime.store, "!room:example.org")
- assert meta["platform_chat_id"] == "chat-platform-1"
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
-Expected: FAIL because no lazy mapping exists
-
-- [ ] **Step 3: Write minimal implementation**
-
-```python
-# adapter/matrix/bot.py
-async def _ensure_platform_chat_id(self, room_id: str, user_id: str) -> str:
- meta = await get_room_meta(self.runtime.store, room_id)
- if meta is None:
- raise ValueError("room metadata is required")
- platform_chat_id = meta.get("platform_chat_id")
- if platform_chat_id:
- return platform_chat_id
- if not hasattr(self.runtime.platform, "create_chat_context"):
- raise ValueError("real platform backend required")
- platform_chat_id = await self.runtime.platform.create_chat_context(user_id)
- meta["platform_chat_id"] = platform_chat_id
- await set_room_meta(self.runtime.store, room_id, meta)
- return platform_chat_id
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/bot.py adapter/matrix/store.py tests/adapter/matrix/test_dispatcher.py
-git commit -m "feat: lazily assign platform chat ids to matrix rooms"
-```
-
-### Task 4: Make `!new` and workspace bootstrap create independent platform contexts
-
-**Files:**
-- Modify: `adapter/matrix/handlers/chat.py`
-- Modify: `adapter/matrix/handlers/auth.py`
-- Modify: `adapter/matrix/bot.py`
-- Test: `tests/adapter/matrix/test_chat_space.py`
-- Test: `tests/adapter/matrix/test_invite_space.py`
-- Test: `tests/adapter/matrix/test_dispatcher.py`
-
-- [ ] **Step 1: Write the failing tests**
-
-```python
-async def test_new_chat_assigns_new_platform_chat_id():
- client = SimpleNamespace(
- room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
- room_put_state=AsyncMock(),
- room_invite=AsyncMock(),
- )
- platform = FakeRealPlatformClient(create_ids=["chat-platform-7"])
- runtime = build_runtime(platform=platform, client=client)
- await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 7})
-
- result = await runtime.dispatcher.dispatch(
- IncomingCommand(user_id="u1", platform="matrix", chat_id="C3", command="new", args=["Research"])
- )
-
- meta = await get_room_meta(runtime.store, "!r2:example")
- assert meta["platform_chat_id"] == "chat-platform-7"
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py -q`
-Expected: FAIL because new chats do not yet store a platform context id
-
-- [ ] **Step 3: Write minimal implementation**
-
-```python
-# adapter/matrix/handlers/chat.py
-# adapter/matrix/handlers/auth.py
-platform_chat_id = None
-if hasattr(platform, "create_chat_context"):
- platform_chat_id = await platform.create_chat_context(event.user_id)
-
-await set_room_meta(store, room_id, {
- "chat_id": chat_id,
- "matrix_user_id": event.user_id,
- "space_id": space_id,
- "platform_chat_id": platform_chat_id,
-})
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py -q`
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/handlers/chat.py adapter/matrix/handlers/auth.py adapter/matrix/bot.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py
-git commit -m "feat: assign platform contexts when creating matrix chats"
-```
-
-### Task 5: Make per-room save, load, and context use the mapped platform context
-
-**Files:**
-- Modify: `adapter/matrix/handlers/context_commands.py`
-- Modify: `adapter/matrix/bot.py`
-- Modify: `sdk/prototype_state.py`
-- Test: `tests/adapter/matrix/test_context_commands.py`
-
-- [ ] **Step 1: Write the failing tests**
-
-```python
-@pytest.mark.asyncio
-async def test_save_command_uses_room_platform_chat_id():
- platform = MatrixCommandPlatform()
- runtime = build_runtime(platform=platform)
- await set_room_meta(runtime.store, "!room:example.org", {
- "chat_id": "C1",
- "matrix_user_id": "u1",
- "platform_chat_id": "chat-platform-1",
- })
- event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="save", args=["session-a"])
-
- result = await make_handle_save(...)(event, runtime.auth_mgr, platform, runtime.chat_mgr, runtime.settings_mgr)
-
- assert platform.saved_calls == [("chat-platform-1", "session-a")]
-
-
-@pytest.mark.asyncio
-async def test_context_command_reports_current_room_platform_chat_id():
- ...
- assert "chat-platform-1" in result[0].text
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_context_commands.py -q`
-Expected: FAIL because save/load/context do not currently use room-level platform mappings
-
-- [ ] **Step 3: Write minimal implementation**
-
-```python
-# adapter/matrix/handlers/context_commands.py
-room_id = await _resolve_room_id(event, chat_mgr)
-meta = await get_room_meta(store, room_id)
-platform_chat_id = meta.get("platform_chat_id")
-
-await platform.save_chat_context(event.user_id, platform_chat_id, name)
-await platform.load_chat_context(event.user_id, platform_chat_id, name)
-
-# sdk/prototype_state.py
-# store current loaded session per user+platform_chat_id instead of only per user when needed for Matrix `!context`
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_context_commands.py -q`
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/handlers/context_commands.py adapter/matrix/bot.py sdk/prototype_state.py tests/adapter/matrix/test_context_commands.py
-git commit -m "feat: bind matrix context commands to platform chat ids"
-```
-
-### Task 6: Add `!branch` and help-text updates
-
-**Files:**
-- Modify: `adapter/matrix/handlers/chat.py`
-- Modify: `adapter/matrix/handlers/__init__.py`
-- Modify: `adapter/matrix/handlers/settings.py`
-- Modify: `adapter/matrix/handlers/auth.py`
-- Modify: `adapter/matrix/bot.py`
-- Test: `tests/adapter/matrix/test_chat_space.py`
-- Test: `tests/adapter/matrix/test_dispatcher.py`
-
-- [ ] **Step 1: Write the failing tests**
-
-```python
-async def test_branch_creates_new_room_with_branched_platform_chat_id():
- client = SimpleNamespace(
- room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r3:example")),
- room_put_state=AsyncMock(),
- room_invite=AsyncMock(),
- )
- platform = FakeRealPlatformClient(branch_ids=["chat-platform-branch"])
- runtime = build_runtime(platform=platform, client=client)
- await set_room_meta(runtime.store, "!current:example", {
- "chat_id": "C2",
- "matrix_user_id": "u1",
- "space_id": "!space:example",
- "platform_chat_id": "chat-platform-source",
- })
-
- result = await runtime.dispatcher.dispatch(
- IncomingCommand(user_id="u1", platform="matrix", chat_id="C2", command="branch", args=["Fork"])
- )
-
- meta = await get_room_meta(runtime.store, "!r3:example")
- assert meta["platform_chat_id"] == "chat-platform-branch"
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_dispatcher.py -q`
-Expected: FAIL because `branch` is not implemented
-
-- [ ] **Step 3: Write minimal implementation**
-
-```python
-# adapter/matrix/handlers/chat.py
-def make_handle_branch(client, store):
- async def handle_branch(event, auth_mgr, platform, chat_mgr, settings_mgr):
- source_room_id = ...
- source_meta = await get_room_meta(store, source_room_id)
- platform_chat_id = await platform.branch_chat_context(event.user_id, source_meta["platform_chat_id"])
- ...
- await set_room_meta(store, new_room_id, {
- "chat_id": new_chat_id,
- "matrix_user_id": event.user_id,
- "space_id": space_id,
- "platform_chat_id": platform_chat_id,
- })
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_dispatcher.py -q`
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/handlers/chat.py adapter/matrix/handlers/__init__.py adapter/matrix/handlers/settings.py adapter/matrix/handlers/auth.py adapter/matrix/bot.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_dispatcher.py
-git commit -m "feat: add matrix branch command for platform contexts"
-```
-
-### Task 7: Verify the full Matrix flow and clean up legacy assumptions
-
-**Files:**
-- Modify: `tests/platform/test_real.py`
-- Modify: `tests/adapter/matrix/test_dispatcher.py`
-- Modify: `tests/adapter/matrix/test_context_commands.py`
-- Modify: `tests/core/test_integration.py`
-
-- [ ] **Step 1: Add integration coverage for independent room contexts**
-
-```python
-@pytest.mark.asyncio
-async def test_two_rooms_send_messages_into_different_platform_contexts():
- platform = FakeRealPlatformClient()
- runtime = build_runtime(platform=platform)
- await set_room_meta(runtime.store, "!r1:example", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "chat-1"})
- await set_room_meta(runtime.store, "!r2:example", {"chat_id": "C2", "matrix_user_id": "u1", "platform_chat_id": "chat-2"})
- ...
- assert platform.sent == [("chat-1", "hello"), ("chat-2", "world")]
-```
-
-- [ ] **Step 2: Run the focused verification suite**
-
-Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py tests/adapter/matrix/test_store.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/core/test_integration.py -q`
-Expected: PASS
-
-- [ ] **Step 3: Run the full Matrix suite**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix -q`
-Expected: PASS
-
-- [ ] **Step 4: Inspect help text and command visibility**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
-Expected: PASS with `!branch` present in help and hidden commands still absent
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add tests/platform/test_real.py tests/adapter/matrix/test_store.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/core/test_integration.py
-git commit -m "test: verify matrix per-chat platform context flow"
-```
-
-## Self-Review
-
-- Spec coverage:
- - `surface_chat -> platform_chat_id` mapping is covered by Tasks 1, 3, and 4.
- - `!new` independent contexts are covered by Task 4.
- - `!branch` snapshot flow is covered by Task 6.
- - per-room `!save`, `!load`, and `!context` are covered by Task 5.
- - lazy migration for legacy rooms is covered by Task 3.
- - verification across rooms is covered by Task 7.
-- Placeholder scan:
- - No `TODO` or `TBD` placeholders remain.
- - Commands and file paths are concrete.
-- Type consistency:
- - The plan consistently uses `platform_chat_id` for stored mapping and `create_chat_context` / `branch_chat_context` / `save_chat_context` / `load_chat_context` for platform-facing methods.
diff --git a/docs/superpowers/plans/2026-04-20-matrix-shared-workspace-file-flow.md b/docs/superpowers/plans/2026-04-20-matrix-shared-workspace-file-flow.md
deleted file mode 100644
index 65c2018..0000000
--- a/docs/superpowers/plans/2026-04-20-matrix-shared-workspace-file-flow.md
+++ /dev/null
@@ -1,624 +0,0 @@
-# Matrix Shared Workspace File Flow Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Move the Matrix surface to a prod-like shared-workspace file flow where incoming files are downloaded into `/workspace`, passed to `platform-agent` as relative attachment paths, and outbound `send_file` events are delivered back to the Matrix room.
-
-**Architecture:** Keep the platform contract path-based and surface-agnostic. Extend the surface attachment model with a workspace-relative path, update the real SDK bridge to forward attachments and modern agent events, then add a Matrix-specific storage helper plus a shared-volume runtime. This preserves room/context semantics while aligning file flow with upstream `platform-agent`.
-
-**Tech Stack:** Python 3.11, matrix-nio, aiohttp, Docker Compose, pytest, pytest-asyncio
-
----
-
-## File Structure
-
-- Modify: `core/protocol.py`
- Purpose: add a workspace-relative attachment field that future surfaces can also use.
-- Modify: `sdk/interface.py`
- Purpose: keep the platform-side attachment shape aligned with the surface model.
-- Modify: `core/handlers/message.py`
- Purpose: stop dropping attachments before platform dispatch.
-- Modify: `sdk/agent_api_wrapper.py`
- Purpose: accept modern upstream agent events and modern WS route semantics.
-- Modify: `sdk/real.py`
- Purpose: convert attachment objects into workspace-relative paths and forward them to the agent API.
-- Create: `adapter/matrix/files.py`
- Purpose: Matrix-specific download/upload helper for shared `/workspace`.
-- Modify: `adapter/matrix/bot.py`
- Purpose: persist incoming Matrix files into shared workspace and render outbound file events back to Matrix.
-- Modify: `tests/core/test_integration.py`
- Purpose: prove message dispatch keeps attachments and platform send path receives them.
-- Modify: `tests/platform/test_real.py`
- Purpose: verify attachment forwarding and outbound file events.
-- Create: `tests/adapter/matrix/test_files.py`
- Purpose: unit coverage for Matrix workspace path building, download handling, and outbound upload behavior.
-- Modify: `tests/adapter/matrix/test_dispatcher.py`
- Purpose: verify Matrix bot file receive/send integration.
-- Modify: `docker-compose.yml`
- Purpose: define shared `/workspace` runtime between `matrix-bot` and `platform-agent`.
-- Modify: `README.md`
- Purpose: document the new default runtime and file flow.
-- Modify: `.env.example`
- Purpose: update real-backend defaults to in-compose service URLs and workspace-aware runtime.
-
-### Task 1: Preserve Attachment Metadata Through Core Message Dispatch
-
-**Files:**
-- Modify: `core/protocol.py`
-- Modify: `sdk/interface.py`
-- Modify: `core/handlers/message.py`
-- Test: `tests/core/test_dispatcher.py`
-- Test: `tests/core/test_integration.py`
-
-- [ ] **Step 1: Write the failing tests**
-
-```python
-# tests/core/test_integration.py
-class RecordingAgentApi:
- def __init__(self) -> None:
- self.calls: list[tuple[str, list[str]]] = []
- self.last_tokens_used = 0
-
- async def send_message(self, text: str, attachments: list[str] | None = None):
- self.calls.append((text, attachments or []))
- yield type("Chunk", (), {"text": f"[REAL] {text}"})()
- self.last_tokens_used = 5
-
-
-async def test_full_flow_with_real_platform_forwards_workspace_attachment(real_dispatcher):
- dispatcher, agent_api = real_dispatcher
-
- start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
- await dispatcher.dispatch(start)
-
- msg = IncomingMessage(
- user_id="u1",
- platform="matrix",
- chat_id="C1",
- text="Посмотри файл",
- attachments=[
- Attachment(
- type="document",
- filename="report.pdf",
- mime_type="application/pdf",
- workspace_path="surfaces/matrix/u1/room/inbox/report.pdf",
- )
- ],
- )
- await dispatcher.dispatch(msg)
-
- assert agent_api.calls == [
- ("Посмотри файл", ["surfaces/matrix/u1/room/inbox/report.pdf"])
- ]
-```
-
-```python
-# tests/core/test_dispatcher.py
-async def test_dispatch_routes_document_before_catchall(dispatcher):
- async def doc_handler(event, **kwargs):
- return [OutgoingMessage(chat_id=event.chat_id, text="document")]
-
- async def catch_all(event, **kwargs):
- return [OutgoingMessage(chat_id=event.chat_id, text="text")]
-
- dispatcher.register(IncomingMessage, "document", doc_handler)
- dispatcher.register(IncomingMessage, "*", catch_all)
-
- doc_msg = IncomingMessage(
- user_id="u1",
- platform="matrix",
- chat_id="C1",
- text="",
- attachments=[Attachment(type="document", workspace_path="surfaces/matrix/u1/file.txt")],
- )
-
- assert (await dispatcher.dispatch(doc_msg))[0].text == "document"
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `PYTHONPATH=. uv run pytest tests/core/test_dispatcher.py tests/core/test_integration.py -q`
-
-Expected:
-- FAIL because `Attachment` has no `workspace_path`
-- FAIL because `handle_message(...)` still sends `attachments=[]`
-
-- [ ] **Step 3: Write minimal implementation**
-
-```python
-# core/protocol.py
-@dataclass
-class Attachment:
- type: str
- url: str | None = None
- content: bytes | None = None
- filename: str | None = None
- mime_type: str | None = None
- workspace_path: str | None = None
-```
-
-```python
-# sdk/interface.py
-class Attachment(BaseModel):
- url: str | None = None
- mime_type: str | None = None
- size: int | None = None
- filename: str | None = None
- workspace_path: str | None = None
-```
-
-```python
-# core/handlers/message.py
-response = await platform.send_message(
- user_id=event.user_id,
- chat_id=event.chat_id,
- text=event.text,
- attachments=event.attachments,
-)
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `PYTHONPATH=. uv run pytest tests/core/test_dispatcher.py tests/core/test_integration.py -q`
-
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add core/protocol.py sdk/interface.py core/handlers/message.py tests/core/test_dispatcher.py tests/core/test_integration.py
-git commit -m "feat: preserve workspace attachments through message dispatch"
-```
-
-### Task 2: Upgrade the Real SDK Bridge to Modern Agent File Events
-
-**Files:**
-- Modify: `sdk/agent_api_wrapper.py`
-- Modify: `sdk/real.py`
-- Test: `tests/platform/test_real.py`
-
-- [ ] **Step 1: Write the failing tests**
-
-```python
-# tests/platform/test_real.py
-class FakeSendFileEvent:
- def __init__(self, path: str) -> None:
- self.path = path
-
-
-class FakeChatAgentApi:
- ...
- async def send_message(self, text: str, attachments: list[str] | None = None):
- self.calls.append((text, attachments or []))
- midpoint = len(text) // 2
- yield FakeChunk(text[:midpoint])
- yield FakeChunk(text[midpoint:])
- self.last_tokens_used = 3
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_send_message_forwards_workspace_paths():
- agent_api = FakeAgentApiFactory()
- client = RealPlatformClient(
- agent_api=agent_api,
- prototype_state=PrototypeStateStore(),
- platform="matrix",
- )
-
- await client.send_message(
- "@alice:example.org",
- "chat-7",
- "hello",
- attachments=[
- type("Attachment", (), {"workspace_path": "surfaces/matrix/alice/room/file.pdf"})()
- ],
- )
-
- assert agent_api.instances["chat-7"].calls == [
- ("hello", ["surfaces/matrix/alice/room/file.pdf"])
- ]
-
-
-def test_agent_api_wrapper_treats_send_file_as_known_event(monkeypatch):
- seen = []
-
- class FakeSendFile:
- type = "AGENT_EVENT_SEND_FILE"
- path = "docs/result.pdf"
-
- monkeypatch.setattr(
- "sdk.agent_api_wrapper.ServerMessage.validate_json",
- lambda raw: FakeSendFile(),
- )
-
- wrapper = AgentApiWrapper(agent_id="agent-1", base_url="https://agent.example.com", chat_id="7")
- wrapper.callback = seen.append
- wrapper._current_queue = None
-
- # use the wrapper's dispatch branch directly inside _listen test harness
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q`
-
-Expected:
-- FAIL because `RealPlatformClient` ignores attachments
-- FAIL because `AgentApiWrapper` only recognizes text/end/error/disconnect events
-
-- [ ] **Step 3: Write minimal implementation**
-
-```python
-# sdk/real.py
-def _attachment_paths(self, attachments) -> list[str]:
- if not attachments:
- return []
- paths = []
- for attachment in attachments:
- path = getattr(attachment, "workspace_path", None)
- if path:
- paths.append(path)
- return paths
-
-async def stream_message(...):
- attachment_paths = self._attachment_paths(attachments)
- ...
- async for event in chat_api.send_message(text, attachments=attachment_paths):
- if hasattr(event, "path"):
- yield MessageChunk(
- message_id=user_id,
- delta="",
- finished=False,
- )
- continue
- yield MessageChunk(...)
-```
-
-```python
-# sdk/agent_api_wrapper.py
-from lambda_agent_api.server import (
- MsgError,
- MsgEventCustomUpdate,
- MsgEventEnd,
- MsgEventSendFile,
- MsgEventTextChunk,
- MsgEventToolCallChunk,
- MsgEventToolResult,
- MsgGracefulDisconnect,
- ServerMessage,
-)
-
-KNOWN_STREAM_EVENTS = (
- MsgEventTextChunk,
- MsgEventToolCallChunk,
- MsgEventToolResult,
- MsgEventCustomUpdate,
- MsgEventSendFile,
- MsgEventEnd,
-)
-
-if isinstance(outgoing_msg, KNOWN_STREAM_EVENTS):
- if isinstance(outgoing_msg, MsgEventEnd):
- self.last_tokens_used = outgoing_msg.tokens_used
- if self._current_queue:
- await self._current_queue.put(outgoing_msg)
- elif self.callback:
- self.callback(outgoing_msg)
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q`
-
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add sdk/agent_api_wrapper.py sdk/real.py tests/platform/test_real.py
-git commit -m "feat: support attachment paths and file events in real sdk bridge"
-```
-
-### Task 3: Implement Matrix Shared-Workspace Receive/Send File Flow
-
-**Files:**
-- Create: `adapter/matrix/files.py`
-- Modify: `adapter/matrix/bot.py`
-- Test: `tests/adapter/matrix/test_files.py`
-- Test: `tests/adapter/matrix/test_dispatcher.py`
-
-- [ ] **Step 1: Write the failing tests**
-
-```python
-# tests/adapter/matrix/test_files.py
-from pathlib import Path
-
-import pytest
-
-from adapter.matrix.files import build_workspace_attachment_path
-
-
-def test_build_workspace_attachment_path_scopes_by_surface_user_and_room(tmp_path):
- rel_path, abs_path = build_workspace_attachment_path(
- workspace_root=tmp_path,
- matrix_user_id="@alice:example.org",
- room_id="!room:example.org",
- filename="report.pdf",
- timestamp="20260420-153000",
- )
-
- assert rel_path == "surfaces/matrix/alice_example.org/room_example.org/inbox/20260420-153000-report.pdf"
- assert abs_path == tmp_path / rel_path
-```
-
-```python
-# tests/adapter/matrix/test_dispatcher.py
-async def test_bot_downloads_matrix_file_to_workspace_before_dispatch(tmp_path):
- runtime = build_runtime(platform=MockPlatformClient())
- await set_room_meta(
- runtime.store,
- "!chat1:example.org",
- {
- "chat_id": "C1",
- "matrix_user_id": "@alice:example.org",
- "platform_chat_id": "matrix:ctx-1",
- },
- )
- client = SimpleNamespace(
- user_id="@bot:example.org",
- download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")),
- )
- bot = MatrixBot(client, runtime)
- bot._send_all = AsyncMock()
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- room = SimpleNamespace(room_id="!chat1:example.org")
- event = SimpleNamespace(
- sender="@alice:example.org",
- body="Посмотри",
- msgtype="m.file",
- url="mxc://server/id",
- mimetype="application/pdf",
- replyto_event_id=None,
- )
-
- await bot.on_room_message(room, event)
-
- dispatched = runtime.dispatcher.dispatch.await_args.args[0]
- assert dispatched.attachments[0].workspace_path.endswith(".pdf")
-```
-
-```python
-# tests/adapter/matrix/test_dispatcher.py
-async def test_send_outgoing_uploads_file_attachment_to_matrix(tmp_path):
- path = tmp_path / "result.txt"
- path.write_text("ready")
- client = SimpleNamespace(
- upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/file"), {})),
- room_send=AsyncMock(),
- )
-
- await send_outgoing(
- client,
- "!room:example.org",
- OutgoingMessage(
- chat_id="!room:example.org",
- text="Файл готов",
- attachments=[
- Attachment(
- type="document",
- filename="result.txt",
- mime_type="text/plain",
- workspace_path=str(path),
- )
- ],
- ),
- )
-
- client.upload.assert_awaited()
- client.room_send.assert_awaited()
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py -q`
-
-Expected:
-- FAIL because `adapter.matrix.files` does not exist
-- FAIL because Matrix bot does not persist files before dispatch
-- FAIL because `send_outgoing(...)` only sends text
-
-- [ ] **Step 3: Write minimal implementation**
-
-```python
-# adapter/matrix/files.py
-from __future__ import annotations
-
-from pathlib import Path
-from datetime import UTC, datetime
-import re
-
-from core.protocol import Attachment
-
-
-def _sanitize_component(value: str) -> str:
- stripped = re.sub(r"[^A-Za-z0-9._-]+", "_", value)
- return stripped.strip("._-") or "unknown"
-
-
-def build_workspace_attachment_path(
- *,
- workspace_root: Path,
- matrix_user_id: str,
- room_id: str,
- filename: str,
- timestamp: str | None = None,
-) -> tuple[str, Path]:
- stamp = timestamp or datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
- safe_user = _sanitize_component(matrix_user_id.lstrip("@"))
- safe_room = _sanitize_component(room_id.lstrip("!"))
- safe_name = _sanitize_component(filename) or "attachment.bin"
- rel_path = Path("surfaces") / "matrix" / safe_user / safe_room / "inbox" / f"{stamp}-{safe_name}"
- return rel_path.as_posix(), workspace_root / rel_path
-```
-
-```python
-# adapter/matrix/bot.py
-from adapter.matrix.files import download_matrix_attachments, load_workspace_attachment
-
-...
-incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id)
-if isinstance(incoming, IncomingMessage) and incoming.attachments:
- incoming = await self._materialize_attachments(room.room_id, sender, incoming)
-...
-
-async def _materialize_attachments(...):
- workspace_root = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace"))
- attachments = await download_matrix_attachments(...)
- return IncomingMessage(..., attachments=attachments, ...)
-```
-
-```python
-# adapter/matrix/bot.py
-if isinstance(event, OutgoingMessage) and event.attachments:
- for attachment in event.attachments:
- if attachment.workspace_path:
- await _send_matrix_file(client, room_id, attachment)
- if event.text:
- await client.room_send(...)
- return
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py -q`
-
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/files.py adapter/matrix/bot.py tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py
-git commit -m "feat: add matrix shared-workspace file receive and send flow"
-```
-
-### Task 4: Make Shared Workspace the Default Local Runtime
-
-**Files:**
-- Modify: `docker-compose.yml`
-- Modify: `README.md`
-- Modify: `.env.example`
-
-- [ ] **Step 1: Write the failing configuration checks**
-
-```bash
-python - <<'PY'
-from pathlib import Path
-text = Path("docker-compose.yml").read_text()
-assert "platform-agent" in text
-assert "/workspace" in text
-assert "matrix-bot" in text
-PY
-```
-
-```bash
-python - <<'PY'
-from pathlib import Path
-readme = Path("README.md").read_text()
-assert "docker compose up" in readme
-assert "/workspace" in readme
-assert "platform-agent" in readme
-PY
-```
-
-- [ ] **Step 2: Run checks to verify they fail**
-
-Run: `python - <<'PY' ... PY`
-
-Expected:
-- FAIL because root compose only defines `matrix-bot`
-- FAIL because README still documents standalone `uvicorn` launch and old WS route
-
-- [ ] **Step 3: Write minimal implementation**
-
-```yaml
-# docker-compose.yml
-services:
- platform-agent:
- build:
- context: ./external/platform-agent
- target: development
- additional_contexts:
- agent_api: ./external/platform-agent_api
- env_file:
- - ./external/platform-agent/.env
- volumes:
- - workspace:/workspace
- - ./external/platform-agent/src:/app/src
- - ./external/platform-agent_api:/agent_api
- ports:
- - "8000:8000"
-
- matrix-bot:
- build: .
- env_file: .env
- depends_on:
- - platform-agent
- volumes:
- - workspace:/workspace
- restart: unless-stopped
-
-volumes:
- workspace:
-```
-
-```env
-# .env.example
-AGENT_WS_URL=ws://platform-agent:8000/v1/agent_ws/0/
-AGENT_BASE_URL=http://platform-agent:8000
-SURFACES_WORKSPACE_DIR=/workspace
-MATRIX_PLATFORM_BACKEND=real
-```
-
-```md
-# README.md
-- make the root `docker compose up` path the primary local runtime
-- describe shared `/workspace` as the file contract
-- remove the statement that real backend is text-only and has no attachments
-- replace the old standalone `uvicorn` instructions with compose-first instructions
-```
-
-- [ ] **Step 4: Run checks to verify they pass**
-
-Run: `python - <<'PY' ... PY`
-
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add docker-compose.yml README.md .env.example
-git commit -m "chore: make shared workspace runtime the default local setup"
-```
-
-## Self-Review
-
-- Spec coverage:
- - shared `/workspace` runtime: Task 4
- - incoming Matrix file persistence: Task 3
- - attachment path propagation to agent API: Tasks 1-2
- - outbound `send_file` flow: Tasks 2-3
- - future-surface-friendly attachment contract: Task 1
-- Placeholder scan:
- - no `TODO`, `TBD`, or “similar to”
- - each task has explicit test, run, implementation, verify, commit steps
-- Type consistency:
- - `workspace_path` is introduced in both attachment models and consumed consistently in Tasks 1-3
- - path-based contract is always relative to `/workspace` until Matrix upload resolution step
-
-## Execution Handoff
-
-User already selected parallel subagent execution. Use subagent-driven development and split ownership like this:
-
-- Worker A: `docker-compose.yml`, `README.md`, `.env.example`
-- Worker B: `sdk/agent_api_wrapper.py`, `sdk/real.py`, `tests/platform/test_real.py`
-- Main session / later worker: `core/protocol.py`, `sdk/interface.py`, `core/handlers/message.py`, `adapter/matrix/files.py`, `adapter/matrix/bot.py`, matrix/core tests
diff --git a/docs/superpowers/plans/2026-04-20-matrix-staged-attachments.md b/docs/superpowers/plans/2026-04-20-matrix-staged-attachments.md
deleted file mode 100644
index cfa8f01..0000000
--- a/docs/superpowers/plans/2026-04-20-matrix-staged-attachments.md
+++ /dev/null
@@ -1,555 +0,0 @@
-# Matrix Staged Attachments Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add Matrix-side staged attachments so file-only events are stored per `(chat_id, user_id)`, acknowledged in chat, and committed to the agent on the next normal user message.
-
-**Architecture:** Keep the existing shared-workspace file contract unchanged and add a thin Matrix-specific staging layer above it. The Matrix adapter will store staged attachment metadata in `adapter.matrix.store`, parse short staging commands in `adapter.matrix.converter`, and orchestrate commit/remove/list behavior in `adapter.matrix.bot` before calling the existing dispatcher.
-
-**Tech Stack:** Python 3.11, matrix-nio, pytest, pytest-asyncio, in-memory state store, shared `/workspace`
-
----
-
-## File Structure
-
-- Modify: `adapter/matrix/store.py`
- Purpose: store staged attachment state per `(room_id, user_id)`.
-- Modify: `adapter/matrix/converter.py`
- Purpose: parse `!list`, `!remove `, `!remove all` into explicit Matrix-side commands.
-- Modify: `adapter/matrix/bot.py`
- Purpose: stage file-only events, emit service acknowledgments, process staging commands, and commit staged files with the next normal message.
-- Modify: `tests/adapter/matrix/test_store.py`
- Purpose: verify staged attachment persistence, ordering, and clear/remove helpers.
-- Modify: `tests/adapter/matrix/test_converter.py`
- Purpose: verify short staging commands parse correctly.
-- Modify: `tests/adapter/matrix/test_dispatcher.py`
- Purpose: verify Matrix bot staging, list/remove behavior, and commit semantics.
-- Modify: `README.md`
- Purpose: document the Matrix staging UX and short commands.
-
-### Task 1: Add Per-Chat Staged Attachment Storage
-
-**Files:**
-- Modify: `adapter/matrix/store.py`
-- Test: `tests/adapter/matrix/test_store.py`
-
-- [ ] **Step 1: Write the failing tests**
-
-```python
-# tests/adapter/matrix/test_store.py
-from adapter.matrix.store import (
- add_staged_attachment,
- clear_staged_attachments,
- get_staged_attachments,
- remove_staged_attachment_at,
-)
-
-
-async def test_staged_attachments_roundtrip(store: InMemoryStore):
- await add_staged_attachment(
- store,
- room_id="!r1:example.org",
- user_id="@alice:example.org",
- attachment={
- "filename": "report.pdf",
- "workspace_path": "surfaces/matrix/alice/r1/inbox/report.pdf",
- "mime_type": "application/pdf",
- },
- )
-
- assert await get_staged_attachments(store, "!r1:example.org", "@alice:example.org") == [
- {
- "filename": "report.pdf",
- "workspace_path": "surfaces/matrix/alice/r1/inbox/report.pdf",
- "mime_type": "application/pdf",
- }
- ]
-
-
-async def test_staged_attachments_are_scoped_by_room_and_user(store: InMemoryStore):
- await add_staged_attachment(
- store,
- room_id="!r1:example.org",
- user_id="@alice:example.org",
- attachment={"filename": "a.pdf", "workspace_path": "a.pdf"},
- )
- await add_staged_attachment(
- store,
- room_id="!r2:example.org",
- user_id="@alice:example.org",
- attachment={"filename": "b.pdf", "workspace_path": "b.pdf"},
- )
- await add_staged_attachment(
- store,
- room_id="!r1:example.org",
- user_id="@bob:example.org",
- attachment={"filename": "c.pdf", "workspace_path": "c.pdf"},
- )
-
- assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@alice:example.org")] == ["a.pdf"]
- assert [item["filename"] for item in await get_staged_attachments(store, "!r2:example.org", "@alice:example.org")] == ["b.pdf"]
- assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@bob:example.org")] == ["c.pdf"]
-
-
-async def test_remove_staged_attachment_by_index(store: InMemoryStore):
- await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"})
- await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "b.pdf", "workspace_path": "b.pdf"})
-
- removed = await remove_staged_attachment_at(store, "!r1:example.org", "@alice:example.org", 1)
-
- assert removed["filename"] == "b.pdf"
- assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@alice:example.org")] == ["a.pdf"]
-
-
-async def test_clear_staged_attachments(store: InMemoryStore):
- await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"})
-
- await clear_staged_attachments(store, "!r1:example.org", "@alice:example.org")
-
- assert await get_staged_attachments(store, "!r1:example.org", "@alice:example.org") == []
-```
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q`
-
-Expected:
-- FAIL because staged attachment helper functions do not exist yet
-
-- [ ] **Step 3: Write minimal implementation**
-
-```python
-# adapter/matrix/store.py
-STAGED_ATTACHMENTS_PREFIX = "matrix_staged_attachments:"
-
-
-def _staged_attachments_key(room_id: str, user_id: str) -> str:
- return f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}"
-
-
-async def get_staged_attachments(store: StateStore, room_id: str, user_id: str) -> list[dict]:
- return list(await store.get(_staged_attachments_key(room_id, user_id)) or [])
-
-
-async def add_staged_attachment(
- store: StateStore,
- room_id: str,
- user_id: str,
- attachment: dict,
-) -> None:
- items = await get_staged_attachments(store, room_id, user_id)
- items.append(attachment)
- await store.set(_staged_attachments_key(room_id, user_id), items)
-
-
-async def remove_staged_attachment_at(
- store: StateStore,
- room_id: str,
- user_id: str,
- index: int,
-) -> dict | None:
- items = await get_staged_attachments(store, room_id, user_id)
- if index < 0 or index >= len(items):
- return None
- removed = items.pop(index)
- await store.set(_staged_attachments_key(room_id, user_id), items)
- return removed
-
-
-async def clear_staged_attachments(store: StateStore, room_id: str, user_id: str) -> None:
- await store.delete(_staged_attachments_key(room_id, user_id))
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q`
-
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/store.py tests/adapter/matrix/test_store.py
-git commit -m "feat: add matrix staged attachment state"
-```
-
-### Task 2: Parse Short Staging Commands
-
-**Files:**
-- Modify: `adapter/matrix/converter.py`
-- Test: `tests/adapter/matrix/test_converter.py`
-
-- [ ] **Step 1: Write the failing tests**
-
-```python
-# tests/adapter/matrix/test_converter.py
-async def test_list_command_maps_to_matrix_staging_command():
- result = from_room_event(text_event("!list"), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingCommand)
- assert result.command == "matrix_list_attachments"
- assert result.args == []
-
-
-async def test_remove_all_maps_to_matrix_staging_command():
- result = from_room_event(text_event("!remove all"), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingCommand)
- assert result.command == "matrix_remove_attachment"
- assert result.args == ["all"]
-
-
-async def test_remove_index_maps_to_matrix_staging_command():
- result = from_room_event(text_event("!remove 2"), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingCommand)
- assert result.command == "matrix_remove_attachment"
- assert result.args == ["2"]
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_converter.py -q`
-
-Expected:
-- FAIL because `!list` and `!remove` still parse as generic unknown commands
-
-- [ ] **Step 3: Write minimal implementation**
-
-```python
-# adapter/matrix/converter.py
-def from_command(body: str, sender: str, chat_id: str, room_id: str | None = None) -> IncomingEvent:
- raw = body.lstrip("!").strip()
- parts = raw.split()
- command = parts[0].lower() if parts else ""
- args = parts[1:]
-
- if command == "list":
- return IncomingCommand(
- user_id=sender,
- platform=PLATFORM,
- chat_id=chat_id,
- command="matrix_list_attachments",
- args=[],
- )
-
- if command == "remove":
- return IncomingCommand(
- user_id=sender,
- platform=PLATFORM,
- chat_id=chat_id,
- command="matrix_remove_attachment",
- args=args,
- )
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_converter.py -q`
-
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/converter.py tests/adapter/matrix/test_converter.py
-git commit -m "feat: parse matrix staged attachment commands"
-```
-
-### Task 3: Stage File-Only Events and Handle List/Remove UX
-
-**Files:**
-- Modify: `adapter/matrix/bot.py`
-- Modify: `adapter/matrix/store.py`
-- Test: `tests/adapter/matrix/test_dispatcher.py`
-
-- [ ] **Step 1: Write the failing tests**
-
-```python
-# tests/adapter/matrix/test_dispatcher.py
-async def test_file_only_event_is_staged_and_does_not_dispatch():
- runtime = build_runtime(platform=MockPlatformClient())
- client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
- bot = MatrixBot(client, runtime)
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- bot._materialize_incoming_attachments = AsyncMock(
- return_value=IncomingMessage(
- user_id="@alice:example.org",
- platform="matrix",
- chat_id="matrix:!r:example.org",
- text="",
- attachments=[
- Attachment(
- type="document",
- filename="report.pdf",
- workspace_path="surfaces/matrix/alice/r/inbox/report.pdf",
- mime_type="application/pdf",
- )
- ],
- )
- )
- room = SimpleNamespace(room_id="!r:example.org")
- event = SimpleNamespace(
- sender="@alice:example.org",
- body="report.pdf",
- msgtype="m.file",
- url="mxc://hs/id",
- mimetype="application/pdf",
- replyto_event_id=None,
- )
-
- await bot.on_room_message(room, event)
-
- runtime.dispatcher.dispatch.assert_not_awaited()
- staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
- assert [item["filename"] for item in staged] == ["report.pdf"]
- client.room_send.assert_awaited_once()
- assert "Следующее сообщение" in client.room_send.await_args.args[2]["body"]
-
-
-async def test_list_command_returns_current_staged_attachments():
- runtime = build_runtime(platform=MockPlatformClient())
- await add_staged_attachment(runtime.store, "!r:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"})
- await add_staged_attachment(runtime.store, "!r:example.org", "@alice:example.org", {"filename": "b.pdf", "workspace_path": "b.pdf"})
- client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
- bot = MatrixBot(client, runtime)
- room = SimpleNamespace(room_id="!r:example.org")
- event = SimpleNamespace(sender="@alice:example.org", body="!list", msgtype="m.text", replyto_event_id=None)
-
- await bot.on_room_message(room, event)
-
- body = client.room_send.await_args.args[2]["body"]
- assert "1. a.pdf" in body
- assert "2. b.pdf" in body
-
-
-async def test_remove_invalid_index_returns_short_error():
- runtime = build_runtime(platform=MockPlatformClient())
- await add_staged_attachment(runtime.store, "!r:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"})
- client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
- bot = MatrixBot(client, runtime)
- room = SimpleNamespace(room_id="!r:example.org")
- event = SimpleNamespace(sender="@alice:example.org", body="!remove 9", msgtype="m.text", replyto_event_id=None)
-
- await bot.on_room_message(room, event)
-
- assert client.room_send.await_args.args[2]["body"] == "Нет такого вложения."
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
-
-Expected:
-- FAIL because file-only events still go straight to dispatcher
-- FAIL because `!list` and `!remove` have no Matrix-side handling in the bot
-
-- [ ] **Step 3: Write minimal implementation**
-
-```python
-# adapter/matrix/bot.py
-def _is_staging_command(self, incoming: IncomingEvent) -> bool:
- return isinstance(incoming, IncomingCommand) and incoming.command in {
- "matrix_list_attachments",
- "matrix_remove_attachment",
- }
-
-
-async def _handle_staging_command(self, room_id: str, user_id: str, incoming: IncomingCommand) -> list[OutgoingMessage]:
- if incoming.command == "matrix_list_attachments":
- return [OutgoingMessage(chat_id=incoming.chat_id, text=self._format_staged_attachments(room_id, user_id))]
- if incoming.command == "matrix_remove_attachment" and incoming.args == ["all"]:
- await clear_staged_attachments(self.runtime.store, room_id, user_id)
- return [OutgoingMessage(chat_id=incoming.chat_id, text="Вложения очищены.")]
-```
-
-```python
-# adapter/matrix/bot.py
-if isinstance(incoming, IncomingMessage) and incoming.attachments and not incoming.text:
- incoming = await self._materialize_incoming_attachments(room.room_id, sender, incoming)
- await self._stage_attachments(room.room_id, sender, incoming.attachments)
- await self._send_all(room.room_id, [OutgoingMessage(chat_id=dispatch_chat_id, text=self._format_staged_attachments(room.room_id, sender, with_hint=True))])
- return
-
-if self._is_staging_command(incoming):
- outgoing = await self._handle_staging_command(room.room_id, sender, incoming)
- await self._send_all(room.room_id, outgoing)
- return
-```
-
-- [ ] **Step 4: Run tests to verify they pass**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
-
-Expected: PASS for staging/list/remove behavior
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/bot.py adapter/matrix/store.py tests/adapter/matrix/test_dispatcher.py
-git commit -m "feat: add matrix staging list and remove flow"
-```
-
-### Task 4: Commit Staged Files With the Next Normal Message
-
-**Files:**
-- Modify: `adapter/matrix/bot.py`
-- Test: `tests/adapter/matrix/test_dispatcher.py`
-- Modify: `README.md`
-
-- [ ] **Step 1: Write the failing tests**
-
-```python
-# tests/adapter/matrix/test_dispatcher.py
-async def test_next_normal_message_commits_staged_attachments():
- runtime = build_runtime(platform=MockPlatformClient())
- await add_staged_attachment(
- runtime.store,
- "!r:example.org",
- "@alice:example.org",
- {
- "filename": "report.pdf",
- "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf",
- "mime_type": "application/pdf",
- },
- )
- client = SimpleNamespace(user_id="@bot:example.org")
- bot = MatrixBot(client, runtime)
- bot._send_all = AsyncMock()
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- room = SimpleNamespace(room_id="!r:example.org")
- event = SimpleNamespace(sender="@alice:example.org", body="Проанализируй", msgtype="m.text", replyto_event_id=None)
-
- await bot.on_room_message(room, event)
-
- dispatched = runtime.dispatcher.dispatch.await_args.args[0]
- assert isinstance(dispatched, IncomingMessage)
- assert dispatched.text == "Проанализируй"
- assert [a.workspace_path for a in dispatched.attachments] == [
- "surfaces/matrix/alice/r/inbox/report.pdf"
- ]
- assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == []
-
-
-async def test_failed_commit_preserves_staged_attachments():
- runtime = build_runtime(platform=MockPlatformClient())
- await add_staged_attachment(
- runtime.store,
- "!r:example.org",
- "@alice:example.org",
- {"filename": "report.pdf", "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf"},
- )
- client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
- bot = MatrixBot(client, runtime)
- runtime.dispatcher.dispatch = AsyncMock(side_effect=PlatformError("boom"))
- room = SimpleNamespace(room_id="!r:example.org")
- event = SimpleNamespace(sender="@alice:example.org", body="Проанализируй", msgtype="m.text", replyto_event_id=None)
-
- await bot.on_room_message(room, event)
-
- staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
- assert [item["filename"] for item in staged] == ["report.pdf"]
-```
-
-- [ ] **Step 2: Run tests to verify they fail**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q`
-
-Expected:
-- FAIL because normal text messages do not yet merge staged attachments
-- FAIL because staged items are never preserved/cleared based on commit outcome
-
-- [ ] **Step 3: Write minimal implementation**
-
-```python
-# adapter/matrix/bot.py
-async def _merge_staged_attachments(
- self,
- room_id: str,
- user_id: str,
- incoming: IncomingMessage,
-) -> IncomingMessage:
- staged = await get_staged_attachments(self.runtime.store, room_id, user_id)
- if not staged:
- return incoming
- return IncomingMessage(
- user_id=incoming.user_id,
- platform=incoming.platform,
- chat_id=incoming.chat_id,
- text=incoming.text,
- reply_to=incoming.reply_to,
- attachments=[
- Attachment(
- type="document",
- filename=item.get("filename"),
- mime_type=item.get("mime_type"),
- workspace_path=item.get("workspace_path"),
- )
- for item in staged
- ],
- )
-```
-
-```python
-# adapter/matrix/bot.py
-staged_before_dispatch = False
-if isinstance(incoming, IncomingMessage) and incoming.text and not incoming.attachments:
- staged = await get_staged_attachments(self.runtime.store, room.room_id, sender)
- if staged:
- incoming = await self._merge_staged_attachments(room.room_id, sender, incoming)
- staged_before_dispatch = True
-
-try:
- outgoing = await self.runtime.dispatcher.dispatch(incoming)
-except PlatformError:
- ...
-else:
- if staged_before_dispatch:
- await clear_staged_attachments(self.runtime.store, room.room_id, sender)
-```
-
-- [ ] **Step 4: Run targeted tests to verify they pass**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py -q`
-
-Expected: PASS
-
-- [ ] **Step 5: Update docs**
-
-Add to `README.md`:
-
-```md
-### Matrix staged attachments
-
-If a Matrix user sends files without a text instruction, the bot stages them per chat and replies with the current list.
-
-- `!list` shows staged files
-- `!remove ` removes one staged file by index
-- `!remove all` clears all staged files
-
-The next normal user message is sent to the agent together with all staged files.
-```
-
-- [ ] **Step 6: Run broader verification**
-
-Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_files.py tests/adapter/matrix/test_send_outgoing.py tests/core/test_integration.py tests/platform/test_real.py -q`
-
-Expected: PASS
-
-- [ ] **Step 7: Commit**
-
-```bash
-git add adapter/matrix/bot.py README.md tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py
-git commit -m "feat: commit staged matrix attachments on next message"
-```
-
-## Self-Review
-
-- Spec coverage:
- - staged per `(chat_id, user_id)`: Task 1
- - short commands `!list`, `!remove `, `!remove all`: Task 2 and Task 3
- - file-only events do not invoke agent: Task 3
- - next normal message commits staged attachments: Task 4
- - failed commit preserves staged attachments: Task 4
- - docs update: Task 4
-- Placeholder scan:
- - no `TODO`, `TBD`, or deferred behavior left in task steps
-- Type consistency:
- - staged storage uses `dict` records with `filename`, `workspace_path`, `mime_type`
- - bot reconstructs `core.protocol.Attachment` from those same keys
diff --git a/docs/superpowers/plans/2026-04-22-transport-layer-thin-adapter.md b/docs/superpowers/plans/2026-04-22-transport-layer-thin-adapter.md
deleted file mode 100644
index b1984ec..0000000
--- a/docs/superpowers/plans/2026-04-22-transport-layer-thin-adapter.md
+++ /dev/null
@@ -1,540 +0,0 @@
-# Transport Layer Thin Adapter Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Replace the current custom transport behavior with a thin upstream-compatible adapter so Matrix uses `platform-agent_api.AgentApi` almost as-is and keeps only integration concerns on the `surfaces` side.
-
-**Architecture:** Keep a tiny `AgentApiWrapper` only as a construction/factory shim (`base_url` normalization + `for_chat(chat_id)`). Move all resilience logic that still belongs to `surfaces` into `RealPlatformClient`: per-chat client caching, per-chat send locks, disconnect-on-failure, and `PlatformError` mapping. Delete all custom stream semantics from the wrapper so bugs in streaming can be attributed cleanly to either upstream or our integration layer.
-
-**Tech Stack:** Python 3.11, `aiohttp`, upstream `lambda_agent_api`, `pytest`, `pytest-asyncio`, `ruff`
-
----
-
-## File Structure
-
-- Modify: `sdk/agent_api_wrapper.py`
- Purpose: shrink the wrapper to a thin constructor/factory shim with no custom `_listen()` or `send_message()` logic.
-- Modify: `sdk/real.py`
- Purpose: keep integration-only behavior: cached per-chat clients, chat locks, attachment forwarding, and failure cleanup.
-- Modify: `adapter/matrix/bot.py`
- Purpose: instantiate the wrapper with the modern `base_url` path instead of a raw `url`, matching the pinned upstream API.
-- Modify: `tests/platform/test_real.py`
- Purpose: remove tests that encode custom wrapper semantics and replace them with tests that prove only the intended integration guarantees.
-- Modify: `README.md`
- Purpose: document that the transport layer now uses the pinned upstream `platform-agent_api` client semantics directly, with only a thin local adapter.
-
-### Task 1: Shrink `AgentApiWrapper` To A Thin Factory Shim
-
-**Files:**
-- Modify: `sdk/agent_api_wrapper.py`
-- Test: `tests/platform/test_real.py`
-
-- [ ] **Step 1: Replace wrapper-only behavior tests with thin-wrapper tests**
-
-Update `tests/platform/test_real.py` so it no longer asserts any custom post-END drain or wrapper-owned timeout behavior. Replace those tests with the following:
-
-```python
-def test_agent_api_wrapper_normalizes_base_url_and_uses_modern_constructor(monkeypatch):
- captured = {}
-
- def fake_init(self, agent_id, base_url=None, chat_id=0, **kwargs):
- captured["agent_id"] = agent_id
- captured["base_url"] = base_url
- captured["chat_id"] = chat_id
-
- monkeypatch.setattr(agent_api_wrapper_module.AgentApi, "__init__", fake_init)
-
- wrapper = AgentApiWrapper(
- agent_id="agent-1",
- base_url="ws://platform-agent:8000/v1/agent_ws/",
- chat_id="41",
- )
-
- assert wrapper.chat_id == "41"
- assert wrapper._base_url == "ws://platform-agent:8000"
- assert captured == {
- "agent_id": "agent-1",
- "base_url": "ws://platform-agent:8000",
- "chat_id": "41",
- }
-
-
-def test_agent_api_wrapper_for_chat_reuses_normalized_base_url(monkeypatch):
- init_calls = []
-
- def fake_init(self, agent_id, base_url=None, chat_id=0, **kwargs):
- self.id = agent_id
- self.chat_id = chat_id
- self.url = base_url
- init_calls.append((agent_id, base_url, chat_id))
-
- monkeypatch.setattr(agent_api_wrapper_module.AgentApi, "__init__", fake_init)
-
- root = AgentApiWrapper(
- agent_id="agent-1",
- base_url="http://platform-agent:8000/v1/agent_ws/",
- chat_id="1",
- )
-
- child = root.for_chat("99")
-
- assert child is not root
- assert child.chat_id == "99"
- assert child._base_url == "http://platform-agent:8000"
- assert init_calls == [
- ("agent-1", "http://platform-agent:8000", "1"),
- ("agent-1", "http://platform-agent:8000", "99"),
- ]
-```
-
-- [ ] **Step 2: Run tests to verify old assumptions fail**
-
-Run:
-
-```bash
-/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "recovers_late_text_after_first_end or times_out_on_idle_stream or normalizes_base_url_and_uses_modern_constructor or for_chat_reuses_normalized_base_url"'
-```
-
-Expected:
-
-- FAIL because the old wrapper-behavior tests still exist
-- FAIL or SKIP for the new thin-wrapper tests until code/tests are aligned
-
-- [ ] **Step 3: Replace `sdk/agent_api_wrapper.py` with a thin wrapper**
-
-Rewrite `sdk/agent_api_wrapper.py` to the minimal implementation below:
-
-```python
-from __future__ import annotations
-
-import inspect
-import re
-import sys
-from pathlib import Path
-from urllib.parse import urlsplit, urlunsplit
-
-_api_root = Path(__file__).resolve().parents[1] / "external" / "platform-agent_api"
-if str(_api_root) not in sys.path:
- sys.path.insert(0, str(_api_root))
-
-from lambda_agent_api.agent_api import AgentApi # noqa: E402
-
-
-class AgentApiWrapper(AgentApi):
- """Thin construction/factory shim over the pinned upstream AgentApi."""
-
- def __init__(
- self,
- agent_id: str,
- base_url: str,
- *,
- chat_id: int | str = 0,
- **kwargs,
- ) -> None:
- self._base_url = self._normalize_base_url(base_url)
- self._init_kwargs = dict(kwargs)
- self.chat_id = chat_id
- if not self._supports_modern_constructor():
- raise RuntimeError("Pinned platform-agent_api is expected to support base_url + chat_id")
-
- super().__init__(
- agent_id=agent_id,
- base_url=self._base_url,
- chat_id=chat_id,
- **kwargs,
- )
-
- @staticmethod
- def _supports_modern_constructor() -> bool:
- try:
- parameters = inspect.signature(AgentApi.__init__).parameters
- except (TypeError, ValueError):
- return False
- return "base_url" in parameters and "chat_id" in parameters
-
- @staticmethod
- def _normalize_base_url(base_url: str) -> str:
- parsed = urlsplit(base_url)
- path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/"))
- return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
-
- def for_chat(self, chat_id: int | str) -> "AgentApiWrapper":
- return type(self)(
- agent_id=self.id,
- base_url=self._base_url,
- chat_id=chat_id,
- **self._init_kwargs,
- )
-```
-
-- [ ] **Step 4: Run the wrapper-focused tests**
-
-Run:
-
-```bash
-/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "normalizes_base_url_and_uses_modern_constructor or for_chat_reuses_normalized_base_url"'
-```
-
-Expected:
-
-- PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add sdk/agent_api_wrapper.py tests/platform/test_real.py
-git commit -m "refactor: shrink agent api wrapper to thin adapter"
-```
-
-### Task 2: Simplify `RealPlatformClient` To The Pinned Modern API
-
-**Files:**
-- Modify: `sdk/real.py`
-- Modify: `adapter/matrix/bot.py`
-- Test: `tests/platform/test_real.py`
-
-- [ ] **Step 1: Add failing integration tests for the desired thin-adapter contract**
-
-Extend `tests/platform/test_real.py` with these assertions:
-
-```python
-@pytest.mark.asyncio
-async def test_real_platform_client_passes_attachments_to_modern_send_message():
- agent_api = FakeAgentApiFactory()
- client = RealPlatformClient(
- agent_api=agent_api,
- prototype_state=PrototypeStateStore(),
- platform="matrix",
- )
-
- attachment = Attachment(
- type="document",
- filename="report.pdf",
- mime_type="application/pdf",
- workspace_path="surfaces/matrix/u1/r1/inbox/report.pdf",
- )
-
- result = await client.send_message(
- "@alice:example.org",
- "chat-1",
- "read this",
- attachments=[attachment],
- )
-
- assert result.response == "read this"
- assert agent_api.instances["chat-1"].calls == [
- ("read this", ["surfaces/matrix/u1/r1/inbox/report.pdf"])
- ]
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_disconnects_chat_after_agent_exception():
- class ErroringChatAgentApi:
- def __init__(self, chat_id: str) -> None:
- self.chat_id = chat_id
- self.connect_calls = 0
- self.close_calls = 0
-
- async def connect(self) -> None:
- self.connect_calls += 1
-
- async def close(self) -> None:
- self.close_calls += 1
-
- async def send_message(self, text: str, attachments: list[str] | None = None):
- raise agent_api_wrapper_module.AgentException("INTERNAL_ERROR", "boom")
- yield
-
- agent_api = FakeAgentApiFactory()
- erroring = ErroringChatAgentApi("chat-1")
- agent_api.for_chat = lambda chat_id: erroring
- client = RealPlatformClient(
- agent_api=agent_api,
- prototype_state=PrototypeStateStore(),
- platform="matrix",
- )
-
- with pytest.raises(PlatformError, match="boom") as exc_info:
- await client.send_message("@alice:example.org", "chat-1", "hello")
-
- assert exc_info.value.code == "INTERNAL_ERROR"
- assert erroring.close_calls == 1
- assert "chat-1" not in client._chat_apis
-```
-
-- [ ] **Step 2: Run tests to verify they fail before simplification**
-
-Run:
-
-```bash
-/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "passes_attachments_to_modern_send_message or disconnects_chat_after_agent_exception"'
-```
-
-Expected:
-
-- FAIL until `sdk/real.py` and Matrix runtime wiring are aligned with the pinned modern API
-
-- [ ] **Step 3: Simplify `sdk/real.py` and Matrix runtime construction**
-
-Make these exact edits:
-
-```python
-# adapter/matrix/bot.py
-def _build_platform_from_env() -> PlatformClient:
- backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower()
- if backend == "real":
- base_url = os.environ["AGENT_BASE_URL"]
- return RealPlatformClient(
- agent_api=AgentApiWrapper(agent_id="matrix-bot", base_url=base_url),
- prototype_state=PrototypeStateStore(),
- platform="matrix",
- )
- return MockPlatformClient()
-```
-
-```python
-# sdk/real.py
-from __future__ import annotations
-
-import asyncio
-from collections.abc import AsyncIterator
-from pathlib import Path
-
-from sdk.agent_api_wrapper import AgentApiWrapper
-from sdk.interface import (
- Attachment,
- MessageChunk,
- MessageResponse,
- PlatformClient,
- PlatformError,
- User,
- UserSettings,
-)
-from sdk.prototype_state import PrototypeStateStore
-
-
-class RealPlatformClient(PlatformClient):
- def __init__(
- self,
- agent_api: AgentApiWrapper,
- prototype_state: PrototypeStateStore,
- platform: str = "matrix",
- ) -> None:
- self._agent_api = agent_api
- self._prototype_state = prototype_state
- self._platform = platform
- self._chat_apis: dict[str, AgentApiWrapper] = {}
- self._chat_api_lock = asyncio.Lock()
- self._chat_send_locks: dict[str, asyncio.Lock] = {}
-
- @property
- def agent_api(self) -> AgentApiWrapper:
- return self._agent_api
-
- async def _get_chat_api(self, chat_id: str) -> AgentApiWrapper:
- chat_key = str(chat_id)
- chat_api = self._chat_apis.get(chat_key)
- if chat_api is None:
- async with self._chat_api_lock:
- chat_api = self._chat_apis.get(chat_key)
- if chat_api is None:
- chat_api = self._agent_api.for_chat(chat_key)
- await chat_api.connect()
- self._chat_apis[chat_key] = chat_api
- return chat_api
-
- def _get_chat_send_lock(self, chat_id: str) -> asyncio.Lock:
- chat_key = str(chat_id)
- lock = self._chat_send_locks.get(chat_key)
- if lock is None:
- lock = asyncio.Lock()
- self._chat_send_locks[chat_key] = lock
- return lock
-
- async def send_message(
- self,
- user_id: str,
- chat_id: str,
- text: str,
- attachments: list[Attachment] | None = None,
- ) -> MessageResponse:
- response_parts: list[str] = []
- tokens_used = 0
- sent_attachments: list[Attachment] = []
- message_id = user_id
-
- lock = self._get_chat_send_lock(chat_id)
- async with lock:
- chat_api = await self._get_chat_api(chat_id)
- try:
- async for event in chat_api.send_message(text, attachments=self._attachment_paths(attachments)):
- if hasattr(event, "text"):
- response_parts.append(event.text)
- elif event.__class__.__name__ == "MsgEventEnd":
- tokens_used = getattr(event, "tokens_used", 0)
- elif "SEND_FILE" in getattr(getattr(event, "type", None), "value", str(getattr(event, "type", ""))):
- attachment = self._attachment_from_send_file_event(event)
- if attachment is not None:
- sent_attachments.append(attachment)
- except Exception as exc:
- await self._handle_chat_api_failure(chat_id, exc)
-
- await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used)
-
- return MessageResponse(
- message_id=message_id,
- response="".join(response_parts),
- tokens_used=tokens_used,
- finished=True,
- attachments=sent_attachments,
- )
-
- async def stream_message(
- self,
- user_id: str,
- chat_id: str,
- text: str,
- attachments: list[Attachment] | None = None,
- ) -> AsyncIterator[MessageChunk]:
- lock = self._get_chat_send_lock(chat_id)
- async with lock:
- chat_api = await self._get_chat_api(chat_id)
- try:
- async for event in chat_api.send_message(text, attachments=self._attachment_paths(attachments)):
- if hasattr(event, "text"):
- yield MessageChunk(
- message_id=user_id,
- delta=event.text,
- finished=False,
- )
- elif event.__class__.__name__ == "MsgEventEnd":
- tokens_used = getattr(event, "tokens_used", 0)
- await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used)
- yield MessageChunk(
- message_id=user_id,
- delta="",
- finished=True,
- tokens_used=tokens_used,
- )
- except Exception as exc:
- await self._handle_chat_api_failure(chat_id, exc)
-
- async def disconnect_chat(self, chat_id: str) -> None:
- chat_key = str(chat_id)
- chat_api = self._chat_apis.pop(chat_key, None)
- self._chat_send_locks.pop(chat_key, None)
- if chat_api is not None:
- await chat_api.close()
-
- async def close(self) -> None:
- for chat_api in list(self._chat_apis.values()):
- await chat_api.close()
- self._chat_apis.clear()
- self._chat_send_locks.clear()
-
- async def _handle_chat_api_failure(self, chat_id: str, exc: Exception) -> None:
- await self.disconnect_chat(chat_id)
- code = getattr(exc, "code", None) or "PLATFORM_CONNECTION_ERROR"
- raise PlatformError(str(exc), code=code) from exc
-
- @staticmethod
- def _attachment_paths(attachments: list[Attachment] | None) -> list[str]:
- if not attachments:
- return []
- return [attachment.workspace_path for attachment in attachments if attachment.workspace_path]
-```
-
-- [ ] **Step 4: Run the focused transport tests**
-
-Run:
-
-```bash
-/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "passes_attachments_to_modern_send_message or disconnects_chat_after_agent_exception or wraps_connection_closed_as_platform_error or reconnects_after_closed_chat_api"'
-```
-
-Expected:
-
-- PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/bot.py sdk/real.py tests/platform/test_real.py
-git commit -m "refactor: use upstream transport semantics in real client"
-```
-
-### Task 3: Remove Custom Transport Assumptions From Tests And Docs
-
-**Files:**
-- Modify: `tests/platform/test_real.py`
-- Modify: `README.md`
-
-- [ ] **Step 1: Delete tests that encode wrapper-owned stream semantics**
-
-Remove any tests that assert:
-
-- late text is recovered after the first `END`
-- duplicate `END` is repaired inside our wrapper
-- wrapper-owned idle timeout semantics
-
-The file should keep only tests for:
-
-- wrapper construction/factory behavior
-- per-chat client reuse
-- reconnect/disconnect after failure
-- attachment forwarding
-- per-chat send locking
-
-- [ ] **Step 2: Update README transport description**
-
-Add this text to the Matrix runtime/backend section in `README.md`:
-
-```md
-Transport layer note:
-
-- `surfaces` now uses the pinned upstream `platform-agent_api.AgentApi` stream semantics directly
-- local code keeps only a thin adapter for client construction and per-chat client factories
-- request serialization, disconnect-on-failure, and `PlatformError` mapping remain in `sdk/real.py`
-- `surfaces` no longer performs local post-END stream reconstruction
-```
-
-- [ ] **Step 3: Run the full verification set**
-
-Run:
-
-```bash
-uv run ruff check adapter/matrix sdk tests/platform/test_real.py
-/bin/zsh -lc 'PYTHONPATH=. uv run pytest tests/adapter/matrix -q'
-/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q'
-```
-
-Expected:
-
-- `ruff` reports `All checks passed!`
-- Matrix adapter tests PASS
-- `tests/platform/test_real.py` PASS
-
-- [ ] **Step 4: Commit**
-
-```bash
-git add README.md tests/platform/test_real.py
-git commit -m "test: remove custom transport semantics assumptions"
-```
-
----
-
-## Self-Review
-
-- Spec coverage:
- - thin adapter target: covered by Task 1
- - integration-only `RealPlatformClient`: covered by Task 2
- - removal of custom stream semantics assumptions: covered by Task 3
- - re-verification after cleanup: covered by Task 3
-
-- Placeholder scan:
- - no `TODO`, `TBD`, or deferred implementation placeholders remain in task steps
-
-- Type consistency:
- - `AgentApiWrapper` remains the construction/factory type used by `RealPlatformClient`
- - failure mapping still terminates in `PlatformError`
- - attachment forwarding consistently uses `attachments: list[str]`
diff --git a/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md b/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md
deleted file mode 100644
index a5227e8..0000000
--- a/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md
+++ /dev/null
@@ -1,855 +0,0 @@
-# Matrix Multi-Agent Routing And Restart State Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add Matrix multi-agent routing with user agent selection, room-level agent binding, and durable surface state that survives normal restart.
-
-**Architecture:** Keep the shared `PlatformClient` protocol unchanged. Add a Matrix-specific routing facade that translates local Matrix chat identity into `(agent_id, platform_chat_id)` and delegates to one `RealPlatformClient` per configured agent. Persist only durable routing state in the existing SQLite-backed surface store and deliberately drop temporary UX state on restart.
-
-**Tech Stack:** Python 3.11, matrix-nio, structlog, PyYAML, pytest, pytest-asyncio
-
----
-
-## File Structure
-
-- Create: `adapter/matrix/agent_registry.py`
- Purpose: load and validate the YAML agent registry used by Matrix runtime.
-- Create: `adapter/matrix/routed_platform.py`
- Purpose: implement a Matrix-specific `PlatformClient` facade that resolves room bindings and delegates to per-agent `RealPlatformClient` instances.
-- Create: `adapter/matrix/handlers/agent.py`
- Purpose: implement `!agent` listing and selection behavior.
-- Create: `tests/adapter/matrix/test_agent_registry.py`
- Purpose: cover YAML loading and registry validation.
-- Create: `tests/adapter/matrix/test_routed_platform.py`
- Purpose: cover room-target resolution and per-agent delegation without changing the shared protocol.
-- Create: `tests/adapter/matrix/test_agent_handler.py`
- Purpose: cover `!agent` UX and persistence of `selected_agent_id`.
-- Create: `tests/adapter/matrix/test_restart_persistence.py`
- Purpose: prove durable user/room state and `PLATFORM_CHAT_SEQ_KEY` survive runtime recreation with SQLite.
-- Create: `config/matrix-agents.example.yaml`
- Purpose: document the expected agent registry format.
-- Modify: `pyproject.toml`
- Purpose: add YAML parsing dependency required by the runtime registry loader.
-- Modify: `.env.example`
- Purpose: document the config path env var for the Matrix agent registry.
-- Modify: `README.md`
- Purpose: document the new config file, `!agent`, and restart persistence expectations.
-- Modify: `adapter/matrix/store.py`
- Purpose: add helpers for `selected_agent_id`, room `agent_id`, and explicit sequence persistence semantics.
-- Modify: `adapter/matrix/bot.py`
- Purpose: load the agent registry, construct the routed platform facade, keep local Matrix chat ids through dispatch, and enforce stale/unbound room behavior before dispatch.
-- Modify: `adapter/matrix/handlers/__init__.py`
- Purpose: register the new `!agent` command.
-- Modify: `adapter/matrix/handlers/chat.py`
- Purpose: require a selected agent for `!new` and bind new rooms to that agent.
-- Modify: `adapter/matrix/handlers/context_commands.py`
- Purpose: keep context commands compatible with local chat ids and routed platform delegation.
-- Modify: `adapter/matrix/handlers/settings.py`
- Purpose: expose `!agent` in help text.
-- Modify: `tests/adapter/matrix/test_dispatcher.py`
- Purpose: cover pre-dispatch gating, stale room behavior, and `!new` semantics.
-- Modify: `tests/adapter/matrix/test_context_commands.py`
- Purpose: keep load/reset/context flows aligned with the routed platform facade.
-
----
-
-### Task 1: Add The Agent Registry And Configuration Wiring
-
-**Files:**
-- Create: `adapter/matrix/agent_registry.py`
-- Create: `tests/adapter/matrix/test_agent_registry.py`
-- Create: `config/matrix-agents.example.yaml`
-- Modify: `pyproject.toml`
-- Modify: `.env.example`
-- Modify: `README.md`
-
-- [ ] **Step 1: Write the failing registry tests**
-
-```python
-# tests/adapter/matrix/test_agent_registry.py
-from pathlib import Path
-
-import pytest
-
-from adapter.matrix.agent_registry import AgentRegistryError, load_agent_registry
-
-
-def test_load_agent_registry_reads_yaml_entries(tmp_path: Path):
- path = tmp_path / "agents.yaml"
- path.write_text(
- "agents:\n"
- " - id: agent-1\n"
- " label: Analyst\n"
- " - id: agent-2\n"
- " label: Research\n",
- encoding="utf-8",
- )
-
- registry = load_agent_registry(path)
-
- assert [agent.agent_id for agent in registry.agents] == ["agent-1", "agent-2"]
- assert registry.get("agent-1").label == "Analyst"
-
-
-def test_load_agent_registry_rejects_duplicate_ids(tmp_path: Path):
- path = tmp_path / "agents.yaml"
- path.write_text(
- "agents:\n"
- " - id: agent-1\n"
- " label: Analyst\n"
- " - id: agent-1\n"
- " label: Duplicate\n",
- encoding="utf-8",
- )
-
- with pytest.raises(AgentRegistryError, match="duplicate agent id"):
- load_agent_registry(path)
-```
-
-- [ ] **Step 2: Run the registry tests to verify they fail**
-
-Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q`
-
-Expected: FAIL with `ModuleNotFoundError` or `ImportError` for `adapter.matrix.agent_registry`.
-
-- [ ] **Step 3: Add the YAML dependency and implement the registry loader**
-
-```toml
-# pyproject.toml
-dependencies = [
- "aiogram>=3.4,<4",
- "matrix-nio>=0.21",
- "pydantic>=2.5",
- "structlog>=24.1",
- "python-dotenv>=1.0",
- "httpx>=0.27",
- "aiohttp>=3.9",
- "PyYAML>=6.0",
-]
-```
-
-```python
-# adapter/matrix/agent_registry.py
-from __future__ import annotations
-
-from dataclasses import dataclass
-from pathlib import Path
-
-import yaml
-
-
-class AgentRegistryError(ValueError):
- pass
-
-
-@dataclass(frozen=True)
-class AgentDefinition:
- agent_id: str
- label: str
-
-
-class AgentRegistry:
- def __init__(self, agents: list[AgentDefinition]) -> None:
- self.agents = agents
- self._by_id = {agent.agent_id: agent for agent in agents}
-
- def get(self, agent_id: str) -> AgentDefinition:
- try:
- return self._by_id[agent_id]
- except KeyError as exc:
- raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc
-
-
-def load_agent_registry(path: str | Path) -> AgentRegistry:
- raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
- entries = raw.get("agents")
- if not isinstance(entries, list) or not entries:
- raise AgentRegistryError("agents registry must contain a non-empty agents list")
-
- agents: list[AgentDefinition] = []
- seen: set[str] = set()
- for entry in entries:
- agent_id = str(entry.get("id", "")).strip()
- label = str(entry.get("label", "")).strip()
- if not agent_id or not label:
- raise AgentRegistryError("each agent entry requires id and label")
- if agent_id in seen:
- raise AgentRegistryError(f"duplicate agent id: {agent_id}")
- seen.add(agent_id)
- agents.append(AgentDefinition(agent_id=agent_id, label=label))
- return AgentRegistry(agents)
-```
-
-- [ ] **Step 4: Add the example config and runtime wiring docs**
-
-```yaml
-# config/matrix-agents.example.yaml
-agents:
- - id: agent-1
- label: Analyst
- - id: agent-2
- label: Research
-```
-
-```env
-# .env.example
-MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml
-```
-
-```markdown
-# README.md
-1. Copy `config/matrix-agents.example.yaml` to `config/matrix-agents.yaml`
-2. Set `MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml`
-3. Use `!agent` in Matrix to select the active upstream agent
-```
-
-- [ ] **Step 5: Run the registry tests to verify they pass**
-
-Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q`
-
-Expected: PASS
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add pyproject.toml .env.example README.md config/matrix-agents.example.yaml adapter/matrix/agent_registry.py tests/adapter/matrix/test_agent_registry.py
-git commit -m "feat: add matrix agent registry loader"
-```
-
----
-
-### Task 2: Add A Matrix Routing Facade Without Changing `PlatformClient`
-
-**Files:**
-- Create: `adapter/matrix/routed_platform.py`
-- Create: `tests/adapter/matrix/test_routed_platform.py`
-- Modify: `adapter/matrix/bot.py`
-
-- [ ] **Step 1: Write the failing routed-platform tests**
-
-```python
-# tests/adapter/matrix/test_routed_platform.py
-import pytest
-
-from adapter.matrix.routed_platform import RoutedPlatformClient
-from adapter.matrix.store import set_room_meta
-from core.chat import ChatManager
-from core.store import InMemoryStore
-from sdk.interface import MessageResponse
-from sdk.prototype_state import PrototypeStateStore
-
-
-class FakeDelegate:
- def __init__(self, agent_id: str) -> None:
- self.agent_id = agent_id
- self.calls = []
-
- async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None):
- self.calls.append((user_id, chat_id, text, attachments))
- return MessageResponse(
- message_id=user_id,
- response=f"{self.agent_id}:{text}",
- tokens_used=0,
- finished=True,
- )
-
- async def get_or_create_user(self, external_id: str, platform: str, display_name=None):
- return await PrototypeStateStore().get_or_create_user(external_id, platform, display_name)
-
- async def get_settings(self, user_id: str):
- return await PrototypeStateStore().get_settings(user_id)
-
- async def update_settings(self, user_id: str, action):
- return None
-
-
-@pytest.mark.asyncio
-async def test_routed_platform_delegates_using_room_agent_and_platform_chat_id():
- store = InMemoryStore()
- chat_mgr = ChatManager(None, store)
- await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1")
- await set_room_meta(
- store,
- "!room:example.org",
- {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"},
- )
-
- delegates = {"agent-2": FakeDelegate("agent-2")}
- platform = RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates)
-
- response = await platform.send_message("u1", "C1", "hello")
-
- assert response.response == "agent-2:hello"
- assert delegates["agent-2"].calls == [("u1", "41", "hello", None)]
-```
-
-- [ ] **Step 2: Run the routed-platform tests to verify they fail**
-
-Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q`
-
-Expected: FAIL with `ImportError` for `RoutedPlatformClient`.
-
-- [ ] **Step 3: Implement the routing facade and integrate runtime construction**
-
-```python
-# adapter/matrix/routed_platform.py
-from __future__ import annotations
-
-from sdk.interface import PlatformClient
-
-
-class RoutedPlatformClient(PlatformClient):
- def __init__(self, store, chat_mgr, delegates: dict[str, PlatformClient]) -> None:
- self._store = store
- self._chat_mgr = chat_mgr
- self._delegates = delegates
-
- async def _resolve_target(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]:
- ctx = await self._chat_mgr.get(local_chat_id, user_id=user_id)
- if ctx is None:
- raise ValueError(f"Chat {local_chat_id} not found for {user_id}")
- room_meta = await self._store.get(f"matrix_room:{ctx.surface_ref}")
- if room_meta is None or not room_meta.get("agent_id") or not room_meta.get("platform_chat_id"):
- raise ValueError(f"Room {ctx.surface_ref} is not bound to an agent target")
- delegate = self._delegates[room_meta["agent_id"]]
- return delegate, str(room_meta["platform_chat_id"])
-
- async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None):
- delegate, platform_chat_id = await self._resolve_target(user_id, chat_id)
- return await delegate.send_message(user_id, platform_chat_id, text, attachments)
-
- async def stream_message(self, user_id: str, chat_id: str, text: str, attachments=None):
- delegate, platform_chat_id = await self._resolve_target(user_id, chat_id)
- async for chunk in delegate.stream_message(user_id, platform_chat_id, text, attachments):
- yield chunk
-
- async def get_or_create_user(self, external_id: str, platform: str, display_name=None):
- first_delegate = next(iter(self._delegates.values()))
- return await first_delegate.get_or_create_user(external_id, platform, display_name)
-
- async def get_settings(self, user_id: str):
- first_delegate = next(iter(self._delegates.values()))
- return await first_delegate.get_settings(user_id)
-
- async def update_settings(self, user_id: str, action):
- first_delegate = next(iter(self._delegates.values()))
- await first_delegate.update_settings(user_id, action)
-```
-
-```python
-# adapter/matrix/bot.py
-from adapter.matrix.agent_registry import load_agent_registry
-from adapter.matrix.routed_platform import RoutedPlatformClient
-
-
-def _build_platform_from_env(store: StateStore, chat_mgr: ChatManager) -> PlatformClient:
- backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower()
- if backend != "real":
- return MockPlatformClient()
-
- registry = load_agent_registry(os.environ["MATRIX_AGENT_REGISTRY_PATH"])
- delegates = {
- agent.agent_id: RealPlatformClient(
- agent_id=agent.agent_id,
- agent_base_url=_agent_base_url_from_env(),
- prototype_state=PrototypeStateStore(),
- platform="matrix",
- )
- for agent in registry.agents
- }
- return RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates)
-
-
-def build_runtime(...):
- store = store or InMemoryStore()
- chat_mgr = ChatManager(None, store)
- platform = platform or _build_platform_from_env(store, chat_mgr)
- auth_mgr = AuthManager(platform, store)
- settings_mgr = SettingsManager(platform, store)
- dispatcher = EventDispatcher(
- platform=platform,
- chat_mgr=chat_mgr,
- auth_mgr=auth_mgr,
- settings_mgr=settings_mgr,
- )
-```
-
-- [ ] **Step 4: Run the routed-platform tests to verify they pass**
-
-Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q`
-
-Expected: PASS
-
-- [ ] **Step 5: Commit**
-
-```bash
-git add adapter/matrix/routed_platform.py adapter/matrix/bot.py tests/adapter/matrix/test_routed_platform.py
-git commit -m "feat: add matrix routed platform facade"
-```
-
----
-
-### Task 3: Add `!agent` Selection And Durable User Agent State
-
-**Files:**
-- Create: `adapter/matrix/handlers/agent.py`
-- Create: `tests/adapter/matrix/test_agent_handler.py`
-- Modify: `adapter/matrix/store.py`
-- Modify: `adapter/matrix/handlers/__init__.py`
-- Modify: `adapter/matrix/handlers/settings.py`
-
-- [ ] **Step 1: Write the failing agent-handler tests**
-
-```python
-# tests/adapter/matrix/test_agent_handler.py
-import pytest
-
-from adapter.matrix.handlers.agent import make_handle_agent
-from adapter.matrix.store import get_room_meta, get_selected_agent_id, set_room_meta
-from core.protocol import IncomingCommand
-from core.store import InMemoryStore
-
-
-class FakeRegistry:
- def __init__(self) -> None:
- self.agents = [
- type("Agent", (), {"agent_id": "agent-1", "label": "Analyst"})(),
- type("Agent", (), {"agent_id": "agent-2", "label": "Research"})(),
- ]
-
-
-@pytest.mark.asyncio
-async def test_agent_command_lists_available_agents():
- handler = make_handle_agent(store=InMemoryStore(), registry=FakeRegistry())
- result = await handler(
- IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=[]),
- None,
- None,
- None,
- None,
- )
- assert "1. Analyst" in result[0].text
- assert "2. Research" in result[0].text
-
-
-@pytest.mark.asyncio
-async def test_agent_command_persists_selected_agent_and_binds_unbound_room():
- store = InMemoryStore()
- await set_room_meta(store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1"})
- handler = make_handle_agent(store=store, registry=FakeRegistry())
- chat_mgr = type(
- "ChatMgr",
- (),
- {"get": staticmethod(lambda chat_id, user_id=None: type("Ctx", (), {"surface_ref": "!room:example.org"})())},
- )()
-
- await handler(
- IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=["2"]),
- None,
- None,
- chat_mgr,
- None,
- )
-
- assert await get_selected_agent_id(store, "u1") == "agent-2"
- room_meta = await get_room_meta(store, "!room:example.org")
- assert room_meta["agent_id"] == "agent-2"
-```
-
-- [ ] **Step 2: Run the agent-handler tests to verify they fail**
-
-Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q`
-
-Expected: FAIL with missing handler or store helpers.
-
-- [ ] **Step 3: Add durable store helpers and implement `!agent`**
-
-```python
-# adapter/matrix/store.py
-async def get_selected_agent_id(store: StateStore, matrix_user_id: str) -> str | None:
- meta = await get_user_meta(store, matrix_user_id) or {}
- value = meta.get("selected_agent_id")
- return str(value) if value else None
-
-
-async def set_selected_agent_id(store: StateStore, matrix_user_id: str, agent_id: str) -> None:
- meta = await get_user_meta(store, matrix_user_id) or {}
- meta["selected_agent_id"] = agent_id
- await set_user_meta(store, matrix_user_id, meta)
-
-
-async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None:
- meta = dict(await get_room_meta(store, room_id) or {})
- meta["agent_id"] = agent_id
- await set_room_meta(store, room_id, meta)
-```
-
-```python
-# adapter/matrix/handlers/agent.py
-from __future__ import annotations
-
-from adapter.matrix.store import (
- get_room_meta,
- get_selected_agent_id,
- next_platform_chat_id,
- set_platform_chat_id,
- set_room_agent_id,
- set_selected_agent_id,
-)
-from core.protocol import IncomingCommand, OutgoingMessage
-
-
-def make_handle_agent(store, registry):
- async def handle_agent(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr):
- if not event.args:
- current = await get_selected_agent_id(store, event.user_id)
- lines = ["Доступные агенты:"]
- for index, agent in enumerate(registry.agents, start=1):
- marker = " (текущий)" if agent.agent_id == current else ""
- lines.append(f"{index}. {agent.label}{marker}")
- lines.append("")
- lines.append("Выбери агента: !agent <номер>")
- return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
-
- agent = registry.agents[int(event.args[0]) - 1]
- await set_selected_agent_id(store, event.user_id, agent.agent_id)
- ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id) if chat_mgr else None
- if ctx is not None:
- room_meta = await get_room_meta(store, ctx.surface_ref)
- if room_meta is not None and not room_meta.get("agent_id"):
- await set_room_agent_id(store, ctx.surface_ref, agent.agent_id)
- if not room_meta.get("platform_chat_id"):
- await set_platform_chat_id(store, ctx.surface_ref, await next_platform_chat_id(store))
- return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Этот чат готов к работе.")]
- return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Для продолжения используй !new.")]
-
- return handle_agent
-```
-
-- [ ] **Step 4: Register the command and update help text**
-
-```python
-# adapter/matrix/handlers/__init__.py
-from adapter.matrix.handlers.agent import make_handle_agent
-
-dispatcher.register(IncomingCommand, "agent", make_handle_agent(store, registry))
-```
-
-```python
-# adapter/matrix/handlers/settings.py
-HELP_TEXT = "\n".join(
- [
- "Команды",
- "",
- "!agent выбрать активного агента",
- "!new [название] создать новый чат",
- "!chats список активных чатов",
- "!rename <название> переименовать текущий чат",
- "!archive архивировать текущий чат",
- "!context показать текущее состояние контекста",
- "!save [имя] сохранить текущий контекст",
- "!load показать сохранённые контексты",
- ]
-)
-```
-
-- [ ] **Step 5: Run the agent-handler tests to verify they pass**
-
-Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q`
-
-Expected: PASS
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add adapter/matrix/store.py adapter/matrix/handlers/agent.py adapter/matrix/handlers/__init__.py adapter/matrix/handlers/settings.py tests/adapter/matrix/test_agent_handler.py
-git commit -m "feat: add matrix agent selection command"
-```
-
----
-
-### Task 4: Bind Rooms Correctly And Block Stale Chats
-
-**Files:**
-- Modify: `adapter/matrix/bot.py`
-- Modify: `adapter/matrix/handlers/chat.py`
-- Modify: `adapter/matrix/handlers/context_commands.py`
-- Modify: `tests/adapter/matrix/test_dispatcher.py`
-- Modify: `tests/adapter/matrix/test_context_commands.py`
-
-- [ ] **Step 1: Write the failing dispatcher and context-command tests**
-
-```python
-# tests/adapter/matrix/test_dispatcher.py
-@pytest.mark.asyncio
-async def test_bot_replies_with_agent_prompt_when_user_has_no_selected_agent():
- runtime = build_runtime(platform=MockPlatformClient())
- client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
- bot = MatrixBot(client, runtime)
- await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "@alice:example.org"})
-
- await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="@alice:example.org", body="hello"))
-
- client.room_send.assert_awaited_once()
- assert "выбери агента" in client.room_send.call_args.args[2]["body"].lower()
-
-
-@pytest.mark.asyncio
-async def test_new_chat_requires_selected_agent_and_binds_room_meta():
- client = SimpleNamespace(
- room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
- room_put_state=AsyncMock(),
- )
- runtime = build_runtime(platform=MockPlatformClient(), client=client)
- await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 2, "selected_agent_id": "agent-2"})
-
- result = await runtime.dispatcher.dispatch(
- IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="new", args=["Research"])
- )
-
- room_meta = await get_room_meta(runtime.store, "!r2:example")
- assert room_meta["agent_id"] == "agent-2"
- assert "Создан чат" in result[0].text
-```
-
-```python
-# tests/adapter/matrix/test_context_commands.py
-@pytest.mark.asyncio
-async def test_load_selection_calls_platform_with_local_chat_id():
- platform = MatrixCommandPlatform()
- runtime = build_runtime(platform=platform)
- await runtime.chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1")
- await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"})
-
- client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
- bot = MatrixBot(client, runtime)
- await set_load_pending(runtime.store, "u1", "!room:example.org", {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]})
-
- await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="u1", body="1"))
-
- platform.send_message.assert_awaited_once_with("u1", "C1", LOAD_PROMPT.format(name="session-a"))
-```
-
-- [ ] **Step 2: Run the dispatcher and context-command tests to verify they fail**
-
-Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q`
-
-Expected: FAIL because the current runtime still injects `platform_chat_id` into normal messages and `!new` does not require or persist `agent_id`.
-
-- [ ] **Step 3: Implement room binding and stale-room checks in runtime**
-
-```python
-# adapter/matrix/bot.py
-from adapter.matrix.store import (
- get_selected_agent_id,
- get_room_meta,
- next_platform_chat_id,
- set_platform_chat_id,
- set_room_agent_id,
-)
-
-
-async def _ensure_active_room_target(self, room_id: str, user_id: str) -> tuple[dict | None, OutgoingMessage | None]:
- room_meta = await get_room_meta(self.runtime.store, room_id)
- selected_agent_id = await get_selected_agent_id(self.runtime.store, user_id)
- if not selected_agent_id:
- return room_meta, OutgoingMessage(chat_id=room_id, text="Сначала выбери агента через !agent.")
- if room_meta is None:
- return room_meta, None
- if not room_meta.get("agent_id"):
- await set_room_agent_id(self.runtime.store, room_id, selected_agent_id)
- if not room_meta.get("platform_chat_id"):
- await set_platform_chat_id(self.runtime.store, room_id, await next_platform_chat_id(self.runtime.store))
- room_meta = await get_room_meta(self.runtime.store, room_id)
- return room_meta, None
- if room_meta["agent_id"] != selected_agent_id:
- return room_meta, OutgoingMessage(chat_id=room_id, text="Этот чат привязан к старому агенту. Используй !new.")
- return room_meta, None
-```
-
-```python
-# adapter/matrix/bot.py
-local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender)
-dispatch_chat_id = local_chat_id
-
-if not body.startswith("!"):
- room_meta, blocking = await self._ensure_active_room_target(room.room_id, sender)
- if blocking is not None:
- await self._send_all(room.room_id, [blocking])
- return
-
-incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id)
-```
-
-- [ ] **Step 4: Require selected agent for `!new` and persist room `agent_id`**
-
-```python
-# adapter/matrix/handlers/chat.py
-from adapter.matrix.store import get_selected_agent_id
-
-selected_agent_id = await get_selected_agent_id(store, event.user_id)
-if not selected_agent_id:
- return [OutgoingMessage(chat_id=event.chat_id, text="Сначала выбери агента через !agent.")]
-
-await set_room_meta(
- store,
- room_id,
- {
- "room_type": "chat",
- "chat_id": chat_id,
- "display_name": room_name,
- "matrix_user_id": event.user_id,
- "space_id": space_id,
- "platform_chat_id": platform_chat_id,
- "agent_id": selected_agent_id,
- },
-)
-```
-
-```python
-# adapter/matrix/bot.py
-room_meta = await get_room_meta(self.runtime.store, room_id)
-local_chat_id = room_meta.get("chat_id", room_id) if room_meta else room_id
-
-await self.runtime.platform.send_message(
- user_id,
- local_chat_id,
- LOAD_PROMPT.format(name=name),
-)
-```
-
-- [ ] **Step 5: Run the dispatcher and context-command tests to verify they pass**
-
-Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q`
-
-Expected: PASS
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add adapter/matrix/bot.py adapter/matrix/handlers/chat.py adapter/matrix/handlers/context_commands.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py
-git commit -m "feat: bind matrix rooms to selected agents"
-```
-
----
-
-### Task 5: Prove Durable Restart State And Sequence Persistence
-
-**Files:**
-- Create: `tests/adapter/matrix/test_restart_persistence.py`
-- Modify: `adapter/matrix/store.py`
-- Modify: `README.md`
-
-- [ ] **Step 1: Write the failing restart-persistence tests**
-
-```python
-# tests/adapter/matrix/test_restart_persistence.py
-import pytest
-
-from adapter.matrix.store import (
- get_selected_agent_id,
- next_platform_chat_id,
- set_room_meta,
- set_selected_agent_id,
-)
-from core.store import SQLiteStore
-
-
-@pytest.mark.asyncio
-async def test_selected_agent_and_room_binding_survive_store_recreation(tmp_path):
- db_path = tmp_path / "matrix.db"
- store = SQLiteStore(str(db_path))
- await set_selected_agent_id(store, "u1", "agent-2")
- await set_room_meta(
- store,
- "!room:example.org",
- {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"},
- )
-
- reopened = SQLiteStore(str(db_path))
- assert await get_selected_agent_id(reopened, "u1") == "agent-2"
- assert (await reopened.get("matrix_room:!room:example.org"))["agent_id"] == "agent-2"
- assert (await reopened.get("matrix_room:!room:example.org"))["platform_chat_id"] == "41"
-
-
-@pytest.mark.asyncio
-async def test_platform_chat_sequence_survives_store_recreation(tmp_path):
- db_path = tmp_path / "matrix.db"
- store = SQLiteStore(str(db_path))
-
- assert await next_platform_chat_id(store) == "1"
- assert await next_platform_chat_id(store) == "2"
-
- reopened = SQLiteStore(str(db_path))
- assert await next_platform_chat_id(reopened) == "3"
-```
-
-- [ ] **Step 2: Run the restart-persistence tests to verify they fail**
-
-Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q`
-
-Expected: FAIL because `selected_agent_id` helpers do not exist yet or sequence persistence behavior is not explicitly covered.
-
-- [ ] **Step 3: Make sequence persistence explicit and document the restart boundary**
-
-```python
-# adapter/matrix/store.py
-PLATFORM_CHAT_SEQ_KEY = "matrix_platform_chat_seq"
-
-
-async def next_platform_chat_id(store: StateStore) -> str:
- async with _PLATFORM_CHAT_SEQ_LOCK:
- data = await store.get(PLATFORM_CHAT_SEQ_KEY)
- index = int((data or {}).get("next_platform_chat_index", 1))
- await store.set(PLATFORM_CHAT_SEQ_KEY, {"next_platform_chat_index": index + 1})
- return str(index)
-```
-
-```markdown
-# README.md
-- Matrix durable state lives in `lambda_matrix.db` and `matrix_store`
-- normal restart is supported only when those paths survive container recreation
-- staged attachments and pending confirmations are intentionally not restored
-```
-
-- [ ] **Step 4: Run the restart-persistence tests to verify they pass**
-
-Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q`
-
-Expected: PASS
-
-- [ ] **Step 5: Run the combined verification sweep**
-
-Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_agent_handler.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_restart_persistence.py tests/platform/test_real.py -q`
-
-Expected: PASS
-
-- [ ] **Step 6: Commit**
-
-```bash
-git add adapter/matrix/store.py README.md tests/adapter/matrix/test_restart_persistence.py
-git commit -m "test: cover matrix restart state persistence"
-```
-
----
-
-## Self-Review
-
-### Spec coverage
-
-- Multi-agent agent registry: Task 1
-- Shared `PlatformClient` preserved via routing facade: Task 2
-- `!agent` UX and durable `selected_agent_id`: Task 3
-- Unbound room activation, `!new`, stale room rejection: Task 4
-- Restart durability for user state, room state, and `PLATFORM_CHAT_SEQ_KEY`: Task 5
-
-### Placeholder scan
-
-- No `TODO`, `TBD`, or “implement later” markers remain.
-- Each task includes exact file paths, tests, commands, and minimal code snippets.
-
-### Type consistency
-
-- `selected_agent_id` lives in user metadata throughout the plan.
-- `agent_id` and `platform_chat_id` live in room metadata throughout the plan.
-- `RoutedPlatformClient` keeps the existing `PlatformClient` method names intact.
diff --git a/docs/superpowers/specs/2026-03-31-forum-topics-design.md b/docs/superpowers/specs/2026-03-31-forum-topics-design.md
deleted file mode 100644
index 1e7cb29..0000000
--- a/docs/superpowers/specs/2026-03-31-forum-topics-design.md
+++ /dev/null
@@ -1,180 +0,0 @@
-# Forum Topics Mode Design
-
-**Date:** 2026-03-31
-**Status:** Approved — ready for implementation
-**Scope:** `adapter/telegram/` — расширение существующего адаптера
-
----
-
-## Контекст
-
-Forum Topics — опциональный advanced-режим поверх существующих виртуальных DM-чатов.
-Пользователь подключает свою Telegram-супергруппу с Topics — и его чаты появляются
-как нативные темы Telegram. DM и Forum работают **одновременно**: один контекст,
-две поверхности.
-
----
-
-## Принцип работы
-
-Каждый чат (`chat_id` = UUID) получает опциональный `forum_thread_id`.
-
-- Пользователь пишет в DM → бот отвечает в DM с тегом `[Чат #N]`
-- Пользователь пишет в Forum-тему → бот отвечает в ту же тему (без тега)
-- Контекст (`chat_id`) один и тот же — платформа видит единый разговор
-
----
-
-## БД — изменения схемы
-
-```sql
-ALTER TABLE tg_users ADD COLUMN forum_group_id INTEGER;
-ALTER TABLE chats ADD COLUMN forum_thread_id INTEGER;
-```
-
-`forum_group_id` — ID супергруппы пользователя (NULL если группа не подключена).
-`forum_thread_id` — ID темы в форуме (NULL если чат создан только в DM).
-
-Новые функции в `db.py`:
-```python
-def set_forum_group(tg_user_id: int, group_id: int) -> None
-def get_forum_group(tg_user_id: int) -> int | None
-def set_forum_thread(chat_id: str, thread_id: int) -> None
-def get_chat_by_thread(tg_user_id: int, thread_id: int) -> dict | None
-```
-
----
-
-## Онбординг — `/forum`
-
-### FSM
-
-```python
-class ForumSetupState(StatesGroup):
- waiting_for_group = State() # ждём пересылку из группы
-```
-
-### Флоу
-
-```
-/forum
-→ FSM: ForumSetupState.waiting_for_group
-→ "Создай супергруппу, включи Topics, добавь меня администратором
- с правом управления темами. Затем перешли мне любое сообщение из группы."
-
-[пользователь пересылает сообщение]
-→ Проверить: forward_from_chat.type == "supergroup"
-→ Проверить права бота (администратор + can_manage_topics)
- ❌ нет прав → объяснить что именно не так, остаться в состоянии
-→ Сохранить forum_group_id в БД
-→ Создать Forum-тему для каждого существующего активного DM-чата
-→ Записать forum_thread_id для каждого чата
-→ Ответить в DM: "✅ Группа подключена! Твои чаты теперь доступны в Forum-темах."
-→ FSM: clear
-```
-
-### Проверка прав
-
-```python
-async def check_forum_admin(bot: Bot, group_id: int) -> bool:
- member = await bot.get_chat_member(group_id, (await bot.get_me()).id)
- return (
- member.status in ("administrator", "creator")
- and getattr(member, "can_manage_topics", False)
- )
-```
-
----
-
-## Создание чатов — синхронизация
-
-### `/new` в DM (группа подключена)
-
-1. Создать UUID-запись в `chats` (как сейчас)
-2. `create_forum_topic(bot, group_id, chat_name)` → получить `thread_id`
-3. Записать `forum_thread_id` в БД
-4. Переключить FSM на новый чат
-5. Ответить в DM: `"✅ [chat_name] создан."`
-
-### `/new` в DM (группа НЕ подключена)
-
-Без изменений — только DM-чат.
-
-### `/new` в Forum-теме
-
-1. Определить `thread_id` из `message.message_thread_id`
-2. Создать UUID-запись в `chats` с `forum_thread_id = thread_id`
-3. Название: из аргумента `/new Название` или из названия темы (`message.chat.forum_topic_created.name` при создании — иначе запросить у Telegram)
-4. Ответить в теме: `"✅ Чат зарегистрирован. Пиши здесь!"`
-
----
-
-## Маршрутизация сообщений
-
-### Определение источника
-
-```python
-def is_forum_message(message: Message) -> bool:
- return message.message_thread_id is not None
-
-def resolve_chat_id(message: Message, tg_user_id: int) -> str | None:
- if is_forum_message(message):
- chat = db.get_chat_by_thread(tg_user_id, message.message_thread_id)
- return chat["chat_id"] if chat else None
- else:
- # DM — берём active_chat_id из FSM StateData (как сейчас)
- return None # caller reads from FSM
-```
-
-### Ответ
-
-- Пришло из DM → `bot.send_message(tg_user_id, f"[{chat_name}] {text}")`
-- Пришло из Forum-темы → `bot.send_message(group_id, text, message_thread_id=thread_id)`
-
-В Forum-теме тег `[Чат #N]` **не нужен** — тема сама является визуальным разделителем.
-
----
-
-## Обработчики — изменения
-
-### `handlers/forum.py` (новый файл)
-
-```python
-router = Router(name="forum")
-
-@router.message(Command("forum"))
-async def cmd_forum(message, state): ... # запускает онбординг
-
-@router.message(ForumSetupState.waiting_for_group, F.forward_from_chat)
-async def handle_group_forward(message, state, dispatcher): ... # регистрирует группу
-```
-
-### `handlers/chat.py` — изменения
-
-- `handle_message`: если `is_forum_message` → брать `chat_id` из БД по `thread_id`, отвечать в тему
-- `cmd_new_chat`: ветвление по источнику (DM vs Forum) и наличию `forum_group_id`
-
-### `states.py` — добавить
-
-```python
-class ForumSetupState(StatesGroup):
- waiting_for_group = State()
-```
-
----
-
-## Что НЕ реализуем
-
-- Отслеживание создания тем пользователем без `/new` — Telegram не присылает событие создания темы в боте
-- Синхронизация удаления темы ↔ архивация DM-чата (только через команды)
-- Поддержка нескольких групп на одного пользователя
-
----
-
-## Порядок реализации
-
-1. `db.py` — миграция + 4 новых функции
-2. `states.py` — `ForumSetupState`
-3. `handlers/forum.py` — `/forum` + onboarding
-4. `handlers/chat.py` — `cmd_new_chat` с ветвлением, `handle_message` с Forum-маршрутизацией
-5. `converter.py` — `is_forum_message`, `resolve_chat_id`
diff --git a/docs/superpowers/specs/2026-03-31-matrix-adapter-design.md b/docs/superpowers/specs/2026-03-31-matrix-adapter-design.md
deleted file mode 100644
index 44ff120..0000000
--- a/docs/superpowers/specs/2026-03-31-matrix-adapter-design.md
+++ /dev/null
@@ -1,283 +0,0 @@
-# Matrix Adapter Design
-
-**Date:** 2026-03-31
-**Status:** Approved — ready for implementation
-**Scope:** `adapter/matrix/`
-
----
-
-## Контекст
-
-Matrix-адаптер — внутренняя поверхность для команды Lambda Lab: разработчики, тестировщики, авторы скиллов. UX ориентирован на удобство работы, не на онбординг.
-
-Адаптер конвертирует matrix-nio события в `IncomingEvent` (core protocol) и отправляет `OutgoingEvent` обратно. Бизнес-логика — в `core/`, адаптер только переводит форматы и управляет Matrix API.
-
-Клиент: Element (web/desktop). Стек: matrix-nio (async), Python 3.11+, SQLite.
-
----
-
-## Онбординг — DM как первый чат (ленивый Space)
-
-**Решение:** DM-комната с ботом = Чат #1. Space создаётся только при первом `!new`.
-
-### Флоу — новый пользователь
-
-1. Пользователь инвайтит бота в личные сообщения
-2. Бот принимает инвайт, вызывает `platform.get_or_create_user(matrix_user_id, "matrix", display_name)`
-3. Бот регистрирует DM-комнату как `chat_room` с `chat_id = C1` в SQLite
-4. Бот пишет приветствие в DM — пользователь сразу пишет
-5. При первом `!new` — бот создаёт Space `Lambda — {display_name}`, добавляет DM-комнату в Space через `m.space.child`, создаёт новую комнату-чат
-
-### Флоу — возвращающийся пользователь
-
-Если `matrix_user_id` уже есть в БД (бот перезапустился, или пользователь пишет повторно) — `get_or_create_user` возвращает `is_new=False`. Бот не создаёт ничего заново, просто обрабатывает сообщение в контексте существующей комнаты.
-
-### Почему не Space сразу
-
-Создание Space при инвайте порождает 3 инвайта подряд (Space + Settings + Чат 1) до первого сообщения. DM-first убирает этот шум, сохраняя такой же UX как Telegram.
-
-### Приветствие
-
-```
-Привет, {display_name}! Пиши — я здесь.
-
-Команды: !new · !chats · !rename · !archive · !skills
-```
-
----
-
-## Архитектура — Room-type routing
-
-При получении события адаптер сначала определяет тип комнаты (`chat` / `settings`), затем маршрутизирует в соответствующий обработчик.
-
-```
-adapter/matrix/
- bot.py — matrix-nio клиент, sync loop
- converter.py — RoomEvent → IncomingEvent, OutgoingEvent → Matrix API
- room_router.py — определяет тип комнаты: chat | settings
- states.py — FSM состояния (per room_id, SQLite)
-
- handlers/
- auth.py — invite → onboarding
- chat.py — сообщения, !new, !chats, !rename, !archive
- settings.py — !skills, !connectors, !soul, !safety, !plan, !status, !whoami
- confirm.py — реакции 👍/❌ и команды !yes / !no
-
- reactions.py — helpers: add_reaction, remove_reactions, parse_reaction_event
-```
-
----
-
-## FSM состояния (per room_id)
-
-```python
-class RoomState(StatesGroup):
- idle = State() # ждём сообщения
- waiting_response = State() # запрос ушёл на платформу
- confirm_pending = State() # ждём !yes/!no или реакцию 👍/❌
- settings_active = State() # Settings-комната (не чат)
-```
-
-`room_type` хранится в SQLite. `room_router.py` читает его при каждом событии.
-
----
-
-## Команды
-
-Все команды на английском. Работают в любой комнате Space.
-
-| Команда | Действие |
-|---------|---------|
-| `!new [name]` | Создать чат. При первом вызове — создаёт Space, переносит DM |
-| `!chats` | Список чатов с текущим активным |
-| `!rename ` | Переименовать текущую комнату |
-| `!archive` | Вывести комнату из Space (не удалять) |
-| `!skills` | Список скиллов — реакции как тумблеры |
-| `!connectors` | Коннекторы (OAuth заглушки) |
-| `!soul` | Личность агента |
-| `!safety` | Настройки безопасности |
-| `!plan` | Подписка и токены |
-| `!status` | Состояние платформы и чатов |
-| `!whoami` | Текущий аккаунт |
-| `!yes` / `!no` | Подтверждение / отмена действия агента |
-
----
-
-## Settings room
-
-Создаётся при первом `!new` вместе со Space. Закреплена вверху Space.
-
-### Скиллы — реакции как тумблеры
-
-`!skills` → бот отправляет список. Каждый скилл пронумерован. Реакция 1️⃣–N️⃣ переключает соответствующий скилл (toggle). Несколько скиллов могут быть включены одновременно. Бот редактирует своё сообщение через `m.replace` после каждого переключения.
-
-```
-✅ 1 web-search — поиск в интернете
-✅ 2 fetch-url — чтение веб-страниц
-✅ 3 email — чтение почты
-❌ 4 browser — управление браузером
-❌ 5 image-gen — генерация изображений
-✅ 6 files — работа с файлами
-
-Реакция 1️⃣–6️⃣ = переключить скилл
-```
-
-### Остальные настройки
-
-`!connectors`, `!soul`, `!safety`, `!plan`, `!status`, `!whoami` — текстовые ответы, без интерактивных элементов. Поля задаются аргументами команды: `!soul name Lambda`, `!soul style brief`, `!safety on email-send`.
-
----
-
-## Подтверждение действий агента
-
-Агент запрашивает подтверждение → бот отправляет сообщение с описанием действия. Пользователь подтверждает **реакцией или командой** — оба способа работают.
-
-```
-🤖 Lambda:
-Отправить письмо azamat@lambda.lab?
-Тема: «Отчёт за неделю»
-
-👍 подтвердить · ❌ отменить
-!yes — подтвердить · !no — отменить
-
-Истекает через 5 минут
-```
-
-После ответа: бот убирает реакции с сообщения, редактирует статус (`m.replace`), переходит в `idle`.
-
-FSM: `waiting_response` → `confirm_pending` → `idle`
-
----
-
-## Долгие задачи — треды
-
-Если задача занимает больше одного хода — бот создаёт тред от своего первого сообщения.
-
-```
-🤖 Lambda (основной поток):
-Начинаю исследование «AI агенты 2025» — займёт 2-3 минуты.
- 🧵 Прогресс (в треде):
- └── ✅ Ищу источники... (12 найдено)
- └── ✅ Анализирую статьи...
- └── ⏳ Формирую отчёт...
- └── ○ Финальная проверка
-```
-
-Основной поток не засоряется. Финальный результат — отдельным сообщением в основной поток.
-
----
-
-## Typing indicator
-
-`m.typing` — отправлять перед запросом к платформе. Если запрос > 5 сек — возобновлять каждые 25 сек (Matrix typing живёт ~30 сек).
-
----
-
-## Converter
-
-`adapter/matrix/converter.py` — конвертация в обе стороны.
-
-### matrix-nio → IncomingEvent
-
-```python
-def from_room_message(event: RoomMessageText, room_id: str, chat_id: str) -> IncomingMessage:
- return IncomingMessage(
- user_id=event.sender, # @user:matrix.org
- platform="matrix",
- chat_id=chat_id, # C1, C2... из rooms таблицы
- text=event.body,
- attachments=extract_attachments(event),
- reply_to=event.replyto_event_id,
- )
-
-def extract_attachments(event: RoomMessageText) -> list[Attachment]:
- # m.image → Attachment(type="image", url=mxc_url, mime_type=...)
- # m.file → Attachment(type="document", url=mxc_url, filename=..., mime_type=...)
- # m.audio → Attachment(type="audio", url=mxc_url, mime_type=...)
- # m.text → []
- msgtype = getattr(event, "msgtype", "m.text")
- if msgtype == "m.image":
- return [Attachment(type="image", url=event.url, mime_type=event.mimetype)]
- elif msgtype == "m.file":
- return [Attachment(type="document", url=event.url,
- filename=event.body, mime_type=event.mimetype)]
- elif msgtype == "m.audio":
- return [Attachment(type="audio", url=event.url, mime_type=event.mimetype)]
- return []
-
-def from_reaction(event: ReactionEvent, room_id: str) -> IncomingCallback | None:
- # Парсит m.reaction → IncomingCallback(action="toggle_skill" | "confirm" | "cancel")
- ...
-
-def from_command(body: str, sender: str, room_id: str, chat_id: str) -> IncomingCommand | None:
- # Парсит !new, !skills, !yes, !no и т.д. → IncomingCommand
- ...
-```
-
-### OutgoingEvent → Matrix
-
-```python
-async def send_outgoing(client: AsyncClient, room_id: str, event: OutgoingEvent) -> None:
- if isinstance(event, OutgoingMessage):
- await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text})
-
- elif isinstance(event, OutgoingUI):
- # Confirmation request — текст + подсказка по реакциям/командам
- body = f"{event.text}\n\n👍 подтвердить · ❌ отменить\n!yes — подтвердить · !no — отменить"
- await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
- await client.room_send(room_id, "m.reaction", {...}) # добавить 👍 и ❌ на сообщение
-
- elif isinstance(event, OutgoingTyping):
- await client.room_typing(room_id, event.is_typing, timeout=25000)
-```
-
----
-
-## БД схема
-
-```sql
-CREATE TABLE matrix_users (
- matrix_user_id TEXT PRIMARY KEY, -- @user:matrix.org
- platform_user_id TEXT NOT NULL, -- из MockPlatformClient
- display_name TEXT,
- space_id TEXT, -- NULL до первого !new
- settings_room_id TEXT, -- NULL до первого !new
- created_at TIMESTAMP
-);
-
-CREATE TABLE rooms (
- room_id TEXT PRIMARY KEY, -- room_id Matrix
- matrix_user_id TEXT NOT NULL,
- room_type TEXT NOT NULL, -- 'chat' | 'settings'
- chat_id TEXT, -- C1, C2... (NULL для settings)
- display_name TEXT,
- created_at TIMESTAMP,
- archived_at TIMESTAMP,
- FOREIGN KEY(matrix_user_id) REFERENCES matrix_users(matrix_user_id)
-);
-```
-
-`StateStore` из `core/store.py` (`SQLiteStore`) — для FSM per room_id.
-
----
-
-## Что НЕ реализуем в прототипе
-
-- Webhook от платформы (используем sync `send_message`)
-- E2E encryption (nio поддерживает, но усложняет прототип)
-- Экспорт истории
-- `!rename`, `!archive` — добавить после основного флоу
-
----
-
-## Порядок реализации
-
-1. `bot.py` — AsyncClient, sync loop, middleware для platform client
-2. `states.py` — RoomState
-3. `room_router.py` — определение типа комнаты
-4. `converter.py` — from_room_message, from_reaction, from_command
-5. `handlers/auth.py` — invite → onboarding
-6. `handlers/chat.py` — сообщения + !new + !chats
-7. `reactions.py` — helpers для работы с реакциями
-8. `handlers/confirm.py` — реакции 👍/❌ + !yes/!no
-9. `handlers/settings.py` — !skills с m.replace + остальные команды
diff --git a/docs/superpowers/specs/2026-03-31-telegram-adapter-design.md b/docs/superpowers/specs/2026-03-31-telegram-adapter-design.md
index 46421a5..ea2346e 100644
--- a/docs/superpowers/specs/2026-03-31-telegram-adapter-design.md
+++ b/docs/superpowers/specs/2026-03-31-telegram-adapter-design.md
@@ -1,7 +1,7 @@
# Telegram Adapter Design
**Date:** 2026-03-31
-**Status:** Approved — ready for implementation
+**Status:** Approved — implemented in `feat/telegram-adapter`
**Scope:** `adapter/telegram/`
---
@@ -10,38 +10,45 @@
Telegram-адаптер — поверхность для взаимодействия пользователя с AI-агентом Lambda через Telegram.
Адаптер конвертирует Telegram-события в `IncomingEvent` (core protocol) и отправляет `OutgoingEvent` обратно.
-Бизнес-логика — в `core/`, адаптер только переводит форматы и управляет Telegram API.
+Бизнес-логика остаётся в `core/`; адаптер отвечает за Telegram API, FSM и локальную SQLite-привязку Telegram-пользователя и чатов.
---
-## Чаты: основной режим — Виртуальные чаты в DM
+## Чаты: hybrid DM + Forum Topics
-**Решение зафиксировано:** основной режим — виртуальные чаты прямо в личке с ботом.
-Forum Topics — опциональный advanced режим (не реализуется в этом прототипе).
+**Зафиксированное решение:** базовая поверхность — личка с ботом, Forum Topics — опциональный advanced-режим поверх тех же самых чатов.
-### Принцип работы
-
-- `active_chat_id` — куда идут входящие сообщения от пользователя в данный момент
-- Ответы от агента всегда приходят в общий DM-поток с тегом: `[Чат #1] Вот ответ...`
-- Пользователь может переключиться до получения ответа — ответ всё равно придёт, тегирован
+- В DM пользователь всегда может писать сразу после `/start`
+- `active_chat_id` хранится в FSM и определяет, в какой чат идут DM-сообщения
+- Если подключена Forum-группа, каждый чат может получить `forum_thread_id`
+- Один и тот же `chat_id` доступен из двух поверхностей:
+ - DM: ответы идут с префиксом `[Название чата]`
+ - Forum-тема: ответы идут прямо в тему без префикса
### UX флоу
-```
+```text
/start
-→ Приветствие + Чат #1 создан автоматически
-→ Пользователь сразу пишет
+→ пользователь аутентифицирован
+→ создаётся или восстанавливается активный DM-чат
-/new [название]
-→ Новый чат создан, переключаемся на него
+/new [название] в DM
+→ создаётся новый чат
+→ если forum уже подключён, бот создаёт и forum topic
-/chats
-→ Инлайн-кнопки: 1. Чат #1 2. Чат #2 3. Исследование рынка
-→ Нажимает — переключился
+/forum
+→ бот просит переслать сообщение из супергруппы с Topics
+→ проверяет admin rights
+→ привязывает группу к пользователю
+→ создаёт topics для существующих чатов
-Сообщение в активный чат
-→ Typing indicator
-→ [Чат #1] Ответ агента
+Сообщение в DM
+→ идёт в active_chat_id
+→ ответ приходит в DM как `[Чат #N] ...`
+
+Сообщение в forum topic
+→ по `message_thread_id` определяется chat_id
+→ ответ приходит в ту же тему без тега
```
---
@@ -50,339 +57,180 @@ Forum Topics — опциональный advanced режим (не реализ
### Флоу (мок)
-1. `/start` → `get_or_create_user(tg_user_id, "telegram", display_name)`
-2. `is_new=True` → создать Чат #1, написать приветствие
-3. `is_new=False` → восстановить `active_chat_id` из БД, написать "С возвращением"
+1. `/start` → `platform.get_or_create_user(external_id=tg_user_id, platform="telegram", display_name=...)`
+2. Бот сохраняет `tg_user_id -> platform_user_id` в локальной БД
+3. Если локальных чатов ещё нет — создаёт `Чат #1`
+4. Если чат уже есть — восстанавливает последний активный чат
### FSM состояния
-```python
-class AuthState(StatesGroup):
- # В моке состояний нет — auth мгновенный
- # Зарезервировано для реального SDK (waiting_confirmation и т.п.)
- pass
-```
-
----
-
-## FSM состояния (полная схема)
-
```python
class ChatState(StatesGroup):
- idle = State() # В активном чате, ждём сообщения
- waiting_response = State() # Запрос ушёл на платформу, ждём ответа
+ idle = State()
+ waiting_response = State()
+
class SettingsState(StatesGroup):
- menu = State() # Главное меню настроек
- soul_editing = State() # Редактирует имя/инструкции агента
- confirm_action = State() # Подтверждение деструктивного действия
+ menu = State()
+ soul_editing = State()
+ confirm_action = State()
+
+
+class ForumSetupState(StatesGroup):
+ waiting_for_group = State()
```
-**`active_chat_id` хранится в FSM StateData, не в состоянии.**
+`active_chat_id` и `active_chat_name` хранятся в `FSMContext` data.
---
## Структура файлов
-```
+```text
adapter/telegram/
- bot.py — точка входа: Dispatcher, routers, middleware
- states.py — FSM StatesGroup
- converter.py — aiogram Message → IncomingEvent и обратно
+ bot.py — точка входа: Dispatcher, middleware, routers
+ converter.py — Message -> IncomingMessage, forum helpers, output formatting
+ db.py — SQLite schema и Telegram-specific persistence
+ states.py — ChatState, SettingsState, ForumSetupState
handlers/
auth.py — /start
- chat.py — /new, /chats, /rename, /archive, сообщения в чате
- settings.py — /settings и callback_query для настроек
- confirm.py — подтверждение действий агента (InlineKeyboard ✅/❌)
+ chat.py — /new, /chats, switch chat, входящие сообщения
+ confirm.py — confirm/cancel callbacks
+ forum.py — /forum onboarding и регистрация forum group
+ settings.py — /settings и callbacks настроек
keyboards/
- chat.py — список чатов, управление чатом
- settings.py — меню настроек, скиллы, коннекторы
- confirm.py — кнопки подтверждения действия
+ chat.py — список чатов
+ confirm.py — confirm keyboard
+ settings.py — меню настроек
```
---
+## Persistence
+
+Локальная БД содержит две Telegram-специфичные сущности:
+
+```sql
+CREATE TABLE tg_users (
+ tg_user_id INTEGER PRIMARY KEY,
+ platform_user_id TEXT NOT NULL,
+ display_name TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ forum_group_id INTEGER
+);
+
+CREATE TABLE chats (
+ chat_id TEXT PRIMARY KEY,
+ tg_user_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ archived_at TIMESTAMP,
+ forum_thread_id INTEGER
+);
+```
+
+- `forum_group_id` — привязанная супергруппа пользователя
+- `forum_thread_id` — опциональная связь конкретного чата с forum topic
+
+---
+
## Converter
-Конвертация в обе стороны — `adapter/telegram/converter.py`.
-
-### aiogram → IncomingEvent
+### Telegram -> IncomingEvent
```python
-def from_message(message: Message) -> IncomingMessage:
+def from_message(message: Message, chat_id: str) -> IncomingMessage:
return IncomingMessage(
user_id=str(message.from_user.id),
- chat_id=active_chat_id, # из FSM StateData
- text=message.text or "",
- attachments=extract_attachments(message),
+ chat_id=chat_id,
+ text=message.text or message.caption or "",
+ attachments=_extract_attachments(message),
platform="telegram",
- raw=message.model_dump(),
)
-def extract_attachments(message: Message) -> list[Attachment]:
- attachments = []
- if message.photo:
- file = message.photo[-1] # наибольшее разрешение
- attachments.append(Attachment(
- url=f"tg://file/{file.file_id}", # резолвим через getFile при необходимости
- mime_type="image/jpeg",
- size=file.file_size,
- ))
- if message.document:
- attachments.append(Attachment(
- url=f"tg://file/{message.document.file_id}",
- mime_type=message.document.mime_type or "application/octet-stream",
- size=message.document.file_size,
- filename=message.document.file_name,
- ))
- if message.voice:
- attachments.append(Attachment(
- url=f"tg://file/{message.voice.file_id}",
- mime_type="audio/ogg",
- size=message.voice.file_size,
- ))
- return attachments
+
+def is_forum_message(message: Message) -> bool:
+ return getattr(message, "message_thread_id", None) is not None
+
+
+def resolve_forum_chat_id(message: Message, tg_user_id: int) -> str | None:
+ thread_id = getattr(message, "message_thread_id", None)
+ if thread_id is None:
+ return None
+
+ chat = db.get_chat_by_thread(tg_user_id, thread_id)
+ return chat["chat_id"] if chat else None
```
-### OutgoingEvent → Telegram
+### OutgoingEvent -> Telegram
```python
-async def send_outgoing(bot: Bot, user_id: int, chat_name: str, event: OutgoingEvent) -> None:
- prefix = f"[{chat_name}] "
-
- if isinstance(event, OutgoingMessage):
- await bot.send_message(user_id, prefix + event.text)
-
- elif isinstance(event, OutgoingUI):
- # Кнопки подтверждения действия
- keyboard = build_confirm_keyboard(event)
- await bot.send_message(user_id, prefix + event.text, reply_markup=keyboard)
+def format_outgoing(chat_name: str, event: OutgoingEvent, *, prefix: bool = True) -> str:
+ rendered_prefix = f"[{chat_name}] " if prefix else ""
+ return rendered_prefix + event.text
```
+- DM-ответы используют `prefix=True`
+- Forum-ответы используют `prefix=False`
+- `OutgoingUI` отправляется с inline-кнопками подтверждения
+
---
## Обработчики
-### auth.py — `/start`
+### `auth.py`
-```python
-@router.message(CommandStart())
-async def cmd_start(message: Message, state: FSMContext, platform: PlatformClient):
- user = await platform.get_or_create_user(
- external_id=str(message.from_user.id),
- platform="telegram",
- display_name=message.from_user.full_name,
- )
+- `/start` создаёт или восстанавливает пользователя
+- если это первый запуск, создаёт `Чат #1`
+- обновляет `active_chat_id` и переводит FSM в `ChatState.idle`
- if user.is_new:
- chat_id = create_chat(user.user_id, "Чат #1") # в локальной БД
- await state.update_data(active_chat_id=chat_id, active_chat_name="Чат #1")
- await state.set_state(ChatState.idle)
- await message.answer(
- f"Привет, {message.from_user.first_name}! 👋\n"
- f"Я создал тебе первый чат. Просто пиши.\n\n"
- f"Команды: /new — новый чат, /chats — список"
- )
- else:
- # Восстановить последний активный чат
- last_chat = get_last_chat(user.user_id)
- await state.update_data(active_chat_id=last_chat.id, active_chat_name=last_chat.name)
- await state.set_state(ChatState.idle)
- await message.answer(f"С возвращением! Продолжаем [{last_chat.name}]")
-```
+### `chat.py`
-### chat.py — сообщения
+- `/new`:
+ - в DM создаёт новый чат
+ - если подключён forum, пытается создать forum topic и сохранить `forum_thread_id`
+ - в forum-теме может зарегистрировать текущую тему как чат
+- `/chats` показывает inline-список чатов
+- `switch::` переключает активный DM-чат
+- `handle_message`:
+ - в DM читает `active_chat_id` из FSM
+ - в forum определяет чат по `message_thread_id`
+ - отправляет `typing`
+ - прокидывает `IncomingMessage` в `EventDispatcher`
+ - возвращает ответ в DM или в тему
-```python
-@router.message(ChatState.idle, F.text)
-async def handle_message(message: Message, state: FSMContext, platform: PlatformClient):
- data = await state.get_data()
- chat_id = data["active_chat_id"]
- chat_name = data["active_chat_name"]
+### `forum.py`
- await state.set_state(ChatState.waiting_response)
- await message.bot.send_chat_action(message.chat.id, "typing")
+- `/forum` переводит FSM в `ForumSetupState.waiting_for_group`
+- пересланное сообщение из супергруппы:
+ - валидирует, что это `supergroup`
+ - проверяет, что бот admin и умеет `can_manage_topics`
+ - сохраняет `forum_group_id`
+ - создаёт topics для существующих чатов без `forum_thread_id`
- incoming = from_message(message, chat_id)
- outgoing_events = await core_handler.handle(incoming, platform)
+### `confirm.py`
- await state.set_state(ChatState.idle)
-
- for event in outgoing_events:
- await send_outgoing(message.bot, message.from_user.id, chat_name, event)
-```
-
-### chat.py — управление чатами
-
-```python
-@router.message(Command("new"))
-async def cmd_new_chat(message: Message, state: FSMContext):
- args = message.text.split(maxsplit=1)
- name = args[1] if len(args) > 1 else None
-
- data = await state.get_data()
- user_id = ... # из платформы
- count = count_chats(user_id)
- chat_name = name or f"Чат #{count + 1}"
-
- chat_id = create_chat(user_id, chat_name)
- await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
- await message.answer(f"✅ [{chat_name}] создан. Пиши!")
-
-
-@router.message(Command("chats"))
-async def cmd_list_chats(message: Message, state: FSMContext):
- chats = get_user_chats(user_id)
- data = await state.get_data()
- active_id = data.get("active_chat_id")
-
- buttons = []
- for chat in chats:
- mark = "● " if chat.id == active_id else ""
- buttons.append([InlineKeyboardButton(
- text=f"{mark}{chat.name}",
- callback_data=f"switch:{chat.id}:{chat.name}"
- )])
- buttons.append([InlineKeyboardButton(text="➕ Новый чат", callback_data="new_chat")])
-
- await message.answer("Твои чаты:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
-
-
-@router.callback_query(F.data.startswith("switch:"))
-async def switch_chat(callback: CallbackQuery, state: FSMContext):
- _, chat_id, chat_name = callback.data.split(":", 2)
- await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name)
- await callback.message.edit_text(f"✅ Переключился на [{chat_name}]")
- await callback.answer()
-```
-
-### confirm.py — подтверждение действий агента
-
-```python
-# Агент хочет выполнить действие → OutgoingUI приходит из core handler
-# Бот показывает кнопки, ждёт ответа
-
-@router.callback_query(F.data.startswith("confirm:"))
-async def handle_confirm(callback: CallbackQuery, state: FSMContext, platform: PlatformClient):
- _, action_id, decision = callback.data.split(":") # "confirm" / "cancel"
- data = await state.get_data()
-
- incoming = IncomingCallback(
- user_id=...,
- chat_id=data["active_chat_id"],
- action="confirm" if decision == "yes" else "cancel",
- payload={"action_id": action_id},
- platform="telegram",
- )
- outgoing_events = await core_handler.handle(incoming, platform)
- await callback.message.edit_reply_markup(reply_markup=None)
- for event in outgoing_events:
- await send_outgoing(callback.bot, callback.from_user.id, data["active_chat_name"], event)
- await callback.answer()
-```
+- обрабатывает `confirm:yes:` и `confirm:no:`
+- в forum-режиме восстанавливает `chat_id` по thread
+- ответ на callback отправляет обратно в тот же канал:
+ - DM -> в личку
+ - Forum -> в тот же `message_thread_id`
---
-## Настройки
+## Текущее покрытие
-`/settings` → инлайн-меню. Структура:
-
-```
-⚙️ Настройки
-[🧩 Скиллы] [🔗 Коннекторы]
-[🧠 Личность] [🔒 Безопасность]
-[💳 Подписка]
-```
-
-**Скиллы** — список с кнопками-переключателями ✅/❌. Нажатие → `SettingsAction(toggle_skill)`.
-
-**Личность** — свободные поля (имя агента, инструкции). Без пресетов стилей.
-FSM: `SettingsState.soul_editing` → бот задаёт вопросы по одному полю.
-
-**Коннекторы** — заглушка OAuth ссылки.
-
-**Безопасность** — переключатели для деструктивных действий.
-
-**Подписка** — заглушка с токенами.
+- unit-тесты на forum routing и forum onboarding: `tests/adapter/telegram/test_forum.py`
+- smoke/integration на dispatcher и core handlers:
+ - `tests/core/test_dispatcher.py`
+ - `tests/core/test_integration.py`
---
-## Хранилище (БД)
+## Что не покрывает этот документ
-Минимальная схема для прототипа:
-
-```sql
-CREATE TABLE tg_users (
- tg_user_id INTEGER PRIMARY KEY,
- platform_user_id TEXT NOT NULL, -- из MockPlatformClient
- display_name TEXT,
- created_at TIMESTAMP
-);
-
-CREATE TABLE chats (
- chat_id TEXT PRIMARY KEY, -- UUID
- tg_user_id INTEGER NOT NULL,
- name TEXT NOT NULL,
- created_at TIMESTAMP,
- archived_at TIMESTAMP,
- FOREIGN KEY(tg_user_id) REFERENCES tg_users(tg_user_id)
-);
-```
-
-`StateStore` из `core/store.py` (`SQLiteStore`) — для FSM и общего состояния.
-
----
-
-## Typing indicator
-
-Отправлять `send_chat_action("typing")` перед запросом к платформе.
-Если запрос > 5 сек — возобновлять каждые 4 сек (action живёт ~5 сек).
-
-```python
-async def with_typing(bot: Bot, chat_id: int, coro):
- async def renew():
- while True:
- await bot.send_chat_action(chat_id, "typing")
- await asyncio.sleep(4)
- task = asyncio.create_task(renew())
- try:
- return await coro
- finally:
- task.cancel()
-```
-
----
-
-## Обработка ответов при смене чата
-
-Ответ всегда приходит в DM-поток с тегом:
-```
-[Чат #1] Вот мой ответ на вопрос про Python...
-```
-
-Пользователь мог переключить `active_chat_id` пока шёл запрос — это нормально.
-`chat_name` берётся из `StateData` в момент отправки запроса (до `set_state(waiting_response)`).
-
----
-
-## Что НЕ реализуем в прототипе
-
-- Forum Topics режим (researched, отложено)
-- Webhook от платформы (platform ещё не готов — используем sync `send_message`)
-- `/rename`, `/archive` для чатов (добавить после основного флоу)
-- Экспорт истории
-
----
-
-## Порядок реализации
-
-1. `bot.py` — Dispatcher, middleware для platform client
-2. `states.py` — FSM классы
-3. `converter.py` — from_message, extract_attachments
-4. `handlers/auth.py` — /start
-5. `handlers/chat.py` — сообщения + /new + /chats
-6. `keyboards/chat.py` — список чатов
-7. `handlers/settings.py` + `keyboards/settings.py` — меню настроек
-8. `handlers/confirm.py` + `keyboards/confirm.py` — подтверждения
+- Matrix-адаптер
+- Реальный SDK платформы вместо `sdk.mock.MockPlatformClient`
+- Автоматическое отслеживание вручную созданных пользователем forum topics без `/new`
diff --git a/docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md b/docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md
deleted file mode 100644
index 529eed1..0000000
--- a/docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md
+++ /dev/null
@@ -1,180 +0,0 @@
-# Telegram Forum Redesign — Forum-First Architecture
-
-**Date:** 2026-04-01
-**Replaces:** `2026-03-31-telegram-adapter-design.md` (DM+Forum hybrid)
-**Branch strategy:** New branch `feat/telegram-forum` from `main` (approach C: cherry-pick from `feat/telegram-adapter`)
-
----
-
-## Overview
-
-Redesign the Telegram adapter to use Bot API 9.3 Threaded Mode as the sole interaction model. The user's private chat with the bot becomes a forum: each topic is an isolated AI agent context. No supergroup, no onboarding flow.
-
----
-
-## File Structure
-
-**Carried over from `feat/telegram-adapter` (adapted):**
-- `adapter/telegram/keyboards/settings.py` — settings inline keyboards
-- `adapter/telegram/converter.py` — base conversion logic, rewritten for new context key
-
-**Written from scratch:**
-```
-adapter/telegram/
- bot.py — entry point, router registration
- db.py — SQLite schema and queries
- handlers/
- start.py — /start handler
- message.py — incoming messages in topics
- topic_events.py — forum_topic_created / edited / closed
- commands.py — /new, /archive, /rename, /settings
- keyboards/
- settings.py — (from feat/telegram-adapter)
-```
-
-**Deleted entirely:**
-- `handlers/forum.py` — old supergroup onboarding
-- `handlers/chats.py` — chat switching via command
-- All `forum_group_id` references in db.py and elsewhere
-
----
-
-## Database Schema
-
-```sql
-CREATE TABLE chats (
- user_id INTEGER NOT NULL,
- thread_id INTEGER NOT NULL,
- chat_name TEXT NOT NULL DEFAULT 'Чат #1',
- archived_at DATETIME,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- PRIMARY KEY (user_id, thread_id)
-);
-```
-
-**Context key:** `(user_id, thread_id)` — the canonical identifier for a chat context everywhere in the adapter.
-
-**Display number** ("Чат #1", "Чат #2") is not stored. Computed on demand:
-```sql
-ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at)
-```
-
-**workspace_id (C1/C2/C3)** is not stored. The adapter passes `thread_id` as `context_id` to the platform; the platform resolves the workspace mapping.
-
-**State** is managed via `core/store.py` with key `(user_id, thread_id)`. No aiogram FSM.
-
----
-
-## Event Handling
-
-### Commands (`handlers/commands.py` + `handlers/start.py`)
-
-| Command | Behaviour |
-|---------|-----------|
-| `/start` | If no active topics: `create_forum_topic("Чат #1")` + `hide_general_forum_topic`. If topics exist: greeting only. Then check all non-archived topics for validity (see Error Handling). |
-| `/new` | `create_forum_topic("Чат #N")` where N = next display number. Insert row in DB. Send welcome message into new topic. |
-| `/archive` | `close_forum_topic(thread_id)`. Set `archived_at = now()` in DB. |
-| `/rename ` | `edit_forum_topic(thread_id, name)`. Update `chat_name` in DB. |
-| `/settings` | Global settings. Works from any topic. |
-
-### Incoming Messages (`handlers/message.py`)
-
-- Message in a topic → `converter.py` → `IncomingMessage(context_id=str(thread_id))` → `EventDispatcher`
-- Message in General topic (`message_thread_id is None`) → ignored silently
-
-### Topic UI Events (`handlers/topic_events.py`)
-
-| Event | Behaviour |
-|-------|-----------|
-| `forum_topic_created` | Register new chat in DB (native topic creation via UI) |
-| `forum_topic_edited` | Update `chat_name` in DB to match new Telegram topic name |
-| `forum_topic_closed` | Set `archived_at = now()` — automatic archive |
-
----
-
-## Data Flow with Streaming
-
-```
-User → Telegram → aiogram router
- → message.py handler
- → converter.py: Message → IncomingMessage(context_id=thread_id)
- → send placeholder "..." into topic
- → EventDispatcher.dispatch(incoming)
- → platform/mock.py (or real SDK)
- → returns AsyncIterator[str] (chunks)
- → for chunk in stream: edit_text(accumulated) every ~1.5s
- → final edit_text with complete response
- → StateStore.set((user_id, thread_id), new state)
-```
-
-**`platform/interface.py` change:**
-```python
-class PlatformClient(Protocol):
- async def send_message(
- self,
- context_id: str,
- text: str,
- on_chunk: Callable[[str], Awaitable[None]] | None = None,
- ) -> str: ...
-```
-
-`on_chunk` is optional. If the platform does not support streaming (mock), it is ignored and the full response is returned at once. The adapter shows "..." while waiting.
-
----
-
-## Error Handling
-
-**Topic deleted by user**
-- Sending to topic raises `BadRequest: message thread not found`
-- Response: set `archived_at = now()` in DB, stop writing to that topic
-- Prevention: on `/start`, call `send_chat_action("typing")` for all non-archived topics; treat error as deleted → set `archived_at`
-
-**Platform unavailable**
-- Real SDK may raise connection/timeout errors
-- Response: edit placeholder → "Сервис временно недоступен, попробуй позже"
-- Do not archive the topic, do not change state
-
-**Threaded Mode not enabled**
-- `create_forum_topic` raises `BadRequest` if bot doesn't have Threaded Mode on
-- Response: `/start` replies with instruction to enable the mode in @BotFather
-- Only case where the bot explains a configuration problem
-
-**General rule:** errors are caught at the handler level, logged, and surfaced to the user as a message. The placeholder never stays as "...".
-
----
-
-## Testing
-
-**Unit — `converter.py`**
-- `Message(thread_id=123)` → `IncomingMessage(context_id="123")`
-- `Message(thread_id=None)` (General) → `None` (ignored)
-
-**Unit — `db.py`**
-- Topic creation, archiving, renaming
-- `ROW_NUMBER()` display number computation
-- Existing `tests/adapter/test_forum_db.py` covers this
-
-**Integration — handlers (mocked bot)**
-- `/start` creates topic and hides General (`bot.create_forum_topic` mocked)
-- `forum_topic_closed` → `archived_at` set
-- `forum_topic_edited` → `chat_name` updated
-- Message in General → `EventDispatcher` not called
-
-**Out of scope for now:**
-- Streaming end-to-end with real Telegram
-- Stale topic recovery on `/start` (requires live bot)
-
----
-
-## Decisions Log
-
-| Question | Decision | Rationale |
-|----------|----------|-----------|
-| Closed topic via UI | Auto-archive | Closing = intent to finish; keeping state in sync |
-| Renamed topic via UI | Sync to DB | Respect user intent; `/rename` is symmetric |
-| Commands | `/new`, `/archive`, `/rename`, `/settings` | UI and commands are parallel paths |
-| DB context key | `(user_id, thread_id)` | `thread_id` is the real identifier in this model |
-| FSM | `core/store.py` only | Avoids duplicating state logic; platform-agnostic |
-| workspace mapping | Platform responsibility | Adapter passes `thread_id` as `context_id`; platform resolves |
-| Streaming | In design via `on_chunk` | Proven pattern from supervisor's examples; `on_chunk` is optional |
-| Branch strategy | Cherry-pick (C) | New branch from `main`; carry over keyboards + converter base only |
diff --git a/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md b/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md
deleted file mode 100644
index 581eb56..0000000
--- a/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md
+++ /dev/null
@@ -1,243 +0,0 @@
-# Matrix Direct-Agent Prototype Design
-
-## Goal
-
-Ship a working Matrix prototype that talks to the real Lambda agent instead of `MockPlatformClient`, while preserving the current Matrix adapter logic and keeping the code expandable toward future platform versions.
-
-## Scope
-
-This design is for a Matrix-only prototype delivered from this repository on a dedicated branch. It is not a `main` branch rollout and it is not a separate prototype repo.
-
-The design assumes a minimal companion fork of `platform/agent` may be used, but changes there must stay as small as possible.
-
-## Constraints
-
-- Preserve the current Matrix transport logic as much as possible.
-- Keep `core/` unaware of platform immaturity.
-- Avoid broad changes to platform repos.
-- Prefer one narrow patch to `platform/agent` over changes to both `platform/agent` and `platform/agent_api`.
-- Keep the backend boundary reusable for future Telegram or other surfaces.
-- Do not pretend unsupported platform capabilities are real.
-
-## Live Platform Findings
-
-Based on the live repo analysis performed on April 7, 2026:
-
-- `platform/master` is not yet a usable consumer-facing backend for surfaces.
-- `platform/agent` exposes a working WebSocket endpoint for prompt/response exchange.
-- `platform/agent_api` documents and implements text-oriented WebSocket messaging, but the bot does not need to depend on that package directly.
-- `platform/agent` currently hardcodes a single shared backend memory thread via `thread_id = "default"`, which would cause all chats to share context.
-
-## Architecture
-
-The prototype remains in this repo and introduces a new real backend path behind the existing SDK boundary.
-
-### New files
-
-- `sdk/real.py`
- - Exports `RealPlatformClient`
- - Implements the existing `PlatformClient` contract from `sdk/interface.py`
- - Composes the lower-level prototype pieces
-
-- `sdk/agent_session.py`
- - Owns direct WebSocket communication with the real agent
- - Manages connection lifecycle, request/response handling, and thread identity
-
-- `sdk/prototype_state.py`
- - Owns local prototype-only state
- - Stores user mapping, local settings, and lightweight metadata needed until a real control plane exists
-
-### Responsibility split
-
-- Matrix adapter remains transport-specific only.
-- `core/` continues to depend only on `PlatformClient`.
-- `RealPlatformClient` acts as the anti-corruption layer between the current bot contract and the platform’s incomplete shape.
-- Local control-plane behavior remains explicit and replaceable later.
-
-## Message and Identity Model
-
-Each Matrix chat gets a stable backend session identity.
-
-### Surface identity
-
-- Surface: `matrix`
-- Surface user id: Matrix MXID, for example `@alice:example.org`
-- Surface chat id: logical chat id from `ChatManager`, for example `C1`
-- Surface ref: Matrix room id
-
-### Backend thread identity
-
-Use a deterministic thread key:
-
-`matrix:{matrix_user_id}:{chat_id}`
-
-Example:
-
-`matrix:@alice:example.org:C1`
-
-### Mapping rules
-
-- One Matrix logical chat maps to one backend memory thread.
-- `!new` creates a fresh logical chat and therefore a fresh backend thread.
-- `!rename` only changes display metadata and does not change backend identity.
-- `!archive` stops active use of the thread in the surface, but does not need to delete backend memory in v1.
-
-## Runtime Flow
-
-### Normal message flow
-
-1. Matrix event arrives in an existing room.
-2. Existing Matrix routing resolves room to logical `chat_id`.
-3. `core/handlers/message.py` calls `platform.send_message(...)`.
-4. `RealPlatformClient` derives the backend thread key from `(platform, user_id, chat_id)`.
-5. `AgentSessionClient` sends the prompt to the agent WebSocket using that thread key.
-6. The reply is converted into the existing `MessageResponse` or `MessageChunk` contract.
-7. Matrix sends the final text back to the room.
-
-### Settings flow
-
-For v1, settings remain local:
-
-- `get_settings()` reads from local prototype state
-- `update_settings()` writes to local prototype state
-
-This is intentional. The prototype must not claim settings are backed by the real platform when no such platform API exists yet.
-
-## Feature Matrix
-
-### Real in v1
-
-- `!start`
-- Plain text messaging with the real agent
-- Matrix chat lifecycle already implemented in this repo:
- - `!new`
- - `!chats`
- - `!rename`
- - `!archive`
-- Per-chat conversation memory, provided the agent accepts dynamic thread identity
-
-### Local in v1
-
-- `!settings`
-- `!skills`
-- `!soul`
-- `!safety`
-- `!status`
-- user registration and local user mapping
-
-### Deferred
-
-- Attachments and file upload to the agent
-- Voice input to the agent
-- Image input to the agent
-- Long-running task callbacks and webhook-style async completion
-- Real control-plane integration through `platform/master`
-
-## Minimal Upstream Change
-
-To avoid shared memory across all conversations, make one narrow change in the forked `platform/agent` repo:
-
-- stop hardcoding `thread_id = "default"`
-- derive thread identity from WebSocket connection context
-
-### Preferred mechanism
-
-Read `thread_id` from WebSocket query parameters rather than changing the message payload format.
-
-Example:
-
-`ws://host:port/agent_ws/?thread_id=matrix:@alice:example.org:C1`
-
-This is preferred because:
-
-- it limits the platform patch to one repo
-- it avoids changing both server and SDK protocol shape
-- it keeps the client message body text-only
-- it makes session identity explicit and easy to reason about
-
-## Why Not Use `platform/agent_api` Directly
-
-The bot should not depend on their client package for the prototype.
-
-Reasons:
-
-- the bot already has its own internal integration boundary in `sdk/interface.py`
-- a tiny local WebSocket client is enough for this protocol
-- avoiding a dependency on `platform/agent_api` keeps rebasing simpler
-- if upstream stabilizes later, the bot can adopt their SDK without affecting Matrix handlers
-
-## Repo Strategy
-
-### This repo
-
-Owns:
-
-- Matrix surface logic
-- SDK compatibility layer
-- local prototype state
-- backend selection and wiring
-
-### Forked `platform/agent`
-
-Owns only:
-
-- minimal thread identity patch required for per-chat memory
-
-### Explicitly not doing
-
-- no separate prototype repo
-- no changes to `platform/master` for v1
-- no unnecessary changes to `platform/agent_api`
-
-## Migration Path
-
-This design is intentionally expandable.
-
-When the platform develops further:
-
-- `sdk/prototype_state.py` can be replaced or reduced by a real `MasterClient`
-- `sdk/agent_session.py` can remain the direct session transport if still relevant
-- `RealPlatformClient` can continue to present the stable bot-facing interface
-- Telegram or another surface can reuse the same backend components without rethinking the integration model
-
-## Risks
-
-### Risk: hidden platform assumptions leak upward
-
-Mitigation:
-- keep all direct-agent logic below `RealPlatformClient`
-- avoid changing `core/` contracts for prototype convenience
-
-### Risk: settings semantics drift from future platform reality
-
-Mitigation:
-- make local settings behavior explicit in code and docs
-- keep settings isolated in `sdk/prototype_state.py`
-
-### Risk: upstream `agent` fork diverges
-
-Mitigation:
-- keep the patch minimal and narrowly scoped to thread identity
-
-### Risk: thread identity source is unstable
-
-Mitigation:
-- derive thread key from existing stable bot-side identities: platform, surface user id, logical chat id
-
-## Testing Strategy
-
-- Unit tests for `sdk/agent_session.py` request/response behavior
-- Unit tests for `sdk/prototype_state.py` local settings and user mapping
-- Unit tests for `sdk/real.py` contract compliance with `PlatformClient`
-- Matrix integration tests confirming:
- - existing commands still work
- - different logical chats map to different backend thread keys
- - rename does not change thread identity
- - archive stops reuse from the surface perspective
-
-## Success Criteria
-
-- Matrix can talk to the real agent without rewriting the Matrix adapter architecture
-- Chats do not share backend memory accidentally
-- Unsupported platform capabilities remain local or deferred rather than being faked as “real”
-- The backend boundary remains suitable for later Telegram or other surfaces
diff --git a/docs/superpowers/specs/2026-04-19-matrix-per-chat-context-design.md b/docs/superpowers/specs/2026-04-19-matrix-per-chat-context-design.md
deleted file mode 100644
index 9807bd6..0000000
--- a/docs/superpowers/specs/2026-04-19-matrix-per-chat-context-design.md
+++ /dev/null
@@ -1,278 +0,0 @@
-# Matrix Per-Chat Context Design
-
-## Goal
-
-Move the Matrix surface from the current shared-agent-context MVP to true per-chat agent contexts using the new platform `chat_id`, while preserving the existing Matrix UX model built around rooms, spaces, and local chat labels such as `C1`, `C2`, and `C3`.
-
-## Core Decision
-
-The Matrix surface remains the owner of user-facing chat organization.
-
-- Matrix rooms, spaces, chat names, and archive state remain surface concerns.
-- The platform agent becomes the owner of actual conversation context.
-- The integration layer stores an explicit mapping from each surface chat to one platform context.
-
-This is the selected "Variant A" architecture:
-
-`surface_chat -> platform_chat_id`
-
-## Why This Decision
-
-The current Matrix adapter already has a stable UX model:
-
-- a user has a space
-- each working room has a local chat id like `C1`
-- commands such as `!new`, `!chats`, `!rename`, and `!archive` operate on that model
-
-Replacing that entire model with platform-native chat ids would be a broader refactor than needed. The surface and the platform solve different problems:
-
-- the surface organizes rooms and commands for users
-- the platform persists and branches real conversation context
-
-Keeping those responsibilities separate lets us add true per-chat context now without rebuilding the Matrix adapter around a new identity model.
-
-## Scope
-
-This design covers:
-
-- true per-chat context for Matrix rooms
-- a new `!branch` command
-- real context-aware semantics for `!new`, `!context`, `!save`, and `!load`
-- lazy migration of legacy Matrix rooms created before platform `chat_id` support
-
-This design does not cover:
-
-- end-to-end Matrix encryption support
-- Telegram changes
-- platform UI for browsing contexts
-- a future unified cross-surface chat browser
-
-## Data Model
-
-### Surface chat identity
-
-The Matrix surface keeps its existing identifiers:
-
-- Matrix room id, for example `!room:example.org`
-- local chat id, for example `C2`
-- room name
-- archive status
-- owning space id
-
-These remain the source of truth for Matrix UX.
-
-### Platform context identity
-
-Each working Matrix room gets a `platform_chat_id` stored in its room metadata.
-
-Example `room_meta` shape:
-
-```json
-{
- "chat_id": "C2",
- "space_id": "!space:example.org",
- "name": "Research",
- "platform_chat_id": "chat_8f2c..."
-}
-```
-
-Rules:
-
-- one working Matrix room maps to exactly one current platform context
-- two Matrix rooms must not share the same `platform_chat_id` unless we intentionally implement that later
-- branching creates a new `platform_chat_id`, never reuses the old one
-
-## Runtime Semantics
-
-### Normal message flow
-
-1. A Matrix message arrives in a working room.
-2. The Matrix adapter resolves the room to local `room_meta`.
-3. The integration layer reads `platform_chat_id` from that metadata.
-4. `RealPlatformClient.send_message(...)` sends the message to the platform using that `platform_chat_id`.
-5. The platform appends the exchange to that specific context and returns the reply.
-6. The Matrix adapter sends the reply back to the room.
-
-The key change is that the agent no longer treats all Matrix rooms as one shared context.
-
-### `!new`
-
-`!new` creates a new user-facing chat and a new empty platform context at the same time.
-
-Flow:
-
-1. Create a new Matrix room in the user space.
-2. Ask the platform to create a new blank context and return its `platform_chat_id`.
-3. Store that `platform_chat_id` in the new room metadata.
-4. Invite the user into the room.
-
-Result:
-
-- the new room is immediately independent
-- sending the first message does not share memory with the previous room
-
-### `!branch`
-
-`!branch` creates a new room whose starting point is a snapshot of the current room context.
-
-Flow:
-
-1. Resolve the current room's `platform_chat_id`.
-2. Ask the platform to create a new context branched from that source.
-3. Create a new Matrix room.
-4. Store the new `platform_chat_id` in the new room metadata.
-5. Invite the user into the new room.
-
-Result:
-
-- the new room starts with the current history and state
-- later messages diverge independently
-
-### `!save`
-
-`!save [name]` saves a snapshot of the current room's platform context under the current user.
-
-Semantics:
-
-- saves are owned by the user, not by the room
-- the saved snapshot originates from the current `platform_chat_id`
-
-### `!load`
-
-`!load` shows the user's saved contexts and loads the chosen snapshot into the current room's platform context.
-
-Semantics:
-
-- a saved context created in one room can be loaded into any other room owned by the same user
-- loading does not replace the Matrix room identity
-- loading affects only the current room's mapped `platform_chat_id`
-
-### `!context`
-
-`!context` reports the state of the current room context, not a global user session.
-
-Minimum expected output:
-
-- current room name or local chat id
-- current `platform_chat_id` presence or status
-- what saved context, if any, was last loaded here
-- last token usage if the platform still returns it
-
-## Legacy Room Migration
-
-Existing Matrix rooms were created before real platform `chat_id` support and therefore may not have `platform_chat_id` in their metadata.
-
-We need a non-destructive migration.
-
-### Lazy migration strategy
-
-For a room without `platform_chat_id`:
-
-1. On the first operation that requires platform context, detect the missing mapping.
-2. Create a new blank platform context for that room.
-3. Persist the new `platform_chat_id` into room metadata.
-4. Continue the requested operation normally.
-
-This applies to:
-
-- first normal message
-- `!context`
-- `!save`
-- `!load`
-- `!branch`
-
-This avoids forcing users to recreate their rooms manually.
-
-## Interface Changes
-
-### Matrix metadata
-
-Extend Matrix `room_meta` helpers to read and write `platform_chat_id`.
-
-### Real platform client
-
-`RealPlatformClient` must stop treating the current `chat_id` parameter as a purely local label when talking to the platform. The surface-facing call can still receive the local `chat_id`, but platform calls must use the resolved `platform_chat_id`.
-
-Recommended integration direction:
-
-- Matrix resolves the room mapping before calling the platform
-- `RealPlatformClient` receives the platform context id it should use
-
-This keeps the platform client simple and avoids giving it Matrix-specific storage responsibilities.
-
-### Agent API wrapper
-
-The wrapper must support platform calls that are explicitly context-aware:
-
-- create new context
-- branch context
-- send message into a specific context
-- save current context
-- load saved context into a specific context
-
-If upstream naming differs, the adapter layer should normalize those operations into stable local methods.
-
-## Command Semantics in MVP
-
-The MVP command set should evolve to this:
-
-- `!new` creates a new room with a new empty platform context
-- `!branch` creates a new room with a branched platform context
-- `!context` reports the current room context
-- `!save` saves the current room context for the user
-- `!load` loads one of the user's saved contexts into the current room
-
-Commands that do not have reliable backend support should remain hidden or explicitly marked unavailable.
-
-## Error Handling
-
-### Missing mapping
-
-If `platform_chat_id` is missing:
-
-- try lazy migration first
-- only return an error if migration fails
-
-### Platform create or branch failure
-
-If the platform cannot create or branch a context:
-
-- do not create partially-initialized room metadata
-- return a user-facing error in the source room
-- log enough detail to diagnose the backend failure
-
-### Save and load failure
-
-The surface must not claim success before the platform confirms success.
-
-For MVP quality:
-
-- user-facing text should say "request sent" only when confirmation is not available
-- once platform confirmation exists, switch to real success or failure messages
-
-## Testing
-
-Add or update tests for:
-
-- a new room gets a new `platform_chat_id`
-- two rooms created with `!new` do not share context ids
-- `!branch` creates a new room with a different `platform_chat_id` derived from the current one
-- sending messages from two rooms uses different platform context ids
-- saved contexts remain user-visible across rooms
-- loading the same saved context into two different rooms affects those rooms independently afterward
-- a legacy room without `platform_chat_id` lazily receives one on first use
-- failures during create, branch, save, and load do not leave broken metadata behind
-
-## Migration Path
-
-This design preserves a clean future direction:
-
-- Matrix continues to own its UX model
-- Telegram can adopt the same `surface_chat -> platform_chat_id` mapping later
-- when the platform matures further, more of the save/load/branch logic can move from prompts or local workarounds into real platform APIs
-
-The key long-term boundary stays stable:
-
-- surfaces own presentation and routing
-- the platform owns context
-- the integration layer owns the mapping
diff --git a/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md b/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md
deleted file mode 100644
index feca84c..0000000
--- a/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md
+++ /dev/null
@@ -1,252 +0,0 @@
-# Matrix Shared Workspace File Flow Design
-
-## Goal
-
-Bring the Matrix surface and `platform-agent` to a single file-handling model that matches the current platform runtime contract as closely as possible.
-
-The result should be:
-
-- Matrix receives user files and makes them visible to the agent through a shared `/workspace`
-- `platform-agent` receives attachment paths, not ad hoc summaries or inline payloads
-- the agent can send files back to the user through the surface via `send_file`
-- local development and the default deployment path use the same storage contract
-
-## Core Decision
-
-The selected architecture is:
-
-`Matrix surface <-> shared /workspace <-> platform-agent`
-
-This means:
-
-- the Matrix bot is responsible for downloading incoming Matrix media
-- downloaded files are written into the same filesystem mounted into `platform-agent`
-- the surface passes relative workspace paths to the agent as `attachments`
-- the agent returns files to the user by emitting `MsgEventSendFile(path=...)`
-
-This is the current platform-native direction and does not require new platform endpoints.
-
-## Why This Decision
-
-The current upstream platform changes already define the file contract:
-
-- `MsgUserMessage.attachments` is `list[str]`
-- each attachment is a path relative to `/workspace`
-- the agent validates those paths against its configured backend root
-- the agent can emit `send_file(path)` back to the client
-
-That is not an upload API and not a remote blob contract. It is an explicit shared-workspace contract.
-
-Trying to preserve the current separate-process launch model would force the surface to fake production behavior with inline text extraction, out-of-band path rewriting, or a future upload API that does not exist yet. That would increase the gap between our runtime and the platform runtime instead of reducing it.
-
-## Scope
-
-This design covers:
-
-- shared workspace runtime for Matrix bot and `platform-agent`
-- incoming Matrix file handling into shared storage
-- attachment path propagation to `RealPlatformClient` and `AgentApi`
-- outbound file delivery from agent to Matrix user
-- local compose/dev workflow and README updates
-
-This design does not cover:
-
-- Telegram file flow
-- encrypted Matrix media handling
-- upload APIs on the platform side
-- OCR, PDF parsing, or content extraction pipelines
-- long-term object storage or file lifecycle policies beyond basic cleanup boundaries
-
-## Runtime Contract
-
-### Shared filesystem
-
-Both containers must mount the same directory at `/workspace`.
-
-Requirements:
-
-- the Matrix bot can create files under `/workspace`
-- `platform-agent` sees the same files at the same relative paths
-- agent-originated files written under `/workspace` are readable by the Matrix bot
-
-The contract is path-based, not URL-based.
-
-### Attachment path format
-
-The surface sends attachments to the agent as relative workspace paths, for example:
-
-- `surfaces/matrix///inbox/20260420-153000-report.pdf`
-- `surfaces/matrix///inbox/20260420-153200-photo.jpg`
-
-Rules:
-
-- paths must be relative to `/workspace`
-- paths must be normalized before sending to the agent
-- surface-owned uploads must live under a dedicated namespace to avoid collisions with agent-created files
-
-## Data Flow
-
-### Incoming file from Matrix user
-
-1. Matrix receives `m.file`, `m.image`, `m.audio`, or `m.video`.
-2. The Matrix bot resolves the target room and platform chat context as usual.
-3. The Matrix bot downloads the media from Matrix.
-4. The file is stored under `/workspace/surfaces/matrix/.../inbox/...`.
-5. The outgoing platform call includes:
- - original user text
- - `attachments=[relative_path_1, ...]`
-6. `platform-agent` validates that those files exist and exposes them to the agent through the upstream attachment mechanism.
-
-Important detail:
-
-- the surface should not rewrite the user message into a synthetic file summary unless the message body is empty
-- when body is empty, the surface may send a minimal synthetic text such as `User sent one or more attachments.`
-
-### Outbound file from agent to Matrix user
-
-1. The agent uses `send_file(path)`.
-2. `platform-agent` emits `MsgEventSendFile(path=...)`.
-3. The Matrix integration catches that event.
-4. The Matrix bot resolves the file inside shared `/workspace`.
-5. The Matrix bot uploads the file to Matrix and sends the appropriate media message to the room.
-
-Surface behavior:
-
-- if MIME type and extension are known, send the closest native Matrix media type
-- otherwise send as `m.file`
-- user-visible failures must be explicit if the referenced file does not exist or cannot be uploaded
-
-## Filesystem Layout
-
-The Matrix surface owns a dedicated subtree:
-
-```text
-/workspace/
- surfaces/
- matrix/
- /
- /
- inbox/
- 20260420-153000-report.pdf
-```
-
-Design constraints:
-
-- sanitize user ids and room ids before using them as path components
-- preserve the original filename in the final basename where possible
-- prefix filenames with a timestamp or unique id to avoid collisions
-
-This layout is intentionally surface-scoped. The agent may read these files, but the surface remains the owner of how inbound messenger files are organized.
-
-## Components
-
-### Matrix attachment storage helper
-
-Add a focused helper module responsible for:
-
-- building stable workspace-relative paths
-- sanitizing path components
-- downloading Matrix media into `/workspace`
-- returning attachment metadata needed by the platform layer
-
-This helper should not know about agent transport details beyond the final relative path output.
-
-### Real platform client
-
-`RealPlatformClient` must pass attachment relative paths through to `AgentApi.send_message(...)`.
-
-It must also surface non-text agent events needed by the Matrix adapter, especially `MsgEventSendFile`.
-
-### Agent API wrapper
-
-`AgentApiWrapper` must be compatible with the modern upstream protocol:
-
-- `/v1/agent_ws/{chat_id}/`
-- `attachments` on outgoing user messages
-- `MsgEventToolCallChunk`
-- `MsgEventToolResult`
-- `MsgEventCustomUpdate`
-- `MsgEventSendFile`
-- `MsgEventEnd`
-
-### Matrix bot outbound renderer
-
-The Matrix adapter must support sending files back to the room.
-
-At minimum it needs:
-
-- path resolution inside shared workspace
-- Matrix upload of the local file
-- send of an `m.file` or native media event with filename and MIME type
-
-## Deployment Changes
-
-### Compose
-
-The repository root `docker-compose.yml` becomes the primary prod-like local runtime.
-
-It should define at least:
-
-- `matrix-bot`
-- `platform-agent`
-- one shared volume mounted as `/workspace` into both services
-
-The default developer workflow should stop describing `platform-agent` as a separately started side process.
-
-### Environment
-
-The Matrix bot must connect to the in-compose `platform-agent` service by service name, not by assuming a separately launched localhost process.
-
-The agent WebSocket configuration in docs and examples must match the modern upstream route.
-
-## Error Handling
-
-### Incoming files
-
-If the Matrix bot cannot download or persist the file:
-
-- do not send a broken attachment path to the agent
-- return a user-visible error in the room
-- log the Matrix event id, room id, and failure reason
-
-### Outbound files
-
-If the agent asks to send a missing file:
-
-- log a structured warning with the requested path
-- send a user-visible message that the file could not be delivered
-
-### Shared workspace mismatch
-
-If the runtime is misconfigured and `/workspace` is not actually shared:
-
-- inbound attachments will fail agent-side path validation
-- outbound `send_file` will fail surface-side file resolution
-
-The implementation should make such failures obvious in logs rather than silently degrading to text-only behavior.
-
-## Testing
-
-The implementation must cover:
-
-- Matrix media download writes into the expected workspace-relative path
-- `RealPlatformClient` forwards attachment relative paths to the agent API
-- Matrix plain messages with attachments preserve the original text while adding attachment paths
-- empty-body attachment-only messages produce the synthetic text fallback
-- `AgentApiWrapper` accepts `MsgEventSendFile` without treating it as unknown
-- Matrix outbound file handling converts `MsgEventSendFile` into a Matrix upload/send call
-- compose configuration mounts the same workspace into both containers
-
-## Non-Goals
-
-- no inline text extraction MVP
-- no temporary URL-passing contract to the agent
-- no fake “prod” mode with separate local filesystems
-- no platform API additions in this phase
-
-## Success Criteria
-
-- the default local runtime uses a shared `/workspace`
-- a user can send a file in Matrix and the agent receives it through upstream `attachments`
-- the agent can emit `send_file(path)` and the Matrix user receives the file in the same room
-- our runtime behavior matches the current platform contract closely enough that moving from local compose to production does not require redesigning file flow
diff --git a/docs/superpowers/specs/2026-04-20-matrix-staged-attachments-design.md b/docs/superpowers/specs/2026-04-20-matrix-staged-attachments-design.md
deleted file mode 100644
index ae8a11a..0000000
--- a/docs/superpowers/specs/2026-04-20-matrix-staged-attachments-design.md
+++ /dev/null
@@ -1,262 +0,0 @@
-# Matrix Staged Attachments Design
-
-## Goal
-
-Make file sending in the Matrix surface usable for an AI agent despite current Matrix client behavior, especially in Element where media is often sent immediately as separate events without a shared text composer.
-
-The result should be:
-
-- files can arrive before the user writes the actual instruction
-- the surface stages those files instead of immediately sending them to the agent
-- the next normal user message in the same chat commits all staged files as one agent turn
-- the user can inspect and remove staged files with short chat commands
-
-## Core Decision
-
-The selected UX model is:
-
-`incoming Matrix media -> staged attachments for (chat_id, user_id) -> next normal message commits them`
-
-This means:
-
-- attachment-only events do not immediately invoke the agent
-- the bot acknowledges staged files with a service message
-- the next normal user message sends text plus all currently staged files to the agent
-- staged files are then cleared
-
-## Why This Decision
-
-Matrix natively models messages as separate events, and common clients do not provide a reliable "one text message with many attachments" composer flow.
-
-In practice this causes two UX failures for an AI bot:
-
-- users may send files first and only then write the task
-- users may send multiple files as multiple independent Matrix events
-
-If the surface treats each incoming file as a full agent turn, the bot becomes noisy and context-fragmented. If it ignores file-only messages, file handling feels broken.
-
-Staging is the smallest surface-side abstraction that fixes both problems without fighting the Matrix event model.
-
-## Scope
-
-This design covers:
-
-- staging inbound Matrix attachments before agent submission
-- per-chat attachment state for a specific user
-- user-facing service messages for staged attachments
-- short commands for listing and removing staged files
-- commit behavior on the next normal message
-
-This design does not cover:
-
-- edits or redactions of original Matrix media events as attachment controls
-- cross-surface shared staging
-- thread-aware staging beyond the existing `chat_id` boundary
-- changes to the platform attachment contract
-
-## State Model
-
-### Staging key
-
-Staged attachments are isolated by:
-
-- `chat_id`
-- `user_id`
-
-This means:
-
-- files staged by a user in one chat never appear in another chat
-- files staged by one user do not mix with another user's files in the same room
-
-### Staged attachment record
-
-Each staged attachment must track at least:
-
-- stable internal id
-- display filename
-- workspace-relative path
-- MIME type if known
-- created timestamp
-
-User-visible commands operate on the current ordered list, not on internal ids.
-
-### Lifecycle
-
-A staged attachment is in exactly one of these states:
-
-1. `staged`
-2. `committed`
-3. `removed`
-
-Rules:
-
-- only `staged` attachments appear in `!list`
-- `committed` attachments are no longer user-removable
-- `removed` attachments are excluded from future commits
-
-## Inbound Behavior
-
-### Attachment-only event
-
-If the Matrix surface receives one or more file/media events from a user without a normal text message to commit them:
-
-1. download each file into shared `/workspace`
-2. add each file to the staged set for `(chat_id, user_id)`
-3. do not call the agent yet
-4. send a service acknowledgment message
-
-### Service acknowledgment
-
-The service message must communicate:
-
-- the current staged attachment list with indices
-- that the next normal message will be sent to the agent together with those files
-- available commands: `!list`, `!remove `, `!remove all`
-
-Example shape:
-
-```text
-Staged attachments:
-1. screenshot.png
-2. invoice.pdf
-
-Your next message will be sent to the agent with these files.
-Commands: !list, !remove , !remove all
-```
-
-### Burst handling
-
-Matrix clients may send multiple files as separate consecutive events.
-
-To avoid bot spam, service acknowledgments should be debounced over a short window and aggregated into one reply where feasible.
-
-The acknowledgment must reflect the full current staged set, not only the most recently received file.
-
-## Commit Behavior
-
-### Commit trigger
-
-The commit trigger is:
-
-- the next normal user message in the same `(chat_id, user_id)` scope
-
-Normal user message means:
-
-- not a staging control command
-- not a pure attachment event being staged
-
-### Commit action
-
-When a commit-triggering message arrives:
-
-1. collect all currently staged attachments for `(chat_id, user_id)`
-2. send the user text plus those attachments to the agent as one turn
-3. mark all included staged attachments as `committed`
-4. clear the staged set
-
-After commit:
-
-- the just-sent attachments must no longer appear in `!list`
-- a later file upload starts a new staged set
-
-## Commands
-
-### `!list`
-
-Shows the current staged attachment list for the user in the current chat.
-
-If the list is empty, the response should be short and explicit.
-
-### `!remove `
-
-Removes the staged attachment at the current 1-based index.
-
-Behavior:
-
-- if the index is valid, remove that staged attachment and return the updated staged list
-- if the index is invalid, return a short error without repeating the list
-
-### `!remove all`
-
-Clears the entire staged set for the user in the current chat.
-
-The response should be short and explicit.
-
-## Ordering Rules
-
-The staged list is ordered by staging time.
-
-User-facing indices:
-
-- are 1-based
-- are recalculated from the current staged set
-- may change after removals
-
-Therefore:
-
-- `!list` always shows the current authoritative numbering
-- after a successful `!remove `, the bot should reply with the refreshed list
-
-## Error Handling
-
-### Download failure
-
-If a file cannot be downloaded or stored:
-
-- do not add it to the staged set
-- do not pretend it will be sent later
-- send a short user-visible failure message
-
-### Invalid command
-
-If the command is malformed or uses an invalid index:
-
-- return a short error
-- do not commit staged attachments
-- do not clear the staged set
-
-### Agent submission failure
-
-If commit fails when sending the text plus staged files to the agent:
-
-- staged attachments must remain available for retry unless the failure is known to be irreversible
-- the user-visible error should make it clear that the files were not consumed
-
-This prevents silent loss of staged context.
-
-## Interaction with Shared Workspace Design
-
-This design assumes the shared-workspace contract defined in
-[2026-04-20-matrix-shared-workspace-file-flow-design.md](/Users/a/MAI/sem2/lambda/surfaces-bot/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md).
-
-Specifically:
-
-- staged files are stored in shared `/workspace`
-- the final commit still passes workspace-relative paths to `platform-agent`
-- staging changes only when the surface chooses to invoke the agent, not how attachments are represented
-
-## Testing
-
-The implementation must cover:
-
-- file-only Matrix events are staged and do not immediately invoke the agent
-- service acknowledgment includes staged filenames and command hints
-- `!list` returns the current staged set for the correct `(chat_id, user_id)`
-- `!remove ` removes the correct staged attachment and refreshes numbering
-- `!remove all` clears the staged set
-- invalid `!remove ` returns a short error and keeps state unchanged
-- the next normal message commits all staged attachments with the text as one agent turn
-- committed attachments disappear from staging after success
-- failed commits preserve staged attachments
-- staging in one chat does not leak into another chat
-- staging for one user does not leak to another user in the same room
-
-## Non-Goals
-
-This design intentionally does not attempt to:
-
-- emulate Telegram-style albums in Matrix
-- rely on special support from Element or other Matrix clients
-- introduce a rich interactive attachment management UI
-
-The goal is a reliable chat-native workflow that works within Matrix's actual event model.
diff --git a/docs/superpowers/specs/2026-04-22-transport-layer-thin-adapter-design.md b/docs/superpowers/specs/2026-04-22-transport-layer-thin-adapter-design.md
deleted file mode 100644
index 5fab5ef..0000000
--- a/docs/superpowers/specs/2026-04-22-transport-layer-thin-adapter-design.md
+++ /dev/null
@@ -1,318 +0,0 @@
-# Transport Layer Thin Adapter Design
-
-## Цель
-
-Упростить transport layer между Matrix surface и `platform-agent` до максимально production-like вида:
-
-- использовать upstream `platform-agent_api.AgentApi` почти как есть
-- убрать из surface-side клиента собственную интерпретацию stream semantics
-- оставить в нашем коде только integration concerns:
- - per-chat lifecycle
- - per-chat serialization
- - attachment path forwarding
- - exception mapping в `PlatformError`
-
-Это нужно, чтобы:
-
-- восстановить чёткую границу ответственности между `surfaces` и платформой
-- убрать из диагностики ложные факторы, внесённые нашей кастомной обёрткой
-- получить честную картину реальных platform bugs до добавления любых policy-надстроек
-
-## Контекст
-
-Сейчас transport path состоит из:
-
-- [sdk/agent_api_wrapper.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_api_wrapper.py)
-- [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py)
-
-Изначально `AgentApiWrapper` был создан по разумным причинам:
-
-- поддержка переходного периода между разными версиями `platform-agent_api`
-- унификация `base_url/url`
-- создание per-chat client instances через `for_chat()`
-- локальный учёт `tokens_used`
-
-Позже в этот слой были добавлены уже не compatibility-функции, а собственные transport semantics:
-
-- custom `_listen()`
-- custom `send_message()`
-- post-END drain window
-- custom idle timeout
-- event-kind reclassification
-
-После этого `surfaces` перестал быть тонким клиентом платформы и начал вести себя как альтернативная реализация transport layer. Это делает диагностику platform bugs нечистой.
-
-## Принципы дизайна
-
-### 1. Transport должен быть скучным
-
-Transport layer не должен:
-
-- спасать поздние chunks
-- лечить duplicate `END`
-- придумывать собственные правила границы ответа
-- по-своему классифицировать stream events сверх upstream client behavior
-
-Если upstream stream protocol повреждён, мы должны видеть это как platform issue, а не скрывать его кастомной очередью.
-
-### 2. Policy и transport разделяются
-
-Transport:
-
-- говорит с upstream API
-- доставляет события
-- закрывает соединение
-
-Policy:
-
-- решает, что считать recoverable failure
-- нужна ли повторная попытка
-- как сообщать ошибку пользователю
-- нужно ли сбрасывать chat session
-
-На первом этапе policy не расширяется. Мы сначала приводим transport к тонкому адаптеру, потом заново оцениваем реальные проблемы.
-
-### 3. Session lifecycle остаётся на нашей стороне
-
-Даже в thin-adapter модели `surfaces` по-прежнему отвечает за:
-
-- кеширование client per chat
-- один send lock на chat
-- сброс мёртвой chat session после failure
-- mapping upstream exceptions в `PlatformError`
-
-Это не transport semantics, а integration lifecycle.
-
-## Варианты
-
-### Вариант A. Оставить текущий кастомный wrapper
-
-Плюсы:
-
-- уже работает на части сценариев
-- содержит built-in mitigations против observed failures
-
-Минусы:
-
-- нарушает границу ответственности
-- усложняет диагностику
-- делает platform bug reports спорными
-- содержит symptom-fix логику в transport layer
-
-Вердикт: не подходит как production-like target.
-
-### Вариант B. Thin upstream adapter
-
-Плюсы:
-
-- чистая архитектура
-- честная диагностика upstream проблем
-- минимальная собственная магия
-
-Минусы:
-
-- локальные mitigations исчезают
-- если upstream client несовершенен, это сразу проявится
-
-Вердикт: правильный первый этап.
-
-### Вариант C. Thin adapter сейчас, outer policy layer потом
-
-Плюсы:
-
-- даёт production-like эволюцию
-- не смешивает transport и resilience policy
-- позволяет сначала увидеть реальные проблемы, потом адресовать только нужные
-
-Минусы:
-
-- требует двух фаз вместо одной
-
-Вердикт: рекомендуемый путь.
-
-## Рекомендуемая архитектура
-
-### Слой 1. Upstream client
-
-Источник истины:
-
-- [external/platform-agent_api/lambda_agent_api/agent_api.py](/Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent_api/lambda_agent_api/agent_api.py)
-
-Мы принимаем его stream semantics как authoritative behavior.
-
-### Слой 2. Thin adapter
-
-Файл:
-
-- [sdk/agent_api_wrapper.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_api_wrapper.py)
-
-После cleanup он должен содержать только:
-
-- создание клиента через modern constructor
-- `base_url` normalization, если это действительно нужно для наших env
-- `for_chat(chat_id)` как factory convenience
-- опционально thin storage для `last_tokens_used`, если это можно сделать без переопределения stream semantics
-
-Он не должен переопределять:
-
-- `_listen()`
-- `send_message()`
-- queue lifecycle
-- post-END behavior
-- timeout behavior
-
-### Слой 3. Integration/session layer
-
-Файл:
-
-- [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py)
-
-Ответственность:
-
-- кешировать chat client instances
-- сериализовать sends по chat lock
-- вызывать `disconnect_chat(chat_id)` после transport failure
-- превращать upstream exceptions в `PlatformError`
-- форвардить `attachments` как relative workspace paths
-- собирать `MessageResponse` / `MessageChunk` для остального приложения
-
-Этот слой не должен заниматься:
-
-- исправлением broken stream boundaries
-- custom post-END reconstruction
-- поздним дренированием очереди
-
-## Что удаляем
-
-Из [sdk/agent_api_wrapper.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_api_wrapper.py):
-
-- custom `_listen()`
-- custom `send_message()`
-- `_drain_post_end_events()`
-- `_event_kind()`
-- `_is_kind()`
-- `_is_text_event()`
-- `_is_end_event()`
-- `_is_send_file_event()`
-- `_POST_END_DRAIN_MS`
-- `_STREAM_IDLE_TIMEOUT_MS`
-- debug logging, завязанное на наш собственный queue lifecycle
-
-## Что оставляем
-
-В thin adapter:
-
-- `__init__()` для modern `base_url/chat_id`
-- `_normalize_base_url()` только если нужен стабильный env input
-- `for_chat(chat_id)`
-
-В [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py):
-
-- `_get_chat_api()`
-- `_get_chat_send_lock()`
-- `_attachment_paths()`
-- `disconnect_chat()`
-- `_handle_chat_api_failure()`
-- `send_message()`
-- `stream_message()`
-
-## Дополнительное упрощение
-
-Если после cleanup мы считаем pinned upstream API обязательным, то из [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py) можно убрать legacy-minded probing:
-
-- `inspect.signature(send_message)`
-- conditional fallback на старый `send_message(text)` без `attachments`
-
-В этом случае `RealPlatformClient` всегда использует современный контракт:
-
-- `send_message(text, attachments=...)`
-
-Это ещё сильнее уменьшит ambiguity.
-
-## Этапы миграции
-
-### Этап 1. Cleanup до thin adapter
-
-Делаем:
-
-- сжимаем `sdk/agent_api_wrapper.py` до thin shim
-- переносим всю допустимую resilience logic только в `sdk/real.py`
-- удаляем тесты, которые закрепляют наши кастомные transport semantics
-
-### Этап 2. Повторная верификация
-
-Заново прогоняем:
-
-- text-only flow
-- staged attachments flow
-- large image failure
-- duplicate `END` behavior
-- behavior after transport disconnect
-
-На этом этапе мы честно увидим, что реально делает upstream transport.
-
-### Этап 3. Опциональный outer policy layer
-
-Только если после Этапа 2 это действительно нужно, добавляем policy поверх transport:
-
-- request timeout целиком
-- retry policy
-- circuit-breaker-like behavior
-
-Но это должно жить не в client wrapper, а выше, в integration layer.
-
-## Тестовая стратегия
-
-### Удаляем как нецелевые тесты
-
-Больше не считаем нормой:
-
-- post-END drain behavior
-- recovery late chunks после `END`
-- idle timeout внутри wrapper как часть client contract
-
-### Оставляем и добавляем
-
-Нужные guarantees:
-
-1. создаётся отдельный client per chat
-2. один chat сериализуется через lock
-3. разные чаты не делят client instance
-4. attachment paths уходят в `send_message(..., attachments=...)`
-5. transport failure приводит к `disconnect_chat(chat_id)`
-6. следующий запрос после failure открывает новую chat session
-7. upstream exception превращается в `PlatformError`
-
-## Риски
-
-### 1. Может снова проявиться реальный upstream bug
-
-Это не regression дизайна, а полезный результат cleanup.
-
-### 2. Может исчезнуть локальная защита от зависших стримов
-
-Это допустимо на первом этапе.
-Если она действительно нужна, она должна вернуться как outer policy, а не как переписанный client transport.
-
-### 3. Может выясниться, что даже thin wrapper не нужен
-
-Если modern upstream `AgentApi` уже полностью покрывает наш use case, файл `sdk/agent_api_wrapper.py` можно будет заменить на маленький factory helper или убрать совсем.
-
-## Критерии успеха
-
-Результат считается успешным, если:
-
-- transport layer в `surfaces` перестаёт иметь собственную stream semantics
-- platform bug reports снова можно формулировать без сильного caveat про кастомный клиент
-- Matrix real backend продолжает работать на text-only и attachments scenarios
-- failure handling остаётся контролируемым, но больше не маскирует transport behavior платформы
-
-## Решение
-
-Принять путь:
-
-- `Thin upstream adapter now`
-- `Observe real behavior`
-- `Add outer policy later only if needed`
-
-Это наиболее близкий к production best practice вариант для текущего состояния проекта.
diff --git a/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md b/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md
deleted file mode 100644
index 02cc89f..0000000
--- a/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md
+++ /dev/null
@@ -1,336 +0,0 @@
-# Matrix Multi-Agent Routing Design
-
-## Goal
-
-Move the Matrix surface from a single hardcoded upstream agent to a user-selectable multi-agent model, while preserving the existing room-based UX and the current `PlatformClient` boundary.
-
-The result should be:
-
-- one Matrix bot can work with multiple upstream agents
-- users can choose an agent from the full configured list
-- each chat is bound to exactly one agent
-- switching the selected agent does not silently retarget an existing chat
-
-## Core Decision
-
-The selected routing model is:
-
-`user.selected_agent_id + room.agent_id + room.platform_chat_id`
-
-This means:
-
-- the user has one current selected agent
-- each Matrix working room stores the agent it is bound to
-- each Matrix working room stores its own `platform_chat_id`
-- a room never changes agent implicitly
-- the shared `PlatformClient` protocol remains unchanged
-- Matrix multi-agent routing is implemented by a single routing facade that delegates to per-agent real clients
-
-## Why This Decision
-
-The current Matrix adapter already separates:
-
-- user-facing room organization
-- local chat labels such as `C1`, `C2`, `C3`
-- platform-facing conversation identity via `platform_chat_id`
-
-Adding multi-agent support should preserve that shape instead of replacing it.
-
-If routing depended only on the current user selection, then an old room could start talking to a different agent after a switch. That would make room history and backend context hard to reason about. Binding an agent to the room keeps the conversation model explicit.
-
-## Scope
-
-This design covers:
-
-- agent selection by the user inside the Matrix surface
-- durable storage of the selected agent
-- durable storage of the room-bound agent
-- routing normal messages and context commands to the correct upstream agent
-- behavior when a room becomes stale after an agent switch
-
-This design does not cover:
-
-- per-agent workspace isolation
-- platform-side agent lifecycle or memory persistence
-- per-user allowlists for available agents
-- Telegram or other surfaces
-
-## Configuration Model
-
-### Agent registry
-
-Available agents are defined in a local config file loaded once at bot startup.
-
-Example:
-
-```yaml
-agents:
- - id: agent-1
- label: Analyst
- - id: agent-2
- label: Research
- - id: agent-3
- label: Ops
-```
-
-Rules:
-
-- every entry must have a stable `id`
-- every entry must have a user-visible `label`
-- all configured agents are selectable by all users
-- config changes apply only after bot restart
-
-### Startup validation
-
-If the agent config is missing, empty, or invalid, the Matrix bot must fail fast on startup with a clear operator error.
-
-## Durable State Model
-
-### User-level state
-
-User metadata keeps the current selected agent.
-
-Example `matrix_user:*` shape:
-
-```json
-{
- "space_id": "!space:example.org",
- "next_chat_index": 4,
- "selected_agent_id": "agent-2"
-}
-```
-
-Meaning:
-
-- `selected_agent_id` controls future chat creation and activation of an unbound room
-- `selected_agent_id` does not rewrite already bound rooms
-
-### Room-level state
-
-Room metadata stores the agent bound to that chat.
-
-Example `matrix_room:*` shape:
-
-```json
-{
- "room_type": "chat",
- "chat_id": "C3",
- "display_name": "Чат 3",
- "matrix_user_id": "@alice:example.org",
- "space_id": "!space:example.org",
- "platform_chat_id": "42",
- "agent_id": "agent-2"
-}
-```
-
-Rules:
-
-- one room binds to exactly one `agent_id`
-- one room binds to exactly one current `platform_chat_id`
-- once a room becomes stale after an agent switch, it never becomes active again
-
-## Runtime Semantics
-
-### `!start`
-
-`!start` remains lightweight:
-
-- if no agent is selected, the bot explains that an agent must be selected before normal messaging
-- if an agent is already selected, the bot reports the current selection and reminds the user that `!new` creates a new room under that agent
-
-### `!agent`
-
-Introduce an agent-selection command.
-
-Behavior:
-
-- `!agent` shows the available agent list
-- agent selection stores `selected_agent_id` in user metadata
-- after a successful switch, the bot tells the user that existing chats bound to another agent are stale and that `!new` is required for continued work
-
-The exact UI can be text-first for MVP. A richer UI can be added later without changing the state model.
-
-### Normal message without selected agent
-
-If the user has not selected an agent yet:
-
-- do not call the platform
-- return the available agent list
-- ask the user to choose one first
-
-This is an intentional one-time routing handshake, not an accidental fallback.
-In a multi-agent deployment, the surface must not silently guess which agent an unbound user should talk to.
-
-### Selecting an agent inside an unbound chat
-
-If the current room has never been bound to any agent:
-
-- store the new `selected_agent_id` for the user
-- bind the current room to that same `agent_id`
-- allow the room to become the active working chat immediately
-
-This avoids forcing `!new` for the user's first usable chat.
-
-### `!new`
-
-`!new` creates a new working room under the current selected agent.
-
-Behavior:
-
-1. require `selected_agent_id`
-2. create the new Matrix room
-3. allocate a new `platform_chat_id`
-4. store `agent_id = selected_agent_id` in the new room metadata
-
-### Normal message in an unbound room with selected agent
-
-If a room exists but has no `agent_id` yet and the user already has `selected_agent_id`:
-
-- bind the room to `selected_agent_id`
-- ensure it has `platform_chat_id`
-- continue normal message dispatch
-
-### Normal message in a bound room
-
-If the room already has `agent_id` and it matches the current selected agent:
-
-- route the message to that `agent_id`
-- use the room's `platform_chat_id`
-
-### Stale room after agent switch
-
-If the room's bound `agent_id` differs from the user's current `selected_agent_id`:
-
-- do not call the platform
-- treat the room as stale
-- return a short message telling the user that this chat belongs to the old agent and that they must use `!new`
-
-### Returning to a previously selected agent
-
-If the user later selects an old agent again:
-
-- previously stale rooms do not become valid again
-- the user must still create a fresh room via `!new`
-
-## Routing and Component Changes
-
-### Agent registry loader
-
-Add a small loader responsible for:
-
-- reading `agents.yaml`
-- validating ids and labels
-- exposing a read-only registry to runtime code
-
-The runtime should not parse YAML ad hoc during message handling.
-
-### Matrix runtime pre-check
-
-Before dispatching a normal message, the Matrix runtime must resolve:
-
-- whether the user has `selected_agent_id`
-- whether the current room already has `agent_id`
-- whether the room can be bound now
-- whether the room is stale
-
-This pre-check happens before handing the message to the existing dispatcher path.
-
-### Routed platform client
-
-The selected implementation keeps the shared `PlatformClient` protocol unchanged.
-
-The Matrix runtime owns one routing-aware facade, for example `RoutedPlatformClient`, that implements `PlatformClient` and delegates to agent-specific real clients.
-
-Responsibilities:
-
-- resolve the current room binding from local Matrix metadata
-- translate a local Matrix logical chat id into the room's `platform_chat_id`
-- choose the correct per-agent delegate for the room's bound `agent_id`
-- keep `get_or_create_user`, `get_settings`, and `update_settings` behavior stable for the rest of the runtime
-
-This keeps the multi-agent logic inside the Matrix integration boundary instead of pushing agent selection into the shared protocol.
-
-### Real platform bridge delegates
-
-The current real backend path hardcodes a single runtime-level `agent_id`.
-That must be replaced with per-agent delegates hidden behind the routing facade.
-
-The selected design is:
-
-- `RealPlatformClient` remains the low-level direct-agent delegate for one configured `agent_id`
-- the routing facade holds or creates one `RealPlatformClient` delegate per configured agent
-- `send_message(...)` and `stream_message(...)` on the facade resolve the room target and forward the call to the matching delegate
-- the delegate creates a fresh upstream `AgentApi` for its configured `agent_id`
-- no long-lived `AgentApi` instances are cached by user
-
-This preserves the current fresh-connection-per-request behavior while avoiding a protocol break for Telegram or other surfaces.
-
-## Error Handling
-
-### Missing or invalid selected agent
-
-If `selected_agent_id` is absent:
-
-- ask the user to select an agent
-
-If `selected_agent_id` points to an agent that no longer exists in config:
-
-- treat the selection as invalid
-- ask the user to select again
-
-### Missing room binding
-
-If the room has no `agent_id`:
-
-- bind it only when the user has a valid current selection
-- otherwise return the selection prompt
-
-### Stale room
-
-If the room is stale:
-
-- do not attempt fallback routing
-- do not silently rewrite room metadata
-- instruct the user to run `!new`
-
-### Invalid config
-
-If the bot cannot load a valid agent registry:
-
-- fail at startup
-- do not start in degraded single-agent mode
-
-## Testing Expectations
-
-Tests for this design should prove:
-
-- config parsing and startup validation
-- selecting an agent persists `selected_agent_id`
-- selecting an agent inside an unbound room activates that room
-- `!new` binds the new room to the selected agent
-- messages in a bound room use that room's `agent_id`
-- stale rooms reject normal messaging with a clear `!new` instruction
-- returning to the same agent later does not revive stale rooms
-
-## Migration Notes
-
-Existing rooms may have `platform_chat_id` but no `agent_id`.
-
-For this MVP, treat those rooms as legacy-unbound rooms:
-
-- if the user has a valid selected agent, the room may be bound on first use
-- if no agent is selected, the room prompts for selection first
-
-No automatic migration across agents is introduced.
-
-### Existing users without `selected_agent_id`
-
-Existing users upgraded from the single-agent model may have working rooms but no stored `selected_agent_id`.
-
-For this MVP, that is handled explicitly:
-
-- normal messaging is paused until the user selects an agent
-- the first valid selection can bind an unbound room immediately
-- the surface does not auto-assign a default agent in a multi-agent config
-
-This is intentional. Once more than one agent exists, silent migration would be ambiguous and could route a user to the wrong backend target.
diff --git a/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md b/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md
deleted file mode 100644
index 1f1cc7b..0000000
--- a/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md
+++ /dev/null
@@ -1,258 +0,0 @@
-# Matrix Surface Restart State Persistence Design
-
-## Goal
-
-Make the Matrix surface survive a normal restart or container recreate without losing the minimal state required to keep working as a bot.
-
-The result should be:
-
-- after restart, the bot can still answer messages and execute commands
-- the bot remembers the selected agent for each user
-- the bot remembers which agent and `platform_chat_id` each room is bound to
-- temporary UX flows may be lost without being treated as a bug
-
-## Core Decision
-
-The selected persistence model is:
-
-`durable surface state only`
-
-This means:
-
-- persist only the state needed for routing and normal command handling
-- do not persist temporary UI and wizard state
-- require persistent local storage for the surface
-- do not attempt recovery if those volumes are lost
-
-## Why This Decision
-
-The Matrix surface already has two different classes of state:
-
-- stable local state that defines how rooms and users are routed
-- temporary UX state that exists only to complete short-lived interactions
-
-Trying to make all temporary UX state survive restart would add complexity and edge cases without improving the core requirement: the bot should still function normally after restart.
-
-The chosen design keeps persistence aligned with what the surface actually owns:
-
-- Matrix-side metadata and routing state are durable
-- agent conversation memory is the platform's responsibility
-- lost local volumes are treated as environment reset, not as an auto-recovery scenario
-
-## Scope
-
-This design covers:
-
-- which Matrix surface data must persist across restart
-- where that data lives
-- how restart behavior interacts with multi-agent routing
-- what state is intentionally non-durable
-
-This design does not cover:
-
-- platform-side persistence of agent memory
-- workspace isolation between multiple agents
-- automatic reconstruction after total local volume loss
-- persistence of temporary UX flows
-
-## Persistence Boundary
-
-### Durable state
-
-The Matrix surface must persist:
-
-- `matrix_user:*`
-- `matrix_room:*`
-- `chat:*`
-- `PLATFORM_CHAT_SEQ_KEY`
-- `selected_agent_id`
-- room-bound `agent_id`
-- room-bound `platform_chat_id`
-
-This is the minimal state required so that, after restart, the surface can:
-
-- identify the user
-- identify the room
-- determine which agent should receive a message
-- determine which `platform_chat_id` should be used
-- continue allocating new `platform_chat_id` values without reusing an already issued sequence number
-
-### Non-durable state
-
-The Matrix surface does not need to persist:
-
-- staged attachments
-- pending `!load` selection
-- pending `!yes/!no` confirmation
-- any temporary service UI step
-- live `AgentApi` instances or connection objects
-
-After restart, those flows may be lost. The bot only needs to remain operational.
-
-## Storage Model
-
-### Surface durable storage
-
-The Matrix surface must use persistent storage for:
-
-- `lambda_matrix.db`
-- `matrix_store`
-
-`lambda_matrix.db` stores the local key-value state used by the surface.
-`matrix_store` stores Matrix client state needed by `nio`.
-
-These paths must be backed by persistent container storage in normal deployments.
-
-### Shared `/workspace`
-
-The current local runtime also uses `/workspace`, but workspace behavior is outside the scope of this design.
-
-For this document, the only requirement is:
-
-- do not make restart persistence depend on solving per-agent workspace isolation first
-
-## Restart Assumptions
-
-This design assumes:
-
-- normal restart or redeploy with persistent local volumes still present
-
-This design does not assume:
-
-- automatic recovery after deleting or losing those volumes
-
-If the relevant volumes are lost, the environment is treated as reset.
-
-## Data Model Requirements
-
-### User metadata
-
-User metadata remains the durable location for user-level routing state.
-
-Example:
-
-```json
-{
- "space_id": "!space:example.org",
- "next_chat_index": 4,
- "selected_agent_id": "agent-2"
-}
-```
-
-### Room metadata
-
-Room metadata remains the durable location for room-level routing state.
-
-Example:
-
-```json
-{
- "room_type": "chat",
- "chat_id": "C3",
- "display_name": "Чат 3",
- "matrix_user_id": "@alice:example.org",
- "space_id": "!space:example.org",
- "platform_chat_id": "42",
- "agent_id": "agent-2"
-}
-```
-
-### Platform chat sequence
-
-The global `PLATFORM_CHAT_SEQ_KEY` remains part of durable surface state.
-
-Its purpose is:
-
-- allocate monotonically increasing `platform_chat_id` values
-- avoid reusing a previously issued platform chat identifier during normal restart or redeploy
-
-This sequence must be stored in the same durable surface store as the room and user metadata.
-
-## Runtime Semantics After Restart
-
-After restart, the Matrix surface must:
-
-1. load the durable Matrix store
-2. load the durable surface key-value state
-3. load the agent registry config
-4. resume normal room routing using persisted `selected_agent_id`, `agent_id`, and `platform_chat_id`
-
-Expected behavior:
-
-- a user with a valid previously selected agent does not need to reselect it
-- a room previously bound to an agent remains bound to that agent
-- normal messages and commands continue to work
-
-### Lost temporary UX state
-
-If the bot restarts during a transient UX flow:
-
-- staged attachments may disappear
-- pending `!load` selections may disappear
-- pending confirmations may disappear
-
-This is acceptable and should not block normal operation after restart.
-
-## Interaction With Multi-Agent Routing
-
-The multi-agent design introduces new durable state that must survive restart:
-
-- `selected_agent_id` on the user
-- `agent_id` on the room
-- `PLATFORM_CHAT_SEQ_KEY` in the surface store
-
-Restart persistence and multi-agent routing therefore belong together.
-
-Without durable storage for those fields, a restart would make room routing ambiguous.
-
-## Failure Handling
-
-### Missing durable surface store
-
-If the durable store paths are missing because the environment was reset:
-
-- do not attempt to reconstruct a full working state from scratch in this design
-- treat startup as a clean environment
-- allow normal onboarding flows to begin again
-
-### Invalid durable references
-
-If persisted `selected_agent_id` or room `agent_id` references an agent no longer present in config:
-
-- do not crash
-- treat the selection or room binding as invalid
-- ask the user to select a valid agent again
-
-### Platform conversation memory
-
-If the upstream platform loses agent memory across restart:
-
-- that is outside the surface persistence boundary
-- the surface must still route correctly
-- platform memory persistence remains a platform responsibility
-
-## Testing Expectations
-
-Tests for this design should prove:
-
-- `selected_agent_id` survives restart through durable local storage
-- room `agent_id` and `platform_chat_id` survive restart through durable local storage
-- the bot can route messages correctly after restart without user reconfiguration
-- missing temporary UX state does not break normal messaging and command handling
-- invalid persisted agent references degrade into reselection prompts rather than crashes
-
-## Operational Notes
-
-For the Matrix surface to survive restart in the intended way, deployment must persist:
-
-- `lambda_matrix.db`
-- `matrix_store`
-
-This is a deployment requirement, not an optional optimization.
-
-The design intentionally stops there. It does not require:
-
-- hot reload of agent config
-- recovery after total local state loss
-- persistence of temporary UX flows
-- a solved multi-agent workspace story
diff --git a/docs/surface-protocol.md b/docs/surface-protocol.md
index f2bd7b1..ca66000 100644
--- a/docs/surface-protocol.md
+++ b/docs/surface-protocol.md
@@ -38,10 +38,9 @@ surfaces-bot/
converter.py — matrix-nio Event → IncomingEvent, OutgoingEvent → Matrix API
bot.py — точка входа, клиент
- sdk/
- interface.py — Protocol: PlatformClient (контракт к SDK)
- real.py — RealPlatformClient (через AgentApi)
- mock.py — MockPlatformClient (для локальных тестов)
+ platform/
+ interface.py — Protocol: PlatformClient
+ mock.py — MockPlatformClient
```
---
@@ -141,7 +140,7 @@ class UIButton:
```
Telegram рендерит это как InlineKeyboard.
-Matrix рендерит как текст (в MVP).
+Matrix рендерит как текст с описанием реакций или HTML-кнопки.
### OutgoingNotification
Асинхронное уведомление — агент закончил долгую задачу.
@@ -210,7 +209,7 @@ class ConfirmationRequest:
```
Telegram показывает как Inline-кнопки.
-Matrix показывает как запрос для `!yes` / `!no`.
+Matrix показывает как реакции 👍 / ❌.
Ядро не знает как именно — только получает `IncomingCallback` с `action: "confirm"`.
---
@@ -305,9 +304,9 @@ class PlatformClient(Protocol):
async def update_settings(self, user_id: str, action: Any) -> None: ...
```
-Бот **не управляет lifecycle контейнеров** агентов. Запуск/перезапуск агентов — ответственность платформы.
-Бот передаёт `user_id` + `chat_id` + текст.
+Бот **не управляет lifecycle контейнеров** — это делает Master (платформа).
+Бот передаёт `user_id` + `chat_id` + текст; Master сам решает нужно ли поднять контейнер, смонтировать `C1/`/`C2/`, запустить агента.
-`MockPlatformClient` реализует этот протокол для локальных тестов.
-Реальный SDK используется через `RealPlatformClient` (`sdk/real.py`), который подключается к `AgentApi` по WebSocket.
-Адаптеры поверхностей и ядро не меняются вообще, привязка идёт через `config/matrix-agents.yaml`.
+`MockPlatformClient` реализует этот протокол сейчас.
+Реальный SDK — тоже реализует этот протокол, заменяя один файл.
+Адаптеры поверхностей и ядро не меняются вообще.
diff --git a/docs/telegram-prototype.md b/docs/telegram-prototype.md
index 17f93cf..b739843 100644
--- a/docs/telegram-prototype.md
+++ b/docs/telegram-prototype.md
@@ -1,18 +1,18 @@
# Telegram — описание прототипа
-> **ВНИМАНИЕ: Telegram-адаптер не является частью текущего MVP-деплоя.**
-> Код Telegram-поверхности находится в отдельной ветке `feat/telegram-adapter`. Данный документ описывает возможности этого адаптера, но многие концепции (например, AuthFlow и MockPlatformClient) устарели по отношению к актуальной архитектуре `main`.
-
## Концепция
-Один бот, несколько чатов через Topics в Forum-группе.
+Один бот, несколько чатов, две поверхности:
-При первом запуске бот создаёт для пользователя персональную Forum-группу
-(супергруппу с включёнными темами). Каждый новый чат с агентом — отдельная тема
-внутри группы. Пользователь видит это как список чатов в одном месте.
+- базовая поверхность — личка с ботом (DM)
+- опциональная advanced-поверхность — Topics в пользовательской Forum-группе
-Бот управляет группой от имени пользователя через Telegram Bot API:
-создаёт темы, переименовывает, архивирует.
+При первом запуске пользователь начинает в DM: бот создаёт первый чат и
+переключает пользователя в него. Если позже пользователь подключает Forum-группу
+через `/forum`, существующие чаты получают соответствующие темы в супергруппе.
+
+DM и Forum используют один и тот же `chat_id`: пользователь может писать
+либо в личке, либо в forum topic, а платформа видит единый разговор.
---
@@ -32,44 +32,51 @@
---
-## Чаты через Forum Topics (вариант В)
+## Чаты в DM и Forum Topics
### Как это работает
-- Бот создаёт супергруппу с Topics для каждого нового пользователя
-- Каждый чат = отдельная тема (Topic) в этой группе
-- История хранится нативно в Telegram (в самой теме)
-- Переключение между чатами = переключение между темами
+- После `/start` бот создаёт `Чат #1` в DM
+- В DM активный чат хранится как `active_chat_id`
+- Ответы в личке приходят в общий поток с тегом `[Чат #N]`
+- После `/forum` пользователь привязывает свою супергруппу с Topics
+- Каждый чат может получить соответствующую тему (`forum_thread_id`)
+- В forum-теме ответы приходят без тега, прямо в тему
+- История forum-разговоров хранится нативно в Telegram
### Управление чатами
-Внутри каждой темы доступны команды:
+В DM доступны команды:
| Команда | Действие |
|---|---|
-| `/new` | Создать новый чат (новую тему) |
-| `/rename Название` | Переименовать текущий чат |
-| `/archive` | Архивировать текущий чат |
+| `/new` | Создать новый чат |
| `/chats` | Показать список всех чатов |
+| `/forum` | Подключить Forum-группу |
+
+В forum-темах поддерживается тот же разговорный контекст, а `/new` может
+зарегистрировать текущую тему как отдельный чат.
### Создание нового чата
-1. Пользователь пишет `/new` или нажимает кнопку
-2. Бот спрашивает название (опционально, можно пропустить)
-3. Бот создаёт новую тему в группе: «Чат 1», «Чат 2» и т.д.
-4. Бот отправляет в новую тему приветствие; при первом сообщении платформа автоматически поднимает контейнер
+1. Пользователь пишет `/new [название]` или нажимает кнопку
+2. Бот создаёт новый чат в локальной БД: `Чат #N` или указанное название
+3. Если Forum уже подключён, бот дополнительно создаёт новую тему в привязанной группе
+4. В DM бот переключает `active_chat_id` на новый чат
### В моке
-- Группа и темы создаются реально через Bot API
-- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...)
-- История в темах хранится нативно в Telegram, ничего не нужно делать
+- DM-чаты работают сразу после `/start`
+- Если Forum подключён, темы создаются реально через Bot API
+- Сообщения из DM и forum topic передаются в `MockPlatformClient` с одним и тем же `chat_id`
---
## Основной диалог
### Флоу сообщения
-1. Пользователь пишет текст в тему
+1. Пользователь пишет текст в DM или в forum topic
2. Бот показывает `typing...`
3. Запрос уходит в платформу (сейчас — MockPlatformClient)
-4. Бот отвечает текстом агента
+4. Бот отвечает:
+ - в DM: с тегом `[Чат #N]`
+ - в forum topic: без тега, в ту же тему
### Вложения
- Фото, документы, голосовые — передаются в платформу как `attachments`
@@ -95,7 +102,7 @@
## Настройки
-Доступны через `/settings` в любой теме или в главном меню бота.
+Доступны через `/settings` в личке или в forum topic.
Реализованы как цепочка инлайн-кнопок.
### Главное меню настроек
@@ -201,16 +208,14 @@
## FSM состояния
-```
-[Start] → AuthPending → AuthConfirmed
- ↓
- GroupSetup → Idle
- ↓
- ReceivingMessage → WaitingResponse → Idle
- ↓
- ConfirmAction → [Confirmed/Cancelled] → Idle
- ↓
- Settings → [подменю] → Idle
+```text
+[Start] -> ChatState.idle
+ ↓
+ ForumSetupState.waiting_for_group
+ ↓
+ ChatState.waiting_response -> ChatState.idle
+ ↓
+ SettingsState.*
```
---
@@ -219,6 +224,6 @@
- Python 3.11+
- aiogram 3.x (Router, FSM, InlineKeyboard, Forum Topics API)
-- MockPlatformClient → `platform/interface.py`
+- MockPlatformClient → `sdk/mock.py`
- structlog для логирования
-- SQLite для хранения `tg_user_id → platform_user_id` и состояния скиллов
+- SQLite для хранения `tg_user_id → platform_user_id`, чатов и forum bindings
diff --git a/docs/user-flow.md b/docs/user-flow.md
new file mode 100644
index 0000000..efe22f1
--- /dev/null
+++ b/docs/user-flow.md
@@ -0,0 +1,65 @@
+# User Flow — Lambda Bot
+
+> **Статус:** ШАБЛОН — заполняет @architect после исследований
+> **Зависит от:** docs/research/telegram-flows.md, docs/research/competitor-ux.md
+
+---
+
+## Основной сценарий (happy path)
+
+```mermaid
+sequenceDiagram
+ actor User
+ participant Bot as Telegram/Matrix Bot
+ participant Platform as Lambda Platform (Master)
+
+ User->>Bot: /start
+ Bot->>Platform: GET /users/{tg_id}?platform=telegram
+ Platform-->>Bot: {user_id, is_new}
+
+ alt Новый пользователь
+ Bot->>User: Приветствие + инструкция
+ else Существующий пользователь
+ Bot->>User: Добро пожаловать обратно
+ end
+
+ loop Диалог (бот не управляет сессиями — Master делает это автоматически)
+ User->>Bot: Сообщение в чат C1/C2/...
+ Bot->>Platform: POST /users/{user_id}/chats/{chat_id}/messages
+ Note over Platform: Master поднимает контейнер,
монтирует нужный чат, запускает агента
+ Platform-->>Bot: {message_id, response, tokens_used}
+ Bot->>User: Ответ агента
+ end
+```
+
+---
+
+## Состояния FSM (Telegram)
+
+```mermaid
+stateDiagram-v2
+ [*] --> Unauthenticated: первый контакт
+
+ Unauthenticated --> Idle: /start (auth confirmed)
+
+ Idle --> WaitingResponse: сообщение пользователя
+ WaitingResponse --> Idle: ответ получен
+ WaitingResponse --> Error: ошибка платформы
+
+ Idle --> Idle: /new (создан новый чат)
+ Idle --> ConfirmAction: агент запрашивает подтверждение
+ ConfirmAction --> Idle: подтверждено / отменено
+
+ Error --> Idle: /start
+```
+
+---
+
+## Открытые вопросы
+
+> Заполняет @researcher и @architect после исследований
+
+- [ ] Как выглядит онбординг новых пользователей у конкурентов?
+- [ ] Нужна ли кнопка "Новая сессия" или сессия стартует автоматически?
+- [ ] Что показываем пока агент думает (typing indicator)?
+- [ ] Как обрабатываем timeout ответа от платформы?
diff --git a/forum_topics_research.md b/forum_topics_research.md
deleted file mode 100644
index b09c695..0000000
--- a/forum_topics_research.md
+++ /dev/null
@@ -1,363 +0,0 @@
-# Telegram-бот как форум для AI-агента: полный технический разбор
-
-С выходом **Bot API 9.3 (31 декабря 2025) и 9.4 (9 февраля 2026)** Telegram действительно позволяет боту «стать форумом» без отдельной supergroup — через режим **Threaded Mode**, включаемый в @BotFather. Личный чат пользователя с ботом получает полноценные forum topics, каждый из которых выступает изолированным контекстом разговора. Параллельно сохраняется классическая архитектура «бот-админ в supergroup с включёнными Topics», обкатанная с Bot API 6.3 (ноябрь 2022). Оба подхода дают `message_thread_id` для маршрутизации сообщений к нужному контексту AI-агента, но отличаются по сценариям применения, ограничениям и настройке.
-
----
-
-## Threaded Mode — бот сам становится форумом
-
-Начиная с Bot API 9.3, в @BotFather появилась настройка **Threaded Mode** (Bot Settings → Threaded Mode). После её включения личный чат пользователя с ботом превращается в форум: сообщения несут `message_thread_id` и `is_topic_message`, точно как в supergroup-форумах.
-
-Ключевые поля и возможности нового режима:
-
-- **`User.has_topics_enabled`** (bool) — показывает, включён ли Threaded Mode у бота для данного пользователя.
-- **`User.allows_users_to_create_topics`** (bool, API 9.4) — может ли пользователь сам создавать топики, или это право только у бота. Управляется через настройку @BotFather Mini App.
-- Бот вызывает **`createForumTopic(chat_id=user_id, name="...")`** прямо в личном чате — без supergroup, без админ-прав (API 9.4).
-- Работают **`editForumTopic`**, **`deleteForumTopic`**, **`unpinAllForumTopicMessages`** — подтверждено для private chats с API 9.3.
-- Все методы отправки (`sendMessage`, `sendPhoto`, `sendDocument` и т.д.) принимают `message_thread_id` в личных чатах.
-
-Это и есть ответ на вопрос «бот становится форумом» — **никакой отдельной группы не нужно**. Пользователь открывает чат с ботом и видит структуру топиков. Каждый топик — отдельный «разговор» с AI-агентом.
-
-Классическая архитектура «supergroup + бот-админ» по-прежнему актуальна для многопользовательских сценариев, где несколько людей работают с агентом в одном пространстве. Но для **персонального AI-ассистента** Threaded Mode — технически чистое решение.
-
----
-
-## Полный справочник Forum Topics API
-
-### Основные методы
-
-| Метод | Параметры | Возврат | Права |
-|-------|-----------|---------|-------|
-| `createForumTopic` | `chat_id`, `name` (1–128 символов), `icon_color`?, `icon_custom_emoji_id`? | `ForumTopic` | `can_manage_topics` (supergroup) / не нужны (private) |
-| `editForumTopic` | `chat_id`, `message_thread_id`, `name`?, `icon_custom_emoji_id`? | `True` | `can_manage_topics` или создатель топика |
-| `closeForumTopic` | `chat_id`, `message_thread_id` | `True` | `can_manage_topics` или создатель |
-| `reopenForumTopic` | `chat_id`, `message_thread_id` | `True` | `can_manage_topics` или создатель |
-| `deleteForumTopic` | `chat_id`, `message_thread_id` | `True` | **`can_delete_messages`** (не `can_manage_topics`!) |
-| `unpinAllForumTopicMessages` | `chat_id`, `message_thread_id` | `True` | `can_pin_messages` |
-| `getForumTopicIconStickers` | — | `Array of Sticker` | не нужны |
-
-### Методы General-топика (только supergroup)
-
-| Метод | Описание |
-|-------|----------|
-| `editGeneralForumTopic(chat_id, name)` | Переименовать General-топик |
-| `closeGeneralForumTopic(chat_id)` | Закрыть General |
-| `reopenGeneralForumTopic(chat_id)` | Открыть General |
-| `hideGeneralForumTopic(chat_id)` | Скрыть General (автоматически закрывает) |
-| `unhideGeneralForumTopic(chat_id)` | Показать General |
-| `unpinAllGeneralForumTopicMessages(chat_id)` | Открепить все сообщения в General |
-
-Все требуют `can_manage_topics`, кроме `unpinAll...` — там нужен `can_pin_messages`.
-
-### Объект ForumTopic
-
-```python
-class ForumTopic:
- message_thread_id: int # уникальный ID топика
- name: str # название (1–128 символов)
- icon_color: int # RGB-цвет иконки
- icon_custom_emoji_id: str # кастомный эмодзи (опционально)
- is_name_implicit: bool # имя назначено автоматически (API 9.3+)
-```
-
-**Допустимые значения `icon_color`**: `0x6FB9F0` (голубой), `0xFFD67E` (жёлтый), `0xCB86DB` (фиолетовый), `0x8EEE98` (зелёный), `0xFF93B2` (розовый), `0xFB6F5F` (красный) — ровно 6 цветов, других API не принимает.
-
-### Как работает message_thread_id
-
-При отправке через `sendMessage` (и все остальные send-методы) параметр `message_thread_id` направляет сообщение в конкретный топик. Входящие сообщения из топиков содержат два поля: **`message_thread_id`** (int) и **`is_topic_message`** (bool = True). Для General-топика `is_topic_message` **не устанавливается** — это ключевое отличие.
-
----
-
-## General-топик: коварная деталь
-
-General-топик имеет фиксированный **`id = 1`** на уровне MTProto API. Однако в Bot API его поведение отличается от кастомных топиков: сообщения в General **не несут `is_topic_message = true`**, а `message_thread_id` может быть `None` или отсутствовать. При этом отправка с `message_thread_id=1` часто возвращает **`400 Bad Request: message thread not found`**. Корректный подход — **просто опустить `message_thread_id`** при отправке в General.
-
-Логика маршрутизации для AI-агента должна учитывать это:
-
-```python
-if message.is_topic_message and message.message_thread_id:
- # Кастомный топик → изолированный контекст
- context_key = (chat_id, message.message_thread_id)
-elif getattr(message.chat, 'is_forum', False):
- # Форум, но не is_topic_message → General-топик
- context_key = (chat_id, "general")
-else:
- # Обычный чат / личное сообщение
- context_key = (chat_id, None)
-```
-
-General-топик **нельзя удалить**, но можно скрыть через `hideGeneralForumTopic`. Для AI-бота рекомендуется скрыть General и направлять все взаимодействия через кастомные топики — это устраняет edge case с маршрутизацией.
-
----
-
-## Рабочий бот на aiogram 3.x с полной изоляцией контекстов
-
-Ниже — **полный минимальный бот**, который создаёт топики по команде `/new`, ведёт изолированную историю для каждого топика и интегрируется с LLM. Код проверен по документации aiogram 3.26.
-
-```python
-"""
-AI-агент с forum topics — aiogram 3.x
-pip install aiogram>=3.20 openai aiosqlite
-"""
-
-import asyncio
-import logging
-import os
-from collections import defaultdict
-
-from aiogram import Bot, Dispatcher, F, Router
-from aiogram.filters import Command, CommandStart
-from aiogram.types import Message, ForumTopic
-from aiogram.client.default import DefaultBotProperties
-from aiogram.enums import ParseMode
-from aiogram.fsm.storage.memory import MemoryStorage
-from aiogram.fsm.strategy import FSMStrategy
-
-# ── Конфигурация ──────────────────────────────────────────────
-TOKEN = os.getenv("BOT_TOKEN")
-GROUP_ID = int(os.getenv("GROUP_ID", "0")) # ID supergroup-форума
-
-router = Router()
-
-# ── Хранилище контекстов: {(chat_id, topic_id): [messages]} ──
-contexts: dict[tuple[int, int | None], list[dict]] = defaultdict(list)
-
-
-# ── /start — приветствие в любом топике ───────────────────────
-@router.message(CommandStart())
-async def cmd_start(message: Message):
- topic = message.message_thread_id
- await message.answer(
- f"👋 AI-агент активен.\n"
- f"Топик: {topic or 'General'}\n\n"
- f"/new <имя> — новый разговор\n"
- f"/clear — очистить контекст\n"
- f"/close — закрыть топик"
- )
-
-
-# ── /new <имя> — создание нового топика-контекста ─────────────
-@router.message(Command("new"))
-async def cmd_new(message: Message, bot: Bot):
- args = message.text.split(maxsplit=1)
- name = args[1] if len(args) > 1 else f"Чат #{message.message_id}"
-
- try:
- topic: ForumTopic = await bot.create_forum_topic(
- chat_id=message.chat.id,
- name=name,
- icon_color=0x6FB9F0,
- )
- # Приветственное сообщение внутри нового топика
- await bot.send_message(
- chat_id=message.chat.id,
- text=f"✅ Контекст «{name}» создан. Пишите сюда — "
- f"я помню только этот разговор.",
- message_thread_id=topic.message_thread_id,
- )
- except Exception as e:
- await message.answer(f"❌ Ошибка: {e}")
-
-
-# ── /clear — сброс контекста текущего топика ──────────────────
-@router.message(Command("clear"))
-async def cmd_clear(message: Message):
- key = (message.chat.id, message.message_thread_id)
- contexts[key].clear()
- await message.answer("🗑 Контекст очищен.")
-
-
-# ── /close — закрытие текущего топика ─────────────────────────
-@router.message(Command("close"), F.message_thread_id)
-async def cmd_close(message: Message, bot: Bot):
- try:
- await bot.close_forum_topic(
- chat_id=message.chat.id,
- message_thread_id=message.message_thread_id,
- )
- # Чистим контекст закрытого топика
- key = (message.chat.id, message.message_thread_id)
- contexts.pop(key, None)
- except Exception as e:
- await message.answer(f"❌ {e}")
-
-
-# ── Обработка текстовых сообщений — маршрутизация по топику ───
-@router.message(F.text, ~F.text.startswith("/"))
-async def handle_user_message(message: Message):
- key = (message.chat.id, message.message_thread_id)
- history = contexts[key]
-
- # Сохраняем сообщение пользователя
- history.append({"role": "user", "content": message.text})
-
- # ── Вызов LLM (заглушка — заменить на реальный вызов) ──
- reply = await call_llm(history)
-
- # Сохраняем ответ ассистента
- history.append({"role": "assistant", "content": reply})
-
- # Ограничиваем историю (скользящее окно)
- if len(history) > 100:
- contexts[key] = history[-100:]
-
- # message.answer() автоматически сохраняет message_thread_id
- await message.answer(reply)
-
-
-# ── Заглушка LLM (заменить на OpenAI / Anthropic / etc.) ─────
-async def call_llm(history: list[dict]) -> str:
- """
- Реальная интеграция:
-
- from openai import AsyncOpenAI
- client = AsyncOpenAI()
-
- messages = [{"role": "system", "content": "Ты полезный ассистент."}]
- messages += [{"role": m["role"], "content": m["content"]}
- for m in history[-20:]]
-
- resp = await client.chat.completions.create(
- model="gpt-4o", messages=messages
- )
- return resp.choices[0].message.content
- """
- return f"[Echo] {history[-1]['content']} (сообщений в контексте: {len(history)})"
-
-
-# ── Точка входа ───────────────────────────────────────────────
-async def main():
- logging.basicConfig(level=logging.INFO)
- bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
-
- dp = Dispatcher(
- storage=MemoryStorage(),
- fsm_strategy=FSMStrategy.CHAT_TOPIC, # изоляция FSM по топикам
- )
- dp.include_router(router)
- await dp.start_polling(bot)
-
-
-if __name__ == "__main__":
- asyncio.run(main())
-```
-
-### Критически важная деталь: FSMStrategy.CHAT_TOPIC
-
-Встроенная в aiogram стратегия `FSMStrategy.CHAT_TOPIC` хранит состояния FSM с ключом `(chat_id, chat_id, thread_id)` — каждый топик получает **собственное** изолированное состояние. Это появилось в aiogram 3.4.0 и специально предназначено для форумных ботов. Без этой стратегии FSM-состояния будут общими для всех топиков в одном чате.
-
----
-
-## Хранение контекстов: от прототипа к продакшену
-
-### In-memory dict — для разработки
-
-Простой `defaultdict(list)` из примера выше теряет данные при перезапуске, но позволяет мгновенно начать работу. Ключ — кортеж `(chat_id, topic_id)`.
-
-### Redis — для продакшена
-
-Redis даёт **нативный TTL** (автоочистка неактивных контекстов), **атомарные операции** (безопасность при конкурентных сообщениях) и **персистентность**. Паттерн хранения:
-
-```python
-import json
-import redis.asyncio as redis
-
-r = redis.from_url("redis://localhost:6379")
-
-async def get_history(chat_id: int, topic_id: int | None) -> list[dict]:
- key = f"ctx:{chat_id}:{topic_id or 'general'}"
- raw = await r.get(key)
- return json.loads(raw) if raw else []
-
-async def append_and_trim(chat_id: int, topic_id: int | None, msg: dict):
- key = f"ctx:{chat_id}:{topic_id or 'general'}"
- history = await get_history(chat_id, topic_id)
- history.append(msg)
- history = history[-50:] # скользящее окно
- await r.set(key, json.dumps(history), ex=86400 * 7) # TTL 7 дней
-```
-
-### SQLite — компромисс
-
-Для однопроцессных развёртываний без инфраструктуры Redis:
-
-```python
-import aiosqlite
-
-async def init_db():
- async with aiosqlite.connect("contexts.db") as db:
- await db.execute("""
- CREATE TABLE IF NOT EXISTS messages (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- chat_id INTEGER NOT NULL,
- topic_id INTEGER,
- role TEXT NOT NULL,
- content TEXT NOT NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- )
- """)
- await db.execute(
- "CREATE INDEX IF NOT EXISTS idx_ctx ON messages(chat_id, topic_id)"
- )
- await db.commit()
-```
-
----
-
-## Настройка supergroup с forum mode
-
-Включить режим форума **через Bot API невозможно** — нет соответствующего метода. Два способа активации:
-
-Для **Threaded Mode в личных чатах**: @BotFather → выбрать бота → Bot Settings → Threaded Mode → включить. Всё. Никаких supergroup не нужно.
-
-Для **supergroup-форума** — шаги через Telegram-клиент:
-
-1. Создать группу (или использовать существующую).
-2. Открыть настройки группы → Edit → включить **Topics**. Telegram автоматически конвертирует группу в supergroup (ID чата изменится).
-3. Добавить бота в группу.
-4. Назначить бота администратором с правами: **`can_manage_topics`** (создание/редактирование/закрытие топиков), **`can_delete_messages`** (удаление топиков), **`can_pin_messages`** (работа с закреплёнными сообщениями).
-
-Минимально необходимое право — `can_manage_topics`. Без него бот не сможет вызвать `createForumTopic`.
-
-MTProto API имеет `channels.toggleForum(enabled=true)`, но это доступно только пользовательским аккаунтам с правами владельца, а не ботам.
-
----
-
-## Лимиты, edge cases и важные ограничения
-
-**До 1 000 000 топиков** в одной supergroup — практически неограниченный потолок. **5 закреплённых топиков** максимум. Общие rate limits Bot API (~30 запросов/сек) распространяются и на создание топиков.
-
-**При удалении топика** все сообщения внутри него **удаляются безвозвратно**, `message_thread_id` становится невалидным. Критическая проблема: **Bot API не доставляет webhook-событие об удалении топика**. Нет поля `forum_topic_deleted` в объекте Message. Для очистки контекстов в хранилище используйте одну из стратегий: TTL-based expiry в Redis, ошибку при попытке отправки в несуществующий thread (error-based cleanup), или ручную очистку, если удаление инициирует сам бот.
-
-**Bot API не предоставляет метод для получения списка существующих топиков.** Нет `getForumTopics`. Бот должен запоминать ID топиков при создании через `createForumTopic` или через service messages `ForumTopicCreated`.
-
-### python-telegram-bot v21 — для сравнения
-
-Эквивалентный вызов создания топика:
-
-```python
-from telegram import Update, ForumTopic
-from telegram.ext import Application, CommandHandler
-
-async def new_topic(update: Update, context):
- topic: ForumTopic = await context.bot.create_forum_topic(
- chat_id=update.effective_chat.id,
- name="Новый разговор",
- icon_color=0x6FB9F0,
- )
- await context.bot.send_message(
- chat_id=update.effective_chat.id,
- text="Топик создан!",
- message_thread_id=topic.message_thread_id,
- )
-```
-
-Ключевое отличие: python-telegram-bot **не имеет встроенных FSM-стратегий** для топиков. Изоляцию состояний по `message_thread_id` нужно реализовывать вручную. Фильтры service-сообщений: `filters.StatusUpdate.FORUM_TOPIC_CREATED`, `.FORUM_TOPIC_CLOSED`, `.FORUM_TOPIC_REOPENED`.
-
----
-
-## Заключение
-
-**Threaded Mode — прорывная возможность** для AI-ботов, появившаяся буквально в конце 2025-го. До этого «бот как форум» требовал обязательной supergroup-обёртки. Теперь личный чат с ботом является полноценным форумом, где каждый топик — изолированный контекст разговора с агентом.
-
-Архитектурная формула проста: `context_key = (chat_id, message_thread_id)` + `FSMStrategy.CHAT_TOPIC` в aiogram дают полную изоляцию из коробки. Для продакшена — Redis с TTL, для прототипа — `defaultdict(list)`. Три граблей, которые нужно знать заранее: General-топик не принимает `message_thread_id=1` при отправке, Bot API не уведомляет об удалении топиков, и получить список существующих топиков нельзя — только запоминать при создании.
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 73dfbd7..8f4978b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,15 +15,12 @@ dependencies = [
"structlog>=24.1",
"python-dotenv>=1.0",
"httpx>=0.27",
- "aiohttp>=3.9",
- "pyyaml>=6.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
- "pytest-aiohttp>=1.0",
"pytest-cov>=4.1",
"ruff>=0.3",
"mypy>=1.8",
diff --git a/sdk/__init__.py b/sdk/__init__.py
index f7939f7..e69de29 100644
--- a/sdk/__init__.py
+++ b/sdk/__init__.py
@@ -1,9 +0,0 @@
-__all__ = ["RealPlatformClient"]
-
-
-def __getattr__(name: str):
- if name == "RealPlatformClient":
- from sdk.real import RealPlatformClient
-
- return RealPlatformClient
- raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/sdk/agent_session.py b/sdk/agent_session.py
deleted file mode 100644
index 187b88a..0000000
--- a/sdk/agent_session.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Compatibility stub: AgentSessionClient was replaced by direct AgentApi usage in Phase 4."""
diff --git a/sdk/interface.py b/sdk/interface.py
index 7b43b1b..e1ff12e 100644
--- a/sdk/interface.py
+++ b/sdk/interface.py
@@ -1,11 +1,10 @@
# platform/interface.py
from __future__ import annotations
-from collections.abc import AsyncIterator
from datetime import datetime
-from typing import Any, Literal, Protocol
+from typing import Any, AsyncIterator, Literal, Protocol
-from pydantic import BaseModel, Field
+from pydantic import BaseModel
class User(BaseModel):
@@ -18,11 +17,10 @@ class User(BaseModel):
class Attachment(BaseModel):
- url: str | None = None
- mime_type: str | None = None
+ url: str
+ mime_type: str
size: int | None = None
filename: str | None = None
- workspace_path: str | None = None
class MessageResponse(BaseModel):
@@ -30,12 +28,10 @@ class MessageResponse(BaseModel):
response: str
tokens_used: int
finished: bool
- attachments: list[Attachment] = Field(default_factory=list)
class MessageChunk(BaseModel):
"""Один кусок стримингового ответа. При sync-режиме — единственный чанк с finished=True."""
-
message_id: str
delta: str
finished: bool
@@ -52,7 +48,6 @@ class UserSettings(BaseModel):
class AgentEvent(BaseModel):
"""Webhook-уведомление от платформы — агент закончил долгую задачу."""
-
event_id: str
user_id: str
chat_id: str
@@ -99,5 +94,4 @@ class PlatformClient(Protocol):
class WebhookReceiver(Protocol):
"""Регистрируется в боте. Платформа зовёт нас когда агент закончил долгую задачу."""
-
async def on_agent_event(self, event: AgentEvent) -> None: ...
diff --git a/sdk/mock.py b/sdk/mock.py
index 06e49ac..353a774 100644
--- a/sdk/mock.py
+++ b/sdk/mock.py
@@ -4,9 +4,8 @@ from __future__ import annotations
import asyncio
import random
import uuid
-from collections.abc import AsyncIterator
from datetime import UTC, datetime
-from typing import Any, Literal
+from typing import Any, AsyncIterator, Literal
import structlog
@@ -23,30 +22,6 @@ from sdk.interface import (
logger = structlog.get_logger(__name__)
-DEFAULT_SKILLS = {
- "web-search": True,
- "fetch-url": True,
- "email": False,
- "browser": False,
- "image-gen": False,
- "files": True,
-}
-
-DEFAULT_SAFETY = {
- "email-send": True,
- "file-delete": True,
- "social-post": True,
-}
-
-DEFAULT_SOUL = {"name": "Лямбда", "instructions": ""}
-
-DEFAULT_PLAN = {
- "name": "Beta",
- "tokens_used": 0,
- "tokens_limit": 1000,
-}
-
-
class MockPlatformClient:
"""
Заглушка SDK платформы Lambda.
@@ -124,19 +99,23 @@ class MockPlatformClient:
attachments: list[Attachment] | None = None,
) -> AsyncIterator[MessageChunk]:
"""
- Сейчас: один чанк с полным ответом.
- При реальном SDK: заменить на SSE/WebSocket итератор.
+ Сейчас: один чанк с полным ответом (sync под капотом).
+ При реальном SDK: заменить на SSE/WebSocket итератор в platform/mock.py.
Адаптеры переписывать не нужно.
"""
await self._latency(200, 600)
message_id, response, tokens = self._build_response(user_id, chat_id, text, attachments)
logger.info("stream_message", user_id=user_id, chat_id=chat_id, message_id=message_id)
- yield MessageChunk(
- message_id=message_id,
- delta=response,
- finished=True,
- tokens_used=tokens,
- )
+
+ async def _gen() -> AsyncIterator[MessageChunk]:
+ yield MessageChunk(
+ message_id=message_id,
+ delta=response,
+ finished=True,
+ tokens_used=tokens,
+ )
+
+ return _gen()
# --------------------------------------------------------------- settings
@@ -144,11 +123,26 @@ class MockPlatformClient:
await self._latency()
stored = self._settings.get(user_id, {})
return UserSettings(
- skills={**DEFAULT_SKILLS, **stored.get("skills", {})},
+ skills=stored.get("skills", {
+ "web-search": True,
+ "fetch-url": True,
+ "email": False,
+ "browser": False,
+ "image-gen": False,
+ "files": True,
+ }),
connectors=stored.get("connectors", {}),
- soul={**DEFAULT_SOUL, **stored.get("soul", {})},
- safety={**DEFAULT_SAFETY, **stored.get("safety", {})},
- plan={**DEFAULT_PLAN, **stored.get("plan", {})},
+ soul=stored.get("soul", {"name": "Лямбда", "instructions": ""}),
+ safety=stored.get("safety", {
+ "email-send": True,
+ "file-delete": True,
+ "social-post": True,
+ }),
+ plan=stored.get("plan", {
+ "name": "Beta",
+ "tokens_used": 0,
+ "tokens_limit": 1000,
+ }),
)
async def update_settings(self, user_id: str, action: Any) -> None:
@@ -156,13 +150,13 @@ class MockPlatformClient:
settings = self._settings.setdefault(user_id, {})
if action.action == "toggle_skill":
- skills = settings.setdefault("skills", DEFAULT_SKILLS.copy())
+ skills = settings.setdefault("skills", {})
skills[action.payload["skill"]] = action.payload.get("enabled", True)
elif action.action == "set_soul":
- soul = settings.setdefault("soul", DEFAULT_SOUL.copy())
+ soul = settings.setdefault("soul", {})
soul[action.payload["field"]] = action.payload["value"]
elif action.action == "set_safety":
- safety = settings.setdefault("safety", DEFAULT_SAFETY.copy())
+ safety = settings.setdefault("safety", {})
safety[action.payload["trigger"]] = action.payload.get("enabled", True)
logger.info("Settings updated", user_id=user_id, action=action.action)
@@ -223,16 +217,14 @@ class MockPlatformClient:
response = f"[MOCK] Ответ на: «{preview}»{attachment_note}"
tokens = len(text.split()) * 2
- self._messages[key].append(
- {
- "message_id": message_id,
- "user_text": text,
- "response": response,
- "tokens_used": tokens,
- "finished": True,
- "created_at": datetime.now(UTC).isoformat(),
- }
- )
+ self._messages[key].append({
+ "message_id": message_id,
+ "user_text": text,
+ "response": response,
+ "tokens_used": tokens,
+ "finished": True,
+ "created_at": datetime.now(UTC).isoformat(),
+ })
return message_id, response, tokens
async def _latency(self, min_ms: int = 10, max_ms: int = 80) -> None:
diff --git a/sdk/prototype_state.py b/sdk/prototype_state.py
deleted file mode 100644
index 6e5fd41..0000000
--- a/sdk/prototype_state.py
+++ /dev/null
@@ -1,129 +0,0 @@
-from __future__ import annotations
-
-from datetime import UTC, datetime
-from typing import Any
-
-from sdk.interface import User, UserSettings
-
-# Keep the prototype backend self-contained; do not import these from sdk.mock.
-DEFAULT_SKILLS: dict[str, bool] = {
- "web-search": True,
- "fetch-url": True,
- "email": False,
- "browser": False,
- "image-gen": False,
- "files": True,
-}
-DEFAULT_SAFETY: dict[str, bool] = {
- "email-send": True,
- "file-delete": True,
- "social-post": True,
-}
-DEFAULT_SOUL: dict[str, str] = {"name": "Лямбда", "instructions": ""}
-DEFAULT_PLAN: dict[str, Any] = {
- "name": "Beta",
- "tokens_used": 0,
- "tokens_limit": 1000,
-}
-
-
-class PrototypeStateStore:
- def __init__(self) -> None:
- self._users: dict[str, User] = {}
- self._settings: dict[str, dict[str, Any]] = {}
- self._saved_sessions: dict[str, list[dict[str, str]]] = {}
- self._context_last_tokens_used: dict[str, int] = {}
- self._context_current_session: dict[str, str] = {}
-
- async def get_or_create_user(
- self,
- external_id: str,
- platform: str,
- display_name: str | None = None,
- ) -> User:
- key = f"{platform}:{external_id}"
- existing = self._users.get(key)
- if existing is not None:
- stored = existing.model_copy(update={"is_new": False})
- self._users[key] = stored
- return stored.model_copy()
-
- user = User(
- user_id=f"usr-{platform}-{external_id}",
- external_id=external_id,
- platform=platform,
- display_name=display_name,
- created_at=datetime.now(UTC),
- is_new=True,
- )
- self._users[key] = user.model_copy(update={"is_new": False})
- return user.model_copy()
-
- async def get_settings(self, user_id: str) -> UserSettings:
- stored = self._settings.get(user_id, {})
- return UserSettings(
- skills={**DEFAULT_SKILLS, **stored.get("skills", {})},
- connectors=dict(stored.get("connectors", {})),
- soul={**DEFAULT_SOUL, **stored.get("soul", {})},
- safety={**DEFAULT_SAFETY, **stored.get("safety", {})},
- plan={**DEFAULT_PLAN, **stored.get("plan", {})},
- )
-
- async def update_settings(self, user_id: str, action: Any) -> None:
- settings = self._settings.setdefault(user_id, {})
-
- if action.action == "toggle_skill":
- skills = settings.setdefault("skills", DEFAULT_SKILLS.copy())
- skills[action.payload["skill"]] = action.payload.get("enabled", True)
- elif action.action == "set_soul":
- soul = settings.setdefault("soul", DEFAULT_SOUL.copy())
- soul[action.payload["field"]] = action.payload["value"]
- elif action.action == "set_safety":
- safety = settings.setdefault("safety", DEFAULT_SAFETY.copy())
- safety[action.payload["trigger"]] = action.payload.get("enabled", True)
-
- async def add_saved_session(
- self,
- user_id: str,
- name: str,
- *,
- source_context_id: str | None = None,
- ) -> None:
- sessions = self._saved_sessions.setdefault(user_id, [])
- session = {"name": name, "created_at": datetime.now(UTC).isoformat()}
- if source_context_id is not None:
- session["source_context_id"] = source_context_id
- sessions.append(session)
-
- async def list_saved_sessions(self, user_id: str) -> list[dict[str, str]]:
- return [dict(session) for session in self._saved_sessions.get(user_id, [])]
-
- async def get_last_tokens_used_for_context(self, context_id: str) -> int:
- return self._context_last_tokens_used.get(context_id, 0)
-
- async def set_last_tokens_used_for_context(self, context_id: str, tokens: int) -> None:
- self._context_last_tokens_used[context_id] = tokens
-
- async def get_current_session_for_context(self, context_id: str) -> str | None:
- return self._context_current_session.get(context_id)
-
- async def set_current_session_for_context(self, context_id: str, name: str) -> None:
- self._context_current_session[context_id] = name
-
- async def clear_current_session_for_context(self, context_id: str) -> None:
- self._context_current_session.pop(context_id, None)
-
- async def get_last_tokens_used(self, context_id: str) -> int:
- return await self.get_last_tokens_used_for_context(context_id)
-
- async def set_last_tokens_used(self, context_id: str, tokens: int) -> None:
- await self.set_last_tokens_used_for_context(context_id, tokens)
-
- async def get_current_session(self, context_id: str) -> str | None:
- return await self.get_current_session_for_context(context_id)
-
- async def set_current_session(self, context_id: str, name: str) -> None:
- await self.set_current_session_for_context(context_id, name)
-
- async def clear_current_session(self, context_id: str) -> None:
- await self.clear_current_session_for_context(context_id)
diff --git a/sdk/real.py b/sdk/real.py
deleted file mode 100644
index 47f639a..0000000
--- a/sdk/real.py
+++ /dev/null
@@ -1,273 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import os
-import re
-from collections.abc import AsyncIterator
-from pathlib import Path
-from urllib.parse import urljoin, urlsplit, urlunsplit
-
-import structlog
-
-from sdk.interface import (
- Attachment,
- MessageChunk,
- MessageResponse,
- PlatformClient,
- PlatformError,
- User,
- UserSettings,
-)
-from sdk.prototype_state import PrototypeStateStore
-from sdk.upstream_agent_api import AgentApi, MsgEventSendFile, MsgEventTextChunk
-
-logger = structlog.get_logger(__name__)
-
-
-def _ws_debug_enabled() -> bool:
- value = os.environ.get("SURFACES_DEBUG_WS", "")
- return value.strip().lower() in {"1", "true", "yes", "on"}
-
-
-class RealPlatformClient(PlatformClient):
- def __init__(
- self,
- agent_id: str,
- agent_base_url: str,
- prototype_state: PrototypeStateStore,
- platform: str = "matrix",
- agent_api_cls=AgentApi,
- ) -> None:
- self._agent_id = agent_id
- self._raw_agent_base_url = agent_base_url
- self._agent_base_url = self._normalize_agent_base_url(agent_base_url)
- self._agent_api_cls = agent_api_cls
- self._prototype_state = prototype_state
- self._platform = platform
- self._chat_send_locks: dict[str, asyncio.Lock] = {}
- if _ws_debug_enabled():
- logger.warning(
- "agent_client_initialized",
- agent_id=self._agent_id,
- platform=self._platform,
- raw_base_url=self._raw_agent_base_url,
- normalized_base_url=self._agent_base_url,
- )
-
- @property
- def agent_id(self) -> str:
- return self._agent_id
-
- @property
- def agent_base_url(self) -> str:
- return self._agent_base_url
-
- def _get_chat_send_lock(self, chat_id: str) -> asyncio.Lock:
- chat_key = str(chat_id)
- lock = self._chat_send_locks.get(chat_key)
- if lock is None:
- lock = asyncio.Lock()
- self._chat_send_locks[chat_key] = lock
- return lock
-
- async def get_or_create_user(
- self,
- external_id: str,
- platform: str,
- display_name: str | None = None,
- ) -> User:
- return await self._prototype_state.get_or_create_user(
- external_id=external_id,
- platform=platform,
- display_name=display_name,
- )
-
- async def send_message(
- self,
- user_id: str,
- chat_id: str,
- text: str,
- attachments: list[Attachment] | None = None,
- ) -> MessageResponse:
- response_parts: list[str] = []
- sent_attachments: list[Attachment] = []
- message_id = user_id
-
- lock = self._get_chat_send_lock(chat_id)
- async with lock:
- chat_api = self._build_chat_api(chat_id)
- try:
- await chat_api.connect()
- async for event in self._stream_agent_events(
- chat_api, text, attachments=attachments
- ):
- message_id = user_id
- if isinstance(event, MsgEventTextChunk) and event.text:
- response_parts.append(event.text)
- elif isinstance(event, MsgEventSendFile):
- attachment = self._attachment_from_send_file_event(event)
- if attachment is not None:
- sent_attachments.append(attachment)
- except Exception as exc:
- raise self._to_platform_error(exc) from exc
- finally:
- await self._close_chat_api(chat_api)
- await self._prototype_state.set_last_tokens_used(str(chat_id), 0)
-
- response_kwargs = {
- "message_id": message_id,
- "response": "".join(response_parts),
- "tokens_used": 0,
- "finished": True,
- "attachments": sent_attachments,
- }
- return MessageResponse(**response_kwargs)
-
- async def stream_message(
- self,
- user_id: str,
- chat_id: str,
- text: str,
- attachments: list[Attachment] | None = None,
- ) -> AsyncIterator[MessageChunk]:
- lock = self._get_chat_send_lock(chat_id)
- async with lock:
- chat_api = self._build_chat_api(chat_id)
- try:
- await chat_api.connect()
- async for event in self._stream_agent_events(
- chat_api, text, attachments=attachments
- ):
- if isinstance(event, MsgEventTextChunk):
- yield MessageChunk(
- message_id=user_id,
- delta=event.text,
- finished=False,
- )
- elif isinstance(event, MsgEventSendFile):
- continue
- except Exception as exc:
- raise self._to_platform_error(exc) from exc
- finally:
- await self._close_chat_api(chat_api)
- await self._prototype_state.set_last_tokens_used(str(chat_id), 0)
- yield MessageChunk(
- message_id=user_id,
- delta="",
- finished=True,
- tokens_used=0,
- )
-
- async def get_settings(self, user_id: str) -> UserSettings:
- return await self._prototype_state.get_settings(user_id)
-
- async def update_settings(self, user_id: str, action) -> None:
- await self._prototype_state.update_settings(user_id, action)
-
- async def disconnect_chat(self, chat_id: str) -> None:
- self._chat_send_locks.pop(str(chat_id), None)
-
- async def close(self) -> None:
- self._chat_send_locks.clear()
-
- async def _stream_agent_events(
- self,
- chat_api,
- text: str,
- attachments: list[Attachment] | None = None,
- ) -> AsyncIterator[object]:
- attachment_paths = self._attachment_paths(attachments)
- event_stream = chat_api.send_message(text, attachments=attachment_paths or None)
- chunk_index = 0
- async for event in event_stream:
- if isinstance(event, MsgEventTextChunk):
- logger.debug("agent_chunk", index=chunk_index, text=repr(event.text[:40]))
- chunk_index += 1
- else:
- logger.debug("agent_event", index=chunk_index, type=type(event).__name__)
- yield event
-
- def _build_chat_api(self, chat_id: str):
- if _ws_debug_enabled():
- logger.warning(
- "agent_chat_api_build",
- agent_id=self._agent_id,
- chat_id=str(chat_id),
- normalized_base_url=self._agent_base_url,
- ws_url=urljoin(self._agent_base_url, f"v1/agent_ws/{chat_id}/"),
- )
- return self._agent_api_cls(
- agent_id=self._agent_id,
- base_url=self._agent_base_url,
- chat_id=str(chat_id),
- )
-
- @staticmethod
- def _normalize_agent_base_url(base_url: str) -> str:
- parsed = urlsplit(base_url)
- path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/"))
- if path:
- path = f"{path}/"
- return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
-
- @staticmethod
- async def _close_chat_api(chat_api) -> None:
- close = getattr(chat_api, "close", None)
- if callable(close):
- try:
- await close()
- except Exception:
- pass
-
- @staticmethod
- def _to_platform_error(exc: Exception) -> PlatformError:
- code = getattr(exc, "code", None) or "PLATFORM_CONNECTION_ERROR"
- return PlatformError(str(exc), code=code)
-
- @staticmethod
- def _normalize_workspace_path(location: str) -> str | None:
- if not location:
- return None
-
- path = Path(location)
- if not path.is_absolute():
- normalized = path.as_posix()
- return normalized or None
-
- parts = path.parts
- if len(parts) >= 2 and parts[1] == "workspace":
- relative = Path(*parts[2:]).as_posix()
- return relative or None
- if len(parts) >= 3 and parts[1] == "agents":
- relative = Path(*parts[3:]).as_posix()
- return relative or None
-
- relative = path.as_posix().lstrip("/")
- return relative or None
-
- @staticmethod
- def _attachment_paths(attachments: list[Attachment] | None) -> list[str]:
- if not attachments:
- return []
- paths = []
- for attachment in attachments:
- if attachment.workspace_path:
- normalized = RealPlatformClient._normalize_workspace_path(
- attachment.workspace_path
- )
- if normalized:
- paths.append(normalized)
- return paths
-
- @staticmethod
- def _attachment_from_send_file_event(event: MsgEventSendFile) -> Attachment:
- location = str(event.path)
- filename = Path(location).name or None
- workspace_path = RealPlatformClient._normalize_workspace_path(location)
- return Attachment(
- url=location,
- mime_type="application/octet-stream",
- size=None,
- filename=filename,
- workspace_path=workspace_path or None,
- )
diff --git a/sdk/upstream_agent_api.py b/sdk/upstream_agent_api.py
deleted file mode 100644
index d0bfdd7..0000000
--- a/sdk/upstream_agent_api.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from __future__ import annotations
-
-import sys
-from pathlib import Path
-
-_api_root = Path(__file__).resolve().parents[1] / "external" / "platform-agent_api"
-if str(_api_root) not in sys.path:
- sys.path.insert(0, str(_api_root))
-
-from lambda_agent_api.agent_api import AgentApi, AgentBusyException, AgentException # noqa: E402
-from lambda_agent_api.server import MsgEventSendFile, MsgEventTextChunk # noqa: E402
-
-__all__ = [
- "AgentApi",
- "AgentBusyException",
- "AgentException",
- "MsgEventSendFile",
- "MsgEventTextChunk",
-]
diff --git a/telegram-cloud-photo-size-2-5440546240941724952-y.jpg b/telegram-cloud-photo-size-2-5440546240941724952-y.jpg
deleted file mode 100644
index af4606d..0000000
Binary files a/telegram-cloud-photo-size-2-5440546240941724952-y.jpg and /dev/null differ
diff --git a/tests/adapter/__init__.py b/tests/adapter/__init__.py
index e69de29..8b13789 100644
--- a/tests/adapter/__init__.py
+++ b/tests/adapter/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/adapter/matrix/__init__.py b/tests/adapter/matrix/__init__.py
deleted file mode 100644
index 9d48db4..0000000
--- a/tests/adapter/matrix/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from __future__ import annotations
diff --git a/tests/adapter/matrix/test_agent_registry.py b/tests/adapter/matrix/test_agent_registry.py
deleted file mode 100644
index a918f84..0000000
--- a/tests/adapter/matrix/test_agent_registry.py
+++ /dev/null
@@ -1,199 +0,0 @@
-from pathlib import Path
-
-import pytest
-
-from adapter.matrix.agent_registry import AgentRegistryError, load_agent_registry
-
-
-def test_load_agent_registry_reads_yaml_entries(tmp_path: Path):
- path = tmp_path / "agents.yaml"
- path.write_text(
- "agents:\n"
- " - id: agent-1\n"
- " label: Analyst\n"
- " - id: agent-2\n"
- " label: Research\n",
- encoding="utf-8",
- )
-
- registry = load_agent_registry(path)
-
- assert [agent.agent_id for agent in registry.agents] == ["agent-1", "agent-2"]
- assert registry.get("agent-1").label == "Analyst"
-
-
-def test_agent_registry_agents_sequence_is_immutable(tmp_path: Path):
- path = tmp_path / "agents.yaml"
- path.write_text(
- "agents:\n"
- " - id: agent-1\n"
- " label: Analyst\n",
- encoding="utf-8",
- )
-
- registry = load_agent_registry(path)
-
- with pytest.raises(AttributeError):
- registry.agents.append( # type: ignore[attr-defined]
- registry.agents[0]
- )
-
-
-def test_load_agent_registry_rejects_duplicate_ids(tmp_path: Path):
- path = tmp_path / "agents.yaml"
- path.write_text(
- "agents:\n"
- " - id: agent-1\n"
- " label: Analyst\n"
- " - id: agent-1\n"
- " label: Duplicate\n",
- encoding="utf-8",
- )
-
- with pytest.raises(AgentRegistryError, match="duplicate agent id"):
- load_agent_registry(path)
-
-
-def test_load_agent_registry_rejects_non_mapping_entries(tmp_path: Path):
- path = tmp_path / "agents.yaml"
- path.write_text(
- "agents:\n"
- " - agent-1\n",
- encoding="utf-8",
- )
-
- with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"):
- load_agent_registry(path)
-
-
-@pytest.mark.parametrize(
- "content",
- [
- "",
- "agents: []\n",
- "agents: agent-1\n",
- "foo: bar\n",
- ],
-)
-def test_load_agent_registry_rejects_missing_non_list_and_empty_agents(
- tmp_path: Path, content: str
-):
- path = tmp_path / "agents.yaml"
- path.write_text(content, encoding="utf-8")
-
- with pytest.raises(AgentRegistryError, match="agents registry must contain a non-empty agents list"):
- load_agent_registry(path)
-
-
-@pytest.mark.parametrize(
- "content, expected",
- [
- (
- "agents:\n"
- " - label: Analyst\n",
- "each agent entry requires id and label",
- ),
- (
- "agents:\n"
- " - id: agent-1\n",
- "each agent entry requires id and label",
- ),
- ],
-)
-def test_load_agent_registry_rejects_missing_id_or_label(tmp_path: Path, content: str, expected: str):
- path = tmp_path / "agents.yaml"
- path.write_text(content, encoding="utf-8")
-
- with pytest.raises(AgentRegistryError, match=expected):
- load_agent_registry(path)
-
-
-def test_load_agent_registry_rejects_invalid_top_level_yaml(tmp_path: Path):
- path = tmp_path / "agents.yaml"
- path.write_text(
- "- id: agent-1\n"
- " label: Analyst\n",
- encoding="utf-8",
- )
-
- with pytest.raises(AgentRegistryError, match="agent registry must be a mapping with an agents list"):
- load_agent_registry(path)
-
-
-def test_load_agent_registry_rejects_malformed_yaml(tmp_path: Path):
- path = tmp_path / "agents.yaml"
- path.write_text(
- "agents:\n"
- " - id: agent-1\n"
- " label: Analyst\n"
- " - id: agent-2\n"
- " label: Research\n"
- " - [\n",
- encoding="utf-8",
- )
-
- with pytest.raises(AgentRegistryError, match="invalid agent registry YAML"):
- load_agent_registry(path)
-
-
-@pytest.mark.parametrize(
- "content",
- [
- "agents:\n"
- " - id: null\n"
- " label: Analyst\n",
- "agents:\n"
- " - id: agent-1\n"
- " label: null\n",
- ],
-)
-def test_load_agent_registry_rejects_null_id_or_label(tmp_path: Path, content: str):
- path = tmp_path / "agents.yaml"
- path.write_text(content, encoding="utf-8")
-
- with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"):
- load_agent_registry(path)
-
-
-@pytest.mark.parametrize(
- "content",
- [
- "agents:\n"
- " - id: ' '\n"
- " label: Analyst\n",
- "agents:\n"
- " - id: agent-1\n"
- " label: ' '\n",
- ],
-)
-def test_load_agent_registry_rejects_blank_id_or_label(tmp_path: Path, content: str):
- path = tmp_path / "agents.yaml"
- path.write_text(content, encoding="utf-8")
-
- with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"):
- load_agent_registry(path)
-
-
-@pytest.mark.parametrize(
- "content",
- [
- "agents:\n"
- " - id: 123\n"
- " label: Analyst\n",
- "agents:\n"
- " - id: agent-1\n"
- " label: 456\n",
- "agents:\n"
- " - id: true\n"
- " label: Analyst\n",
- "agents:\n"
- " - id: agent-1\n"
- " label: false\n",
- ],
-)
-def test_load_agent_registry_rejects_non_string_id_or_label(tmp_path: Path, content: str):
- path = tmp_path / "agents.yaml"
- path.write_text(content, encoding="utf-8")
-
- with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"):
- load_agent_registry(path)
diff --git a/tests/adapter/matrix/test_chat_space.py b/tests/adapter/matrix/test_chat_space.py
deleted file mode 100644
index e33fb98..0000000
--- a/tests/adapter/matrix/test_chat_space.py
+++ /dev/null
@@ -1,202 +0,0 @@
-from __future__ import annotations
-
-from types import SimpleNamespace
-from unittest.mock import AsyncMock
-
-from nio.api import RoomVisibility
-from nio.responses import RoomCreateError
-
-from adapter.matrix.handlers.chat import (
- make_handle_archive,
- make_handle_new_chat,
- make_handle_rename,
-)
-from adapter.matrix.store import get_room_meta, set_user_meta
-from core.auth import AuthManager
-from core.chat import ChatManager
-from core.protocol import IncomingCommand, OutgoingMessage
-from core.settings import SettingsManager
-from core.store import InMemoryStore
-from sdk.mock import MockPlatformClient
-
-
-async def _setup():
- platform = MockPlatformClient()
- store = InMemoryStore()
- chat_mgr = ChatManager(platform, store)
- auth_mgr = AuthManager(platform, store)
- settings_mgr = SettingsManager(platform, store)
- await auth_mgr.confirm("@alice:example.org")
- return platform, store, chat_mgr, auth_mgr, settings_mgr
-
-
-async def test_mat04_new_chat_calls_room_put_state_with_space_id():
- platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
- await set_user_meta(
- store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2}
- )
-
- client = SimpleNamespace(
- room_create=AsyncMock(return_value=SimpleNamespace(room_id="!newroom:ex")),
- room_put_state=AsyncMock(),
- room_invite=AsyncMock(),
- )
- handler = make_handle_new_chat(client, store)
- event = IncomingCommand(
- user_id="@alice:example.org",
- platform="matrix",
- chat_id="C1",
- command="new",
- args=["Test"],
- )
- result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
-
- client.room_create.assert_awaited_once_with(
- name="Test",
- visibility=RoomVisibility.private,
- is_direct=False,
- invite=["@alice:example.org"],
- )
- client.room_put_state.assert_awaited_once()
- client.room_invite.assert_not_awaited()
- kwargs = client.room_put_state.call_args.kwargs
- assert kwargs.get("room_id") == "!space:ex"
- assert kwargs.get("event_type") == "m.space.child"
- assert kwargs.get("state_key") == "!newroom:ex"
- room_meta = await get_room_meta(store, "!newroom:ex")
- assert room_meta is not None
- assert room_meta["platform_chat_id"] == "1"
- assert any(isinstance(item, OutgoingMessage) and "Test" in item.text for item in result)
-
-
-async def test_mat05_new_chat_without_space_id_returns_error():
- platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
- await set_user_meta(store, "@alice:example.org", {"next_chat_index": 1})
-
- client = SimpleNamespace(
- room_create=AsyncMock(),
- room_put_state=AsyncMock(),
- room_invite=AsyncMock(),
- )
- handler = make_handle_new_chat(client, store)
- event = IncomingCommand(
- user_id="@alice:example.org",
- platform="matrix",
- chat_id="C1",
- command="new",
- )
- result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
-
- assert len(result) == 1
- assert isinstance(result[0], OutgoingMessage)
- assert "Space" in result[0].text or "ошибка" in result[0].text.lower()
- client.room_create.assert_not_awaited()
-
-
-async def test_mat10_archive_calls_chat_mgr_archive():
- platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
-
- client = SimpleNamespace(room_leave=AsyncMock())
- handler = make_handle_archive(client, store)
- event = IncomingCommand(
- user_id="@alice:example.org",
- platform="matrix",
- chat_id="C1",
- command="archive",
- )
- await chat_mgr.get_or_create(
- user_id="@alice:example.org",
- chat_id="C1",
- platform="matrix",
- surface_ref="!room:ex",
- name="Test",
- )
-
- result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
-
- assert len(result) == 1
- assert "архивирован" in result[0].text
- client.room_leave.assert_awaited_once_with("!room:ex")
- chats = await chat_mgr.list_active("@alice:example.org")
- assert chats == []
-
-
-async def test_mat11_rename_updates_matrix_room_name_via_state_event():
- platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
- await chat_mgr.get_or_create(
- user_id="@alice:example.org",
- chat_id="C1",
- platform="matrix",
- surface_ref="!room:ex",
- name="Old",
- )
-
- client = SimpleNamespace(room_put_state=AsyncMock())
- handler = make_handle_rename(client, store)
- event = IncomingCommand(
- user_id="@alice:example.org",
- platform="matrix",
- chat_id="C1",
- command="rename",
- args=["New", "Name"],
- )
-
- result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
-
- client.room_put_state.assert_awaited_once_with(
- room_id="!room:ex",
- event_type="m.room.name",
- content={"name": "New Name"},
- state_key="",
- )
- assert len(result) == 1
- assert "Переименован" in result[0].text
-
-
-async def test_mat11b_rename_from_unregistered_room_returns_error_message():
- platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
-
- client = SimpleNamespace(room_put_state=AsyncMock())
- handler = make_handle_rename(client, store)
- event = IncomingCommand(
- user_id="@alice:example.org",
- platform="matrix",
- chat_id="unregistered:!old:example.org",
- command="rename",
- args=["New"],
- )
-
- result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
-
- client.room_put_state.assert_not_awaited()
- assert len(result) == 1
- assert "не найден" in result[0].text.lower() or "примите приглашение" in result[0].text.lower()
-
-
-async def test_mat12_room_create_error_returns_user_message():
- platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
- await set_user_meta(
- store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2}
- )
-
- client = SimpleNamespace(
- room_create=AsyncMock(
- return_value=RoomCreateError(message="rate limited", status_code="429")
- ),
- room_put_state=AsyncMock(),
- room_invite=AsyncMock(),
- )
- handler = make_handle_new_chat(client, store)
- event = IncomingCommand(
- user_id="@alice:example.org",
- platform="matrix",
- chat_id="C1",
- command="new",
- args=["Fail"],
- )
- result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
-
- assert len(result) == 1
- assert isinstance(result[0], OutgoingMessage)
- assert "Не удалось" in result[0].text or "не удалось" in result[0].text
- client.room_put_state.assert_not_awaited()
diff --git a/tests/adapter/matrix/test_confirm.py b/tests/adapter/matrix/test_confirm.py
deleted file mode 100644
index bf52613..0000000
--- a/tests/adapter/matrix/test_confirm.py
+++ /dev/null
@@ -1,130 +0,0 @@
-from __future__ import annotations
-
-from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_confirm
-from adapter.matrix.store import get_pending_confirm, set_pending_confirm
-from core.auth import AuthManager
-from core.chat import ChatManager
-from core.protocol import IncomingCallback, OutgoingMessage
-from core.settings import SettingsManager
-from core.store import InMemoryStore
-from sdk.mock import MockPlatformClient
-
-
-async def test_mat09_yes_reads_pending_confirm():
- store = InMemoryStore()
- platform = MockPlatformClient()
- chat_mgr = ChatManager(platform, store)
- auth_mgr = AuthManager(platform, store)
- settings_mgr = SettingsManager(platform, store)
-
- await set_pending_confirm(
- store,
- "@alice:example.org",
- "!confirm:example.org",
- {
- "action_id": "delete_file",
- "description": "Удалить файл config.yaml",
- "payload": {},
- },
- )
-
- handler = make_handle_confirm(store)
- event = IncomingCallback(
- user_id="@alice:example.org",
- platform="matrix",
- chat_id="C7",
- action="confirm",
- payload={"source": "command", "command": "yes", "room_id": "!confirm:example.org"},
- )
- result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
-
- assert len(result) == 1
- assert isinstance(result[0], OutgoingMessage)
- assert "Удалить файл config.yaml" in result[0].text
- assert await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") is None
-
-
-async def test_no_clears_pending_confirm():
- store = InMemoryStore()
- platform = MockPlatformClient()
- chat_mgr = ChatManager(platform, store)
- auth_mgr = AuthManager(platform, store)
- settings_mgr = SettingsManager(platform, store)
-
- await set_pending_confirm(
- store,
- "@alice:example.org",
- "!confirm:example.org",
- {
- "action_id": "delete_file",
- "description": "Удалить файл",
- "payload": {},
- },
- )
-
- handler = make_handle_cancel(store)
- event = IncomingCallback(
- user_id="@alice:example.org",
- platform="matrix",
- chat_id="C7",
- action="cancel",
- payload={"source": "command", "command": "no", "room_id": "!confirm:example.org"},
- )
- result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
-
- assert len(result) == 1
- assert "отменено" in result[0].text.lower()
- assert await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") is None
-
-
-async def test_yes_without_pending_returns_no_pending():
- store = InMemoryStore()
- platform = MockPlatformClient()
- chat_mgr = ChatManager(platform, store)
- auth_mgr = AuthManager(platform, store)
- settings_mgr = SettingsManager(platform, store)
-
- handler = make_handle_confirm(store)
- event = IncomingCallback(
- user_id="@alice:example.org",
- platform="matrix",
- chat_id="C1",
- action="confirm",
- payload={},
- )
- result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
-
- assert len(result) == 1
- assert "Нет ожидающих" in result[0].text
-
-
-async def test_yes_falls_back_to_legacy_chat_key_without_room_payload():
- store = InMemoryStore()
- platform = MockPlatformClient()
- chat_mgr = ChatManager(platform, store)
- auth_mgr = AuthManager(platform, store)
- settings_mgr = SettingsManager(platform, store)
-
- await set_pending_confirm(
- store,
- "legacy-chat",
- {
- "action_id": "delete_file",
- "description": "Legacy confirm",
- "payload": {},
- },
- )
-
- handler = make_handle_confirm(store)
- event = IncomingCallback(
- user_id="@alice:example.org",
- platform="matrix",
- chat_id="legacy-chat",
- action="confirm",
- payload={"source": "command", "command": "yes"},
- )
- result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
-
- assert len(result) == 1
- assert "Legacy confirm" in result[0].text
- assert await get_pending_confirm(store, "legacy-chat") is None
diff --git a/tests/adapter/matrix/test_context_commands.py b/tests/adapter/matrix/test_context_commands.py
deleted file mode 100644
index 9264a06..0000000
--- a/tests/adapter/matrix/test_context_commands.py
+++ /dev/null
@@ -1,350 +0,0 @@
-from __future__ import annotations
-
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, Mock
-
-import pytest
-
-from adapter.matrix.bot import MatrixBot, build_runtime
-from adapter.matrix.handlers import register_matrix_handlers
-from adapter.matrix.handlers.context_commands import (
- make_handle_context,
- make_handle_load,
- make_handle_reset,
- make_handle_save,
-)
-from adapter.matrix.store import (
- get_load_pending,
- set_load_pending,
- set_room_meta,
-)
-from core.protocol import IncomingCommand, OutgoingMessage
-from core.store import InMemoryStore
-from sdk.interface import MessageResponse
-from sdk.mock import MockPlatformClient
-from sdk.prototype_state import PrototypeStateStore
-
-
-class MatrixCommandPlatform(MockPlatformClient):
- def __init__(self) -> None:
- super().__init__()
- self._prototype_state = PrototypeStateStore()
- self._agent_api = object()
- self.disconnect_chat = AsyncMock()
- self.send_message = AsyncMock(
- return_value=MessageResponse(
- message_id="msg-1",
- response="ok",
- tokens_used=0,
- finished=True,
- )
- )
-
-
-@pytest.fixture(autouse=True)
-def clear_matrix_registry_env(monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False)
- monkeypatch.delenv("MATRIX_PLATFORM_BACKEND", raising=False)
-
-
-@pytest.mark.asyncio
-async def test_save_command_auto_name_records_session():
- platform = MatrixCommandPlatform()
- store = InMemoryStore()
- await set_room_meta(
- store,
- "!room:example.org",
- {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
- )
- handler = make_handle_save(
- agent_api=platform._agent_api,
- store=store,
- prototype_state=platform._prototype_state,
- )
- event = IncomingCommand(
- user_id="u1",
- platform="matrix",
- chat_id="!room:example.org",
- command="save",
- args=[],
- )
-
- result = await handler(event, None, platform, None, None)
-
- assert len(result) == 1
- assert isinstance(result[0], OutgoingMessage)
- assert "Запрос на сохранение отправлен агенту" in result[0].text
- sessions = await platform._prototype_state.list_saved_sessions("u1")
- assert len(sessions) == 1
- assert sessions[0]["name"].startswith("context-")
- assert sessions[0]["source_context_id"] == "41"
-
-
-@pytest.mark.asyncio
-async def test_save_command_with_name_uses_given_name():
- platform = MatrixCommandPlatform()
- store = InMemoryStore()
- await set_room_meta(
- store,
- "!room:example.org",
- {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
- )
- handler = make_handle_save(
- agent_api=platform._agent_api,
- store=store,
- prototype_state=platform._prototype_state,
- )
- event = IncomingCommand(
- user_id="u1",
- platform="matrix",
- chat_id="!room:example.org",
- command="save",
- args=["my-session"],
- )
-
- await handler(event, None, platform, None, None)
-
- sessions = await platform._prototype_state.list_saved_sessions("u1")
- assert [session["name"] for session in sessions] == ["my-session"]
-
-
-@pytest.mark.asyncio
-async def test_load_command_shows_numbered_list_and_sets_pending():
- platform = MatrixCommandPlatform()
- runtime = build_runtime(platform=platform)
- await runtime.chat_mgr.get_or_create(
- user_id="u1",
- chat_id="C1",
- platform="matrix",
- surface_ref="!room:example.org",
- name="Chat 1",
- )
- await platform._prototype_state.add_saved_session("u1", "session-a")
- await platform._prototype_state.add_saved_session("u1", "session-b")
-
- handler = make_handle_load(store=runtime.store, prototype_state=platform._prototype_state)
- event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="load", args=[])
-
- result = await handler(
- event,
- runtime.auth_mgr,
- platform,
- runtime.chat_mgr,
- runtime.settings_mgr,
- )
-
- assert "1. session-a" in result[0].text
- assert "2. session-b" in result[0].text
- pending = await get_load_pending(runtime.store, "u1", "!room:example.org")
- assert pending is not None
- assert [session["name"] for session in pending["saves"]] == ["session-a", "session-b"]
-
-
-@pytest.mark.asyncio
-async def test_load_command_without_saved_sessions_reports_empty():
- platform = MatrixCommandPlatform()
- store = InMemoryStore()
- handler = make_handle_load(store=store, prototype_state=platform._prototype_state)
- event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="load", args=[])
-
- result = await handler(event, None, platform, None, None)
-
- assert "Нет сохранённых сессий" in result[0].text
-
-
-@pytest.mark.asyncio
-async def test_reset_command_assigns_new_platform_chat_id():
- from adapter.matrix.store import get_platform_chat_id, set_room_meta
- from sdk.prototype_state import PrototypeStateStore
-
- prototype_state = PrototypeStateStore()
- platform = MatrixCommandPlatform()
- runtime = build_runtime(platform=platform)
- store = runtime.store
-
- await set_room_meta(store, "!room:example.org", {"platform_chat_id": "7"})
-
- handler = make_handle_reset(store=store, prototype_state=prototype_state)
- event = IncomingCommand(
- user_id="u1",
- platform="matrix",
- chat_id="!room:example.org",
- command="reset",
- args=[],
- )
-
- result = await handler(
- event,
- runtime.auth_mgr,
- platform,
- runtime.chat_mgr,
- runtime.settings_mgr,
- )
-
- new_id = await get_platform_chat_id(store, "!room:example.org")
- assert new_id != "7"
- assert new_id == "1"
- assert "сброшен" in result[0].text.lower()
-
-
-@pytest.mark.asyncio
-async def test_clear_command_rotates_only_current_room_and_disconnects_old_upstream_chat():
- from adapter.matrix.store import get_platform_chat_id
-
- platform = MatrixCommandPlatform()
- runtime = build_runtime(platform=platform)
- await runtime.chat_mgr.get_or_create(
- user_id="u1",
- chat_id="C1",
- platform="matrix",
- surface_ref="!room-a:example.org",
- name="Chat A",
- )
- await runtime.chat_mgr.get_or_create(
- user_id="u1",
- chat_id="C2",
- platform="matrix",
- surface_ref="!room-b:example.org",
- name="Chat B",
- )
- await set_room_meta(
- runtime.store,
- "!room-a:example.org",
- {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
- )
- await set_room_meta(
- runtime.store,
- "!room-b:example.org",
- {"chat_id": "C2", "matrix_user_id": "u1", "platform_chat_id": "99"},
- )
-
- handler = make_handle_reset(store=runtime.store, prototype_state=platform._prototype_state)
- event = IncomingCommand(
- user_id="u1",
- platform="matrix",
- chat_id="C1",
- command="clear",
- args=[],
- )
-
- result = await handler(
- event,
- runtime.auth_mgr,
- platform,
- runtime.chat_mgr,
- runtime.settings_mgr,
- )
-
- room_a_chat_id = await get_platform_chat_id(runtime.store, "!room-a:example.org")
- room_b_chat_id = await get_platform_chat_id(runtime.store, "!room-b:example.org")
- assert room_a_chat_id == "1"
- assert room_a_chat_id != "41"
- assert room_b_chat_id == "99"
- platform.disconnect_chat.assert_awaited_once_with("41")
- assert "сброшен" in result[0].text.lower()
-
-
-def test_register_matrix_handlers_exposes_clear_and_optional_reset_alias():
- dispatcher = SimpleNamespace(register=Mock())
-
- register_matrix_handlers(
- dispatcher,
- client=object(),
- store=object(),
- registry=None,
- prototype_state=PrototypeStateStore(),
- )
-
- clear_calls = [
- call
- for call in dispatcher.register.call_args_list
- if call.args[:2] == (IncomingCommand, "clear")
- ]
- reset_calls = [
- call
- for call in dispatcher.register.call_args_list
- if call.args[:2] == (IncomingCommand, "reset")
- ]
- assert clear_calls
- assert len(reset_calls) <= 1
-
-
-@pytest.mark.asyncio
-async def test_context_command_shows_current_snapshot():
- platform = MatrixCommandPlatform()
- runtime = build_runtime(platform=platform)
- await runtime.chat_mgr.get_or_create(
- user_id="u1",
- chat_id="C1",
- platform="matrix",
- surface_ref="!room:example.org",
- name="Chat 1",
- )
- await set_room_meta(
- runtime.store,
- "!room:example.org",
- {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"},
- )
- await platform._prototype_state.set_current_session("41", "session-a")
- await platform._prototype_state.set_last_tokens_used("41", 99)
- await platform._prototype_state.add_saved_session("u1", "session-a")
- handler = make_handle_context(store=runtime.store, prototype_state=platform._prototype_state)
- event = IncomingCommand(
- user_id="u1",
- platform="matrix",
- chat_id="C1",
- command="context",
- args=[],
- )
-
- result = await handler(
- event,
- runtime.auth_mgr,
- platform,
- runtime.chat_mgr,
- runtime.settings_mgr,
- )
-
- assert "Контекст чата: 41" in result[0].text
- assert "Сессия: session-a" in result[0].text
- assert "Токены (последний ответ): 99" in result[0].text
- assert "session-a" in result[0].text
-
-
-@pytest.mark.asyncio
-async def test_bot_intercepts_numeric_load_selection():
- platform = MatrixCommandPlatform()
- runtime = build_runtime(platform=platform)
- await set_room_meta(
- runtime.store,
- "!room:example.org",
- {
- "chat_id": "C1",
- "matrix_user_id": "@alice:example.org",
- "platform_chat_id": "41",
- },
- )
- client = SimpleNamespace(
- user_id="@bot:example.org",
- room_send=AsyncMock(),
- )
- bot = MatrixBot(client, runtime)
- await set_load_pending(
- runtime.store,
- "@alice:example.org",
- "!room:example.org",
- {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]},
- )
- room = SimpleNamespace(room_id="!room:example.org")
- event = SimpleNamespace(sender="@alice:example.org", body="1")
-
- await bot.on_room_message(room, event)
-
- platform.send_message.assert_awaited_once()
- assert await platform._prototype_state.get_current_session("41") == "session-a"
- assert await platform._prototype_state.get_current_session("C1") == "session-a"
- client.room_send.assert_awaited_once_with(
- "!room:example.org",
- "m.room.message",
- {"msgtype": "m.text", "body": "Запрос на загрузку отправлен агенту: session-a"},
- )
diff --git a/tests/adapter/matrix/test_converter.py b/tests/adapter/matrix/test_converter.py
deleted file mode 100644
index 3513913..0000000
--- a/tests/adapter/matrix/test_converter.py
+++ /dev/null
@@ -1,179 +0,0 @@
-from __future__ import annotations
-
-from types import SimpleNamespace
-
-import adapter.matrix.converter as converter
-from adapter.matrix.converter import from_command, from_room_event
-from core.protocol import IncomingCallback, IncomingCommand, IncomingMessage
-
-
-def text_event(body: str, sender: str = "@a:m.org", event_id: str = "$e1"):
- return SimpleNamespace(
- sender=sender, body=body, event_id=event_id, msgtype="m.text", replyto_event_id=None
- )
-
-
-def file_event(url: str = "mxc://x/y", filename: str = "doc.pdf", mime: str = "application/pdf"):
- return SimpleNamespace(
- sender="@a:m.org",
- body=filename,
- event_id="$e2",
- msgtype="m.file",
- replyto_event_id=None,
- url=url,
- mimetype=mime,
- )
-
-
-def image_event(url: str = "mxc://x/img", mime: str = "image/jpeg"):
- return SimpleNamespace(
- sender="@a:m.org",
- body="img.jpg",
- event_id="$e3",
- msgtype="m.image",
- replyto_event_id=None,
- url=url,
- mimetype=mime,
- )
-
-
-def content_file_event():
- return SimpleNamespace(
- sender="@a:m.org",
- body="doc.pdf",
- event_id="$e4",
- msgtype=None,
- replyto_event_id=None,
- content={
- "msgtype": "m.file",
- "body": "nested.pdf",
- "url": "mxc://x/nested",
- "info": {"mimetype": "application/pdf"},
- },
- )
-
-
-def source_only_content_file_event():
- return SimpleNamespace(
- sender="@a:m.org",
- body="doc.pdf",
- event_id="$e5",
- msgtype=None,
- replyto_event_id=None,
- source={
- "content": {
- "msgtype": "m.file",
- "body": "source-only.pdf",
- "url": "mxc://x/source-only",
- "info": {"mimetype": "application/pdf"},
- }
- },
- )
-
-
-def test_plain_text_to_incoming_message():
- result = from_room_event(text_event("Hello"), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingMessage)
- assert result.text == "Hello"
- assert result.platform == "matrix"
- assert result.chat_id == "C1"
- assert result.attachments == []
-
-
-def test_bang_command_to_incoming_command():
- result = from_room_event(text_event("!new Analysis"), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingCommand)
- assert result.command == "new"
- assert result.args == ["Analysis"]
-
-
-def test_list_command_maps_to_matrix_list_attachments():
- result = from_room_event(text_event("!list"), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingCommand)
- assert result.command == "matrix_list_attachments"
- assert result.args == []
-
-
-def test_remove_all_maps_to_matrix_remove_attachment():
- result = from_room_event(text_event("!remove all"), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingCommand)
- assert result.command == "matrix_remove_attachment"
- assert result.args == ["all"]
-
-
-def test_remove_index_maps_to_matrix_remove_attachment():
- result = from_room_event(text_event("!remove 2"), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingCommand)
- assert result.command == "matrix_remove_attachment"
- assert result.args == ["2"]
-
-
-def test_remove_arbitrary_index_maps_to_matrix_remove_attachment():
- result = from_room_event(text_event("!remove 99"), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingCommand)
- assert result.command == "matrix_remove_attachment"
- assert result.args == ["99"]
-
-
-def test_skills_alias_to_settings_command():
- result = from_command("!skills", sender="@a:m.org", chat_id="C1")
- assert isinstance(result, IncomingCommand)
- assert result.command == "settings_skills"
-
-
-def test_yes_to_callback():
- result = from_room_event(text_event("!yes"), room_id="!room:example.org", chat_id="C7")
- assert isinstance(result, IncomingCallback)
- assert result.action == "confirm"
- assert result.chat_id == "C7"
- assert result.payload["room_id"] == "!room:example.org"
-
-
-def test_no_to_callback():
- result = from_room_event(text_event("!no"), room_id="!room:example.org", chat_id="C7")
- assert isinstance(result, IncomingCallback)
- assert result.action == "cancel"
- assert result.chat_id == "C7"
- assert result.payload["room_id"] == "!room:example.org"
-
-
-def test_file_attachment():
- result = from_room_event(file_event(), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingMessage)
- assert len(result.attachments) == 1
- a = result.attachments[0]
- assert a.type == "document"
- assert a.url == "mxc://x/y"
- assert a.filename == "doc.pdf"
- assert a.mime_type == "application/pdf"
-
-
-def test_image_attachment():
- result = from_room_event(image_event(), room_id="!r:m.org", chat_id="C1")
- assert result.attachments[0].type == "image"
- assert result.attachments[0].filename == "img.jpg"
- assert result.attachments[0].mime_type == "image/jpeg"
-
-
-def test_attachment_falls_back_to_content_payload():
- result = from_room_event(content_file_event(), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingMessage)
- a = result.attachments[0]
- assert a.type == "document"
- assert a.url == "mxc://x/nested"
- assert a.filename == "nested.pdf"
- assert a.mime_type == "application/pdf"
-
-
-def test_attachment_falls_back_to_source_content_payload():
- result = from_room_event(source_only_content_file_event(), room_id="!r:m.org", chat_id="C1")
- assert isinstance(result, IncomingMessage)
- a = result.attachments[0]
- assert a.type == "document"
- assert a.url == "mxc://x/source-only"
- assert a.filename == "source-only.pdf"
- assert a.mime_type == "application/pdf"
-
-
-def test_converter_module_does_not_expose_reaction_callbacks():
- assert not hasattr(converter, "from_reaction")
diff --git a/tests/adapter/matrix/test_dispatcher.py b/tests/adapter/matrix/test_dispatcher.py
deleted file mode 100644
index 1240f86..0000000
--- a/tests/adapter/matrix/test_dispatcher.py
+++ /dev/null
@@ -1,1110 +0,0 @@
-from __future__ import annotations
-
-import importlib
-from types import SimpleNamespace
-from unittest.mock import AsyncMock
-
-import pytest
-from nio import (
- RoomMessageAudio,
- RoomMessageFile,
- RoomMessageImage,
- RoomMessageText,
- RoomMessageVideo,
-)
-from nio.api import RoomVisibility
-from nio.responses import SyncResponse
-
-from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry
-from adapter.matrix.bot import MatrixBot, build_runtime, prepare_live_sync
-from adapter.matrix.handlers.auth import handle_invite
-from adapter.matrix.routed_platform import RoutedPlatformClient
-from adapter.matrix.store import (
- add_staged_attachment,
- get_platform_chat_id,
- get_room_meta,
- get_staged_attachments,
- get_user_meta,
- set_load_pending,
- set_room_meta,
- set_user_meta,
-)
-from core.protocol import (
- Attachment,
- IncomingCallback,
- IncomingCommand,
- IncomingMessage,
- OutgoingMessage,
-)
-from sdk.interface import PlatformError
-from sdk.mock import MockPlatformClient
-
-
-async def test_matrix_dispatcher_registers_custom_handlers():
- runtime = build_runtime(platform=MockPlatformClient())
- current_chat_id = "C9"
-
- start = IncomingCommand(
- user_id="u1", platform="matrix", chat_id=current_chat_id, command="start"
- )
- await runtime.dispatcher.dispatch(start)
-
- new = IncomingCommand(
- user_id="u1", platform="matrix", chat_id=current_chat_id, command="new", args=["Research"]
- )
- result = await runtime.dispatcher.dispatch(new)
- assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result)
-
- chats = await runtime.chat_mgr.list_active("u1")
- assert [c.chat_id for c in chats] == ["C1"]
- assert [c.surface_ref for c in chats] == [current_chat_id]
-
- new2 = IncomingCommand(
- user_id="u1", platform="matrix", chat_id=current_chat_id, command="new", args=["Ops"]
- )
- await runtime.dispatcher.dispatch(new2)
- chats = await runtime.chat_mgr.list_active("u1")
- assert [c.chat_id for c in chats] == ["C1", "C2"]
-
- skills = IncomingCommand(
- user_id="u1", platform="matrix", chat_id=current_chat_id, command="settings_skills"
- )
- result = await runtime.dispatcher.dispatch(skills)
- assert any(isinstance(r, OutgoingMessage) and "mvp" in r.text.lower() for r in result)
-
- toggle = IncomingCallback(
- user_id="u1",
- platform="matrix",
- chat_id="C1",
- action="toggle_skill",
- payload={"skill_index": 2},
- )
- result = await runtime.dispatcher.dispatch(toggle)
- assert any(isinstance(r, OutgoingMessage) and "mvp" in r.text.lower() for r in result)
-
-
-async def test_new_chat_creates_real_matrix_room_when_client_available():
- client = SimpleNamespace(
- room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
- room_put_state=AsyncMock(),
- room_invite=AsyncMock(),
- )
- runtime = build_runtime(platform=MockPlatformClient(), client=client)
- await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 7})
-
- start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C3", command="start")
- await runtime.dispatcher.dispatch(start)
-
- new = IncomingCommand(
- user_id="u1",
- platform="matrix",
- chat_id="C3",
- command="new",
- args=["Research"],
- )
- result = await runtime.dispatcher.dispatch(new)
-
- # room_create is now called with agent_id=None when registry is not configured
- assert client.room_create.await_count >= 1
- client.room_put_state.assert_awaited_once()
- put_call = client.room_put_state.call_args
- assert (
- put_call.kwargs.get("room_id") == "!space:example"
- or put_call.args[0] == "!space:example"
- )
- chats = await runtime.chat_mgr.list_active("u1")
- assert [c.chat_id for c in chats] == ["C7"]
- assert [c.surface_ref for c in chats] == ["!r2:example"]
- assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result)
-
-
-async def test_invite_event_creates_space_and_chat_room():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 4})
- space_resp = SimpleNamespace(room_id="!space:example.org")
- chat_resp = SimpleNamespace(room_id="!chat1:example.org")
- client = SimpleNamespace(
- join=AsyncMock(),
- room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
- room_put_state=AsyncMock(),
- room_invite=AsyncMock(),
- room_send=AsyncMock(),
- )
- room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
- event = SimpleNamespace(sender="@alice:example.org", membership="invite")
-
- await handle_invite(
- client,
- room,
- event,
- runtime.platform,
- runtime.store,
- runtime.auth_mgr,
- runtime.chat_mgr,
- )
-
- assert client.room_create.await_count == 2
- first_call = client.room_create.call_args_list[0]
- assert first_call.kwargs.get("space") is True or (
- len(first_call.args) > 0 and first_call.kwargs.get("space") is True
- )
- assert first_call.kwargs.get("visibility") is RoomVisibility.private
- assert first_call.kwargs.get("invite") == ["@alice:example.org"]
- second_call = client.room_create.call_args_list[1]
- assert second_call.kwargs.get("visibility") is RoomVisibility.private
- assert second_call.kwargs.get("invite") == ["@alice:example.org"]
- client.room_invite.assert_not_awaited()
-
- client.room_put_state.assert_awaited_once()
- put_state_call = client.room_put_state.call_args
- assert (
- put_state_call.kwargs.get("event_type") == "m.space.child"
- or put_state_call.args[1] == "m.space.child"
- )
-
- user_meta = await get_user_meta(runtime.store, "@alice:example.org")
- assert user_meta is not None
- assert user_meta.get("space_id") == "!space:example.org"
-
- room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
- assert room_meta is not None
- assert room_meta["chat_id"] == "C4"
- assert room_meta["space_id"] == "!space:example.org"
-
- assert await runtime.auth_mgr.is_authenticated("@alice:example.org") is True
- assert user_meta.get("next_chat_index") == 5
- client.room_send.assert_awaited_once()
-
-
-async def test_invite_event_is_idempotent_per_user():
- runtime = build_runtime(platform=MockPlatformClient())
- space_resp = SimpleNamespace(room_id="!space:example.org")
- chat_resp = SimpleNamespace(room_id="!chat1:example.org")
- client = SimpleNamespace(
- join=AsyncMock(),
- room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
- room_put_state=AsyncMock(),
- room_invite=AsyncMock(),
- room_send=AsyncMock(),
- )
- room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
- event = SimpleNamespace(sender="@alice:example.org", membership="invite")
-
- await handle_invite(
- client,
- room,
- event,
- runtime.platform,
- runtime.store,
- runtime.auth_mgr,
- runtime.chat_mgr,
- )
- await handle_invite(
- client,
- room,
- event,
- runtime.platform,
- runtime.store,
- runtime.auth_mgr,
- runtime.chat_mgr,
- )
-
- assert client.join.await_count == 2
- assert client.room_create.await_count == 2
- assert client.room_send.await_count == 2
-
-
-async def test_bot_ignores_its_own_messages():
- runtime = build_runtime(platform=MockPlatformClient())
- client = SimpleNamespace(user_id="@bot:example.org")
- bot = MatrixBot(client, runtime)
- bot._send_all = AsyncMock()
- runtime.dispatcher.dispatch = AsyncMock()
- room = SimpleNamespace(room_id="!dm:example.org")
- event = SimpleNamespace(sender="@bot:example.org", body="hello")
-
- await bot.on_room_message(room, event)
-
- runtime.dispatcher.dispatch.assert_not_awaited()
- bot._send_all.assert_not_awaited()
-
-
-async def test_bot_degrades_platform_errors_to_user_reply():
- runtime = build_runtime(platform=MockPlatformClient())
- client = SimpleNamespace(
- user_id="@bot:example.org",
- room_send=AsyncMock(),
- )
- bot = MatrixBot(client, runtime)
- runtime.dispatcher.dispatch = AsyncMock(
- side_effect=PlatformError("Missing Authentication header", code="401")
- )
- room = SimpleNamespace(room_id="!dm:example.org")
- event = SimpleNamespace(sender="@alice:example.org", body="hello")
-
- await bot.on_room_message(room, event)
-
- client.room_send.assert_awaited_once_with(
- "!dm:example.org",
- "m.room.message",
- {
- "msgtype": "m.text",
- "body": "Сервис временно недоступен. Попробуйте ещё раз позже.",
- },
- )
-
-
-async def test_bot_assigns_platform_chat_id_for_existing_managed_room():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_room_meta(
- runtime.store,
- "!chat1:example.org",
- {"chat_id": "C1", "matrix_user_id": "@alice:example.org"},
- )
- client = SimpleNamespace(user_id="@bot:example.org")
- bot = MatrixBot(client, runtime)
- bot._send_all = AsyncMock()
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- room = SimpleNamespace(room_id="!chat1:example.org")
- event = SimpleNamespace(sender="@alice:example.org", body="hello")
-
- await bot.on_room_message(room, event)
-
- assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "1"
- runtime.dispatcher.dispatch.assert_awaited_once()
-
-
-async def test_bot_keeps_local_chat_id_for_plain_messages():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_room_meta(
- runtime.store,
- "!chat1:example.org",
- {
- "chat_id": "C1",
- "matrix_user_id": "@alice:example.org",
- "platform_chat_id": "41",
- },
- )
- client = SimpleNamespace(user_id="@bot:example.org")
- bot = MatrixBot(client, runtime)
- bot._send_all = AsyncMock()
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- room = SimpleNamespace(room_id="!chat1:example.org")
- event = SimpleNamespace(sender="@alice:example.org", body="hello")
-
- await bot.on_room_message(room, event)
-
- dispatched = runtime.dispatcher.dispatch.await_args.args[0]
- assert dispatched.chat_id == "C1"
- assert dispatched.text == "hello"
-
-
-async def test_bot_downloads_matrix_file_to_workspace_before_staging(tmp_path, monkeypatch):
- monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path))
- runtime = build_runtime(platform=MockPlatformClient())
- await set_room_meta(
- runtime.store,
- "!chat1:example.org",
- {
- "chat_id": "C1",
- "matrix_user_id": "@alice:example.org",
- "platform_chat_id": "41",
- },
- )
- client = SimpleNamespace(
- user_id="@bot:example.org",
- download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")),
- )
- bot = MatrixBot(client, runtime)
- bot._send_all = AsyncMock()
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- room = SimpleNamespace(room_id="!chat1:example.org")
- event = SimpleNamespace(
- sender="@alice:example.org",
- body="report.pdf",
- msgtype="m.file",
- replyto_event_id=None,
- url="mxc://server/id",
- mimetype="application/pdf",
- )
-
- await bot.on_room_message(room, event)
-
- runtime.dispatcher.dispatch.assert_not_awaited()
- staged = await get_staged_attachments(runtime.store, "!chat1:example.org", "@alice:example.org")
- assert staged[0]["workspace_path"] is not None
- assert (tmp_path / staged[0]["workspace_path"]).read_bytes() == b"%PDF-1.7"
- bot._send_all.assert_not_awaited()
-
-
-async def test_bot_downloads_matrix_file_to_configured_agent_workspace(tmp_path, monkeypatch):
- monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents"))
- runtime = build_runtime(platform=MockPlatformClient())
- runtime.registry = AgentRegistry(
- [
- AgentDefinition(
- agent_id="agent-17",
- label="Agent 17",
- base_url="http://lambda.coredump.ru:7000/agent_17/",
- workspace_path=str(tmp_path / "agents" / "17"),
- )
- ],
- user_agents={"@alice:example.org": "agent-17"},
- )
- await set_room_meta(
- runtime.store,
- "!chat17:example.org",
- {
- "chat_id": "C17",
- "matrix_user_id": "@alice:example.org",
- "platform_chat_id": "17",
- "agent_id": "agent-17",
- },
- )
- client = SimpleNamespace(
- user_id="@bot:example.org",
- download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")),
- )
- bot = MatrixBot(client, runtime)
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- room = SimpleNamespace(room_id="!chat17:example.org")
- event = SimpleNamespace(
- sender="@alice:example.org",
- body="report.pdf",
- msgtype="m.file",
- replyto_event_id=None,
- url="mxc://server/id",
- mimetype="application/pdf",
- )
-
- await bot.on_room_message(room, event)
-
- staged = await get_staged_attachments(
- runtime.store, "!chat17:example.org", "@alice:example.org"
- )
- assert staged[0]["workspace_path"] == "report.pdf"
- assert (
- tmp_path / "agents" / "17" / staged[0]["workspace_path"]
- ).read_bytes() == b"%PDF-1.7"
-
-
-async def test_bot_uploads_agent_output_from_configured_agent_workspace(tmp_path, monkeypatch):
- monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents"))
- output_file = tmp_path / "agents" / "17" / "result.txt"
- output_file.parent.mkdir(parents=True)
- output_file.write_text("ready", encoding="utf-8")
- runtime = build_runtime(platform=MockPlatformClient())
- runtime.registry = AgentRegistry(
- [
- AgentDefinition(
- agent_id="agent-17",
- label="Agent 17",
- base_url="http://lambda.coredump.ru:7000/agent_17/",
- workspace_path=str(tmp_path / "agents" / "17"),
- )
- ],
- user_agents={"@alice:example.org": "agent-17"},
- )
- await set_room_meta(
- runtime.store,
- "!chat17:example.org",
- {
- "chat_id": "C17",
- "matrix_user_id": "@alice:example.org",
- "platform_chat_id": "17",
- "agent_id": "agent-17",
- },
- )
- client = SimpleNamespace(
- user_id="@bot:example.org",
- upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/result"), {})),
- room_send=AsyncMock(),
- )
- bot = MatrixBot(client, runtime)
- runtime.dispatcher.dispatch = AsyncMock(
- return_value=[
- OutgoingMessage(
- chat_id="C17",
- text="Файл готов",
- attachments=[
- Attachment(
- type="document",
- filename="result.txt",
- mime_type="text/plain",
- workspace_path="result.txt",
- )
- ],
- )
- ]
- )
- room = SimpleNamespace(room_id="!chat17:example.org")
- event = SimpleNamespace(
- sender="@alice:example.org",
- body="сделай отчёт",
- msgtype="m.text",
- replyto_event_id=None,
- )
-
- await bot.on_room_message(room, event)
-
- uploaded_handle = client.upload.await_args.args[0]
- assert uploaded_handle.name == str(output_file)
- assert client.room_send.await_args_list[1].args[2]["url"] == "mxc://server/result"
-
-
-async def test_file_only_event_is_staged_and_does_not_dispatch():
- runtime = build_runtime(platform=MockPlatformClient())
- client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
- bot = MatrixBot(client, runtime)
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- bot._materialize_incoming_attachments = AsyncMock(
- return_value=IncomingMessage(
- user_id="@alice:example.org",
- platform="matrix",
- chat_id="!r:example.org",
- text="",
- attachments=[
- Attachment(
- type="document",
- filename="report.pdf",
- workspace_path="surfaces/matrix/alice/r/inbox/report.pdf",
- mime_type="application/pdf",
- )
- ],
- )
- )
- room = SimpleNamespace(room_id="!r:example.org")
- event = SimpleNamespace(
- sender="@alice:example.org",
- body="report.pdf",
- msgtype="m.file",
- url="mxc://hs/id",
- mimetype="application/pdf",
- replyto_event_id=None,
- )
-
- await bot.on_room_message(room, event)
-
- runtime.dispatcher.dispatch.assert_not_awaited()
- staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
- assert [item["filename"] for item in staged] == ["report.pdf"]
- client.room_send.assert_not_awaited()
-
-
-async def test_list_command_returns_current_staged_attachments():
- runtime = build_runtime(platform=MockPlatformClient())
- await add_staged_attachment(
- runtime.store,
- "!r:example.org",
- "@alice:example.org",
- {"filename": "a.pdf", "workspace_path": "a.pdf"},
- )
- await add_staged_attachment(
- runtime.store,
- "!r:example.org",
- "@alice:example.org",
- {"filename": "b.pdf", "workspace_path": "b.pdf"},
- )
- client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
- bot = MatrixBot(client, runtime)
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- room = SimpleNamespace(room_id="!r:example.org")
- event = SimpleNamespace(
- sender="@alice:example.org", body="!list", msgtype="m.text", replyto_event_id=None
- )
-
- await bot.on_room_message(room, event)
-
- runtime.dispatcher.dispatch.assert_not_awaited()
- body = client.room_send.await_args.args[2]["body"]
- assert "1. a.pdf" in body
- assert "2. b.pdf" in body
-
-
-async def test_remove_invalid_index_returns_short_error():
- runtime = build_runtime(platform=MockPlatformClient())
- await add_staged_attachment(
- runtime.store,
- "!r:example.org",
- "@alice:example.org",
- {"filename": "a.pdf", "workspace_path": "a.pdf"},
- )
- client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
- bot = MatrixBot(client, runtime)
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- room = SimpleNamespace(room_id="!r:example.org")
- event = SimpleNamespace(
- sender="@alice:example.org", body="!remove 9", msgtype="m.text", replyto_event_id=None
- )
-
- await bot.on_room_message(room, event)
-
- runtime.dispatcher.dispatch.assert_not_awaited()
- assert client.room_send.await_args.args[2]["body"] == "Нет такого вложения."
-
-
-async def test_remove_attachment_updates_list_and_state():
- runtime = build_runtime(platform=MockPlatformClient())
- await add_staged_attachment(
- runtime.store,
- "!r:example.org",
- "@alice:example.org",
- {"filename": "a.pdf", "workspace_path": "a.pdf"},
- )
- await add_staged_attachment(
- runtime.store,
- "!r:example.org",
- "@alice:example.org",
- {"filename": "b.pdf", "workspace_path": "b.pdf"},
- )
- client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
- bot = MatrixBot(client, runtime)
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- room = SimpleNamespace(room_id="!r:example.org")
- event = SimpleNamespace(
- sender="@alice:example.org", body="!remove 1", msgtype="m.text", replyto_event_id=None
- )
-
- await bot.on_room_message(room, event)
-
- runtime.dispatcher.dispatch.assert_not_awaited()
- staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
- assert [item["filename"] for item in staged] == ["b.pdf"]
- body = client.room_send.await_args.args[2]["body"]
- assert "1. b.pdf" in body
- assert "a.pdf" not in body
-
-
-async def test_remove_all_clears_state():
- runtime = build_runtime(platform=MockPlatformClient())
- await add_staged_attachment(
- runtime.store,
- "!r:example.org",
- "@alice:example.org",
- {"filename": "a.pdf", "workspace_path": "a.pdf"},
- )
- client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
- bot = MatrixBot(client, runtime)
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- room = SimpleNamespace(room_id="!r:example.org")
- event = SimpleNamespace(
- sender="@alice:example.org",
- body="!remove all",
- msgtype="m.text",
- replyto_event_id=None,
- )
-
- await bot.on_room_message(room, event)
-
- runtime.dispatcher.dispatch.assert_not_awaited()
- assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == []
- assert client.room_send.await_args.args[2]["body"] == "Все вложения удалены."
-
-
-async def test_staged_attachment_commands_are_scoped_by_room_and_user():
- runtime = build_runtime(platform=MockPlatformClient())
- await add_staged_attachment(
- runtime.store,
- "!r-one:example.org",
- "@alice:example.org",
- {"filename": "alice-room-one.pdf", "workspace_path": "alice-room-one.pdf"},
- )
- await add_staged_attachment(
- runtime.store,
- "!r-two:example.org",
- "@alice:example.org",
- {"filename": "alice-room-two.pdf", "workspace_path": "alice-room-two.pdf"},
- )
- await add_staged_attachment(
- runtime.store,
- "!r-one:example.org",
- "@bob:example.org",
- {"filename": "bob-room-one.pdf", "workspace_path": "bob-room-one.pdf"},
- )
- client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
- bot = MatrixBot(client, runtime)
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- room = SimpleNamespace(room_id="!r-one:example.org")
- event = SimpleNamespace(
- sender="@alice:example.org",
- body="!list",
- msgtype="m.text",
- replyto_event_id=None,
- )
-
- await bot.on_room_message(room, event)
-
- runtime.dispatcher.dispatch.assert_not_awaited()
- body = client.room_send.await_args.args[2]["body"]
- assert "alice-room-one.pdf" in body
- assert "alice-room-two.pdf" not in body
- assert "bob-room-one.pdf" not in body
-
-
-async def test_next_normal_message_commits_staged_attachments():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_room_meta(
- runtime.store,
- "!r:example.org",
- {
- "chat_id": "C1",
- "matrix_user_id": "@alice:example.org",
- "platform_chat_id": "41",
- },
- )
- await add_staged_attachment(
- runtime.store,
- "!r:example.org",
- "@alice:example.org",
- {
- "type": "document",
- "filename": "report.pdf",
- "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf",
- "mime_type": "application/pdf",
- },
- )
- client = SimpleNamespace(user_id="@bot:example.org")
- bot = MatrixBot(client, runtime)
- bot._send_all = AsyncMock()
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- room = SimpleNamespace(room_id="!r:example.org")
- event = SimpleNamespace(
- sender="@alice:example.org",
- body="Проанализируй",
- msgtype="m.text",
- replyto_event_id=None,
- )
-
- await bot.on_room_message(room, event)
-
- dispatched = runtime.dispatcher.dispatch.await_args.args[0]
- assert isinstance(dispatched, IncomingMessage)
- assert dispatched.text == "Проанализируй"
- assert [a.workspace_path for a in dispatched.attachments] == [
- "surfaces/matrix/alice/r/inbox/report.pdf"
- ]
- assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == []
-
-
-async def test_failed_commit_preserves_staged_attachments():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_room_meta(
- runtime.store,
- "!r:example.org",
- {
- "chat_id": "C1",
- "matrix_user_id": "@alice:example.org",
- "platform_chat_id": "41",
- },
- )
- await add_staged_attachment(
- runtime.store,
- "!r:example.org",
- "@alice:example.org",
- {
- "type": "document",
- "filename": "report.pdf",
- "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf",
- },
- )
- client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock())
- bot = MatrixBot(client, runtime)
- runtime.dispatcher.dispatch = AsyncMock(side_effect=PlatformError("boom"))
- room = SimpleNamespace(room_id="!r:example.org")
- event = SimpleNamespace(
- sender="@alice:example.org",
- body="Проанализируй",
- msgtype="m.text",
- replyto_event_id=None,
- )
-
- await bot.on_room_message(room, event)
-
- staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org")
- assert [item["filename"] for item in staged] == ["report.pdf"]
-
-
-async def test_bot_keeps_commands_on_local_chat_id():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_room_meta(
- runtime.store,
- "!chat1:example.org",
- {
- "chat_id": "C1",
- "matrix_user_id": "@alice:example.org",
- "platform_chat_id": "41",
- },
- )
- client = SimpleNamespace(user_id="@bot:example.org")
- bot = MatrixBot(client, runtime)
- bot._send_all = AsyncMock()
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- room = SimpleNamespace(room_id="!chat1:example.org")
- event = SimpleNamespace(sender="@alice:example.org", body="!rename New")
-
- await bot.on_room_message(room, event)
-
- dispatched = runtime.dispatcher.dispatch.await_args.args[0]
- assert dispatched.chat_id == "C1"
- assert dispatched.command == "rename"
-
-
-async def test_bot_leaves_existing_platform_chat_id_unchanged():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_room_meta(
- runtime.store,
- "!chat1:example.org",
- {
- "chat_id": "C1",
- "matrix_user_id": "@alice:example.org",
- "platform_chat_id": "99",
- },
- )
- client = SimpleNamespace(user_id="@bot:example.org")
- bot = MatrixBot(client, runtime)
- bot._send_all = AsyncMock()
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- room = SimpleNamespace(room_id="!chat1:example.org")
- event = SimpleNamespace(sender="@alice:example.org", body="hello")
-
- await bot.on_room_message(room, event)
-
- assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "99"
- runtime.dispatcher.dispatch.assert_awaited_once()
-
-
-async def test_bot_assigns_platform_chat_id_before_load_selection():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_room_meta(
- runtime.store,
- "!chat1:example.org",
- {"chat_id": "C1", "matrix_user_id": "@alice:example.org"},
- )
- client = SimpleNamespace(
- user_id="@bot:example.org",
- room_send=AsyncMock(),
- )
- bot = MatrixBot(client, runtime)
- await set_load_pending(
- runtime.store,
- "@alice:example.org",
- "!chat1:example.org",
- {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]},
- )
- room = SimpleNamespace(room_id="!chat1:example.org")
- event = SimpleNamespace(sender="@alice:example.org", body="0")
-
- await bot.on_room_message(room, event)
-
- assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "1"
- client.room_send.assert_awaited_once_with(
- "!chat1:example.org",
- "m.room.message",
- {"msgtype": "m.text", "body": "Отменено."},
- )
-
-
-async def test_unregistered_room_bootstraps_space_and_chat_on_first_message():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 1})
- space_resp = SimpleNamespace(room_id="!space:example.org")
- chat_resp = SimpleNamespace(room_id="!chat1:example.org")
- client = SimpleNamespace(
- user_id="@bot:example.org",
- room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
- room_put_state=AsyncMock(),
- room_send=AsyncMock(),
- )
- bot = MatrixBot(client, runtime)
- room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
- event = SimpleNamespace(sender="@alice:example.org", body="hello")
-
- await bot.on_room_message(room, event)
-
- assert client.room_create.await_count == 2
- first_call = client.room_create.call_args_list[0]
- second_call = client.room_create.call_args_list[1]
- assert first_call.kwargs.get("space") is True
- assert first_call.kwargs.get("invite") == ["@alice:example.org"]
- assert second_call.kwargs.get("name") == "Чат 1"
- assert second_call.kwargs.get("invite") == ["@alice:example.org"]
- client.room_put_state.assert_awaited_once()
- room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
- assert room_meta is not None
- assert room_meta["chat_id"] == "C1"
- user_meta = await get_user_meta(runtime.store, "@alice:example.org")
- assert user_meta is not None
- assert user_meta["space_id"] == "!space:example.org"
- room_send_calls = client.room_send.await_args_list
- assert any(call.args[0] == "!chat1:example.org" for call in room_send_calls)
- assert any(call.args[0] == "!entry:example.org" for call in room_send_calls)
- entry_meta = await get_room_meta(runtime.store, "!entry:example.org")
- assert entry_meta == {
- "matrix_user_id": "@alice:example.org",
- "redirect_room_id": "!chat1:example.org",
- "redirect_chat_id": "C1",
- }
-
-
-async def test_unregistered_room_second_message_reuses_existing_bootstrap():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 1})
- space_resp = SimpleNamespace(room_id="!space:example.org")
- chat_resp = SimpleNamespace(room_id="!chat1:example.org")
- client = SimpleNamespace(
- user_id="@bot:example.org",
- room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
- room_put_state=AsyncMock(),
- room_send=AsyncMock(),
- )
- bot = MatrixBot(client, runtime)
- room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
-
- await bot.on_room_message(room, SimpleNamespace(sender="@alice:example.org", body="hello"))
- await bot.on_room_message(
- room, SimpleNamespace(sender="@alice:example.org", body="hello again")
- )
-
- assert client.room_create.await_count == 2
- room_send_calls = client.room_send.await_args_list
- assert any(call.args[0] == "!entry:example.org" for call in room_send_calls)
- assert any(
- call.args[0] == "!entry:example.org"
- and "Рабочий чат уже создан: C1" in call.args[2]["body"]
- for call in room_send_calls
- )
- entry_meta = await get_room_meta(runtime.store, "!entry:example.org")
- assert entry_meta is not None
- assert "platform_chat_id" not in entry_meta
-
-
-async def test_unregistered_room_welcome_send_failure_does_not_repeat_bootstrap():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 1})
- space_resp = SimpleNamespace(room_id="!space:example.org")
- chat_resp = SimpleNamespace(room_id="!chat1:example.org")
- client = SimpleNamespace(
- user_id="@bot:example.org",
- room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
- room_put_state=AsyncMock(),
- room_send=AsyncMock(side_effect=[RuntimeError("welcome failed"), None]),
- )
- bot = MatrixBot(client, runtime)
- room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
-
- with pytest.raises(RuntimeError, match="welcome failed"):
- await bot.on_room_message(room, SimpleNamespace(sender="@alice:example.org", body="hello"))
-
- entry_meta = await get_room_meta(runtime.store, "!entry:example.org")
- assert entry_meta == {
- "matrix_user_id": "@alice:example.org",
- "redirect_room_id": "!chat1:example.org",
- "redirect_chat_id": "C1",
- }
-
- await bot.on_room_message(
- room, SimpleNamespace(sender="@alice:example.org", body="hello again")
- )
-
- assert client.room_create.await_count == 2
- room_send_calls = client.room_send.await_args_list
- assert any(
- call.args[0] == "!entry:example.org"
- and "Рабочий чат уже создан: C1" in call.args[2]["body"]
- for call in room_send_calls
- )
-
-
-async def test_unregistered_room_creates_new_chat_in_existing_space():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_user_meta(
- runtime.store,
- "@alice:example.org",
- {"space_id": "!space:example.org", "next_chat_index": 4},
- )
- chat_resp = SimpleNamespace(room_id="!chat4:example.org")
- client = SimpleNamespace(
- user_id="@bot:example.org",
- room_create=AsyncMock(return_value=chat_resp),
- room_put_state=AsyncMock(),
- room_send=AsyncMock(),
- )
- bot = MatrixBot(client, runtime)
- room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry")
- event = SimpleNamespace(sender="@alice:example.org", body="hello")
-
- await bot.on_room_message(room, event)
-
- client.room_create.assert_awaited_once_with(
- name="Чат 4",
- visibility=RoomVisibility.private,
- is_direct=False,
- invite=["@alice:example.org"],
- )
- client.room_put_state.assert_awaited_once()
- room_meta = await get_room_meta(runtime.store, "!chat4:example.org")
- assert room_meta is not None
- assert room_meta["chat_id"] == "C4"
-
-
-async def test_mat11_settings_returns_mvp_unavailable_message():
- runtime = build_runtime(platform=MockPlatformClient())
- current_chat_id = "C9"
-
- start = IncomingCommand(
- user_id="u1", platform="matrix", chat_id=current_chat_id, command="start"
- )
- await runtime.dispatcher.dispatch(start)
-
- settings_cmd = IncomingCommand(
- user_id="u1", platform="matrix", chat_id=current_chat_id, command="settings"
- )
- result = await runtime.dispatcher.dispatch(settings_cmd)
-
- assert len(result) == 1
- text = result[0].text
- assert "недоступна" in text.lower()
- assert "mvp" in text.lower()
-
-
-async def test_mat12_help_returns_command_reference():
- runtime = build_runtime(platform=MockPlatformClient())
-
- result = await runtime.dispatcher.dispatch(
- IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="help")
- )
-
- assert len(result) == 1
- text = result[0].text
- assert "!new" in text
- assert "!chats" in text
- assert "!rename" in text
- assert "!archive" in text
- assert "!clear" in text
- assert "!list" in text
- assert "!yes" in text
- assert "!context" not in text
- assert "!save" not in text
- assert "!load" not in text
- assert "!agent" not in text
- assert "!settings" not in text
- assert "!skills" not in text
-
-
-async def test_unknown_command_returns_helpful_message():
- runtime = build_runtime(platform=MockPlatformClient())
-
- result = await runtime.dispatcher.dispatch(
- IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="clear")
- )
-
- assert len(result) == 1
- assert "неизвестная команда" in result[0].text.lower()
-
-
-async def test_prepare_live_sync_returns_next_batch_from_bootstrap_sync():
- client = SimpleNamespace(
- sync=AsyncMock(
- return_value=SyncResponse(
- next_batch="s123",
- rooms={},
- device_key_count={},
- device_list=SimpleNamespace(changed=[], left=[]),
- to_device_events=[],
- presence_events=[],
- account_data_events=[],
- )
- )
- )
-
- since = await prepare_live_sync(client)
-
- client.sync.assert_awaited_once_with(timeout=0, full_state=True)
- assert since == "s123"
-
-
-async def test_build_runtime_uses_routed_platform_when_matrix_backend_is_real(
- monkeypatch, tmp_path
-):
- registry_path = tmp_path / "agents.yaml"
- registry_path.write_text(
- "agents:\n - id: agent-1\n label: Analyst\n", encoding="utf-8"
- )
- monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
- monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example")
- monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path))
-
- runtime = build_runtime()
-
- assert isinstance(runtime.platform, RoutedPlatformClient)
-
-
-async def test_matrix_main_closes_platform_without_connecting_root_agent(monkeypatch):
- bot_module = importlib.import_module("adapter.matrix.bot")
-
- platform_close = AsyncMock()
- runtime = SimpleNamespace(platform=SimpleNamespace(close=platform_close))
-
- class FakeAsyncClient:
- def __init__(self, *args, **kwargs):
- self.access_token = None
- self.callbacks = []
- self.sync_forever = AsyncMock()
- self.close = AsyncMock()
-
- async def login(self, *args, **kwargs):
- raise AssertionError("login should not be called when access token is provided")
-
- def add_event_callback(self, callback, event_type):
- self.callbacks.append((callback, event_type))
-
- monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
- monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org")
- monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token")
- monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient)
- monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime)
- monkeypatch.setattr(bot_module, "prepare_live_sync", AsyncMock(return_value="s123"))
-
- await bot_module.main()
-
- platform_close.assert_awaited_once()
-
-
-async def test_matrix_main_registers_media_message_callbacks(monkeypatch):
- bot_module = importlib.import_module("adapter.matrix.bot")
-
- runtime = SimpleNamespace(platform=SimpleNamespace(close=AsyncMock()))
- created_clients = []
-
- class FakeAsyncClient:
- def __init__(self, *args, **kwargs):
- self.access_token = None
- self.callbacks = []
- self.sync_forever = AsyncMock()
- self.close = AsyncMock()
- created_clients.append(self)
-
- async def login(self, *args, **kwargs):
- raise AssertionError("login should not be called when access token is provided")
-
- def add_event_callback(self, callback, event_type):
- self.callbacks.append((callback, event_type))
-
- monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
- monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org")
- monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token")
- monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient)
- monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime)
- monkeypatch.setattr(bot_module, "prepare_live_sync", AsyncMock(return_value="s123"))
-
- await bot_module.main()
-
- assert len(created_clients) == 1
- registered_types = [event_type for _, event_type in created_clients[0].callbacks]
- assert (
- RoomMessageText,
- RoomMessageFile,
- RoomMessageImage,
- RoomMessageVideo,
- RoomMessageAudio,
- ) in registered_types
diff --git a/tests/adapter/matrix/test_files.py b/tests/adapter/matrix/test_files.py
deleted file mode 100644
index a3a9146..0000000
--- a/tests/adapter/matrix/test_files.py
+++ /dev/null
@@ -1,94 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-from types import SimpleNamespace
-
-from adapter.matrix.files import (
- build_agent_workspace_path,
- download_matrix_attachment,
-)
-from core.protocol import Attachment
-
-
-async def test_download_matrix_attachment_persists_file_and_returns_workspace_path(tmp_path: Path):
- async def download(url: str):
- assert url == "mxc://server/id"
- return SimpleNamespace(body=b"%PDF-1.7")
-
- client = SimpleNamespace(download=download)
- attachment = Attachment(
- type="document",
- url="mxc://server/id",
- filename="report.pdf",
- mime_type="application/pdf",
- )
-
- saved = await download_matrix_attachment(
- client=client,
- workspace_root=tmp_path,
- matrix_user_id="@alice:example.org",
- room_id="!room:example.org",
- attachment=attachment,
- timestamp="20260420-153000",
- )
-
- assert saved.workspace_path == "report.pdf"
- assert (tmp_path / "report.pdf").read_bytes() == b"%PDF-1.7"
-
-
-def test_build_agent_workspace_path_uses_agent_workspace_volume(tmp_path: Path):
- rel_path, abs_path = build_agent_workspace_path(
- workspace_root=tmp_path / "agents" / "17",
- filename="quarterly status.pdf",
- )
-
- assert rel_path == "quarterly status.pdf"
- assert abs_path == tmp_path / "agents" / "17" / rel_path
-
-
-def test_build_agent_workspace_path_uses_windows_style_copy_index(tmp_path: Path):
- workspace_root = tmp_path / "agents" / "17"
- workspace_root.mkdir(parents=True)
- (workspace_root / "report.pdf").write_bytes(b"old")
- (workspace_root / "report (1).pdf").write_bytes(b"older")
-
- rel_path, abs_path = build_agent_workspace_path(
- workspace_root=workspace_root,
- filename="report.pdf",
- )
-
- assert rel_path == "report (2).pdf"
- assert abs_path == workspace_root / "report (2).pdf"
-
-
-def test_build_agent_workspace_path_sanitizes_to_basename(tmp_path: Path):
- rel_path, abs_path = build_agent_workspace_path(
- workspace_root=tmp_path / "agents" / "17",
- filename="../../quarterly: status?.pdf",
- )
-
- assert rel_path == "quarterly_ status_.pdf"
- assert abs_path == tmp_path / "agents" / "17" / "quarterly_ status_.pdf"
-
-
-async def test_download_matrix_attachment_uses_agent_workspace_root(tmp_path: Path):
- async def download(url: str):
- assert url == "mxc://server/id"
- return SimpleNamespace(body=b"%PDF-1.7")
-
- saved = await download_matrix_attachment(
- client=SimpleNamespace(download=download),
- workspace_root=tmp_path / "agents" / "17",
- matrix_user_id="@alice:example.org",
- room_id="!room:example.org",
- attachment=Attachment(
- type="document",
- url="mxc://server/id",
- filename="report.pdf",
- mime_type="application/pdf",
- ),
- timestamp="20260428-110000",
- )
-
- assert saved.workspace_path == "report.pdf"
- assert (tmp_path / "agents" / "17" / saved.workspace_path).read_bytes() == b"%PDF-1.7"
diff --git a/tests/adapter/matrix/test_invite_space.py b/tests/adapter/matrix/test_invite_space.py
deleted file mode 100644
index 15ca57c..0000000
--- a/tests/adapter/matrix/test_invite_space.py
+++ /dev/null
@@ -1,174 +0,0 @@
-from __future__ import annotations
-
-from types import SimpleNamespace
-from unittest.mock import AsyncMock
-
-from nio.api import RoomVisibility
-
-from adapter.matrix.bot import build_runtime
-from adapter.matrix.handlers.auth import handle_invite
-from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta
-from sdk.mock import MockPlatformClient
-
-
-def _make_client():
- space_resp = SimpleNamespace(room_id="!space:example.org")
- chat_resp = SimpleNamespace(room_id="!chat1:example.org")
- return SimpleNamespace(
- join=AsyncMock(),
- room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
- room_put_state=AsyncMock(),
- room_invite=AsyncMock(),
- room_send=AsyncMock(),
- )
-
-
-async def test_mat01_invite_creates_space_and_chat1():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 4})
- client = _make_client()
- room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
- event = SimpleNamespace(sender="@alice:example.org", membership="invite")
-
- await handle_invite(
- client,
- room,
- event,
- runtime.platform,
- runtime.store,
- runtime.auth_mgr,
- runtime.chat_mgr,
- )
-
- first_call = client.room_create.call_args_list[0]
- assert first_call.kwargs.get("space") is True
- assert first_call.kwargs.get("visibility") is RoomVisibility.private
- assert first_call.kwargs.get("invite") == ["@alice:example.org"]
- second_call = client.room_create.call_args_list[1]
- assert second_call.kwargs.get("visibility") is RoomVisibility.private
- assert second_call.kwargs.get("invite") == ["@alice:example.org"]
- assert client.room_create.await_count == 2
- client.room_invite.assert_not_awaited()
-
- client.room_put_state.assert_awaited_once()
- kwargs = client.room_put_state.call_args.kwargs
- assert kwargs.get("event_type") == "m.space.child"
- assert kwargs.get("state_key") == "!chat1:example.org"
- assert kwargs.get("room_id") == "!space:example.org"
-
- user_meta = await get_user_meta(runtime.store, "@alice:example.org")
- assert user_meta is not None
- assert user_meta["space_id"] == "!space:example.org"
-
- room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
- assert room_meta is not None
- assert room_meta["chat_id"] == "C4"
- assert room_meta["space_id"] == "!space:example.org"
- assert room_meta["platform_chat_id"] == "1"
- assert user_meta["next_chat_index"] == 5
-
- chats = await runtime.chat_mgr.list_active("@alice:example.org")
- assert [chat.chat_id for chat in chats] == ["C4"]
- assert [chat.surface_ref for chat in chats] == ["!chat1:example.org"]
-
-
-async def test_mat02_invite_idempotent():
- runtime = build_runtime(platform=MockPlatformClient())
- client = _make_client()
- room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
- event = SimpleNamespace(sender="@alice:example.org", membership="invite")
-
- await handle_invite(
- client,
- room,
- event,
- runtime.platform,
- runtime.store,
- runtime.auth_mgr,
- runtime.chat_mgr,
- )
- await handle_invite(
- client,
- room,
- event,
- runtime.platform,
- runtime.store,
- runtime.auth_mgr,
- runtime.chat_mgr,
- )
-
- assert client.room_create.await_count == 2
-
-
-async def test_existing_user_invite_reinvites_space_and_active_chats():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_user_meta(
- runtime.store,
- "@alice:example.org",
- {"space_id": "!space:example.org", "next_chat_index": 2},
- )
- await set_room_meta(
- runtime.store,
- "!chat1:example.org",
- {
- "room_type": "chat",
- "chat_id": "C1",
- "display_name": "Чат 1",
- "matrix_user_id": "@alice:example.org",
- "space_id": "!space:example.org",
- "platform_chat_id": "1",
- "agent_id": "agent-1",
- },
- )
- await runtime.chat_mgr.get_or_create(
- user_id="@alice:example.org",
- chat_id="C1",
- platform="matrix",
- surface_ref="!chat1:example.org",
- name="Чат 1",
- )
- client = _make_client()
- room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
- event = SimpleNamespace(sender="@alice:example.org", membership="invite")
-
- await handle_invite(
- client,
- room,
- event,
- runtime.platform,
- runtime.store,
- runtime.auth_mgr,
- runtime.chat_mgr,
- )
-
- client.room_create.assert_not_awaited()
- client.room_invite.assert_any_await("!space:example.org", "@alice:example.org")
- client.room_invite.assert_any_await("!chat1:example.org", "@alice:example.org")
- client.room_send.assert_awaited()
-
-
-async def test_mat03_no_hardcoded_c1():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 7})
- client = _make_client()
- room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
- event = SimpleNamespace(sender="@alice:example.org", membership="invite")
-
- await handle_invite(
- client,
- room,
- event,
- runtime.platform,
- runtime.store,
- runtime.auth_mgr,
- runtime.chat_mgr,
- )
-
- room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
- assert room_meta is not None
- assert room_meta["chat_id"] == "C7"
- assert room_meta["platform_chat_id"] == "1"
-
- user_meta = await get_user_meta(runtime.store, "@alice:example.org")
- assert user_meta is not None
- assert user_meta["next_chat_index"] == 8
diff --git a/tests/adapter/matrix/test_reactions.py b/tests/adapter/matrix/test_reactions.py
deleted file mode 100644
index 7974239..0000000
--- a/tests/adapter/matrix/test_reactions.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from __future__ import annotations
-
-from adapter.matrix.reactions import (
- build_confirmation_text,
- build_skills_text,
-)
-from sdk.interface import UserSettings
-
-
-def test_build_skills_text():
- settings = UserSettings(
- skills={"web-search": True, "fetch-url": False},
- connectors={},
- soul={},
- safety={},
- plan={},
- )
- text = build_skills_text(settings)
- assert "web-search" in text
- assert "fetch-url" in text
- assert "!skill on/off" in text
- assert "1️⃣" not in text
- assert "2️⃣" not in text
- assert "👍" not in text
- assert "❌" not in text
-
-
-def test_build_confirmation_text():
- text = build_confirmation_text("Отправить письмо?")
- assert "Отправить письмо?" in text
- assert "!yes" in text
- assert "!no" in text
diff --git a/tests/adapter/matrix/test_reconciliation.py b/tests/adapter/matrix/test_reconciliation.py
deleted file mode 100644
index c44ffc0..0000000
--- a/tests/adapter/matrix/test_reconciliation.py
+++ /dev/null
@@ -1,253 +0,0 @@
-from __future__ import annotations
-
-import importlib
-from types import SimpleNamespace
-from unittest.mock import AsyncMock
-
-from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry
-from adapter.matrix.bot import MatrixBot, build_runtime
-from adapter.matrix.reconciliation import reconcile_startup_state
-from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta
-from sdk.mock import MockPlatformClient
-
-
-def _room(
- room_id: str,
- name: str,
- members: list[str],
- *,
- parents: tuple[str, ...] = (),
-):
- return SimpleNamespace(
- room_id=room_id,
- name=name,
- display_name=name,
- users={user_id: SimpleNamespace(user_id=user_id) for user_id in members},
- space_parents=set(parents),
- )
-
-
-async def test_reconcile_startup_state_restores_space_room_and_chat_bindings():
- runtime = build_runtime(platform=MockPlatformClient())
- client = SimpleNamespace(
- user_id="@bot:example.org",
- rooms={
- "!space:example.org": _room(
- "!space:example.org",
- "Lambda - Alice",
- ["@bot:example.org", "@alice:example.org"],
- ),
- "!chat3:example.org": _room(
- "!chat3:example.org",
- "Чат 3",
- ["@bot:example.org", "@alice:example.org"],
- parents=("!space:example.org",),
- ),
- },
- )
-
- await reconcile_startup_state(client, runtime)
-
- user_meta = await get_user_meta(runtime.store, "@alice:example.org")
- assert user_meta is not None
- assert user_meta["space_id"] == "!space:example.org"
- assert user_meta["next_chat_index"] == 4
-
- room_meta = await get_room_meta(runtime.store, "!chat3:example.org")
- assert room_meta is not None
- assert room_meta["room_type"] == "chat"
- assert room_meta["chat_id"] == "C3"
- assert room_meta["space_id"] == "!space:example.org"
- assert room_meta["matrix_user_id"] == "@alice:example.org"
- assert room_meta["platform_chat_id"] == "1"
-
- chats = await runtime.chat_mgr.list_active("@alice:example.org")
- assert [chat.chat_id for chat in chats] == ["C3"]
- assert [chat.surface_ref for chat in chats] == ["!chat3:example.org"]
-
-
-async def test_reconcile_startup_state_is_idempotent_with_existing_local_state():
- runtime = build_runtime(platform=MockPlatformClient())
- client = SimpleNamespace(
- user_id="@bot:example.org",
- rooms={
- "!space:example.org": _room(
- "!space:example.org",
- "Lambda - Alice",
- ["@bot:example.org", "@alice:example.org"],
- ),
- "!chat3:example.org": _room(
- "!chat3:example.org",
- "Чат 3",
- ["@bot:example.org", "@alice:example.org"],
- parents=("!space:example.org",),
- ),
- },
- )
- await set_user_meta(
- runtime.store,
- "@alice:example.org",
- {"space_id": "!space:example.org", "next_chat_index": 8},
- )
- await set_room_meta(
- runtime.store,
- "!chat3:example.org",
- {
- "room_type": "chat",
- "chat_id": "C3",
- "display_name": "Existing name",
- "matrix_user_id": "@alice:example.org",
- "space_id": "!space:example.org",
- "platform_chat_id": "42",
- },
- )
- await runtime.chat_mgr.get_or_create(
- user_id="@alice:example.org",
- chat_id="C3",
- platform="matrix",
- surface_ref="!chat3:example.org",
- name="Existing name",
- )
-
- await reconcile_startup_state(client, runtime)
- await reconcile_startup_state(client, runtime)
-
- user_meta = await get_user_meta(runtime.store, "@alice:example.org")
- assert user_meta == {"space_id": "!space:example.org", "next_chat_index": 8}
-
- room_meta = await get_room_meta(runtime.store, "!chat3:example.org")
- assert room_meta is not None
- assert room_meta["display_name"] == "Existing name"
- assert room_meta["platform_chat_id"] == "42"
-
- chats = await runtime.chat_mgr.list_active("@alice:example.org")
- assert len(chats) == 1
- assert chats[0].chat_id == "C3"
-
-
-async def test_reconcile_updates_default_agent_assignment_after_user_is_configured():
- runtime = build_runtime(platform=MockPlatformClient())
- runtime.registry = AgentRegistry(
- [
- AgentDefinition("agent-default", "Default"),
- AgentDefinition("agent-alice", "Alice"),
- ],
- user_agents={"@alice:example.org": "agent-alice"},
- )
- client = SimpleNamespace(
- user_id="@bot:example.org",
- rooms={
- "!space:example.org": _room(
- "!space:example.org",
- "Lambda - Alice",
- ["@bot:example.org", "@alice:example.org"],
- ),
- "!chat3:example.org": _room(
- "!chat3:example.org",
- "Чат 3",
- ["@bot:example.org", "@alice:example.org"],
- parents=("!space:example.org",),
- ),
- },
- )
- await set_room_meta(
- runtime.store,
- "!chat3:example.org",
- {
- "room_type": "chat",
- "chat_id": "C3",
- "display_name": "Чат 3",
- "matrix_user_id": "@alice:example.org",
- "space_id": "!space:example.org",
- "platform_chat_id": "42",
- "agent_id": "agent-default",
- "agent_assignment": "default",
- },
- )
-
- await reconcile_startup_state(client, runtime)
-
- room_meta = await get_room_meta(runtime.store, "!chat3:example.org")
- assert room_meta is not None
- assert room_meta["agent_id"] == "agent-alice"
- assert room_meta["agent_assignment"] == "configured"
- assert room_meta["platform_chat_id"] == "42"
-
-
-async def test_reconciliation_prevents_lazy_bootstrap_for_existing_room():
- runtime = build_runtime(platform=MockPlatformClient())
- client = SimpleNamespace(
- user_id="@bot:example.org",
- rooms={
- "!space:example.org": _room(
- "!space:example.org",
- "Lambda - Alice",
- ["@bot:example.org", "@alice:example.org"],
- ),
- "!chat3:example.org": _room(
- "!chat3:example.org",
- "Чат 3",
- ["@bot:example.org", "@alice:example.org"],
- parents=("!space:example.org",),
- ),
- },
- room_send=AsyncMock(),
- )
- bot = MatrixBot(client=client, runtime=runtime)
- bot._bootstrap_unregistered_room = AsyncMock()
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
-
- await reconcile_startup_state(client, runtime)
- await bot.on_room_message(
- SimpleNamespace(room_id="!chat3:example.org"),
- SimpleNamespace(sender="@alice:example.org", body="hello"),
- )
-
- bot._bootstrap_unregistered_room.assert_not_awaited()
- runtime.dispatcher.dispatch.assert_awaited_once()
-
-
-async def test_matrix_main_runs_reconciliation_before_sync_forever(monkeypatch):
- bot_module = importlib.import_module("adapter.matrix.bot")
-
- runtime = SimpleNamespace(platform=SimpleNamespace(close=AsyncMock()))
- call_order: list[str] = []
-
- class FakeAsyncClient:
- def __init__(self, *args, **kwargs):
- self.access_token = None
- self.callbacks = []
- self.close = AsyncMock()
- self.sync_forever = AsyncMock(side_effect=self._sync_forever)
-
- async def _sync_forever(self, *args, **kwargs):
- call_order.append("sync_forever")
-
- async def login(self, *args, **kwargs):
- raise AssertionError("login should not be called when access token is provided")
-
- def add_event_callback(self, callback, event_type):
- self.callbacks.append((callback, event_type))
-
- async def fake_prepare_live_sync(client):
- call_order.append("prepare_live_sync")
- return "s123"
-
- async def fake_reconcile_startup_state(client, runtime):
- call_order.append("reconcile_startup_state")
-
- monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
- monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org")
- monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token")
- monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient)
- monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime)
- monkeypatch.setattr(bot_module, "prepare_live_sync", fake_prepare_live_sync)
- monkeypatch.setattr(bot_module, "reconcile_startup_state", fake_reconcile_startup_state)
-
- await bot_module.main()
-
- assert call_order == [
- "prepare_live_sync",
- "reconcile_startup_state",
- "sync_forever",
- ]
diff --git a/tests/adapter/matrix/test_restart_persistence.py b/tests/adapter/matrix/test_restart_persistence.py
deleted file mode 100644
index ac05423..0000000
--- a/tests/adapter/matrix/test_restart_persistence.py
+++ /dev/null
@@ -1,114 +0,0 @@
-from __future__ import annotations
-
-from types import SimpleNamespace
-
-from adapter.matrix.bot import build_runtime
-from adapter.matrix.reconciliation import reconcile_startup_state
-from adapter.matrix.store import (
- get_room_meta,
- next_platform_chat_id,
- set_room_meta,
-)
-from core.store import SQLiteStore
-from sdk.mock import MockPlatformClient
-
-
-async def test_room_agent_id_and_platform_chat_id_survive_restart(tmp_path):
- db = str(tmp_path / "state.db")
- store = SQLiteStore(db)
- await set_room_meta(store, "!room:example.org", {
- "room_type": "chat",
- "agent_id": "agent-1",
- "platform_chat_id": "42",
- })
-
- store2 = SQLiteStore(db)
- meta = await get_room_meta(store2, "!room:example.org")
- assert meta is not None
- assert meta["agent_id"] == "agent-1"
- assert meta["platform_chat_id"] == "42"
-
-
-async def test_platform_chat_seq_survives_restart(tmp_path):
- db = str(tmp_path / "state.db")
- store = SQLiteStore(db)
- assert await next_platform_chat_id(store) == "1"
- assert await next_platform_chat_id(store) == "2"
- assert await next_platform_chat_id(store) == "3"
-
- store2 = SQLiteStore(db)
- assert await next_platform_chat_id(store2) == "4"
-
-
-async def test_routing_state_survives_restart_and_routes_correctly(tmp_path):
- db = str(tmp_path / "state.db")
- store = SQLiteStore(db)
- await set_room_meta(store, "!convo:example.org", {
- "room_type": "chat",
- "agent_id": "agent-1",
- "platform_chat_id": "10",
- })
-
- store2 = SQLiteStore(db)
- meta = await get_room_meta(store2, "!convo:example.org")
- assert meta is not None
- assert meta["agent_id"] == "agent-1"
- assert meta["platform_chat_id"] == "10"
-
-
-async def test_missing_durable_store_starts_clean(tmp_path):
- db = str(tmp_path / "brand_new.db")
- store = SQLiteStore(db)
- assert await get_room_meta(store, "!nonexistent:example.org") is None
-
-
-async def test_startup_reconciliation_backfills_legacy_platform_chat_id_before_restart_routes(
- tmp_path,
-):
- db = str(tmp_path / "state.db")
- store = SQLiteStore(db)
- await set_room_meta(
- store,
- "!chat2:example.org",
- {
- "room_type": "chat",
- "chat_id": "C2",
- "display_name": "Чат 2",
- "matrix_user_id": "@alice:example.org",
- "space_id": "!space:example.org",
- },
- )
-
- runtime = build_runtime(platform=MockPlatformClient(), store=store)
- client = SimpleNamespace(
- user_id="@bot:example.org",
- rooms={
- "!space:example.org": SimpleNamespace(
- room_id="!space:example.org",
- name="Lambda - Alice",
- display_name="Lambda - Alice",
- users={
- "@bot:example.org": SimpleNamespace(user_id="@bot:example.org"),
- "@alice:example.org": SimpleNamespace(user_id="@alice:example.org"),
- },
- space_parents=set(),
- ),
- "!chat2:example.org": SimpleNamespace(
- room_id="!chat2:example.org",
- name="Чат 2",
- display_name="Чат 2",
- users={
- "@bot:example.org": SimpleNamespace(user_id="@bot:example.org"),
- "@alice:example.org": SimpleNamespace(user_id="@alice:example.org"),
- },
- space_parents={"!space:example.org"},
- ),
- },
- )
-
- await reconcile_startup_state(client, runtime)
-
- store2 = SQLiteStore(db)
- room_meta = await get_room_meta(store2, "!chat2:example.org")
- assert room_meta is not None
- assert room_meta["platform_chat_id"] == "1"
diff --git a/tests/adapter/matrix/test_routed_platform.py b/tests/adapter/matrix/test_routed_platform.py
deleted file mode 100644
index c3efca5..0000000
--- a/tests/adapter/matrix/test_routed_platform.py
+++ /dev/null
@@ -1,342 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import AsyncIterator
-from pathlib import Path
-from types import SimpleNamespace
-from unittest.mock import AsyncMock
-
-import pytest
-
-from adapter.matrix.bot import MatrixBot, build_runtime
-from adapter.matrix.routed_platform import RoutedPlatformClient
-from adapter.matrix.store import set_room_meta
-from core.chat import ChatManager
-from core.store import InMemoryStore
-from sdk.interface import MessageChunk, MessageResponse, User, UserSettings
-from sdk.mock import MockPlatformClient
-from sdk.interface import PlatformError
-
-
-class FakeDelegate:
- def __init__(self, *, name: str) -> None:
- self.name = name
- self.send_calls: list[dict] = []
- self.stream_calls: list[dict] = []
- self.user_calls: list[dict] = []
- self.settings_calls: list[str] = []
- self.update_calls: list[tuple[str, object]] = []
-
- async def get_or_create_user(
- self,
- external_id: str,
- platform: str,
- display_name: str | None = None,
- ) -> User:
- self.user_calls.append(
- {
- "external_id": external_id,
- "platform": platform,
- "display_name": display_name,
- }
- )
- return User(
- user_id=f"user-{self.name}",
- external_id=external_id,
- platform=platform,
- display_name=display_name,
- created_at="2025-01-01T00:00:00Z",
- is_new=False,
- )
-
- async def send_message(
- self,
- user_id: str,
- chat_id: str,
- text: str,
- attachments=None,
- ) -> MessageResponse:
- self.send_calls.append(
- {
- "user_id": user_id,
- "chat_id": chat_id,
- "text": text,
- "attachments": attachments,
- }
- )
- return MessageResponse(
- message_id=f"msg-{self.name}",
- response=f"reply-{self.name}",
- tokens_used=0,
- finished=True,
- )
-
- async def stream_message(
- self,
- user_id: str,
- chat_id: str,
- text: str,
- attachments=None,
- ) -> AsyncIterator[MessageChunk]:
- self.stream_calls.append(
- {
- "user_id": user_id,
- "chat_id": chat_id,
- "text": text,
- "attachments": attachments,
- }
- )
- yield MessageChunk(
- message_id=f"stream-{self.name}",
- delta=f"delta-{self.name}",
- finished=True,
- tokens_used=0,
- )
-
- async def get_settings(self, user_id: str) -> UserSettings:
- self.settings_calls.append(user_id)
- return UserSettings(skills={"files": True})
-
- async def update_settings(self, user_id: str, action: object) -> None:
- self.update_calls.append((user_id, action))
-
-
-@pytest.fixture(autouse=True)
-def clear_matrix_registry_env(monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False)
- monkeypatch.delenv("MATRIX_PLATFORM_BACKEND", raising=False)
-
-
-@pytest.mark.asyncio
-async def test_send_message_routes_by_room_agent_and_platform_chat_id():
- store = InMemoryStore()
- chat_mgr = ChatManager(None, store)
- await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
- await set_room_meta(
- store,
- "!room:example.org",
- {"platform_chat_id": "41", "agent_id": "agent-2"},
- )
- delegates = {
- "agent-1": FakeDelegate(name="agent-1"),
- "agent-2": FakeDelegate(name="agent-2"),
- }
- platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates)
-
- response = await platform.send_message("u1", "C1", "hello", attachments=[])
-
- assert response.response == "reply-agent-2"
- assert delegates["agent-1"].send_calls == []
- assert delegates["agent-2"].send_calls == [
- {
- "user_id": "u1",
- "chat_id": "41",
- "text": "hello",
- "attachments": [],
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_stream_message_routes_by_room_agent_and_platform_chat_id():
- store = InMemoryStore()
- chat_mgr = ChatManager(None, store)
- await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
- await set_room_meta(
- store,
- "!room:example.org",
- {"platform_chat_id": "41", "agent_id": "agent-2"},
- )
- delegates = {
- "agent-1": FakeDelegate(name="agent-1"),
- "agent-2": FakeDelegate(name="agent-2"),
- }
- platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates)
-
- chunks = [chunk async for chunk in platform.stream_message("u1", "C1", "hello")]
-
- assert [chunk.delta for chunk in chunks] == ["delta-agent-2"]
- assert delegates["agent-1"].stream_calls == []
- assert delegates["agent-2"].stream_calls == [
- {
- "user_id": "u1",
- "chat_id": "41",
- "text": "hello",
- "attachments": None,
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_send_message_fails_fast_when_platform_chat_id_is_missing():
- store = InMemoryStore()
- chat_mgr = ChatManager(None, store)
- await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
- await set_room_meta(
- store,
- "!room:example.org",
- {"agent_id": "agent-2"},
- )
- platform = RoutedPlatformClient(
- chat_mgr=chat_mgr,
- store=store,
- delegates={"agent-2": FakeDelegate(name="agent-2")},
- )
-
- with pytest.raises(PlatformError, match="routing is incomplete") as exc_info:
- await platform.send_message("u1", "C1", "hello")
-
- assert exc_info.value.code == "MATRIX_ROUTE_INCOMPLETE"
-
-
-@pytest.mark.asyncio
-async def test_stream_message_fails_fast_when_agent_id_is_missing():
- store = InMemoryStore()
- chat_mgr = ChatManager(None, store)
- await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
- await set_room_meta(
- store,
- "!room:example.org",
- {"platform_chat_id": "41"},
- )
- platform = RoutedPlatformClient(
- chat_mgr=chat_mgr,
- store=store,
- delegates={"agent-2": FakeDelegate(name="agent-2")},
- )
-
- with pytest.raises(PlatformError, match="routing is incomplete") as exc_info:
- await anext(platform.stream_message("u1", "C1", "hello"))
-
- assert exc_info.value.code == "MATRIX_ROUTE_INCOMPLETE"
-
-
-@pytest.mark.asyncio
-async def test_routing_uses_repaired_room_metadata_without_runtime_backfill():
- store = InMemoryStore()
- chat_mgr = ChatManager(None, store)
- await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org")
- await set_room_meta(
- store,
- "!room:example.org",
- {"platform_chat_id": "restored-41", "agent_id": "agent-2"},
- )
- delegate = FakeDelegate(name="agent-2")
- platform = RoutedPlatformClient(
- chat_mgr=chat_mgr,
- store=store,
- delegates={"agent-2": delegate},
- )
-
- await platform.send_message("u1", "C1", "hello")
-
- assert delegate.send_calls == [
- {
- "user_id": "u1",
- "chat_id": "restored-41",
- "text": "hello",
- "attachments": None,
- }
- ]
-
-
-@pytest.mark.asyncio
-async def test_user_and_settings_delegate_to_default_client():
- store = InMemoryStore()
- chat_mgr = ChatManager(None, store)
- delegates = {
- "agent-1": FakeDelegate(name="agent-1"),
- "agent-2": FakeDelegate(name="agent-2"),
- }
- platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates)
-
- user = await platform.get_or_create_user("ext-1", "matrix", display_name="Alice")
- settings = await platform.get_settings("u1")
- await platform.update_settings("u1", {"action": "noop"})
-
- assert user.user_id == "user-agent-1"
- assert settings.skills == {"files": True}
- assert delegates["agent-1"].user_calls == [
- {
- "external_id": "ext-1",
- "platform": "matrix",
- "display_name": "Alice",
- }
- ]
- assert delegates["agent-2"].user_calls == []
- assert delegates["agent-1"].settings_calls == ["u1"]
- assert delegates["agent-1"].update_calls == [("u1", {"action": "noop"})]
-
-
-@pytest.mark.asyncio
-async def test_build_runtime_real_backend_uses_routed_platform_with_registry(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
-):
- registry_path = tmp_path / "matrix-agents.yaml"
- registry_path.write_text(
- "agents:\n"
- " - id: agent-1\n"
- " label: Analyst\n"
- " - id: agent-2\n"
- " label: Research\n",
- encoding="utf-8",
- )
- monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
- monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path))
- monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example")
-
- runtime = build_runtime()
-
- assert isinstance(runtime.platform, RoutedPlatformClient)
- assert set(runtime.platform._delegates) == {"agent-1", "agent-2"}
- assert runtime.platform._delegates["agent-1"].agent_base_url == "http://agent.example"
- assert runtime.platform._delegates["agent-1"].agent_id == "agent-1"
- assert runtime.platform._delegates["agent-2"].agent_id == "agent-2"
-
-
-def test_build_runtime_real_backend_requires_registry_path(monkeypatch: pytest.MonkeyPatch):
- monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
- monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False)
- monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example")
-
- with pytest.raises(RuntimeError, match="MATRIX_AGENT_REGISTRY_PATH"):
- build_runtime()
-
-
-def test_build_runtime_real_backend_fails_explicitly_on_invalid_registry(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
-):
- registry_path = tmp_path / "missing.yaml"
- monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real")
- monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path))
- monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example")
-
- with pytest.raises(RuntimeError, match="failed to load matrix agent registry"):
- build_runtime()
-
-
-@pytest.mark.asyncio
-async def test_bot_keeps_local_chat_id_for_plain_message_dispatch():
- runtime = build_runtime(platform=MockPlatformClient())
- await set_room_meta(
- runtime.store,
- "!chat1:example.org",
- {
- "chat_id": "C1",
- "matrix_user_id": "@alice:example.org",
- "platform_chat_id": "41",
- "agent_id": "agent-2",
- },
- )
- runtime.dispatcher.dispatch = AsyncMock(return_value=[])
- bot = MatrixBot(SimpleNamespace(user_id="@bot:example.org"), runtime)
-
- await bot.on_room_message(
- SimpleNamespace(room_id="!chat1:example.org"),
- SimpleNamespace(sender="@alice:example.org", body="hello"),
- )
-
- dispatched = runtime.dispatcher.dispatch.await_args.args[0]
- assert dispatched.chat_id == "C1"
- assert dispatched.text == "hello"
diff --git a/tests/adapter/matrix/test_send_outgoing.py b/tests/adapter/matrix/test_send_outgoing.py
deleted file mode 100644
index 72b9fa6..0000000
--- a/tests/adapter/matrix/test_send_outgoing.py
+++ /dev/null
@@ -1,194 +0,0 @@
-from __future__ import annotations
-
-from types import SimpleNamespace
-from unittest.mock import AsyncMock
-
-from adapter.matrix.bot import send_outgoing
-from adapter.matrix.converter import from_room_event
-from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_confirm
-from adapter.matrix.store import get_pending_confirm, set_room_meta
-from core.auth import AuthManager
-from core.chat import ChatManager
-from core.protocol import Attachment, OutgoingMessage, OutgoingUI, UIButton
-from core.settings import SettingsManager
-from core.store import InMemoryStore
-from sdk.mock import MockPlatformClient
-
-
-async def test_mat06_outgoing_ui_renders_text_with_yes_no():
- client = SimpleNamespace(room_send=AsyncMock())
- store = InMemoryStore()
- await set_room_meta(store, "!confirm:example.org", {"matrix_user_id": "@alice:example.org"})
- event = OutgoingUI(
- chat_id="C7",
- text="Удалить файл?",
- buttons=[UIButton(label="Подтвердить", action="confirm")],
- )
-
- await send_outgoing(client, "!confirm:example.org", event, store=store)
-
- client.room_send.assert_awaited_once()
- body = client.room_send.call_args.args[2]["body"]
- assert "Удалить файл?" in body
- assert "!yes" in body
- assert "!no" in body
- assert "Подтвердить" in body
-
-
-async def test_mat07_outgoing_ui_no_reaction_sent():
- client = SimpleNamespace(room_send=AsyncMock())
- store = InMemoryStore()
- await set_room_meta(store, "!confirm:example.org", {"matrix_user_id": "@alice:example.org"})
- event = OutgoingUI(
- chat_id="C7",
- text="Confirm action?",
- buttons=[UIButton(label="OK", action="confirm", payload={"id": 1})],
- )
-
- await send_outgoing(client, "!confirm:example.org", event, store=store)
-
- assert client.room_send.await_count == 1
- assert client.room_send.call_args.args[1] == "m.room.message"
- for call in client.room_send.call_args_list:
- assert call.args[1] != "m.reaction"
-
- pending = await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org")
- assert pending == {
- "action_id": "confirm",
- "description": "Confirm action?",
- "payload": {"id": 1},
- }
-
-
-async def test_outgoing_ui_yes_round_trip_uses_user_and_room_scope():
- client = SimpleNamespace(room_send=AsyncMock())
- store = InMemoryStore()
- platform = MockPlatformClient()
- chat_mgr = ChatManager(platform, store)
- auth_mgr = AuthManager(platform, store)
- settings_mgr = SettingsManager(platform, store)
- await set_room_meta(store, "!confirm:example.org", {"matrix_user_id": "@alice:example.org"})
- await set_room_meta(store, "!other:example.org", {"matrix_user_id": "@bob:example.org"})
-
- await send_outgoing(
- client,
- "!confirm:example.org",
- OutgoingUI(
- chat_id="C7",
- text="Archive room",
- buttons=[UIButton(label="Confirm", action="archive", payload={"id": 7})],
- ),
- store=store,
- )
- await send_outgoing(
- client,
- "!other:example.org",
- OutgoingUI(
- chat_id="C8",
- text="Keep other room",
- buttons=[UIButton(label="Confirm", action="archive", payload={"id": 8})],
- ),
- store=store,
- )
-
- callback = from_room_event(
- SimpleNamespace(
- sender="@alice:example.org",
- body="!yes",
- event_id="$yes",
- msgtype="m.text",
- replyto_event_id=None,
- ),
- room_id="!confirm:example.org",
- chat_id="C7",
- )
- result = await make_handle_confirm(store)(callback, auth_mgr, platform, chat_mgr, settings_mgr)
-
- assert "Archive room" in result[0].text
- assert await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") is None
- assert await get_pending_confirm(store, "@bob:example.org", "!other:example.org") is not None
-
-
-async def test_outgoing_ui_no_round_trip_uses_user_and_room_scope():
- client = SimpleNamespace(room_send=AsyncMock())
- store = InMemoryStore()
- platform = MockPlatformClient()
- chat_mgr = ChatManager(platform, store)
- auth_mgr = AuthManager(platform, store)
- settings_mgr = SettingsManager(platform, store)
- await set_room_meta(store, "!confirm:example.org", {"matrix_user_id": "@alice:example.org"})
- await set_room_meta(store, "!other:example.org", {"matrix_user_id": "@bob:example.org"})
-
- await send_outgoing(
- client,
- "!confirm:example.org",
- OutgoingUI(
- chat_id="C7",
- text="Delete room",
- buttons=[UIButton(label="Confirm", action="delete", payload={"id": 7})],
- ),
- store=store,
- )
- await send_outgoing(
- client,
- "!other:example.org",
- OutgoingUI(
- chat_id="C8",
- text="Keep other room",
- buttons=[UIButton(label="Confirm", action="archive", payload={"id": 8})],
- ),
- store=store,
- )
-
- callback = from_room_event(
- SimpleNamespace(
- sender="@alice:example.org",
- body="!no",
- event_id="$no",
- msgtype="m.text",
- replyto_event_id=None,
- ),
- room_id="!confirm:example.org",
- chat_id="C7",
- )
- result = await make_handle_cancel(store)(callback, auth_mgr, platform, chat_mgr, settings_mgr)
-
- assert "отменено" in result[0].text.lower()
- assert await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") is None
- assert await get_pending_confirm(store, "@bob:example.org", "!other:example.org") is not None
-
-
-async def test_send_outgoing_uploads_workspace_file_attachment(tmp_path, monkeypatch):
- workspace_file = tmp_path / "surfaces" / "matrix" / "alice" / "room" / "inbox" / "result.txt"
- workspace_file.parent.mkdir(parents=True, exist_ok=True)
- workspace_file.write_text("ready")
- monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path))
-
- client = SimpleNamespace(
- upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/file"), {})),
- room_send=AsyncMock(),
- )
-
- await send_outgoing(
- client,
- "!room:example.org",
- OutgoingMessage(
- chat_id="!room:example.org",
- text="Файл готов",
- attachments=[
- Attachment(
- type="document",
- filename="result.txt",
- mime_type="text/plain",
- workspace_path="surfaces/matrix/alice/room/inbox/result.txt",
- )
- ],
- ),
- )
-
- client.upload.assert_awaited_once()
- client.room_send.assert_awaited()
- assert client.room_send.await_args_list[0].args[2]["body"] == "Файл готов"
- file_call = client.room_send.await_args_list[1]
- assert file_call.args[2]["msgtype"] == "m.file"
- assert file_call.args[2]["url"] == "mxc://server/file"
diff --git a/tests/adapter/matrix/test_store.py b/tests/adapter/matrix/test_store.py
deleted file mode 100644
index 7c4a216..0000000
--- a/tests/adapter/matrix/test_store.py
+++ /dev/null
@@ -1,246 +0,0 @@
-from __future__ import annotations
-
-import pytest
-
-from adapter.matrix.store import (
- STAGED_ATTACHMENTS_PREFIX,
- add_staged_attachment,
- clear_pending_confirm,
- clear_staged_attachments,
- get_pending_confirm,
- get_platform_chat_id,
- get_room_meta,
- get_room_state,
- get_skills_message_id,
- get_staged_attachments,
- get_user_meta,
- next_chat_id,
- next_platform_chat_id,
- remove_staged_attachment_at,
- set_pending_confirm,
- set_platform_chat_id,
- set_room_meta,
- set_room_state,
- set_skills_message_id,
- set_user_meta,
-)
-from core.store import InMemoryStore
-
-
-@pytest.fixture
-def store() -> InMemoryStore:
- return InMemoryStore()
-
-
-async def test_room_meta_roundtrip(store: InMemoryStore):
- meta = {
- "room_type": "chat",
- "chat_id": "C1",
- "display_name": "Чат 1",
- "matrix_user_id": "@alice:m.org",
- }
- await set_room_meta(store, "!r:m.org", meta)
- assert await get_room_meta(store, "!r:m.org") == meta
-
-
-async def test_room_meta_roundtrip_with_platform_chat_id(store: InMemoryStore):
- meta = {
- "chat_id": "C1",
- "matrix_user_id": "@alice:example.org",
- "platform_chat_id": "chat-platform-1",
- }
- await set_room_meta(store, "!r:m.org", meta)
- saved = await get_room_meta(store, "!r:m.org")
- assert saved is not None
- assert saved["platform_chat_id"] == "chat-platform-1"
-
-
-async def test_platform_chat_id_helpers_roundtrip(store: InMemoryStore):
- meta = {
- "chat_id": "C1",
- "matrix_user_id": "@alice:example.org",
- "display_name": "Research",
- }
- await set_room_meta(store, "!r:m.org", meta)
- await set_platform_chat_id(store, "!r:m.org", "chat-platform-1")
-
- assert await get_platform_chat_id(store, "!r:m.org") == "chat-platform-1"
- assert await get_room_meta(store, "!r:m.org") == {
- "chat_id": "C1",
- "matrix_user_id": "@alice:example.org",
- "display_name": "Research",
- "platform_chat_id": "chat-platform-1",
- }
-
-
-async def test_room_meta_missing(store: InMemoryStore):
- assert await get_room_meta(store, "!nonexistent:m.org") is None
-
-
-async def test_user_meta_roundtrip(store: InMemoryStore):
- meta = {
- "platform_user_id": "usr-1",
- "display_name": "Alice",
- "space_id": None,
- "settings_room_id": None,
- "next_chat_index": 1,
- }
- await set_user_meta(store, "@alice:m.org", meta)
- assert await get_user_meta(store, "@alice:m.org") == meta
-
-
-async def test_room_state_roundtrip(store: InMemoryStore):
- await set_room_state(store, "!r:m.org", "idle")
- assert await get_room_state(store, "!r:m.org") == "idle"
- await set_room_state(store, "!r:m.org", "waiting_response")
- assert await get_room_state(store, "!r:m.org") == "waiting_response"
-
-
-async def test_room_state_default_idle(store: InMemoryStore):
- assert await get_room_state(store, "!unknown:m.org") == "idle"
-
-
-async def test_next_chat_id_increments(store: InMemoryStore):
- uid = "@alice:m.org"
- await set_user_meta(store, uid, {"next_chat_index": 1})
- assert await next_chat_id(store, uid) == "C1"
- assert await next_chat_id(store, uid) == "C2"
- assert await next_chat_id(store, uid) == "C3"
-
-
-async def test_next_platform_chat_id_increments(store: InMemoryStore):
- assert await next_platform_chat_id(store) == "1"
- assert await next_platform_chat_id(store) == "2"
- assert await next_platform_chat_id(store) == "3"
-
-
-async def test_skills_message_roundtrip(store: InMemoryStore):
- await set_skills_message_id(store, "!room", "$event")
- assert await get_skills_message_id(store, "!room") == "$event"
-
-
-async def test_pending_confirm_roundtrip(store: InMemoryStore):
- assert await get_pending_confirm(store, "!room:m.org") is None
-
- meta = {"action_id": "test", "description": "Do thing"}
- await set_pending_confirm(store, "!room:m.org", meta)
- assert await get_pending_confirm(store, "!room:m.org") == meta
-
- await clear_pending_confirm(store, "!room:m.org")
- assert await get_pending_confirm(store, "!room:m.org") is None
-
-
-async def test_staged_attachments_roundtrip(store: InMemoryStore):
- room_id = "!room:m.org"
- user_id = "@alice:m.org"
-
- assert await get_staged_attachments(store, room_id, user_id) == []
-
- first = {"id": "att-1", "name": "screenshot.png"}
- second = {"id": "att-2", "name": "invoice.pdf"}
-
- await add_staged_attachment(store, room_id, user_id, first)
- await add_staged_attachment(store, room_id, user_id, second)
-
- assert await get_staged_attachments(store, room_id, user_id) == [
- first,
- second,
- ]
-
-
-@pytest.mark.parametrize(
- "stored_value",
- [
- None,
- "not-a-dict",
- [],
- 123,
- ],
-)
-async def test_staged_attachments_invalid_container_state_returns_empty_list(
- store: InMemoryStore,
- stored_value,
-):
- room_id = "!room:m.org"
- user_id = "@alice:m.org"
-
- await store.set(f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}", stored_value)
-
- assert await get_staged_attachments(store, room_id, user_id) == []
-
-
-async def test_staged_attachments_filters_invalid_entries(store: InMemoryStore):
- room_id = "!room:m.org"
- user_id = "@alice:m.org"
- valid_one = {"id": "att-1", "name": "alpha.png"}
- valid_two = {"id": "att-2", "name": "beta.pdf"}
-
- await store.set(
- f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}",
- {
- "attachments": [
- valid_one,
- "bad-entry",
- None,
- {"id": "ignored"},
- valid_two,
- ]
- },
- )
-
- assert await get_staged_attachments(store, room_id, user_id) == [
- valid_one,
- {"id": "ignored"},
- valid_two,
- ]
-
-
-async def test_staged_attachments_are_scoped_by_room_and_user(store: InMemoryStore):
- room_a = "!room-a:m.org"
- room_b = "!room-b:m.org"
- user_a = "@alice:m.org"
- user_b = "@bob:m.org"
-
- attachment_a = {"id": "att-a", "name": "alpha.png"}
- attachment_b = {"id": "att-b", "name": "beta.png"}
- attachment_c = {"id": "att-c", "name": "gamma.png"}
-
- await add_staged_attachment(store, room_a, user_a, attachment_a)
- await add_staged_attachment(store, room_a, user_b, attachment_b)
- await add_staged_attachment(store, room_b, user_a, attachment_c)
-
- assert await get_staged_attachments(store, room_a, user_a) == [attachment_a]
- assert await get_staged_attachments(store, room_a, user_b) == [attachment_b]
- assert await get_staged_attachments(store, room_b, user_a) == [attachment_c]
- assert await get_staged_attachments(store, room_b, user_b) == []
-
-
-async def test_remove_staged_attachment_at_by_zero_based_index(
- store: InMemoryStore,
-):
- room_id = "!room:m.org"
- user_id = "@alice:m.org"
- first = {"id": "att-1", "name": "first.png"}
- second = {"id": "att-2", "name": "second.png"}
- third = {"id": "att-3", "name": "third.png"}
-
- await add_staged_attachment(store, room_id, user_id, first)
- await add_staged_attachment(store, room_id, user_id, second)
- await add_staged_attachment(store, room_id, user_id, third)
-
- assert await remove_staged_attachment_at(store, room_id, user_id, 1) == second
- assert await get_staged_attachments(store, room_id, user_id) == [first, third]
- assert await remove_staged_attachment_at(store, room_id, user_id, 99) is None
- assert await remove_staged_attachment_at(store, room_id, user_id, -1) is None
-
-
-async def test_clear_staged_attachments(store: InMemoryStore):
- room_id = "!room:m.org"
- user_id = "@alice:m.org"
-
- await add_staged_attachment(store, room_id, user_id, {"id": "att-1"})
- await add_staged_attachment(store, room_id, user_id, {"id": "att-2"})
-
- await clear_staged_attachments(store, room_id, user_id)
-
- assert await get_staged_attachments(store, room_id, user_id) == []
diff --git a/tests/adapter/telegram/__init__.py b/tests/adapter/telegram/__init__.py
index e69de29..8b13789 100644
--- a/tests/adapter/telegram/__init__.py
+++ b/tests/adapter/telegram/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/adapter/telegram/test_commands.py b/tests/adapter/telegram/test_commands.py
deleted file mode 100644
index a9b6676..0000000
--- a/tests/adapter/telegram/test_commands.py
+++ /dev/null
@@ -1,120 +0,0 @@
-from __future__ import annotations
-
-import importlib
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, MagicMock
-
-import pytest
-from aiogram.exceptions import TelegramBadRequest
-
-
-@pytest.fixture(autouse=True)
-def fresh_db(tmp_path, monkeypatch):
- monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
- import adapter.telegram.db as db_mod
- importlib.reload(db_mod)
- db_mod.init_db()
- return db_mod
-
-
-def make_message(*, user_id=1, thread_id=42, chat_id=100, text="/new"):
- m = SimpleNamespace()
- m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
- m.message_thread_id = thread_id
- m.chat = SimpleNamespace(id=chat_id)
- m.text = text
- m.answer = AsyncMock()
- m.reply = AsyncMock()
- m.bot = MagicMock()
- m.bot.create_forum_topic = AsyncMock(
- return_value=SimpleNamespace(message_thread_id=200)
- )
- m.bot.close_forum_topic = AsyncMock()
- m.bot.delete_forum_topic = AsyncMock()
- m.bot.edit_forum_topic = AsyncMock()
- m.bot.send_message = AsyncMock()
- return m
-
-
-async def test_cmd_new_creates_topic(fresh_db, monkeypatch):
- import adapter.telegram.handlers.commands as mod
- importlib.reload(mod)
- fresh_db.create_chat(1, 42, "Чат #1")
- msg = make_message(user_id=1, thread_id=42, chat_id=100)
- await mod.cmd_new(msg)
- msg.bot.create_forum_topic.assert_called_once()
- call_kwargs = str(msg.bot.create_forum_topic.call_args)
- assert "Чат #2" in call_kwargs
- assert fresh_db.get_chat(1, 200) is not None
-
-
-async def test_cmd_archive_deletes_topic_when_possible(fresh_db, monkeypatch):
- """When delete_forum_topic succeeds (user-created topic), no answer is sent."""
- import adapter.telegram.handlers.commands as mod
- importlib.reload(mod)
- fresh_db.create_chat(1, 42, "Чат #1")
- msg = make_message(user_id=1, thread_id=42, chat_id=100)
- await mod.cmd_archive(msg)
- msg.bot.delete_forum_topic.assert_called_once_with(chat_id=100, message_thread_id=42)
- assert fresh_db.get_chat(1, 42)["archived_at"] is not None
- msg.answer.assert_not_called()
-
-
-async def test_cmd_archive_fallback_message_when_delete_fails(fresh_db, monkeypatch):
- """When delete_forum_topic fails (bot-created topic), user gets explanation."""
- import adapter.telegram.handlers.commands as mod
- importlib.reload(mod)
- fresh_db.create_chat(1, 42, "Чат #1")
- msg = make_message(user_id=1, thread_id=42, chat_id=100)
- msg.bot.delete_forum_topic = AsyncMock(
- side_effect=TelegramBadRequest(method=MagicMock(), message="not a supergroup forum")
- )
- await mod.cmd_archive(msg)
- assert fresh_db.get_chat(1, 42)["archived_at"] is not None
- msg.answer.assert_called_once()
- assert "архивирован" in msg.answer.call_args[0][0]
-
-
-async def test_cmd_archive_unknown_topic_replies_error(fresh_db, monkeypatch):
- import adapter.telegram.handlers.commands as mod
- importlib.reload(mod)
- msg = make_message(user_id=1, thread_id=999, chat_id=100)
- await mod.cmd_archive(msg)
- msg.answer.assert_called_once()
-
-
-async def test_cmd_rename_updates_db_and_topic(fresh_db, monkeypatch):
- import adapter.telegram.handlers.commands as mod
- importlib.reload(mod)
- fresh_db.create_chat(1, 42, "Чат #1")
- msg = make_message(user_id=1, thread_id=42, chat_id=100, text="/rename Работа")
- await mod.cmd_rename(msg)
- msg.bot.edit_forum_topic.assert_called_once_with(
- chat_id=100, message_thread_id=42, name="Работа"
- )
- assert fresh_db.get_chat(1, 42)["chat_name"] == "Работа"
-
-
-async def test_cmd_new_topics_limit(fresh_db):
- """When Telegram returns topics limit error, user gets a friendly message."""
- import adapter.telegram.handlers.commands as mod
- importlib.reload(mod)
- msg = make_message(user_id=1, thread_id=42, chat_id=100)
- msg.bot.create_forum_topic = AsyncMock(
- side_effect=TelegramBadRequest(method=MagicMock(), message="topics limit exceeded")
- )
- await mod.cmd_new(msg)
- msg.answer.assert_called_once()
- assert "лимит" in msg.answer.call_args[0][0]
- # No chat should be created
- assert fresh_db.count_active_chats(1) == 0
-
-
-async def test_cmd_archive_general_topic(fresh_db):
- """/archive in General topic (thread_id=None) replies with 'not found'."""
- import adapter.telegram.handlers.commands as mod
- importlib.reload(mod)
- msg = make_message(user_id=1, thread_id=None, chat_id=100)
- await mod.cmd_archive(msg)
- msg.answer.assert_called_once()
- msg.bot.close_forum_topic.assert_not_called()
diff --git a/tests/adapter/telegram/test_converter.py b/tests/adapter/telegram/test_converter.py
deleted file mode 100644
index 38fd70a..0000000
--- a/tests/adapter/telegram/test_converter.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from __future__ import annotations
-
-from types import SimpleNamespace
-
-from adapter.telegram.converter import format_outgoing, from_message
-from core.protocol import OutgoingMessage, OutgoingUI
-
-
-def make_message(*, text="hello", thread_id=42, user_id=1):
- m = SimpleNamespace()
- m.text = text
- m.caption = None
- m.photo = None
- m.document = None
- m.voice = None
- m.message_thread_id = thread_id
- m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
- return m
-
-
-def test_from_message_in_topic():
- msg = make_message(thread_id=42, user_id=7)
- result = from_message(msg)
- assert result is not None
- assert result.user_id == "7"
- assert result.chat_id == "42"
- assert result.text == "hello"
- assert result.platform == "telegram"
-
-
-def test_from_message_in_general_returns_none():
- msg = make_message(thread_id=None)
- assert from_message(msg) is None
-
-
-def test_from_message_uses_caption_if_no_text():
- msg = make_message(text=None, thread_id=10)
- msg.caption = "caption text"
- result = from_message(msg)
- assert result.text == "caption text"
-
-
-def test_format_outgoing_message():
- event = OutgoingMessage(chat_id="42", text="response")
- assert format_outgoing(event) == "response"
-
-
-def test_format_outgoing_ui():
- event = OutgoingUI(chat_id="42", text="choose")
- assert format_outgoing(event) == "choose"
diff --git a/tests/adapter/telegram/test_forum.py b/tests/adapter/telegram/test_forum.py
new file mode 100644
index 0000000..d0f7aaa
--- /dev/null
+++ b/tests/adapter/telegram/test_forum.py
@@ -0,0 +1,374 @@
+from __future__ import annotations
+
+from datetime import datetime
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+from aiogram.fsm.context import FSMContext
+from aiogram.types import Chat, MessageOriginChat
+
+from adapter.telegram.converter import is_forum_message, resolve_forum_chat_id
+from adapter.telegram.handlers import chat as chat_handler
+from adapter.telegram.handlers import confirm as confirm_handler
+from adapter.telegram.handlers import forum as forum_handler
+from adapter.telegram.states import ChatState, ForumSetupState
+from core.protocol import OutgoingMessage
+
+
+def make_message(*, text: str = "hello", thread_id: int | None = None):
+ message = SimpleNamespace()
+ message.text = text
+ message.caption = None
+ message.photo = None
+ message.document = None
+ message.voice = None
+ message.message_thread_id = thread_id
+ message.chat = SimpleNamespace(id=-100123)
+ message.from_user = SimpleNamespace(id=42, full_name="Alice", first_name="Alice")
+ message.answer = AsyncMock()
+ message.edit_text = AsyncMock()
+ message.edit_reply_markup = AsyncMock()
+ message.bot = SimpleNamespace(
+ send_message=AsyncMock(),
+ send_chat_action=AsyncMock(),
+ create_forum_topic=AsyncMock(),
+ get_me=AsyncMock(),
+ get_chat_member=AsyncMock(),
+ )
+ message.chat_shared = None
+ return message
+
+
+class FakeTask:
+ def cancel(self) -> None:
+ self.cancelled = True
+
+ def __await__(self):
+ async def _done():
+ return None
+
+ return _done().__await__()
+
+
+async def test_forum_helpers_detect_and_resolve(monkeypatch):
+ message = make_message(thread_id=77)
+ monkeypatch.setattr(
+ chat_handler.db,
+ "get_chat_by_thread",
+ lambda tg_user_id, thread_id: {"chat_id": "chat-77"} if thread_id == 77 else None,
+ )
+
+ assert is_forum_message(message) is True
+ assert resolve_forum_chat_id(message, 42) == "chat-77"
+
+
+async def test_cmd_forum_enters_setup_state():
+ message = make_message(text="/forum")
+ state = AsyncMock(spec=FSMContext)
+
+ await forum_handler.cmd_forum(message, state)
+
+ state.set_state.assert_awaited_once_with(ForumSetupState.waiting_for_group)
+ message.answer.assert_awaited_once()
+ assert message.answer.await_args.kwargs["reply_markup"] is not None
+
+
+async def test_handle_group_forward_registers_group_and_topics(monkeypatch):
+ message = make_message(text="forwarded")
+ message.forward_from_chat = SimpleNamespace(id=-100200, type="supergroup", title="Lambda")
+ message.bot.get_me.return_value = SimpleNamespace(id=999)
+ message.bot.get_chat_member.return_value = SimpleNamespace(
+ status="administrator",
+ can_manage_topics=True,
+ )
+ message.bot.create_forum_topic.side_effect = [
+ SimpleNamespace(message_thread_id=11),
+ SimpleNamespace(message_thread_id=22),
+ ]
+ state = AsyncMock(spec=FSMContext)
+
+ monkeypatch.setattr(
+ forum_handler.db,
+ "get_user_chats",
+ lambda tg_user_id: [
+ {"chat_id": "chat-1", "name": "One", "forum_thread_id": None},
+ {"chat_id": "chat-2", "name": "Two", "forum_thread_id": None},
+ ],
+ )
+ set_forum_group = Mock()
+ set_forum_thread = Mock()
+ monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group)
+ monkeypatch.setattr(forum_handler.db, "set_forum_thread", set_forum_thread)
+
+ await forum_handler.handle_group_forward(message, state)
+
+ set_forum_group.assert_called_once_with(42, -100200)
+ assert message.bot.create_forum_topic.await_count == 2
+ set_forum_thread.assert_any_call("chat-1", 11)
+ set_forum_thread.assert_any_call("chat-2", 22)
+ state.set_state.assert_awaited_once_with(ChatState.idle)
+ assert "Группа подключена" in message.answer.await_args.args[0]
+
+
+async def test_handle_group_forward_accepts_forward_origin_chat(monkeypatch):
+ message = make_message(text="forwarded")
+ message.forward_from_chat = None
+ message.forward_origin = MessageOriginChat(
+ date=datetime.now(),
+ sender_chat=Chat(id=-100200, type="supergroup", title="Lambda", is_forum=True),
+ )
+ message.bot.get_me.return_value = SimpleNamespace(id=999)
+ message.bot.get_chat_member.return_value = SimpleNamespace(
+ status="administrator",
+ can_manage_topics=True,
+ )
+ state = AsyncMock(spec=FSMContext)
+
+ monkeypatch.setattr(forum_handler.db, "get_user_chats", lambda tg_user_id: [])
+ set_forum_group = Mock()
+ monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group)
+
+ await forum_handler.handle_group_forward(message, state)
+
+ set_forum_group.assert_called_once_with(42, -100200)
+ state.set_state.assert_awaited_once_with(ChatState.idle)
+ assert "Группа подключена" in message.answer.await_args.args[0]
+
+
+async def test_handle_group_forward_accepts_chat_shared(monkeypatch):
+ message = make_message(text="selected")
+ message.chat_shared = SimpleNamespace(request_id=1, chat_id=-100200, title="Lambda")
+ message.bot.get_me.return_value = SimpleNamespace(id=999)
+ message.bot.get_chat_member.return_value = SimpleNamespace(
+ status="administrator",
+ can_manage_topics=True,
+ )
+ state = AsyncMock(spec=FSMContext)
+
+ monkeypatch.setattr(forum_handler.db, "get_user_chats", lambda tg_user_id: [])
+ set_forum_group = Mock()
+ monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group)
+
+ await forum_handler.handle_group_forward(message, state)
+
+ set_forum_group.assert_called_once_with(42, -100200)
+ state.set_state.assert_awaited_once_with(ChatState.idle)
+ assert "Группа подключена" in message.answer.await_args.args[0]
+
+
+async def test_handle_group_forward_reports_missing_forward_metadata():
+ message = make_message(text="not forwarded")
+ message.forward_from_chat = None
+ message.forward_origin = None
+ state = AsyncMock(spec=FSMContext)
+
+ await forum_handler.handle_group_forward(message, state)
+
+ message.answer.assert_awaited_once()
+ assert "данных о группе" in message.answer.await_args.args[0]
+ state.set_state.assert_not_awaited()
+
+
+async def test_handle_group_forward_reports_non_forum_supergroup():
+ message = make_message(text="forwarded")
+ message.forward_from_chat = SimpleNamespace(
+ id=-100200,
+ type="supergroup",
+ title="Lambda",
+ is_forum=False,
+ )
+ state = AsyncMock(spec=FSMContext)
+
+ await forum_handler.handle_group_forward(message, state)
+
+ message.answer.assert_awaited_once()
+ assert "выключены Topics" in message.answer.await_args.args[0]
+ state.set_state.assert_not_awaited()
+
+
+async def test_handle_message_routes_forum_thread(monkeypatch):
+ message = make_message(thread_id=77)
+ dispatcher = SimpleNamespace(
+ dispatch=AsyncMock(
+ return_value=[OutgoingMessage(chat_id="chat-77", text="ok")]
+ )
+ )
+ state = AsyncMock(spec=FSMContext)
+ state.get_data.return_value = {}
+
+ monkeypatch.setattr(
+ chat_handler.db,
+ "get_or_create_tg_user",
+ lambda tg_user_id, platform_user_id, display_name: {
+ "platform_user_id": "usr-42",
+ "display_name": display_name,
+ },
+ )
+ monkeypatch.setattr(
+ chat_handler.db,
+ "get_chat_by_thread",
+ lambda tg_user_id, thread_id: {"chat_id": "chat-77", "name": "Forum chat"},
+ )
+ monkeypatch.setattr(
+ chat_handler.db,
+ "get_chat_by_id",
+ lambda chat_id: {"chat_id": chat_id, "name": "Forum chat"},
+ )
+ monkeypatch.setattr(
+ chat_handler.asyncio,
+ "create_task",
+ lambda coro: (coro.close(), FakeTask())[1],
+ )
+
+ await chat_handler.handle_message(message, state, dispatcher)
+
+ incoming = dispatcher.dispatch.await_args.args[0]
+ assert incoming.chat_id == "chat-77"
+ assert incoming.user_id == "usr-42"
+ assert state.update_data.await_args.kwargs == {
+ "active_chat_id": "chat-77",
+ "active_chat_name": "Forum chat",
+ }
+ message.bot.send_message.assert_awaited_once()
+ assert message.bot.send_message.await_args.args[0] == -100123
+ assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 77
+ assert message.bot.send_message.await_args.args[1] == "ok"
+
+
+async def test_cmd_new_chat_creates_forum_topic_for_dm(monkeypatch):
+ message = make_message(text="/new Analysis")
+ state = AsyncMock(spec=FSMContext)
+ message.bot.create_forum_topic.return_value = SimpleNamespace(message_thread_id=333)
+
+ monkeypatch.setattr(chat_handler.db, "get_forum_group", lambda tg_user_id: -100200)
+ monkeypatch.setattr(chat_handler.db, "count_chats", lambda tg_user_id: 2)
+ create_chat = Mock(return_value="chat-3")
+ set_forum_thread = Mock()
+ monkeypatch.setattr(chat_handler.db, "create_chat", create_chat)
+ monkeypatch.setattr(chat_handler.db, "set_forum_thread", set_forum_thread)
+
+ await chat_handler.cmd_new_chat(message, state)
+
+ create_chat.assert_called_once_with(42, "Analysis")
+ message.bot.create_forum_topic.assert_awaited_once_with(chat_id=-100200, name="Analysis")
+ set_forum_thread.assert_called_once_with("chat-3", 333)
+ state.update_data.assert_awaited_once_with(active_chat_id="chat-3", active_chat_name="Analysis")
+ message.answer.assert_awaited_once()
+ assert "Форум-тема тоже создана" in message.answer.await_args.args[0]
+
+
+async def test_cmd_new_chat_registers_topic(monkeypatch):
+ message = make_message(text="/new Research", thread_id=88)
+ state = AsyncMock(spec=FSMContext)
+
+ monkeypatch.setattr(
+ chat_handler.db,
+ "get_chat_by_thread",
+ lambda tg_user_id, thread_id: None,
+ )
+ monkeypatch.setattr(chat_handler.db, "count_chats", lambda tg_user_id: 4)
+ create_chat = Mock(return_value="chat-5")
+ set_forum_thread = Mock()
+ monkeypatch.setattr(chat_handler.db, "create_chat", create_chat)
+ monkeypatch.setattr(chat_handler.db, "set_forum_thread", set_forum_thread)
+
+ await chat_handler.cmd_new_chat(message, state)
+
+ create_chat.assert_called_once_with(42, "Research")
+ set_forum_thread.assert_called_once_with("chat-5", 88)
+ message.bot.send_message.assert_awaited_once()
+ assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 88
+ state.update_data.assert_awaited_once_with(active_chat_id="chat-5", active_chat_name="Research")
+
+
+async def test_cmd_list_chats_rejected_in_forum_topic():
+ message = make_message(text="/chats", thread_id=88)
+ state = AsyncMock(spec=FSMContext)
+
+ await chat_handler.cmd_list_chats(message, state)
+
+ message.bot.send_message.assert_awaited_once()
+ assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 88
+ assert "отключено" in message.bot.send_message.await_args.args[1]
+
+
+async def test_switch_chat_rejected_in_forum_topic():
+ callback = SimpleNamespace(
+ data="switch:chat-9:Other",
+ from_user=SimpleNamespace(id=42, full_name="Alice"),
+ message=make_message(thread_id=88),
+ answer=AsyncMock(),
+ )
+ state = AsyncMock(spec=FSMContext)
+
+ await chat_handler.switch_chat(callback, state)
+
+ state.update_data.assert_not_awaited()
+ callback.answer.assert_awaited_once_with(
+ "Переключение чатов доступно только в личке с ботом.",
+ show_alert=True,
+ )
+
+
+async def test_new_chat_callback_rejected_in_forum_topic(monkeypatch):
+ callback = SimpleNamespace(
+ data="new_chat",
+ from_user=SimpleNamespace(id=42, full_name="Alice"),
+ message=make_message(thread_id=88),
+ answer=AsyncMock(),
+ )
+ state = AsyncMock(spec=FSMContext)
+ create_chat = Mock()
+ monkeypatch.setattr(chat_handler.db, "create_chat", create_chat)
+
+ await chat_handler.cb_new_chat(callback, state)
+
+ create_chat.assert_not_called()
+ state.update_data.assert_not_awaited()
+ callback.answer.assert_awaited_once_with(
+ "Создание нового чата из списка доступно только в личке с ботом.",
+ show_alert=True,
+ )
+
+
+async def test_confirm_callback_routes_back_to_forum_thread(monkeypatch):
+ message = make_message(thread_id=77)
+ callback = SimpleNamespace(
+ data="confirm:yes:action-1",
+ from_user=message.from_user,
+ message=message,
+ answer=AsyncMock(),
+ )
+ dispatcher = SimpleNamespace(
+ dispatch=AsyncMock(
+ return_value=[OutgoingMessage(chat_id="chat-77", text="done")]
+ )
+ )
+ state = AsyncMock(spec=FSMContext)
+ state.get_data.return_value = {}
+
+ monkeypatch.setattr(
+ confirm_handler.db,
+ "get_or_create_tg_user",
+ lambda tg_user_id, platform_user_id, display_name: {
+ "platform_user_id": "usr-42",
+ "display_name": display_name,
+ },
+ )
+ monkeypatch.setattr(
+ confirm_handler.db,
+ "get_chat_by_thread",
+ lambda tg_user_id, thread_id: {"chat_id": "chat-77"},
+ )
+ monkeypatch.setattr(
+ confirm_handler.db,
+ "get_chat_by_id",
+ lambda chat_id: {"chat_id": chat_id, "name": "Forum chat"},
+ )
+
+ await confirm_handler.handle_confirm(callback, state, dispatcher)
+
+ assert dispatcher.dispatch.await_args.args[0].chat_id == "chat-77"
+ assert callback.message.bot.send_message.await_count == 1
+ assert callback.message.bot.send_message.await_args.args[1] == "done"
+ assert callback.message.bot.send_message.await_args.kwargs["message_thread_id"] == 77
diff --git a/tests/adapter/telegram/test_message.py b/tests/adapter/telegram/test_message.py
deleted file mode 100644
index 69aab1e..0000000
--- a/tests/adapter/telegram/test_message.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from __future__ import annotations
-
-import importlib
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-
-
-@pytest.fixture(autouse=True)
-def fresh_db(tmp_path, monkeypatch):
- monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
- import adapter.telegram.db as db_mod
- importlib.reload(db_mod)
- db_mod.init_db()
- return db_mod
-
-
-def make_message(*, user_id=1, thread_id=42, chat_id=100):
- m = SimpleNamespace()
- m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
- m.message_thread_id = thread_id
- m.chat = SimpleNamespace(id=chat_id)
- m.text = "Hello"
- m.photo = None
- m.document = None
- m.voice = None
- m.video = None
- m.sticker = None
- m.answer = AsyncMock()
- placeholder = MagicMock()
- placeholder.edit_text = AsyncMock()
- m.reply = AsyncMock(return_value=placeholder)
- m.bot = MagicMock()
- return m, placeholder
-
-
-def make_dispatcher(chunks=None, raise_exc=None):
- """Build a mock EventDispatcher with configurable stream_message behaviour."""
- async def _stream(*args, **kwargs):
- if raise_exc is not None:
- raise raise_exc
- for chunk in (chunks or []):
- yield chunk
-
- platform = MagicMock()
- platform.get_or_create_user = AsyncMock(
- return_value=SimpleNamespace(user_id="uid-1")
- )
- platform.stream_message = _stream
-
- dispatcher = MagicMock()
- dispatcher._platform = platform
- return dispatcher
-
-
-async def test_stream_exception_shows_error(fresh_db):
- """When stream_message raises, the placeholder is updated with an error message."""
- fresh_db.create_chat(1, 42, "Чат #1")
- import adapter.telegram.handlers.message as mod
- importlib.reload(mod)
-
- msg, placeholder = make_message()
- dispatcher = make_dispatcher(raise_exc=RuntimeError("boom"))
-
- await mod.handle_topic_message(msg, dispatcher)
-
- placeholder.edit_text.assert_called()
- last_call_text = placeholder.edit_text.call_args[0][0]
- assert "недоступен" in last_call_text or "ошибка" in last_call_text.lower()
-
-
-async def test_stream_success_edits_placeholder(fresh_db):
- """When stream_message succeeds, the placeholder is updated with the response."""
- fresh_db.create_chat(1, 42, "Чат #1")
- import adapter.telegram.handlers.message as mod
- importlib.reload(mod)
-
- chunks = [SimpleNamespace(delta="Hello "), SimpleNamespace(delta="world")]
- msg, placeholder = make_message()
- dispatcher = make_dispatcher(chunks=chunks)
-
- await mod.handle_topic_message(msg, dispatcher)
-
- placeholder.edit_text.assert_called()
- last_call_text = placeholder.edit_text.call_args[0][0]
- assert "Hello world" in last_call_text
diff --git a/tests/adapter/telegram/test_topic_events.py b/tests/adapter/telegram/test_topic_events.py
deleted file mode 100644
index fb490af..0000000
--- a/tests/adapter/telegram/test_topic_events.py
+++ /dev/null
@@ -1,74 +0,0 @@
-from __future__ import annotations
-
-import importlib
-from types import SimpleNamespace
-from unittest.mock import AsyncMock
-
-import pytest
-
-
-@pytest.fixture(autouse=True)
-def fresh_db(tmp_path, monkeypatch):
- monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
- import adapter.telegram.db as db_mod
- importlib.reload(db_mod)
- db_mod.init_db()
- return db_mod
-
-
-BOT_ID = 9999 # distinct from any test user_id
-
-
-def make_service_message(*, user_id=1, thread_id=42, topic_name="Мой чат"):
- m = SimpleNamespace()
- m.message_thread_id = thread_id
- m.from_user = SimpleNamespace(id=user_id, full_name="Alice")
- m.chat = SimpleNamespace(id=user_id)
- m.forum_topic_created = SimpleNamespace(name=topic_name)
- m.forum_topic_edited = SimpleNamespace(name="Новое имя")
- m.forum_topic_closed = SimpleNamespace()
- m.answer = AsyncMock()
- m.bot = SimpleNamespace(id=BOT_ID)
- return m
-
-
-async def test_on_topic_created_registers_chat(fresh_db, monkeypatch):
- import adapter.telegram.handlers.topic_events as mod
- importlib.reload(mod)
- msg = make_service_message(user_id=5, thread_id=99, topic_name="Мой чат")
- await mod.on_topic_created(msg)
- chat = fresh_db.get_chat(5, 99)
- assert chat is not None
- assert chat["chat_name"] == "Мой чат"
-
-
-async def test_on_topic_edited_renames_chat(fresh_db, monkeypatch):
- import adapter.telegram.handlers.topic_events as mod
- importlib.reload(mod)
- fresh_db.create_chat(5, 99, "Старое имя")
- msg = make_service_message(user_id=5, thread_id=99)
- await mod.on_topic_edited(msg)
- assert fresh_db.get_chat(5, 99)["chat_name"] == "Новое имя"
-
-
-async def test_on_topic_edited_unknown_chat_is_noop(fresh_db):
- import adapter.telegram.handlers.topic_events as mod
- importlib.reload(mod)
- msg = make_service_message(user_id=5, thread_id=999)
- await mod.on_topic_edited(msg) # should not raise
-
-
-async def test_on_topic_closed_archives_chat(fresh_db):
- import adapter.telegram.handlers.topic_events as mod
- importlib.reload(mod)
- fresh_db.create_chat(5, 99, "Чат #1")
- msg = make_service_message(user_id=5, thread_id=99)
- await mod.on_topic_closed(msg)
- assert fresh_db.get_chat(5, 99)["archived_at"] is not None
-
-
-async def test_on_topic_closed_unknown_chat_is_noop(fresh_db):
- import adapter.telegram.handlers.topic_events as mod
- importlib.reload(mod)
- msg = make_service_message(user_id=5, thread_id=999)
- await mod.on_topic_closed(msg) # should not raise
diff --git a/tests/adapter/test_forum_db.py b/tests/adapter/test_forum_db.py
deleted file mode 100644
index e69adc4..0000000
--- a/tests/adapter/test_forum_db.py
+++ /dev/null
@@ -1,80 +0,0 @@
-from __future__ import annotations
-
-import importlib
-import pytest
-
-
-@pytest.fixture(autouse=True)
-def fresh_db(tmp_path, monkeypatch):
- monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db"))
- import adapter.telegram.db as db_mod
- importlib.reload(db_mod)
- db_mod.init_db()
- return db_mod
-
-
-def test_create_and_get_chat(fresh_db):
- db = fresh_db
- db.create_chat(user_id=1, thread_id=100, chat_name="Чат #1")
- chat = db.get_chat(user_id=1, thread_id=100)
- assert chat is not None
- assert chat["chat_name"] == "Чат #1"
- assert chat["archived_at"] is None
-
-
-def test_get_chat_missing(fresh_db):
- assert fresh_db.get_chat(user_id=1, thread_id=999) is None
-
-
-def test_archive_chat(fresh_db):
- db = fresh_db
- db.create_chat(1, 100, "Чат #1")
- db.archive_chat(1, 100)
- chat = db.get_chat(1, 100)
- assert chat["archived_at"] is not None
-
-
-def test_rename_chat(fresh_db):
- db = fresh_db
- db.create_chat(1, 100, "Чат #1")
- db.rename_chat(1, 100, "Новое имя")
- assert db.get_chat(1, 100)["chat_name"] == "Новое имя"
-
-
-def test_get_active_chats(fresh_db):
- db = fresh_db
- db.create_chat(1, 100, "Чат #1")
- db.create_chat(1, 200, "Чат #2")
- db.archive_chat(1, 100)
- chats = db.get_active_chats(1)
- assert len(chats) == 1
- assert chats[0]["thread_id"] == 200
-
-
-def test_display_number(fresh_db):
- db = fresh_db
- db.create_chat(1, 100, "Чат #1")
- db.create_chat(1, 200, "Чат #2")
- db.create_chat(1, 300, "Чат #3")
- assert db.get_display_number(1, 100) == 1
- assert db.get_display_number(1, 200) == 2
- assert db.get_display_number(1, 300) == 3
-
-
-def test_count_active_chats(fresh_db):
- db = fresh_db
- db.create_chat(1, 100, "Чат #1")
- db.create_chat(1, 200, "Чат #2")
- db.archive_chat(1, 100)
- assert db.count_active_chats(1) == 1
-
-
-def test_different_users_isolated(fresh_db):
- db = fresh_db
- db.create_chat(1, 100, "Чат #1")
- db.create_chat(2, 100, "Чат #1") # same thread_id, different user
- assert db.get_chat(1, 100)["chat_name"] == "Чат #1"
- assert db.get_chat(2, 100)["chat_name"] == "Чат #1"
- db.archive_chat(1, 100)
- assert db.get_chat(1, 100)["archived_at"] is not None
- assert db.get_chat(2, 100)["archived_at"] is None
diff --git a/tests/core/test_dispatcher.py b/tests/core/test_dispatcher.py
index fad2a4f..eb437d2 100644
--- a/tests/core/test_dispatcher.py
+++ b/tests/core/test_dispatcher.py
@@ -75,27 +75,6 @@ async def test_dispatch_routes_audio_before_catchall(dispatcher):
assert (await dispatcher.dispatch(text_msg))[0].text == "text"
-async def test_dispatch_routes_document_before_catchall(dispatcher):
- async def document_handler(event, **kwargs):
- return [OutgoingMessage(chat_id=event.chat_id, text="document")]
-
- async def catch_all(event, **kwargs):
- return [OutgoingMessage(chat_id=event.chat_id, text="text")]
-
- dispatcher.register(IncomingMessage, "document", document_handler)
- dispatcher.register(IncomingMessage, "*", catch_all)
-
- document_msg = IncomingMessage(
- user_id="u1",
- platform="matrix",
- chat_id="C1",
- text="",
- attachments=[Attachment(type="document", workspace_path="surfaces/matrix/u1/file.pdf")],
- )
-
- assert (await dispatcher.dispatch(document_msg))[0].text == "document"
-
-
async def test_dispatch_callback_by_action(dispatcher):
async def confirm_handler(event, **kwargs):
return [OutgoingMessage(chat_id=event.chat_id, text="confirmed")]
diff --git a/tests/core/test_integration.py b/tests/core/test_integration.py
index 9260ec8..207a0ba 100644
--- a/tests/core/test_integration.py
+++ b/tests/core/test_integration.py
@@ -4,57 +4,18 @@ Smoke test: полный цикл через dispatcher + реальные manag
Имитирует что делает адаптер (Telegram или Matrix) при получении события.
"""
import pytest
-
-from core.auth import AuthManager
+from sdk.mock import MockPlatformClient
+from core.store import InMemoryStore
from core.chat import ChatManager
+from core.auth import AuthManager
+from core.settings import SettingsManager
from core.handler import EventDispatcher
from core.handlers import register_all
from core.protocol import (
- Attachment,
- IncomingCallback,
- IncomingCommand,
- IncomingMessage,
- OutgoingMessage,
- OutgoingUI,
+ IncomingCommand, IncomingMessage, IncomingCallback,
+ OutgoingMessage, OutgoingUI,
+ Attachment, SettingsAction,
)
-from core.settings import SettingsManager
-from core.store import InMemoryStore
-from sdk.mock import MockPlatformClient
-from sdk.prototype_state import PrototypeStateStore
-from sdk.real import RealPlatformClient
-from sdk.upstream_agent_api import MsgEventTextChunk
-
-
-class FakeAgentApi:
- def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None:
- self.agent_id = agent_id
- self.base_url = base_url
- self.chat_id = chat_id
- self.calls: list[tuple[str, list[str]]] = []
- self.connect_calls = 0
- self.close_calls = 0
-
- async def connect(self) -> None:
- self.connect_calls += 1
-
- async def close(self) -> None:
- self.close_calls += 1
-
- async def send_message(self, text: str, attachments: list[str] | None = None):
- self.calls.append((text, attachments or []))
- yield MsgEventTextChunk(text=f"[REAL] {text}")
-
-
-class FakeAgentApiFactory:
- def __init__(self) -> None:
- self.created_chat_ids: list[str] = []
- self.instances: dict[str, list[FakeAgentApi]] = {}
-
- def __call__(self, agent_id: str, base_url: str, chat_id: str) -> FakeAgentApi:
- chat_api = FakeAgentApi(agent_id, base_url, chat_id)
- self.created_chat_ids.append(chat_id)
- self.instances.setdefault(chat_id, []).append(chat_api)
- return chat_api
@pytest.fixture
@@ -71,27 +32,6 @@ def dispatcher():
return d
-@pytest.fixture
-def real_dispatcher():
- agent_api = FakeAgentApiFactory()
- platform = RealPlatformClient(
- agent_id="matrix-bot",
- agent_base_url="http://platform-agent:8000",
- agent_api_cls=agent_api,
- prototype_state=PrototypeStateStore(),
- platform="matrix",
- )
- store = InMemoryStore()
- d = EventDispatcher(
- platform=platform,
- chat_mgr=ChatManager(platform, store),
- auth_mgr=AuthManager(platform, store),
- settings_mgr=SettingsManager(platform, store),
- )
- register_all(d)
- return d, agent_api
-
-
async def test_full_flow_start_then_message(dispatcher):
start = IncomingCommand(user_id="tg_123", platform="telegram", chat_id="C1", command="start")
result = await dispatcher.dispatch(start)
@@ -107,13 +47,7 @@ async def test_new_chat_command(dispatcher):
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
await dispatcher.dispatch(start)
- new = IncomingCommand(
- user_id="u1",
- platform="matrix",
- chat_id="C2",
- command="new",
- args=["Анализ"],
- )
+ new = IncomingCommand(user_id="u1", platform="matrix", chat_id="C2", command="new", args=["Анализ"])
result = await dispatcher.dispatch(new)
assert any("Анализ" in r.text for r in result if isinstance(r, OutgoingMessage))
@@ -149,46 +83,3 @@ async def test_toggle_skill_callback(dispatcher):
)
result = await dispatcher.dispatch(cb)
assert any("browser" in r.text for r in result if isinstance(r, OutgoingMessage))
-
-
-async def test_full_flow_with_real_platform_uses_direct_agent_api(real_dispatcher):
- dispatcher, agent_api = real_dispatcher
-
- start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
- result = await dispatcher.dispatch(start)
- assert any(isinstance(r, OutgoingMessage) for r in result)
-
- msg = IncomingMessage(user_id="u1", platform="matrix", chat_id="C1", text="Привет!")
- result = await dispatcher.dispatch(msg)
- texts = [r.text for r in result if isinstance(r, OutgoingMessage)]
-
- assert texts == ["[REAL] Привет!"]
- assert agent_api.created_chat_ids == ["C1"]
- assert [instance.calls for instance in agent_api.instances["C1"]] == [[("Привет!", [])]]
-
-
-async def test_full_flow_with_real_platform_forwards_workspace_attachment(real_dispatcher):
- dispatcher, agent_api = real_dispatcher
-
- start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
- await dispatcher.dispatch(start)
-
- msg = IncomingMessage(
- user_id="u1",
- platform="matrix",
- chat_id="C1",
- text="Посмотри файл",
- attachments=[
- Attachment(
- type="document",
- filename="report.pdf",
- mime_type="application/pdf",
- workspace_path="surfaces/matrix/u1/room/inbox/report.pdf",
- )
- ],
- )
- await dispatcher.dispatch(msg)
-
- assert [instance.calls for instance in agent_api.instances["C1"]] == [
- [("Посмотри файл", ["surfaces/matrix/u1/room/inbox/report.pdf"])]
- ]
diff --git a/tests/platform/test_agent_session.py b/tests/platform/test_agent_session.py
deleted file mode 100644
index c398e8c..0000000
--- a/tests/platform/test_agent_session.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""Compatibility tests after the Phase 4 migration."""
-
-from pathlib import Path
-
-
-def test_lambda_agent_api_module_is_importable():
- from sdk.upstream_agent_api import AgentApi
-
- assert AgentApi is not None
-
-
-def test_lambda_agent_api_preserves_base_url_path_suffix():
- from sdk.upstream_agent_api import AgentApi
-
- api = AgentApi(
- agent_id="matrix-bot",
- base_url="http://platform-agent:8000/proxy/",
- chat_id="chat-7",
- )
-
- assert api.url == "http://platform-agent:8000/proxy/v1/agent_ws/chat-7/"
-
-
-def test_agent_session_module_is_intentionally_stubbed():
- contents = Path(__file__).resolve().parents[2] / "sdk" / "agent_session.py"
-
- assert "replaced by direct AgentApi usage" in contents.read_text()
diff --git a/tests/platform/test_mock.py b/tests/platform/test_mock.py
index 18003d2..86e4afe 100644
--- a/tests/platform/test_mock.py
+++ b/tests/platform/test_mock.py
@@ -43,19 +43,3 @@ async def test_update_settings_toggle_skill():
await client.update_settings("usr-1", action)
settings = await client.get_settings("usr-1")
assert settings.skills.get("browser") is True
-
-
-async def test_update_settings_toggle_skill_preserves_other_skills():
- client = MockPlatformClient()
-
- initial = await client.get_settings("usr-1")
- initial_skill_names = set(initial.skills)
-
- action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True})
- await client.update_settings("usr-1", action)
-
- settings = await client.get_settings("usr-1")
-
- assert set(settings.skills) == initial_skill_names
- assert settings.skills["browser"] is True
- assert settings.skills["web-search"] is True
diff --git a/tests/platform/test_prototype_state.py b/tests/platform/test_prototype_state.py
deleted file mode 100644
index 376c0c4..0000000
--- a/tests/platform/test_prototype_state.py
+++ /dev/null
@@ -1,184 +0,0 @@
-import pytest
-
-from core.protocol import SettingsAction
-from sdk.interface import UserSettings
-from sdk.prototype_state import PrototypeStateStore
-
-
-@pytest.mark.asyncio
-async def test_get_or_create_user_is_stable_per_surface_identity():
- store = PrototypeStateStore()
-
- first = await store.get_or_create_user("@alice:example.org", "matrix", "Alice")
- second = await store.get_or_create_user("@alice:example.org", "matrix")
-
- assert first.user_id == "usr-matrix-@alice:example.org"
- assert first.is_new is True
- assert store._users["matrix:@alice:example.org"].is_new is False
-
- first.display_name = "Mallory"
- first.is_new = False
-
- assert second.user_id == first.user_id
- assert second.is_new is False
- assert second.display_name == "Alice"
- assert store._users["matrix:@alice:example.org"].display_name == "Alice"
- assert store._users["matrix:@alice:example.org"].is_new is False
-
-
-@pytest.mark.asyncio
-async def test_settings_defaults_match_existing_mock_shape():
- store = PrototypeStateStore()
-
- settings = await store.get_settings("usr-matrix-@alice:example.org")
-
- assert isinstance(settings, UserSettings)
- assert settings.skills == {
- "web-search": True,
- "fetch-url": True,
- "email": False,
- "browser": False,
- "image-gen": False,
- "files": True,
- }
- assert settings.safety == {
- "email-send": True,
- "file-delete": True,
- "social-post": True,
- }
- assert settings.soul == {"name": "Лямбда", "instructions": ""}
- assert settings.plan == {"name": "Beta", "tokens_used": 0, "tokens_limit": 1000}
-
-
-@pytest.mark.asyncio
-async def test_get_settings_returns_connectors_copy():
- store = PrototypeStateStore()
- store._settings["usr-matrix-@alice:example.org"] = {
- "connectors": {"github": {"enabled": True}},
- }
-
- settings = await store.get_settings("usr-matrix-@alice:example.org")
- settings.connectors["github"]["enabled"] = False
- settings.connectors["slack"] = {"enabled": True}
-
- assert store._settings["usr-matrix-@alice:example.org"]["connectors"] == {
- "github": {"enabled": True},
- }
-
-
-@pytest.mark.asyncio
-async def test_update_settings_supports_toggle_skill_and_setters():
- store = PrototypeStateStore()
-
- await store.update_settings(
- "usr-matrix-@alice:example.org",
- SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}),
- )
- await store.update_settings(
- "usr-matrix-@alice:example.org",
- SettingsAction(action="set_soul", payload={"field": "instructions", "value": "Be concise"}),
- )
- await store.update_settings(
- "usr-matrix-@alice:example.org",
- SettingsAction(action="set_safety", payload={"trigger": "social-post", "enabled": False}),
- )
-
- settings = await store.get_settings("usr-matrix-@alice:example.org")
-
- assert settings.skills["browser"] is True
- assert settings.skills["web-search"] is True
- assert settings.soul["instructions"] == "Be concise"
- assert settings.safety["social-post"] is False
-
-
-@pytest.mark.asyncio
-async def test_add_saved_session_appends_named_entries():
- store = PrototypeStateStore()
-
- await store.add_saved_session(
- "usr-matrix-@alice:example.org",
- "alpha",
- source_context_id="ctx-room-1",
- )
- await store.add_saved_session("usr-matrix-@alice:example.org", "beta")
-
- sessions = await store.list_saved_sessions("usr-matrix-@alice:example.org")
-
- assert [session["name"] for session in sessions] == ["alpha", "beta"]
- assert all("created_at" in session for session in sessions)
- assert sessions[0]["source_context_id"] == "ctx-room-1"
-
-
-@pytest.mark.asyncio
-async def test_list_saved_sessions_returns_copy():
- store = PrototypeStateStore()
-
- await store.add_saved_session("usr-matrix-@alice:example.org", "alpha")
-
- sessions = await store.list_saved_sessions("usr-matrix-@alice:example.org")
- sessions.append({"name": "tampered", "created_at": "never"})
-
- stored = await store.list_saved_sessions("usr-matrix-@alice:example.org")
-
- assert [session["name"] for session in stored] == ["alpha"]
-
-
-@pytest.mark.asyncio
-async def test_get_last_tokens_used_defaults_to_zero():
- store = PrototypeStateStore()
-
- assert await store.get_last_tokens_used_for_context("ctx-room-1") == 0
-
-
-@pytest.mark.asyncio
-async def test_live_tokens_used_are_scoped_per_context():
- store = PrototypeStateStore()
-
- await store.set_last_tokens_used_for_context("ctx-room-1", 321)
- await store.set_last_tokens_used_for_context("ctx-room-2", 654)
-
- assert await store.get_last_tokens_used_for_context("ctx-room-1") == 321
- assert await store.get_last_tokens_used_for_context("ctx-room-2") == 654
-
-
-@pytest.mark.asyncio
-async def test_current_session_roundtrip_is_scoped_per_context():
- store = PrototypeStateStore()
-
- assert await store.get_current_session_for_context("ctx-room-1") is None
- assert await store.get_current_session_for_context("ctx-room-2") is None
-
- await store.set_current_session_for_context("ctx-room-1", "session-1")
- await store.set_current_session_for_context("ctx-room-2", "session-2")
-
- assert await store.get_current_session_for_context("ctx-room-1") == "session-1"
- assert await store.get_current_session_for_context("ctx-room-2") == "session-2"
-
-
-@pytest.mark.asyncio
-async def test_clear_current_session_removes_only_target_context():
- store = PrototypeStateStore()
-
- await store.set_current_session_for_context("ctx-room-1", "session-1")
- await store.set_current_session_for_context("ctx-room-2", "session-2")
-
- await store.clear_current_session_for_context("ctx-room-1")
-
- assert await store.get_current_session_for_context("ctx-room-1") is None
- assert await store.get_current_session_for_context("ctx-room-2") == "session-2"
-
-
-@pytest.mark.asyncio
-async def test_saved_sessions_remain_user_scoped_separate_from_live_context_state():
- store = PrototypeStateStore()
-
- await store.set_current_session_for_context("ctx-room-1", "room-session")
- await store.set_last_tokens_used_for_context("ctx-room-1", 77)
- await store.add_saved_session("usr-matrix-@alice:example.org", "alpha")
-
- sessions = await store.list_saved_sessions("usr-matrix-@alice:example.org")
-
- assert [session["name"] for session in sessions] == ["alpha"]
- assert all(isinstance(session["created_at"], str) for session in sessions)
- assert await store.get_current_session_for_context("ctx-room-1") == "room-session"
- assert await store.get_last_tokens_used_for_context("ctx-room-1") == 77
diff --git a/tests/platform/test_real.py b/tests/platform/test_real.py
deleted file mode 100644
index 8bce30b..0000000
--- a/tests/platform/test_real.py
+++ /dev/null
@@ -1,465 +0,0 @@
-import asyncio
-
-import pytest
-from pydantic import Field
-
-from core.protocol import SettingsAction
-from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformError, UserSettings
-from sdk.prototype_state import PrototypeStateStore
-from sdk.real import RealPlatformClient
-from sdk.upstream_agent_api import MsgEventSendFile, MsgEventTextChunk
-
-
-class FakeChatAgentApi:
- def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None:
- self.agent_id = agent_id
- self.base_url = base_url
- self.chat_id = str(chat_id)
- self.calls: list[str] = []
- self.connect_calls = 0
- self.close_calls = 0
-
- async def connect(self) -> None:
- self.connect_calls += 1
-
- async def close(self) -> None:
- self.close_calls += 1
-
- async def send_message(self, text: str, attachments: list[str] | None = None):
- self.calls.append(text)
- midpoint = len(text) // 2
- yield MsgEventTextChunk(text=text[:midpoint])
- yield MsgEventTextChunk(text=text[midpoint:])
-
-
-class FakeAgentApiFactory:
- def __init__(self, chat_api_cls=FakeChatAgentApi) -> None:
- self.chat_api_cls = chat_api_cls
- self.created_calls: list[tuple[str, str, str]] = []
- self.instances_by_chat: dict[str, list[FakeChatAgentApi]] = {}
-
- def __call__(self, agent_id: str, base_url: str, chat_id: str):
- chat_key = str(chat_id)
- chat_api = self.chat_api_cls(agent_id, base_url, chat_key)
- self.created_calls.append((agent_id, base_url, chat_key))
- self.instances_by_chat.setdefault(chat_key, []).append(chat_api)
- return chat_api
-
- def latest(self, chat_id: str):
- return self.instances_by_chat[str(chat_id)][-1]
-
-
-class BlockingTracker:
- def __init__(self) -> None:
- self.active_calls = 0
- self.max_active_calls = 0
- self.started = asyncio.Event()
- self.release = asyncio.Event()
-
-
-class BlockingChatAgentApi(FakeChatAgentApi):
- def __init__(
- self,
- agent_id: str,
- base_url: str,
- chat_id: str,
- *,
- tracker: BlockingTracker,
- ) -> None:
- super().__init__(agent_id, base_url, chat_id)
- self._tracker = tracker
-
- async def send_message(self, text: str, attachments: list[str] | None = None):
- self.calls.append(text)
- self._tracker.active_calls += 1
- self._tracker.max_active_calls = max(
- self._tracker.max_active_calls,
- self._tracker.active_calls,
- )
- self._tracker.started.set()
- await self._tracker.release.wait()
- self._tracker.active_calls -= 1
- yield MsgEventTextChunk(text=text)
-
-
-class BlockingAgentApiFactory(FakeAgentApiFactory):
- def __init__(self) -> None:
- super().__init__()
- self.tracker = BlockingTracker()
-
- def __call__(self, agent_id: str, base_url: str, chat_id: str):
- chat_key = str(chat_id)
- chat_api = BlockingChatAgentApi(
- agent_id,
- base_url,
- chat_key,
- tracker=self.tracker,
- )
- self.created_calls.append((agent_id, base_url, chat_key))
- self.instances_by_chat.setdefault(chat_key, []).append(chat_api)
- return chat_api
-
-
-class AttachmentTrackingChatAgentApi(FakeChatAgentApi):
- def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None:
- super().__init__(agent_id, base_url, chat_id)
- self.calls: list[tuple[str, list[str] | None]] = []
-
- async def send_message(self, text: str, attachments: list[str] | None = None):
- self.calls.append((text, attachments))
- yield MsgEventTextChunk(text=text)
-
-
-class FlakyChatAgentApi(FakeChatAgentApi):
- async def send_message(self, text: str, attachments: list[str] | None = None):
- raise ConnectionError("Connection closed")
- yield
-
-
-class ReuseSensitiveChatAgentApi(FakeChatAgentApi):
- def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None:
- super().__init__(agent_id, base_url, chat_id)
- self._send_calls = 0
-
- async def send_message(self, text: str, attachments: list[str] | None = None):
- self.calls.append(text)
- self._send_calls += 1
- if text == "first":
- yield MsgEventTextChunk(text="tool ok")
- return
- if text == "second" and self._send_calls == 1:
- yield MsgEventTextChunk(text="Missing")
-
-
-class MessageResponseWithAttachments(MessageResponse):
- attachments: list[Attachment] = Field(default_factory=list)
-
-
-def make_real_platform_client(
- agent_api_cls,
- *,
- prototype_state: PrototypeStateStore | None = None,
-) -> RealPlatformClient:
- return RealPlatformClient(
- agent_id="matrix-bot",
- agent_base_url="http://platform-agent:8000",
- agent_api_cls=agent_api_cls,
- prototype_state=prototype_state or PrototypeStateStore(),
- platform="matrix",
- )
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_get_or_create_user_uses_local_state():
- client = make_real_platform_client(FakeAgentApiFactory())
-
- first = await client.get_or_create_user("u1", "matrix", "Alice")
- second = await client.get_or_create_user("u1", "matrix")
-
- assert first.user_id == "usr-matrix-u1"
- assert first.is_new is True
- assert second.user_id == first.user_id
- assert second.is_new is False
- assert second.display_name == "Alice"
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_send_message_uses_direct_agent_api_per_chat():
- agent_api = FakeAgentApiFactory()
- prototype_state = PrototypeStateStore()
- client = make_real_platform_client(agent_api, prototype_state=prototype_state)
-
- result = await client.send_message("@alice:example.org", "chat-7", "hello")
-
- assert result == MessageResponse(
- message_id="@alice:example.org",
- response="hello",
- tokens_used=0,
- finished=True,
- )
- assert agent_api.created_calls == [("matrix-bot", "http://platform-agent:8000", "chat-7")]
- assert agent_api.latest("chat-7").chat_id == "chat-7"
- assert agent_api.latest("chat-7").calls == ["hello"]
- assert agent_api.latest("chat-7").connect_calls == 1
- assert agent_api.latest("chat-7").close_calls == 1
- assert await prototype_state.get_last_tokens_used_for_context("chat-7") == 0
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_preserves_path_base_url_without_trailing_slash():
- agent_api = FakeAgentApiFactory()
- client = RealPlatformClient(
- agent_id="agent-17",
- agent_base_url="http://lambda.coredump.ru:7000/agent_17",
- agent_api_cls=agent_api,
- prototype_state=PrototypeStateStore(),
- platform="matrix",
- )
-
- await client.send_message("@alice:example.org", "41", "hello")
-
- assert agent_api.created_calls == [
- ("agent-17", "http://lambda.coredump.ru:7000/agent_17/", "41")
- ]
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_forwards_attachments_to_chat_api():
- agent_api = FakeAgentApiFactory(chat_api_cls=AttachmentTrackingChatAgentApi)
- client = make_real_platform_client(agent_api)
- attachment = Attachment(
- url="/agents/7/surfaces/matrix/alice/room/inbox/report.pdf",
- workspace_path="surfaces/matrix/alice/room/inbox/report.pdf",
- mime_type="application/pdf",
- filename="report.pdf",
- size=123,
- )
-
- result = await client.send_message(
- "@alice:example.org",
- "chat-7",
- "hello",
- attachments=[attachment],
- )
-
- assert agent_api.latest("chat-7").calls == [
- ("hello", ["surfaces/matrix/alice/room/inbox/report.pdf"])
- ]
- assert result.response == "hello"
- assert result.tokens_used == 0
-
-
-def test_attachment_paths_normalize_workspace_roots_to_relative_paths():
- attachments = [
- Attachment(workspace_path="/workspace/report.pdf"),
- Attachment(workspace_path="/agents/7/report.csv"),
- Attachment(workspace_path="note.txt"),
- ]
-
- assert RealPlatformClient._attachment_paths(attachments) == [
- "report.pdf",
- "report.csv",
- "note.txt",
- ]
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_preserves_send_file_events_in_sync_result(monkeypatch):
- class FileEventAgentApi(AttachmentTrackingChatAgentApi):
- async def send_message(self, text: str, attachments: list[str] | None = None):
- self.calls.append((text, attachments))
- yield MsgEventTextChunk(text="he")
- yield MsgEventSendFile(path="report.pdf")
- yield MsgEventTextChunk(text="llo")
-
- agent_api = FakeAgentApiFactory(chat_api_cls=FileEventAgentApi)
- client = make_real_platform_client(agent_api)
-
- monkeypatch.setattr("sdk.real.MessageResponse", MessageResponseWithAttachments)
-
- result = await client.send_message("@alice:example.org", "chat-7", "hello")
-
- assert result.response == "hello"
- assert result.tokens_used == 0
- assert result.attachments == [
- Attachment(
- url="report.pdf",
- mime_type="application/octet-stream",
- filename="report.pdf",
- size=None,
- workspace_path="report.pdf",
- )
- ]
-
-
-@pytest.mark.parametrize(
- ("location", "expected_workspace_path"),
- [
- ("/workspace/report.pdf", "report.pdf"),
- ("/agents/7/report.pdf", "report.pdf"),
- (
- "surfaces/matrix/alice/room/inbox/report.pdf",
- "surfaces/matrix/alice/room/inbox/report.pdf",
- ),
- ],
-)
-def test_attachment_from_send_file_event_normalizes_shared_volume_paths(
- location: str, expected_workspace_path: str
-):
- attachment = RealPlatformClient._attachment_from_send_file_event(
- MsgEventSendFile(path=location)
- )
-
- assert attachment.url == location
- assert attachment.workspace_path == expected_workspace_path
- assert attachment.filename == "report.pdf"
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_uses_fresh_agent_connection_per_request():
- agent_api = FakeAgentApiFactory()
- client = make_real_platform_client(agent_api)
-
- await client.send_message("@alice:example.org", "chat-1", "hello")
- await client.send_message("@alice:example.org", "chat-1", "again")
-
- assert agent_api.created_calls == [
- ("matrix-bot", "http://platform-agent:8000", "chat-1"),
- ("matrix-bot", "http://platform-agent:8000", "chat-1"),
- ]
- assert [instance.calls for instance in agent_api.instances_by_chat["chat-1"]] == [
- ["hello"],
- ["again"],
- ]
- assert all(instance.connect_calls == 1 for instance in agent_api.instances_by_chat["chat-1"])
- assert all(instance.close_calls == 1 for instance in agent_api.instances_by_chat["chat-1"])
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_avoids_reuse_sensitive_second_message_loss():
- agent_api = FakeAgentApiFactory(chat_api_cls=ReuseSensitiveChatAgentApi)
- client = make_real_platform_client(agent_api)
-
- first = await client.send_message("@alice:example.org", "chat-1", "first")
- second = await client.send_message("@alice:example.org", "chat-1", "second")
-
- assert first.response == "tool ok"
- assert second.response == "Missing"
- assert len(agent_api.instances_by_chat["chat-1"]) == 2
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_wraps_connection_closed_as_platform_error():
- agent_api = FakeAgentApiFactory(chat_api_cls=FlakyChatAgentApi)
- client = make_real_platform_client(agent_api)
-
- with pytest.raises(PlatformError, match="Connection closed") as exc_info:
- await client.send_message("@alice:example.org", "chat-1", "hello")
-
- assert exc_info.value.code == "PLATFORM_CONNECTION_ERROR"
- assert agent_api.latest("chat-1").close_calls == 1
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_uses_fresh_connection_after_failure():
- class SometimesFlakyAgentApi(FakeChatAgentApi):
- async def send_message(self, text: str, attachments: list[str] | None = None):
- if text == "hello":
- raise ConnectionError("Connection closed")
- self.calls.append(text)
- yield MsgEventTextChunk(text=text)
-
- agent_api = FakeAgentApiFactory(chat_api_cls=SometimesFlakyAgentApi)
- client = make_real_platform_client(agent_api)
-
- with pytest.raises(PlatformError, match="Connection closed"):
- await client.send_message("@alice:example.org", "chat-1", "hello")
-
- result = await client.send_message("@alice:example.org", "chat-1", "again")
-
- assert result.response == "again"
- assert agent_api.created_calls == [
- ("matrix-bot", "http://platform-agent:8000", "chat-1"),
- ("matrix-bot", "http://platform-agent:8000", "chat-1"),
- ]
- assert agent_api.latest("chat-1").calls == ["again"]
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_serializes_same_chat_streams_across_send_paths():
- agent_api = BlockingAgentApiFactory()
- client = make_real_platform_client(agent_api)
-
- async def consume_stream():
- chunks = []
- async for chunk in client.stream_message("@alice:example.org", "chat-1", "hello"):
- chunks.append(chunk)
- return chunks
-
- stream_task = asyncio.create_task(consume_stream())
- await asyncio.wait_for(agent_api.tracker.started.wait(), timeout=1)
-
- send_task = asyncio.create_task(client.send_message("@alice:example.org", "chat-1", "again"))
- await asyncio.sleep(0)
-
- assert len(agent_api.instances_by_chat["chat-1"]) == 1
- assert agent_api.instances_by_chat["chat-1"][0].calls == ["hello"]
- assert agent_api.tracker.max_active_calls == 1
-
- agent_api.tracker.release.set()
- stream_chunks = await stream_task
- send_result = await send_task
-
- assert [chunk.delta for chunk in stream_chunks] == ["hello", ""]
- assert send_result.response == "again"
- assert [instance.calls for instance in agent_api.instances_by_chat["chat-1"]] == [
- ["hello"],
- ["again"],
- ]
- assert agent_api.tracker.max_active_calls == 1
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_creates_distinct_connections_per_chat():
- agent_api = FakeAgentApiFactory()
- client = make_real_platform_client(agent_api)
-
- await client.send_message("@alice:example.org", "chat-1", "hello")
- await client.send_message("@alice:example.org", "chat-2", "world")
-
- assert agent_api.created_calls == [
- ("matrix-bot", "http://platform-agent:8000", "chat-1"),
- ("matrix-bot", "http://platform-agent:8000", "chat-2"),
- ]
- assert agent_api.latest("chat-1").calls == ["hello"]
- assert agent_api.latest("chat-2").calls == ["world"]
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_stream_message_emits_final_tokens_chunk():
- agent_api = FakeAgentApiFactory()
- client = make_real_platform_client(agent_api)
-
- chunks = []
- async for chunk in client.stream_message("@alice:example.org", "chat-1", "hello"):
- chunks.append(chunk)
-
- assert chunks == [
- MessageChunk(
- message_id="@alice:example.org",
- delta="he",
- finished=False,
- tokens_used=0,
- ),
- MessageChunk(
- message_id="@alice:example.org",
- delta="llo",
- finished=False,
- tokens_used=0,
- ),
- MessageChunk(
- message_id="@alice:example.org",
- delta="",
- finished=True,
- tokens_used=0,
- ),
- ]
- assert agent_api.created_calls == [("matrix-bot", "http://platform-agent:8000", "chat-1")]
- assert agent_api.latest("chat-1").calls == ["hello"]
- assert agent_api.latest("chat-1").close_calls == 1
-
-
-@pytest.mark.asyncio
-async def test_real_platform_client_settings_are_local():
- client = make_real_platform_client(FakeAgentApiFactory())
-
- await client.update_settings(
- "usr-matrix-u1",
- SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}),
- )
-
- settings = await client.get_settings("usr-matrix-u1")
-
- assert isinstance(settings, UserSettings)
- assert settings.skills["browser"] is True
- assert settings.skills["web-search"] is True
diff --git a/tests/test_check_matrix_agents.py b/tests/test_check_matrix_agents.py
deleted file mode 100644
index 25f63bd..0000000
--- a/tests/test_check_matrix_agents.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from tools.check_matrix_agents import build_agent_ws_url
-
-
-def test_build_agent_ws_url_preserves_path_prefix_without_trailing_slash():
- assert (
- build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17", "41")
- == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
- )
-
-
-def test_build_agent_ws_url_preserves_path_prefix_with_trailing_slash():
- assert (
- build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17/", "41")
- == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
- )
-
-
-def test_build_agent_ws_url_accepts_existing_agent_ws_url():
- assert (
- build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/0/", "41")
- == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
- )
diff --git a/tests/test_deploy_handoff.py b/tests/test_deploy_handoff.py
deleted file mode 100644
index 0cf2057..0000000
--- a/tests/test_deploy_handoff.py
+++ /dev/null
@@ -1,102 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-import yaml
-
-ROOT = Path(__file__).resolve().parents[1]
-
-
-def _compose(path: str) -> dict:
- return yaml.safe_load((ROOT / path).read_text(encoding="utf-8"))
-
-
-def test_prod_compose_uses_registry_image_not_local_build():
- prod = _compose("docker-compose.prod.yml")
- service = prod["services"]["matrix-bot"]
-
- assert "image" in service
- assert "build" not in service
- assert service["image"].startswith("${SURFACES_BOT_IMAGE:?")
-
-
-def test_fullstack_compose_keeps_local_dev_build_with_agent_api_context():
- fullstack = _compose("docker-compose.fullstack.yml")
- service = fullstack["services"]["matrix-bot"]
-
- assert service["build"]["target"] == "development"
- assert service["build"]["additional_contexts"]["agent_api"] == "./external/platform-agent_api"
- assert service["extends"]["file"] == "docker-compose.prod.yml"
-
-
-def test_dockerfile_production_build_does_not_require_local_external_tree():
- dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
-
- assert "/app/external/platform-agent_api" not in dockerfile
- assert "external/platform-agent_api" not in dockerfile
- assert "git+https://git.lambda.coredump.ru/platform/agent_api.git" in dockerfile
- assert "python -m pip install --no-cache-dir --ignore-requires-python" in dockerfile
- assert "uv pip install --system --ignore-requires-python" not in dockerfile
-
-
-def test_dockerfile_installs_agent_api_after_final_uv_sync():
- dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
- development = dockerfile.split("FROM base AS development", maxsplit=1)[1].split(
- "FROM base AS production", maxsplit=1
- )[0]
- production = dockerfile.split("FROM base AS production", maxsplit=1)[1]
-
- assert development.index("RUN uv sync --no-dev --frozen") < development.index(
- "pip install --no-cache-dir --ignore-requires-python -e /agent_api/"
- )
- assert production.index("RUN uv sync --no-dev --frozen") < production.index(
- "git+https://git.lambda.coredump.ru/platform/agent_api.git"
- )
-
-
-def test_dockerignore_excludes_local_only_and_runtime_artifacts():
- dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8")
-
- assert "external/" in dockerignore
- assert ".planning/" in dockerignore
- assert "config/matrix-agents.yaml" in dockerignore
- assert ".env" in dockerignore
-
-
-def test_agent_registry_example_documents_multi_agent_volume_contract():
- registry = yaml.safe_load(
- (ROOT / "config" / "matrix-agents.example.yaml").read_text(encoding="utf-8")
- )
- agents = registry["agents"]
-
- assert len(agents) >= 3
- assert len({agent["id"] for agent in agents}) == len(agents)
- assert len({agent["workspace_path"] for agent in agents}) == len(agents)
- for index, agent in enumerate(agents):
- assert agent["base_url"].endswith(f"/agent_{index}/")
- assert agent["workspace_path"] == f"/agents/{index}"
-
-
-def test_smoke_compose_models_deploy_like_proxy_and_surface_checker():
- smoke = _compose("docker-compose.smoke.yml")
-
- assert set(smoke["services"]) >= {"surface-smoke", "agent-proxy", "agent-0", "agent-1"}
- assert "tools.check_matrix_agents" in smoke["services"]["surface-smoke"]["command"]
- assert smoke["services"]["agent-proxy"]["ports"] == ["${SMOKE_PROXY_PORT:-7000}:7000"]
-
-
-def test_smoke_timeout_override_routes_one_agent_to_no_status_stub():
- smoke_timeout = _compose("docker-compose.smoke.timeout.yml")
-
- assert set(smoke_timeout["services"]) >= {"agent-proxy", "agent-no-status"}
-
-
-def test_smoke_registry_targets_local_proxy_routes():
- registry = yaml.safe_load(
- (ROOT / "config" / "matrix-agents.smoke.yaml").read_text(encoding="utf-8")
- )
-
- assert [agent["base_url"] for agent in registry["agents"]] == [
- "http://agent-proxy:7000/agent_0/",
- "http://agent-proxy:7000/agent_1/",
- ]
diff --git a/tools/__init__.py b/tools/__init__.py
deleted file mode 100644
index a1d9c25..0000000
--- a/tools/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Operational tools for surfaces-bot."""
diff --git a/tools/check_matrix_agents.py b/tools/check_matrix_agents.py
deleted file mode 100644
index d6035aa..0000000
--- a/tools/check_matrix_agents.py
+++ /dev/null
@@ -1,197 +0,0 @@
-from __future__ import annotations
-
-import argparse
-import asyncio
-import json
-import os
-import time
-from dataclasses import asdict, dataclass
-from pathlib import Path
-from urllib.parse import urljoin
-
-import aiohttp
-
-from adapter.matrix.agent_registry import AgentDefinition, load_agent_registry
-from sdk.real import RealPlatformClient
-
-
-@dataclass
-class AgentCheckResult:
- agent_id: str
- label: str
- chat_id: str
- base_url: str
- ws_url: str
- ok: bool
- stage: str
- latency_ms: int
- error: str = ""
- response_type: str = ""
-
-
-def build_agent_ws_url(base_url: str, chat_id: str) -> str:
- normalized = RealPlatformClient._normalize_agent_base_url(base_url)
- return urljoin(normalized, f"v1/agent_ws/{chat_id}/")
-
-
-def _message_type(payload: str) -> str:
- try:
- data = json.loads(payload)
- except json.JSONDecodeError:
- return ""
- value = data.get("type")
- return value if isinstance(value, str) else ""
-
-
-async def _receive_text(ws: aiohttp.ClientWebSocketResponse, timeout: float) -> str:
- msg = await asyncio.wait_for(ws.receive(), timeout=timeout)
- if msg.type == aiohttp.WSMsgType.TEXT:
- return str(msg.data)
- if msg.type == aiohttp.WSMsgType.ERROR:
- raise RuntimeError(f"websocket error: {ws.exception()}")
- raise RuntimeError(f"unexpected websocket message type: {msg.type.name}")
-
-
-async def check_agent(
- agent: AgentDefinition,
- *,
- fallback_base_url: str,
- chat_id: str,
- timeout: float,
- message: str | None,
-) -> AgentCheckResult:
- base_url = agent.base_url or fallback_base_url
- ws_url = build_agent_ws_url(base_url, chat_id) if base_url else ""
- started = time.perf_counter()
-
- def result(ok: bool, stage: str, error: str = "", response_type: str = "") -> AgentCheckResult:
- return AgentCheckResult(
- agent_id=agent.agent_id,
- label=agent.label,
- chat_id=chat_id,
- base_url=base_url,
- ws_url=ws_url,
- ok=ok,
- stage=stage,
- latency_ms=int((time.perf_counter() - started) * 1000),
- error=error,
- response_type=response_type,
- )
-
- if not base_url:
- return result(False, "config", "missing base_url and AGENT_BASE_URL")
-
- try:
- client_timeout = aiohttp.ClientTimeout(
- total=timeout,
- connect=timeout,
- sock_connect=timeout,
- sock_read=timeout,
- )
- async with aiohttp.ClientSession(timeout=client_timeout) as session:
- async with session.ws_connect(ws_url, heartbeat=30) as ws:
- raw_status = await _receive_text(ws, timeout)
- status_type = _message_type(raw_status)
- if status_type != "STATUS":
- return result(
- False,
- "status",
- f"expected STATUS, got {raw_status[:200]}",
- status_type,
- )
-
- if not message:
- return result(True, "status", response_type=status_type)
-
- payload = {
- "type": "USER_MESSAGE",
- "text": message,
- "attachments": [],
- }
- await ws.send_str(json.dumps(payload))
-
- while True:
- raw_event = await _receive_text(ws, timeout)
- event_type = _message_type(raw_event)
- if event_type == "ERROR":
- return result(False, "message", raw_event[:200], event_type)
- if event_type == "AGENT_EVENT_END":
- return result(True, "message", response_type=event_type)
- if not event_type:
- return result(False, "message", f"invalid JSON event: {raw_event[:200]}")
- except TimeoutError:
- return result(False, "timeout", f"no response within {timeout:g}s")
- except Exception as exc:
- return result(False, "connect", str(exc))
-
-
-def _select_agents(
- agents: tuple[AgentDefinition, ...],
- selected: set[str],
-) -> list[AgentDefinition]:
- if not selected:
- return list(agents)
- return [agent for agent in agents if agent.agent_id in selected]
-
-
-async def run_checks(args: argparse.Namespace) -> list[AgentCheckResult]:
- registry = load_agent_registry(args.config)
- selected = _select_agents(registry.agents, set(args.agent))
- if not selected:
- raise SystemExit("no matching agents selected")
-
- fallback_base_url = args.base_url or os.environ.get("AGENT_BASE_URL", "")
- semaphore = asyncio.Semaphore(args.concurrency)
-
- async def run_one(index: int, agent: AgentDefinition) -> AgentCheckResult:
- chat_id = str(args.chat_id if args.chat_id is not None else args.chat_id_base + index)
- async with semaphore:
- return await check_agent(
- agent,
- fallback_base_url=fallback_base_url,
- chat_id=chat_id,
- timeout=args.timeout,
- message=args.message,
- )
-
- return await asyncio.gather(*(run_one(index, agent) for index, agent in enumerate(selected)))
-
-
-def print_table(results: list[AgentCheckResult]) -> None:
- for item in results:
- status = "OK" if item.ok else "FAIL"
- detail = item.response_type or item.error
- print(
- f"{status:4} {item.agent_id:20} {item.stage:8} "
- f"{item.latency_ms:5}ms chat={item.chat_id} url={item.ws_url} {detail}"
- )
-
-
-def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(
- description="Smoke-check Matrix agent WebSocket endpoints from matrix-agents.yaml."
- )
- parser.add_argument("--config", type=Path, default=Path("config/matrix-agents.yaml"))
- parser.add_argument("--agent", action="append", default=[], help="Agent id to check")
- parser.add_argument("--base-url", default="", help="Fallback base URL when an agent has none")
- parser.add_argument("--timeout", type=float, default=10.0)
- parser.add_argument("--concurrency", type=int, default=5)
- parser.add_argument("--chat-id", type=int, default=None, help="Use one explicit chat id")
- parser.add_argument("--chat-id-base", type=int, default=900000)
- parser.add_argument("--message", default=None, help="Optional test message after STATUS")
- parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
- return parser.parse_args()
-
-
-def main() -> int:
- args = parse_args()
- results = asyncio.run(run_checks(args))
- if args.json:
- print(json.dumps([asdict(result) for result in results], ensure_ascii=False, indent=2))
- else:
- print_table(results)
- return 0 if all(result.ok for result in results) else 1
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/tools/no_status_agent.py b/tools/no_status_agent.py
deleted file mode 100644
index adb563a..0000000
--- a/tools/no_status_agent.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from __future__ import annotations
-
-import argparse
-import asyncio
-
-from aiohttp import web
-
-
-async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
- ws = web.WebSocketResponse()
- await ws.prepare(request)
- await asyncio.sleep(3600)
- return ws
-
-
-def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(
- description="WebSocket stub that accepts connections but sends no STATUS."
- )
- parser.add_argument("--host", default="127.0.0.1")
- parser.add_argument("--port", type=int, default=8000)
- return parser.parse_args()
-
-
-def main() -> None:
- args = parse_args()
- app = web.Application()
- app.router.add_get("/v1/agent_ws/{chat_id}/", websocket_handler)
- web.run_app(app, host=args.host, port=args.port)
-
-
-if __name__ == "__main__":
- main()
diff --git a/uv.lock b/uv.lock
index 76a9426..600768a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -39,7 +39,7 @@ wheels = [
[[package]]
name = "aiohttp"
-version = "3.13.3"
+version = "3.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -50,93 +50,93 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" },
- { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" },
- { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" },
- { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" },
- { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" },
- { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" },
- { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" },
- { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" },
- { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" },
- { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" },
- { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" },
- { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" },
- { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" },
- { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" },
- { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" },
- { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" },
- { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" },
- { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
- { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
- { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
- { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
- { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
- { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
- { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
- { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
- { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
- { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
- { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
- { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
- { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
- { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
- { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
- { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
- { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
- { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
- { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
- { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
- { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
- { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
- { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
- { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
- { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
- { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
- { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
- { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
- { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
- { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
- { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
- { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
- { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
- { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
- { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
- { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
- { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
- { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
- { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
- { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
- { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
- { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
- { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
- { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
- { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
- { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
- { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
- { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
- { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
- { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
- { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
- { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
- { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
- { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
- { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
- { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
- { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
- { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
- { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
- { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
- { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
- { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
- { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
- { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
- { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
- { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
- { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
- { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" },
+ { url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" },
+ { url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" },
+ { url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" },
+ { url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" },
+ { url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" },
+ { url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" },
+ { url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" },
+ { url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" },
+ { url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" },
+ { url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" },
+ { url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" },
+ { url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" },
+ { url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" },
+ { url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" },
+ { url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" },
+ { url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" },
+ { url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" },
+ { url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" },
+ { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" },
+ { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" },
+ { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" },
+ { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" },
+ { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" },
+ { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" },
+ { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" },
+ { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" },
+ { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" },
+ { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" },
+ { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" },
+ { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" },
+ { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" },
+ { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" },
+ { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
]
[[package]]
@@ -756,7 +756,7 @@ wheels = [
[[package]]
name = "mypy"
-version = "1.19.1"
+version = "1.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
@@ -764,33 +764,38 @@ dependencies = [
{ name = "pathspec" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" },
- { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" },
- { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" },
- { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" },
- { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" },
- { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" },
- { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" },
- { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" },
- { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" },
- { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" },
- { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" },
- { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" },
- { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
- { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
- { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
- { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
- { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
- { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
- { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
- { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
- { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
- { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
- { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
- { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
- { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972, upload-time = "2026-03-31T16:49:04.887Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007, upload-time = "2026-03-31T16:50:08.151Z" },
+ { url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752, upload-time = "2026-03-31T16:49:26.064Z" },
+ { url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265, upload-time = "2026-03-31T16:53:55.077Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476, upload-time = "2026-03-31T16:50:34.587Z" },
+ { url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226, upload-time = "2026-03-31T16:53:15.624Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091, upload-time = "2026-03-31T16:54:12.162Z" },
+ { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" },
+ { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" },
+ { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" },
+ { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" },
+ { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" },
+ { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" },
+ { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" },
+ { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" },
+ { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" },
+ { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" },
+ { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" },
]
[[package]]
@@ -1072,11 +1077,11 @@ wheels = [
[[package]]
name = "pygments"
-version = "2.19.2"
+version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
@@ -1095,20 +1100,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
-[[package]]
-name = "pytest-aiohttp"
-version = "1.1.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "aiohttp" },
- { name = "pytest" },
- { name = "pytest-asyncio" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" },
-]
-
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
@@ -1154,61 +1145,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087, upload-time = "2026-02-16T05:23:59.147Z" },
]
-[[package]]
-name = "pyyaml"
-version = "6.0.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
- { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
- { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
- { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
- { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
- { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
- { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
- { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
- { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
- { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
- { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
- { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
- { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
- { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
- { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
- { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
- { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
- { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
- { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
- { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
- { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
- { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
- { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
- { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
- { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
- { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
- { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
- { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
- { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
- { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
- { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
- { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
- { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
- { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
- { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
- { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
- { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
- { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
- { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
- { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
- { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
- { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
- { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
- { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
- { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
- { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
- { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
-]
-
[[package]]
name = "referencing"
version = "0.37.0"
@@ -1371,12 +1307,10 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiogram" },
- { name = "aiohttp" },
{ name = "httpx" },
{ name = "matrix-nio" },
{ name = "pydantic" },
{ name = "python-dotenv" },
- { name = "pyyaml" },
{ name = "structlog" },
]
@@ -1384,7 +1318,6 @@ dependencies = [
dev = [
{ name = "mypy" },
{ name = "pytest" },
- { name = "pytest-aiohttp" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "ruff" },
@@ -1393,17 +1326,14 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiogram", specifier = ">=3.4,<4" },
- { name = "aiohttp", specifier = ">=3.9" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "matrix-nio", specifier = ">=0.21" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8" },
{ name = "pydantic", specifier = ">=2.5" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
- { name = "pytest-aiohttp", marker = "extra == 'dev'", specifier = ">=1.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
{ name = "python-dotenv", specifier = ">=1.0" },
- { name = "pyyaml", specifier = ">=6.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3" },
{ name = "structlog", specifier = ">=24.1" },
]