, 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
new file mode 100755
index 0000000..8e6eadf
--- /dev/null
+++ b/bot-examples/matrix_bot_rooms.py
@@ -0,0 +1,2667 @@
+"""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
new file mode 100644
index 0000000..03e2e7f
--- /dev/null
+++ b/bot-examples/matrix_main.py
@@ -0,0 +1,123 @@
+"""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
new file mode 100644
index 0000000..491c579
--- /dev/null
+++ b/bot-examples/telegram_bot_topics.py
@@ -0,0 +1,511 @@
+"""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
new file mode 100644
index 0000000..cf5d13e
--- /dev/null
+++ b/bot-examples/telegram_main.py
@@ -0,0 +1,75 @@
+"""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/docs/known-limitations.md b/docs/known-limitations.md
index 2d92e9c..e98f0ba 100644
--- a/docs/known-limitations.md
+++ b/docs/known-limitations.md
@@ -30,3 +30,22 @@ Threaded Mode — относительно новая фича Bot API. Ряд
---
*Все перечисленные ограничения — на стороне платформы 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-prototype.md b/docs/matrix-prototype.md
index 5e57c88..bebf0b4 100644
--- a/docs/matrix-prototype.md
+++ b/docs/matrix-prototype.md
@@ -2,7 +2,7 @@
## Концепция
-Один бот, каждый чат — отдельная комната, все комнаты собраны в Space.
+Один бот, каждый чат — отдельная комната, все комнаты собраны в personal Space.
При первом входе бот создаёт для пользователя личное пространство (Space) —
это как папка в Element. Внутри Space бот создаёт комнату для каждого нового
@@ -11,7 +11,8 @@
ничего дополнительно делать не нужно.
Matrix выбран как внутренняя поверхность: команды лаборатории, тестировщики,
-разработчики скиллов. Поэтому UX здесь — про удобство работы, а не онбординг.
+разработчики скиллов. Поэтому UX здесь прагматичный: минимум магии, явные
+команды `!`, локальный state-store и нативные Matrix rooms.
---
@@ -36,7 +37,6 @@ Matrix выбран как внутренняя поверхность: кома
### Структура
```
Space: «Lambda — {display_name}»
- ├── 📌 Настройки ← специальная комната для команд управления
├── 💬 Чат 1 ← первый чат, создаётся автоматически
├── 💬 Чат 2
└── 💬 Исследование рынка ← пользователь сам называет
@@ -45,33 +45,42 @@ Space: «Lambda — {display_name}»
### Создание Space
При первом входе бот:
1. Создаёт Space `Lambda — {display_name}`
-2. Создаёт комнату `Настройки` (закреплена вверху)
-3. Создаёт первую комнату-чат `Чат 1`
-4. Приглашает пользователя во все комнаты
-5. Пишет в `Чат 1` приветствие
+2. Создаёт первую комнату-чат `Чат 1`
+3. Передаёт `invite=[matrix_user_id]` прямо в `room_create(...)` для Space и комнаты
+4. Привязывает `chat_id ↔ room_id` в локальном состоянии
+5. Пишет приветствие в `Чат 1`
### Управление чатами
-Команды работают в любой комнате Space:
+Команды работают в зарегистрированных комнатах бота:
| Команда | Действие |
|---|---|
| `!new` | Создать новый чат (новую комнату в Space) |
| `!new Название` | Создать чат с именем |
+| `!help` | Показать шпаргалку по доступным командам |
| `!rename Название` | Переименовать текущую комнату |
-| `!archive` | Вывести комнату из Space (не удалять) |
+| `!archive` | Архивировать чат и вывести бота из комнаты |
| `!chats` | Показать список чатов |
+| `!settings`, `!skills`, `!soul`, `!safety`, `!plan`, `!status`, `!whoami` | Настройки и диагностика |
### Создание нового чата
1. Пользователь пишет `!new` или `!new Анализ конкурентов`
2. Бот создаёт новую комнату в Space
-3. Приглашает пользователя
-4. Пишет приветствие; при первом сообщении платформа автоматически поднимает контейнер
+3. Сразу приглашает пользователя через `room_create(..., invite=[user_id])`
+4. Регистрирует комнату в локальном состоянии и `ChatManager`
5. Пользователь переходит в новую комнату — начинает диалог
### В моке
- Space и комнаты создаются реально через matrix-nio
- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...)
- История хранится в Matrix нативно
+- Дефолтные `skills`, `safety`, `soul`, `plan` подмешиваются даже после частичных локальных обновлений настроек
+
+### Переименование и архивирование
+
+- `!rename` обновляет имя комнаты через state event `m.room.name`
+- `!archive` архивирует чат в `ChatManager` и делает `room_leave(...)`
+- Если бот потерял локальное состояние и видит комнату как `unregistered:*`, то `!rename` и `!archive` возвращают защитное сообщение вместо сломанного действия
---
@@ -117,10 +126,11 @@ Matrix поддерживает реакции на сообщения (`m.react
---
-## Комната «Настройки»
+## Настройки и диагностика
-Специальная комната для управления агентом. Закреплена вверху Space.
-Команды работают только здесь — не мешают диалогу в чатах.
+Отдельной комнаты `Настройки` в текущей версии нет. Команды вызываются как обычные
+`!`-команды из зарегистрированных комнат бота, а `!settings` отдаёт сводный dashboard
+по скиллам, личности, безопасности и активным чатам.
### Коннекторы
```
@@ -245,4 +255,12 @@ Matrix поддерживает реакции на сообщения (`m.react
- matrix-nio (async) — Matrix клиент
- MockPlatformClient → `platform/interface.py`
- structlog для логирования
-- SQLite для хранения `matrix_user_id → platform_user_id`, состояния скиллов, маппинга `chat_id → room_id`
+- SQLite / in-memory store для хранения `matrix_user_id → platform_user_id`, состояния скиллов и маппинга `chat_id → room_id`
+
+---
+
+## Ограничения текущей версии
+
+- Ручной QA и текущая разработка идут только в незашифрованных комнатах
+- После рестарта бот делает bootstrap sync и стартует с `since`, поэтому старые события не должны переигрываться повторно
+- Если удалить локальную БД/стор, старые Matrix rooms останутся, но команды, завязанные на локальную регистрацию чатов, перестанут работать для этих комнат до повторного онбординга
diff --git a/docs/reports/2026-04-01-surfaces-progress-report.md b/docs/reports/2026-04-01-surfaces-progress-report.md
new file mode 100644
index 0000000..2c2e408
--- /dev/null
+++ b/docs/reports/2026-04-01-surfaces-progress-report.md
@@ -0,0 +1,601 @@
+# Отчёт о проделанной работе
+
+**Проект:** 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/superpowers/plans/2026-03-31-matrix-adapter.md b/docs/superpowers/plans/2026-03-31-matrix-adapter.md
new file mode 100644
index 0000000..7f3ea28
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-31-matrix-adapter.md
@@ -0,0 +1,1681 @@
+# 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/workflow-backup-2026-04-01.md b/docs/workflow-backup-2026-04-01.md
new file mode 100644
index 0000000..9b77d68
--- /dev/null
+++ b/docs/workflow-backup-2026-04-01.md
@@ -0,0 +1,174 @@
+# Surfaces team — Lambda Lab 3.0
+
+Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda.
+
+## Правило №1: не быть ждуном
+
+Платформа (SDK от Азамата) ещё не готова. Это **не блокер**.
+
+- Все вызовы платформы — через `platform/interface.py` (Protocol)
+- Реализация сейчас — `platform/mock.py` (MockPlatformClient)
+- При подключении реального SDK — меняем только `platform/mock.py`
+- Архитектурные решения принимаем сами, фиксируем в `docs/api-contract.md`
+
+---
+
+## Архитектура
+
+```
+surfaces-bot/
+ core/
+ protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...)
+ handler.py — EventDispatcher: IncomingEvent → OutgoingEvent (общее для всех ботов)
+ handlers/ — обработчики по типам событий (start, message, chat, settings, callback)
+ store.py — StateStore Protocol + InMemoryStore + SQLiteStore
+ chat.py — ChatManager: метаданные чатов C1/C2/C3
+ auth.py — AuthManager: AuthFlow
+ settings.py — SettingsManager: SettingsAction
+
+ adapter/
+ telegram/ — aiogram адаптер
+ converter.py — aiogram Event → IncomingEvent и обратно
+ bot.py — точка входа
+ handlers/ — aiogram роутеры
+ keyboards/ — инлайн-клавиатуры
+ states.py — FSM состояния
+ matrix/ — matrix-nio адаптер
+ converter.py — matrix-nio Event → IncomingEvent и обратно
+ bot.py — точка входа
+ handlers/ — обработчики событий
+
+ platform/
+ interface.py — Protocol: PlatformClient (контракт к SDK)
+ mock.py — MockPlatformClient (заглушка)
+
+ docs/ — вся документация
+ tests/ — pytest тесты
+ .claude/agents/ — конфиги агентов
+```
+
+Подробно об унификации: `docs/surface-protocol.md`
+Telegram функционал: `docs/telegram-prototype.md`
+Matrix функционал: `docs/matrix-prototype.md`
+
+---
+
+## Агенты
+
+| Агент | Когда запускать | Модель | Токены |
+|-------|----------------|--------|--------|
+| `@researcher` | Изучить API, найти примеры | Haiku | ~дёшево |
+| `@architect` | Спроектировать решение | Sonnet | ~средне |
+| `@tg-developer` | Писать код Telegram-адаптера | Sonnet | ~средне |
+| `@matrix-developer` | Писать код Matrix-адаптера | Sonnet | ~средне |
+| `@core-developer` | Писать core/ и platform/ | Sonnet | ~средне |
+| `@reviewer` | Проверить код перед PR | Sonnet | ~средне |
+
+**Важно (Pro-лимиты):** не запускай больше двух Sonnet-агентов одновременно.
+Haiku можно запускать параллельно сколько угодно.
+
+---
+
+## Стратегия параллельной разработки
+
+Два бота разрабатываются параллельно, но через общее ядро.
+
+### Порядок работы
+
+```
+1. core/ — сначала (однократно, все ждут)
+ @core-developer пишет protocol.py, handler.py, session.py, auth.py, settings.py
+
+2. platform/ — сразу после core/
+ @core-developer пишет interface.py и mock.py
+
+3. adapter/telegram/ и adapter/matrix/ — параллельно
+ @tg-developer → adapter/telegram/
+ @matrix-developer → adapter/matrix/
+ Не пересекаются по файлам — можно одновременно в разных терминалах.
+```
+
+### Что можно делать одновременно (разные терминалы)
+
+```bash
+# Терминал 1 — Telegram адаптер
+claude "Use @tg-developer to implement adapter/telegram/handlers/start.py"
+
+# Терминал 2 — Matrix адаптер (параллельно)
+claude "Use @matrix-developer to implement adapter/matrix/handlers/start.py"
+```
+
+### Что нельзя делать одновременно
+
+- Два агента в одном файле
+- @core-developer параллельно с @tg-developer или @matrix-developer
+ (core/ должен быть готов до адаптеров)
+- Больше двух Sonnet-агентов одновременно (Pro-лимит)
+
+---
+
+## Git worktree workflow
+
+Каждая фича в отдельном worktree — адаптеры не мешают друг другу:
+
+```bash
+# Создать worktrees для параллельной работы
+git worktree add .worktrees/telegram -b feat/telegram-adapter
+git worktree add .worktrees/matrix -b feat/matrix-adapter
+
+# Работать в каждом независимо
+cd .worktrees/telegram && claude "Use @tg-developer to ..."
+cd .worktrees/matrix && claude "Use @matrix-developer to ..."
+
+# Смержить когда готово
+git checkout main
+git merge feat/telegram-adapter
+git merge feat/matrix-adapter
+```
+
+---
+
+## Команды запуска
+
+```bash
+# Установить зависимости
+uv sync
+
+# Запустить тесты
+pytest tests/ -v
+
+# Запустить только тесты Telegram
+pytest tests/adapter/telegram/ -v
+
+# Запустить только тесты Matrix
+pytest tests/adapter/matrix/ -v
+
+# Запустить только тесты ядра
+pytest tests/core/ -v
+
+# Запустить Telegram бота
+python -m adapter.telegram.bot
+
+# Запустить Matrix бота
+python -m adapter.matrix.bot
+```
+
+---
+
+## Переменные окружения
+
+```bash
+cp .env.example .env
+```
+
+Никогда не коммить `.env`.
+
+---
+
+## Экономия токенов (Pro-лимиты)
+
+- Исследования → всегда `@researcher` (Haiku), не Sonnet
+- Точечные правки в одном файле → напрямую без агента
+- Ревью → только перед PR, не после каждого коммита
+- Длинный контекст → дай агенту конкретный файл, не весь проект
+- Если агент "завис" в рассуждениях → прерви, переформулируй задачу точнее
diff --git a/forum_topics_research.md b/forum_topics_research.md
new file mode 100644
index 0000000..b09c695
--- /dev/null
+++ b/forum_topics_research.md
@@ -0,0 +1,363 @@
+# 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/sdk/mock.py b/sdk/mock.py
index 105b715..622d0d3 100644
--- a/sdk/mock.py
+++ b/sdk/mock.py
@@ -22,6 +22,30 @@ 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.
@@ -119,26 +143,11 @@ class MockPlatformClient:
await self._latency()
stored = self._settings.get(user_id, {})
return UserSettings(
- skills=stored.get("skills", {
- "web-search": True,
- "fetch-url": True,
- "email": False,
- "browser": False,
- "image-gen": False,
- "files": True,
- }),
+ skills={**DEFAULT_SKILLS, **stored.get("skills", {})},
connectors=stored.get("connectors", {}),
- 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,
- }),
+ 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:
@@ -146,13 +155,13 @@ class MockPlatformClient:
settings = self._settings.setdefault(user_id, {})
if action.action == "toggle_skill":
- skills = settings.setdefault("skills", {})
+ 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", {})
+ soul = settings.setdefault("soul", DEFAULT_SOUL.copy())
soul[action.payload["field"]] = action.payload["value"]
elif action.action == "set_safety":
- safety = settings.setdefault("safety", {})
+ safety = settings.setdefault("safety", DEFAULT_SAFETY.copy())
safety[action.payload["trigger"]] = action.payload.get("enabled", True)
logger.info("Settings updated", user_id=user_id, action=action.action)
diff --git a/tests/adapter/matrix/test_chat_space.py b/tests/adapter/matrix/test_chat_space.py
index f3a23f5..91ee27a 100644
--- a/tests/adapter/matrix/test_chat_space.py
+++ b/tests/adapter/matrix/test_chat_space.py
@@ -3,9 +3,10 @@ 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
+from adapter.matrix.handlers.chat import make_handle_archive, make_handle_new_chat, make_handle_rename
from adapter.matrix.store import set_user_meta
from core.auth import AuthManager
from core.chat import ChatManager
@@ -44,7 +45,14 @@ async def test_mat04_new_chat_calls_room_put_state_with_space_id():
)
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"
@@ -79,7 +87,8 @@ async def test_mat05_new_chat_without_space_id_returns_error():
async def test_mat10_archive_calls_chat_mgr_archive():
platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
- handler = make_handle_archive(None, store)
+ client = SimpleNamespace(room_leave=AsyncMock())
+ handler = make_handle_archive(client, store)
event = IncomingCommand(
user_id="@alice:example.org",
platform="matrix",
@@ -98,6 +107,61 @@ async def test_mat10_archive_calls_chat_mgr_archive():
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():
diff --git a/tests/adapter/matrix/test_dispatcher.py b/tests/adapter/matrix/test_dispatcher.py
index c91342c..dce9243 100644
--- a/tests/adapter/matrix/test_dispatcher.py
+++ b/tests/adapter/matrix/test_dispatcher.py
@@ -3,7 +3,10 @@ from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
-from adapter.matrix.bot import MatrixBot, build_runtime
+from nio.api import RoomVisibility
+from nio.responses import SyncResponse
+
+from adapter.matrix.bot import MatrixBot, build_runtime, prepare_live_sync
from adapter.matrix.handlers.auth import handle_invite
from adapter.matrix.store import get_room_meta, get_user_meta, set_user_meta
from core.protocol import IncomingCallback, IncomingCommand, OutgoingMessage
@@ -72,7 +75,12 @@ async def test_new_chat_creates_real_matrix_room_when_client_available():
)
result = await runtime.dispatcher.dispatch(new)
- client.room_create.assert_awaited_once_with(name="Research", visibility="private", is_direct=False)
+ client.room_create.assert_awaited_once_with(
+ name="Research",
+ visibility=RoomVisibility.private,
+ is_direct=False,
+ invite=["u1"],
+ )
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"
@@ -97,13 +105,27 @@ async def test_invite_event_creates_space_and_chat_room():
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)
+ 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
@@ -137,8 +159,24 @@ async def test_invite_event_is_idempotent_per_user():
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)
- await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
+ 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
@@ -179,3 +217,40 @@ async def test_mat11_settings_returns_dashboard():
assert "Изменить" not in text
assert "!connectors" not in text
assert "!whoami" not in text
+
+
+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 "!rename" in text
+ assert "!archive" in text
+ assert "!settings" in text
+ assert "!yes" in text
+
+
+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"
diff --git a/tests/adapter/matrix/test_invite_space.py b/tests/adapter/matrix/test_invite_space.py
index ee2ebd3..a14ef0a 100644
--- a/tests/adapter/matrix/test_invite_space.py
+++ b/tests/adapter/matrix/test_invite_space.py
@@ -3,6 +3,8 @@ 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_user_meta
@@ -28,11 +30,25 @@ async def test_mat01_invite_creates_space_and_chat1():
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)
+ 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
@@ -50,6 +66,10 @@ async def test_mat01_invite_creates_space_and_chat1():
assert room_meta["space_id"] == "!space:example.org"
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())
@@ -57,8 +77,24 @@ async def test_mat02_invite_idempotent():
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)
- await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
+ 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
@@ -70,7 +106,15 @@ async def test_mat03_no_hardcoded_c1():
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)
+ 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
diff --git a/tests/platform/test_mock.py b/tests/platform/test_mock.py
index 86e4afe..18003d2 100644
--- a/tests/platform/test_mock.py
+++ b/tests/platform/test_mock.py
@@ -43,3 +43,19 @@ 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