diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 2d88441..0000000 --- a/.dockerignore +++ /dev/null @@ -1,22 +0,0 @@ -.git -.gitignore -.DS_Store -__pycache__/ -.pytest_cache/ -.ruff_cache/ -.venv/ -.worktrees/ -external/ -.planning/ -docs/superpowers/ -tests/ - -# Local runtime state must not be baked into the image. -lambda_matrix.db -matrix_store/ -lambda_bot.db -config/matrix-agents.yaml - -# Local environment and editor state -.env -.idea/ diff --git a/.env.example b/.env.example index cc5f2e0..ef8e7ce 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,14 @@ -# Matrix bot credentials -MATRIX_HOMESERVER=https://matrix.example.org -MATRIX_USER_ID=@lambda-bot:example.org -# Use ONE of: MATRIX_PASSWORD or MATRIX_ACCESS_TOKEN +# Telegram +TELEGRAM_BOT_TOKEN=your_bot_token_here + +# Matrix +MATRIX_HOMESERVER=https://matrix.org +MATRIX_USER_ID=@bot:matrix.org MATRIX_PASSWORD=your_password_here -# MATRIX_ACCESS_TOKEN=your_access_token_here -# Backend: "real" connects to platform-agent via AgentApi; "mock" uses local stub (testing only) -MATRIX_PLATFORM_BACKEND=real +# Lambda Platform +LAMBDA_PLATFORM_URL=http://localhost:8000 +LAMBDA_SERVICE_TOKEN=your_service_token_here -# Published surface image used by docker-compose.prod.yml. -# Must point to a Docker Hub/registry namespace where you have push/pull access. -SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest - -# platform/agent_api ref used when building a surface image -LAMBDA_AGENT_API_REF=master - -# Path to agent registry inside the container (mounted via ./config:/app/config:ro) -MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml - -# HTTP URL of the platform-agent endpoint -# Production: external agent managed by the platform -# Fullstack E2E: overridden to http://platform-agent:8000 by docker-compose.fullstack.yml -AGENT_BASE_URL=http://your-agent-host:8000 - -# Shared volume path inside the bot container (default: /agents). -# For multi-agent production, each agent gets a subdirectory such as /agents/0. -SURFACES_WORKSPACE_DIR=/agents - -# Docker volume names (created automatically on first run) -SURFACES_SHARED_VOLUME=surfaces-agents -SURFACES_BOT_STATE_VOLUME=surfaces-bot-state +# Режим работы: "mock" или "production" +PLATFORM_MODE=mock diff --git a/.gitignore b/.gitignore index 6930373..b15d994 100644 --- a/.gitignore +++ b/.gitignore @@ -15,23 +15,14 @@ build/ # Git worktrees (не трекаем в репо) .worktrees/ -external/ # IDE .idea/ .vscode/ *.swp -# Visual brainstorming sessions -.superpowers/ - # Tests .pytest_cache/ .coverage htmlcov/ *.DS_Store - -# Local runtime artifacts -*.db -matrix_store/ -image*.png diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md deleted file mode 100644 index d90b47e..0000000 --- a/.planning/PROJECT.md +++ /dev/null @@ -1,53 +0,0 @@ -# Lambda Lab 3.0 — Surfaces - -## What This Is - -Surfaces (поверхности) — это тонкие адаптеры-клиенты, соединяющие мессенджеры с агентами платформы Lambda. -Текущая и главная реализация — **Matrix MVP**. Бот работает как stateless-прослойка: преобразует события Matrix во внутренний протокол `core/` и маршрутизирует их на внешние контейнеры агентов (через `AgentApi` по WebSocket). - -## Core Value - -Пользователь может бесшовно взаимодействовать с изолированными AI-агентами через нативные интерфейсы мессенджеров (с поддержкой пересылки файлов и работы в комнатах), в то время как сама платформа агентов не зависит от транспорта. - -## Requirements - -### Validated - -- ✓ `core/` — унифицированный протокол событий, EventDispatcher, StateStore, ChatManager. -- ✓ `adapter/matrix/` — Space+rooms адаптер. Прием инвайтов, автосоздание иерархии комнат, команды `!new`, `!archive`, `!clear`, `!yes`/`!no`. -- ✓ `sdk/real.py` — интеграция с AgentApi. Поддержка WebSocket для обмена сообщениями и передачи вложений в обе стороны. -- ✓ Shared Volume — прямая передача файлов в локальные рабочие папки агентов (`/agents/`). -- ✓ Dynamic Routing — маршрутизация чатов к агентам на основе `config/matrix-agents.yaml`. -- ✓ Deployment — Разделение окружений на `docker-compose.prod.yml` (только бот) и `docker-compose.fullstack.yml` (бот + локальный агент для E2E). - -### Out of Scope / Deferred - -- E2EE для Matrix (отложено из-за сложностей сборки `python-olm` на кросс-платформенных средах). -- Интеграция с Master-сервисом платформы (временно используется прямое соединение с `platform-agent` через AgentApi). -- Telegram-адаптер (вынесен в легаси ветку `feat/telegram-adapter`, MVP фокусируется на Matrix). - -## Context - -- Стек: Python 3.11+, `matrix-nio`, `uv`, `pydantic`. -- Бот хранит только локальную привязку (`room_id` <-> `platform_chat_id`) в SQLite. Вся долговременная память и история диалогов хранятся на стороне агента. -- Жизненный цикл контейнеров агентов управляется платформой, а не ботом. - -## Key Decisions - -| Decision | Rationale | Outcome | -|----------|-----------|---------| -| Space+rooms для Matrix | Room-based UX и явные чаты (по одному на тред) удобнее, чем DM-каша | ✓ Good | -| Прямая интеграция AgentApi | Master API не был готов, прямое WebSocket соединение позволяет передавать стейт и файлы | ✓ Good | -| Shared Volume для файлов | Избавляет от необходимости гонять base64 по сети, быстрый прямой доступ к файлам | ✓ Good | -| Stateless бот | Бот легко перезапускать и масштабировать, память изолирована в агентах | ✓ Good | - -## Evolution - -**After each phase transition:** -1. Requirements invalidated? → Move to Out of Scope with reason -2. Requirements validated? → Move to Validated with phase reference -3. New requirements emerged? → Add to Active -4. Decisions to log? → Add to Key Decisions - ---- -*Last updated: 2026-05-03 after codebase consolidation* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md deleted file mode 100644 index ffd6801..0000000 --- a/.planning/ROADMAP.md +++ /dev/null @@ -1,32 +0,0 @@ -# Roadmap — v1.0 - -## Milestone: v1.0 — Production-ready Matrix MVP - -### Phase 01: Matrix QA & Polish -**Goal:** Переработать Matrix адаптер с DM-first на Space+rooms, убрать реакции в пользу `!yes`/`!no`. -**Status:** Completed -**Deliverables:** -- Space+rooms architecture for Matrix adapter -- !yes/!no text-based confirmation -- Test suite green - -### Phase 04: Matrix MVP: Agent Integration -**Goal:** Подключить реального агента через `AgentApi`, добавить команды управления контекстом (`!clear`). -**Status:** Completed -**Deliverables:** -- `sdk/real.py` — реализация `PlatformClient` через реальный SDK (`AgentApi`). -- Поддержка WebSocket стриминга. -- Команды управления контекстом. -- Обертка в Docker. - -### Phase 05: MVP Deployment -**Goal:** Подготовить Matrix-бот к реальному деплою на lambda.coredump.ru с маршрутизацией по агентам и передачей файлов. -**Status:** Completed -**Deliverables:** -- Загрузка `matrix-agents.yaml` для маппинга пользователей к агентам. -- Per-room `platform_chat_id` routing. -- File transfer через shared `/agents/` volume. -- Разделение `docker-compose.prod.yml` и `docker-compose.fullstack.yml`. - ---- -*Note: Легаси-фазы, связанные с Telegram, прототипами и Mock-платформой, были удалены из Roadmap после закрепления архитектуры MVP в ветке main.* diff --git a/.planning/STATE.md b/.planning/STATE.md deleted file mode 100644 index 47a860b..0000000 --- a/.planning/STATE.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -gsd_state_version: 1.0 -milestone: v1.0 -milestone_name: — Production-ready surfaces -status: MVP Deployed -last_updated: "2026-05-03T23:00:00Z" -progress: - total_phases: 3 - completed_phases: 3 - total_plans: 13 - completed_plans: 13 ---- - -# State - -## Project Reference - -See: `.planning/PROJECT.md` (updated 2026-05-03) - -**Core value:** Пользователь бесшовно взаимодействует с изолированными агентами через нативные интерфейсы (Matrix), в то время как платформа агентов не зависит от транспорта. -**Current focus:** Итерационное развитие текущей архитектуры, добавление новых фич и поверхностей (по мере необходимости). - -## Current Phase - -Текущий MVP успешно завершен. Все базовые механизмы внедрены и работают: -- Маршрутизация к `AgentApi` -- Shared Volume файловый обмен (`/agents/`) -- Dynamic config через `matrix-agents.yaml` -- Изоляция контекстов через `platform_chat_id` - -Проект находится в чистом состоянии для начала нового планирования. Неактуальные легаси фазы и прототипы (Telegram, MockPlatformClient) удалены из Roadmap и трекинга. - -## Decisions - -- **Space+rooms для Matrix**: Разделение тредов на отдельные Matrix-комнаты, собранные в едином Space пользователя. -- **AgentApi**: Прямая интеграция с локальным агентом без Master-прослойки по WebSocket. -- **Shared Volume**: Файлы кладутся напрямую в рабочую папку агента, избавляя от необходимости гонять их по сети в Base64. -- **Статическая маршрутизация**: На данном этапе пользователи маппятся на агентов жестко через YAML. - -## Blockers - -- Отсутствуют. Проект готов к деплою (см. `docker-compose.prod.yml`). - -## Accumulated Context - -### Roadmap Evolution - -- Изначальный Roadmap включал множество ответвлений (прототипы Telegram, локальный mock-клиент). После закрепления MVP в `main` Roadmap был очищен, чтобы отражать только актуальный путь продукта. -- Следующие фазы будут добавляться по мере возникновения новых задач (например, переход от YAML-конфига к БД для реестра агентов). diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md deleted file mode 100644 index 05f7a7f..0000000 --- a/.planning/codebase/ARCHITECTURE.md +++ /dev/null @@ -1,14 +0,0 @@ -# Архитектура (ARCHITECTURE.md) - -## Паттерн "Thin Adapter" (Тонкая поверхность) - -Система разделена на три логических слоя: -1. **Транспортный слой (Adapter)**: Подключается к внешней платформе (Matrix). Занимается конвертацией нативных событий (`room.message`) во внутренние структуры (`IncomingMessage`). -2. **Ядро (Core)**: Предоставляет единый протокол (`core/protocol.py`), не зависящий от конкретной реализации (Matrix, Telegram и т.д.). -3. **Платформенный слой (SDK)**: `RealPlatformClient` инкапсулирует подключение по WebSocket к реальным агентам (AgentApi). - -## Routing & Registry -Бот может обслуживать множество агентов (multi-tenant). Маршрутизация настраивается статически через `config/matrix-agents.yaml`. Каждый пользователь (`@user:server`) привязан к конкретному `agent_id`, у которого есть свой HTTP URL и свой изолированный `workspace_path` (например, `/agents/1/`). - -## Файловый контракт -Файлы не передаются агенту в base64. Бот сохраняет вложение напрямую в локальную директорию (общий volume), и передает агенту только относительный путь (`workspace_path`). diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md deleted file mode 100644 index 5848135..0000000 --- a/.planning/codebase/CONCERNS.md +++ /dev/null @@ -1,6 +0,0 @@ -# Известные проблемы (CONCERNS.md) - -- **Отсутствие E2E шифрования в Matrix**: На данный момент бот не поддерживает зашифрованные комнаты, так как библиотека `matrix-nio` требует нативной сборки `python-olm`, что усложняет кросс-платформенный деплой. -- **Потеря стейта агентов**: Так как текущий `platform-agent` часто работает с `MemorySaver`, его стейт теряется при перезапусках. Это проблема внешнего агента, но она напрямую влияет на UX поверхности. -- **Общий том (Shared Volume)**: Контракт обязывает бота и агента запускаться на одном физическом хосте (или иметь распределенный сетевой диск), что может стать бутылочным горлышком при сильном масштабировании. -- **Hardcoded роутинг**: `matrix-agents.yaml` требует ручного редактирования и перезапуска бота при добавлении новых агентов. Желательно вынести этот процесс в динамическую БД или API. diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md deleted file mode 100644 index 36a4ed5..0000000 --- a/.planning/codebase/CONVENTIONS.md +++ /dev/null @@ -1,7 +0,0 @@ -# Конвенции (CONVENTIONS.md) - -- **Асинхронность**: Весь код бота асинхронный (`asyncio`). Вызовы SDK и Matrix-клиента выполняются через `await`. Блокирующие вызовы (если они есть) должны выноситься в тредпул. -- **Обработка ошибок**: Бот не должен падать из-за ошибок отдельного агента. Ошибки SDK (например, `PlatformError`) отлавливаются в боте и возвращаются пользователю в виде системных сообщений или уведомлений. -- **Стейтлесс-подход**: Поверхность хранит минимальный стейт (только локальный SQLite для связки `room_id` <-> `platform_chat_id`). Вся история сообщений и память лежат на стороне агентов. -- **Переменные окружения**: Бот полностью конфигурируется через `.env` (префиксы `MATRIX_` и `SURFACES_`). -- **Добавление новой поверхности**: Новая поверхность должна быть самостоятельной папкой в `adapter/`, реализовывать `converter.py`, и переиспользовать `sdk/real.py` и `core/protocol.py`. diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md deleted file mode 100644 index cd771d1..0000000 --- a/.planning/codebase/INTEGRATIONS.md +++ /dev/null @@ -1,15 +0,0 @@ -# Интеграции (INTEGRATIONS.md) - -## Platform Agent API -- **Тип**: WebSocket (через `AgentApi` SDK) -- **Назначение**: Связь между Matrix-адаптером и внешней LLM-платформой. -- **Контракт**: Surface выступает "тупым" клиентом. Он отправляет `platform_chat_id` и `user_id` вместе с сообщениями. Платформа/Агент отвечает текстом и вложениями. Контейнерами агентов бот не управляет. - -## Matrix Homeserver -- **Тип**: HTTP/HTTPS API (via `matrix-nio`) -- **Назначение**: Пользовательский интерфейс и транспорт сообщений для бота. -- **Ограничения**: Поддерживается только нешифрованное (unencrypted) взаимодействие. - -## Файловая система (Shared Volume) -- **Тип**: Docker Shared Volume (`/agents/`) -- **Назначение**: Прямая передача файлов между поверхностью и агентами в обход сети. Поверхность пишет файлы в поддиректорию конкретного агента, агент их читает, и наоборот. diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md deleted file mode 100644 index b40772d..0000000 --- a/.planning/codebase/STACK.md +++ /dev/null @@ -1,14 +0,0 @@ -# Технологический стек (STACK.md) - -## Язык и Runtime -- **Python**: 3.11-slim (используется в Docker-образах) -- **Пакетный менеджер**: `uv` (используется для быстрой и строгой установки зависимостей, frozen lockfiles). - -## Ключевые библиотеки -- **matrix-nio**: Асинхронный клиент для Matrix (события, синхронизация, отправка). -- **pydantic**: Для валидации структур данных (события из AgentApi). -- **structlog**: Структурированное логирование (json/console). - -## Инфраструктура -- **Docker / Docker Compose**: Используется для локального (fullstack) и продакшн развертывания. -- **SQLite**: Легковесная локальная база данных для хранения маппингов пользователей/комнат (`adapter/matrix/store.py`). diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md deleted file mode 100644 index 9ea8a18..0000000 --- a/.planning/codebase/STRUCTURE.md +++ /dev/null @@ -1,18 +0,0 @@ -# Структура (STRUCTURE.md) - -- `core/`: - - `protocol.py` — Унифицированные структуры данных (сообщения, файлы, UI). -- `adapter/matrix/`: - - `bot.py` — Главный event-loop Matrix. - - `converter.py` — Конвертация событий Matrix-nio ⇄ `core/protocol.py`. - - `agent_registry.py` — Парсинг `matrix-agents.yaml`. - - `files.py` — Работа с вложениями и shared volume. - - `store.py` — SQLite база для маппинга чатов Matrix и `platform_chat_id`. - - `routed_platform.py` — Динамический роутинг вызовов к нужным агентам на лету. -- `sdk/`: - - `interface.py` — Интерфейс PlatformClient. - - `real.py` — Имплементация WebSocket клиента (`AgentApi`). - - `mock.py` — Мок-клиент для E2E тестов без платформы. -- `config/`: Конфиги маршрутизации (YAML). -- `docs/`: Актуальная документация по развертыванию и архитектуре. -- `docker-compose*.yml`: Продакшн и локальные манифесты для сборки. diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md deleted file mode 100644 index 07311dc..0000000 --- a/.planning/codebase/TESTING.md +++ /dev/null @@ -1,17 +0,0 @@ -# Тестирование (TESTING.md) - -## Unit-тесты -Расположены в `tests/`. Покрытие сфокусировано на логике Matrix адаптера (пока он является основной поверхностью): -- Файловый контракт (`test_files.py`) -- Диспетчер и конвертация (`test_dispatcher.py`) -- Взаимодействие с PlatformClient (`test_routed_platform.py`) -- Работа с контекстными командами бота (`test_context_commands.py`) - -## E2E тестирование -Локально тестируется через запуск контейнеров из `docker-compose.fullstack.yml`, который поднимает один инстанс бота и один локальный `platform-agent`. Это позволяет имитировать полную цепочку взаимодействия (Matrix -> Бот -> Агент) с общим каталогом для файлов. - -## Запуск тестов -```bash -# Запуск юнит-тестов (только для Matrix адаптера) -pytest tests/adapter/matrix/ -v -``` diff --git a/.planning/config.json b/.planning/config.json deleted file mode 100644 index 327e955..0000000 --- a/.planning/config.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "model_profile": "budget", - "commit_docs": true, - "parallelization": true, - "search_gitignored": false, - "brave_search": false, - "firecrawl": false, - "exa_search": false, - "git": { - "branching_strategy": "none", - "phase_branch_template": "gsd/phase-{phase}-{slug}", - "milestone_branch_template": "gsd/{milestone}-{slug}", - "quick_branch_template": null - }, - "workflow": { - "research": true, - "plan_check": true, - "verifier": true, - "nyquist_validation": true, - "auto_advance": true, - "node_repair": true, - "node_repair_budget": 2, - "ui_phase": true, - "ui_safety_gate": true, - "text_mode": false, - "research_before_questions": false, - "discuss_mode": "discuss", - "skip_discuss": false, - "_auto_chain_active": false - }, - "hooks": { - "context_warnings": true - }, - "agent_skills": {}, - "mode": "yolo", - "granularity": "coarse" -} \ No newline at end of file diff --git a/.planning/phases/01-matrix-qa-polish/01-01-PLAN.md b/.planning/phases/01-matrix-qa-polish/01-01-PLAN.md deleted file mode 100644 index ac40025..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-01-PLAN.md +++ /dev/null @@ -1,373 +0,0 @@ ---- -phase: 01-matrix-qa-polish -plan: 01 -type: execute -wave: 1 -depends_on: [] -files_modified: - - adapter/matrix/store.py - - adapter/matrix/handlers/auth.py - - adapter/matrix/room_router.py -autonomous: true -requirements: [] - -must_haves: - truths: - - "Bot creates a Space named 'Lambda - {display_name}' on first invite" - - "Bot creates 'Chat 1' room inside that Space" - - "Bot invites user to both Space and chat room" - - "space_id is stored in user_meta for future lookups" - - "Repeated invite does not create a second Space (idempotent)" - - "chat_id uses next_chat_id, not hardcoded C1" - artifacts: - - path: "adapter/matrix/store.py" - provides: "pending_confirm helpers + PENDING_CONFIRM_PREFIX" - contains: "PENDING_CONFIRM_PREFIX" - - path: "adapter/matrix/handlers/auth.py" - provides: "Space+rooms invite flow" - contains: "space=True" - - path: "adapter/matrix/room_router.py" - provides: "space-aware resolve_chat_id" - key_links: - - from: "adapter/matrix/handlers/auth.py" - to: "adapter/matrix/store.py" - via: "set_user_meta with space_id" - pattern: "set_user_meta.*space_id" - - from: "adapter/matrix/handlers/auth.py" - to: "adapter/matrix/store.py" - via: "next_chat_id for dynamic C-number" - pattern: "next_chat_id" ---- - - -Rewrite the Matrix invite flow from DM-first to Space+rooms architecture, and add pending_confirm store helpers. - -Purpose: Per D-01/D-02, the bot must create a Space per user on first invite, with a "Chat 1" room inside it. The old DM join + hardcoded C1 flow must be fully replaced. Additionally, pending_confirm helpers are added to store.py now (used by Plan 03) to avoid file conflicts. - -Output: Working handle_invite that creates Space + chat room, stores space_id in user_meta, uses next_chat_id. Store has pending_confirm helpers. - - - -@$HOME/.claude/get-shit-done/workflows/execute-plan.md -@$HOME/.claude/get-shit-done/templates/summary.md - - - -@.planning/PROJECT.md -@.planning/ROADMAP.md -@.planning/STATE.md -@.planning/phases/01-matrix-qa-polish/01-CONTEXT.md -@.planning/phases/01-matrix-qa-polish/01-RESEARCH.md - -@adapter/matrix/store.py -@adapter/matrix/handlers/auth.py -@adapter/matrix/room_router.py -@core/protocol.py - - - - -```python -ROOM_META_PREFIX = "matrix_room:" -USER_META_PREFIX = "matrix_user:" -ROOM_STATE_PREFIX = "matrix_state:" -SKILLS_MSG_PREFIX = "matrix_skills_msg:" - -async def get_room_meta(store: StateStore, room_id: str) -> dict | None -async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None -async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None -async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None -async def get_room_state(store: StateStore, room_id: str) -> str -async def set_room_state(store: StateStore, room_id: str, state: str) -> None -async def get_skills_message_id(store: StateStore, room_id: str) -> str | None -async def set_skills_message_id(store: StateStore, room_id: str, event_id: str) -> None -async def next_chat_id(store: StateStore, matrix_user_id: str) -> str -``` - - - -```python -@dataclass -class OutgoingMessage: - chat_id: str - text: str - parse_mode: str = "plain" - attachments: list[Attachment] = field(default_factory=list) - reply_to: str | None = None -``` - - - -```python -from nio.responses import RoomCreateError, RoomPutStateError, RoomInviteError -# RoomCreateError has .status_code, no .room_id -# RoomPutStateError has .status_code -``` - - - - - - - Task 1: Add pending_confirm helpers to store.py - adapter/matrix/store.py - adapter/matrix/store.py - -Add three new helper functions and one constant to `adapter/matrix/store.py`, AFTER the existing `next_chat_id` function. Keep ALL existing code unchanged. - -Add this constant after line 8 (after `SKILLS_MSG_PREFIX`): - -```python -PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:" -``` - -Add these three functions at the end of the file: - -```python -async def get_pending_confirm(store: StateStore, room_id: str) -> dict | None: - return await store.get(f"{PENDING_CONFIRM_PREFIX}{room_id}") - - -async def set_pending_confirm(store: StateStore, room_id: str, meta: dict) -> None: - await store.set(f"{PENDING_CONFIRM_PREFIX}{room_id}", meta) - - -async def clear_pending_confirm(store: StateStore, room_id: str) -> None: - await store.delete(f"{PENDING_CONFIRM_PREFIX}{room_id}") -``` - -Note: `store.delete` is already available on `StateStore` (both `InMemoryStore` and `SQLiteStore` implement it). Verify by checking `core/store.py` — if `delete` is not present, use `store.set(key, None)` as equivalent. - -Per D-08: pending_confirm is keyed by room_id (not user_id+room_id) because in Space model each room belongs to one user. - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.store import get_pending_confirm, set_pending_confirm, clear_pending_confirm, PENDING_CONFIRM_PREFIX; print('OK')" - - -- `adapter/matrix/store.py` contains the string `PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"` -- `adapter/matrix/store.py` contains function `async def get_pending_confirm(store: StateStore, room_id: str) -> dict | None:` -- `adapter/matrix/store.py` contains function `async def set_pending_confirm(store: StateStore, room_id: str, meta: dict) -> None:` -- `adapter/matrix/store.py` contains function `async def clear_pending_confirm(store: StateStore, room_id: str) -> None:` -- All existing functions (`get_room_meta`, `set_room_meta`, `get_user_meta`, `set_user_meta`, `get_room_state`, `set_room_state`, `get_skills_message_id`, `set_skills_message_id`, `next_chat_id`) still exist unchanged -- `pytest tests/adapter/matrix/test_store.py -x -q` passes (all existing store tests green) - - pending_confirm helpers importable and existing store tests pass - - - - Task 2: Rewrite handle_invite for Space+rooms (per D-01, D-02) - adapter/matrix/handlers/auth.py - adapter/matrix/handlers/auth.py, adapter/matrix/store.py, core/protocol.py - -Completely rewrite `adapter/matrix/handlers/auth.py`. The new `handle_invite` must: - -1. **Idempotency check on user_meta (not room_meta):** Check `get_user_meta(store, matrix_user_id)`. If it already has a `space_id`, return early (do nothing). This replaces the old `get_room_meta(store, room.room_id)` check. Per Pitfall 5 from RESEARCH.md. - -2. **Create Space:** Call `await client.room_create(name=f"Lambda -- {display_name}", space=True, visibility="private")`. Check `isinstance(resp, RoomCreateError)` — if error, log and return early. - -3. **Create first chat room:** Call `await client.room_create(name="Chat 1", visibility="private", is_direct=False)`. Check `isinstance(resp, RoomCreateError)`. - -4. **Add room to Space:** Call `await client.room_put_state(room_id=space_id, event_type="m.space.child", content={"via": [homeserver]}, state_key=chat_room_id)`. Extract `homeserver` as `matrix_user_id.split(":")[-1]`. - -5. **Invite user to both:** `await client.room_invite(space_id, matrix_user_id)` and `await client.room_invite(chat_room_id, matrix_user_id)`. - -6. **Use next_chat_id:** Call `chat_id = await next_chat_id(store, matrix_user_id)` to get "C1" (not hardcoded). Per D-05 and Pitfall 6 from RESEARCH.md. - -7. **Store user_meta:** `await set_user_meta(store, matrix_user_id, {"space_id": space_id, "next_chat_index": 2})`. Note: next_chat_id already incremented to 2, so store will already have next_chat_index=2 after the call. Just ensure space_id is stored in user_meta. - -8. **Store room_meta:** `await set_room_meta(store, chat_room_id, {"room_type": "chat", "chat_id": chat_id, "display_name": "Chat 1", "matrix_user_id": matrix_user_id, "space_id": space_id})`. - -9. **Auth confirm:** Keep `await auth_mgr.confirm(matrix_user_id)`. - -10. **Platform get_or_create_user:** Keep existing call. - -11. **Welcome message:** Send to the CHAT ROOM (not the invite room). Text: -``` -"Привет, {display_name}! Пиши -- я здесь.\n\nКоманды: !new . !chats . !rename . !archive . !skills . !soul . !safety . !settings" -``` - -12. **Also join the original invite room:** Keep `await client.join(room.room_id)` so the bot accepts the DM invite (otherwise nio may not process events from this user). Put this BEFORE Space creation. - -Complete replacement for `adapter/matrix/handlers/auth.py`: - -```python -from __future__ import annotations - -import structlog -from typing import Any - -from nio.responses import RoomCreateError - -from adapter.matrix.store import get_user_meta, set_user_meta, set_room_meta, next_chat_id - -logger = structlog.get_logger(__name__) - - -async def handle_invite(client: Any, room: Any, event: Any, platform, store, auth_mgr) -> None: - matrix_user_id = getattr(event, "sender", "") - display_name = getattr(room, "display_name", None) or matrix_user_id - - # Idempotency: if user already has a Space, skip - existing = await get_user_meta(store, matrix_user_id) - if existing and existing.get("space_id"): - return - - # Accept the invite room (so nio tracks this user) - await client.join(room.room_id) - - # Register user on platform - user = await platform.get_or_create_user( - external_id=matrix_user_id, - platform="matrix", - display_name=display_name, - ) - await auth_mgr.confirm(matrix_user_id) - - homeserver = matrix_user_id.split(":")[-1] - - # 1. Create Space - space_resp = await client.room_create( - name=f"Lambda \u2014 {display_name}", - space=True, - visibility="private", - ) - if isinstance(space_resp, RoomCreateError): - logger.error("space creation failed", user=matrix_user_id, error=getattr(space_resp, "status_code", None)) - return - space_id = space_resp.room_id - - # 2. Create first chat room - chat_resp = await client.room_create( - name="\u0427\u0430\u0442 1", - visibility="private", - is_direct=False, - ) - if isinstance(chat_resp, RoomCreateError): - logger.error("chat room creation failed", user=matrix_user_id, error=getattr(chat_resp, "status_code", None)) - return - chat_room_id = chat_resp.room_id - - # 3. Link chat room into Space - await client.room_put_state( - room_id=space_id, - event_type="m.space.child", - content={"via": [homeserver]}, - state_key=chat_room_id, - ) - - # 4. Invite user - await client.room_invite(space_id, matrix_user_id) - await client.room_invite(chat_room_id, matrix_user_id) - - # 5. Store metadata - chat_id = await next_chat_id(store, matrix_user_id) # Returns "C1", increments to 2 - - # Update user_meta to include space_id (next_chat_id already set next_chat_index) - user_meta = await get_user_meta(store, matrix_user_id) or {} - user_meta["space_id"] = space_id - await set_user_meta(store, matrix_user_id, user_meta) - - await set_room_meta(store, chat_room_id, { - "room_type": "chat", - "chat_id": chat_id, - "display_name": "\u0427\u0430\u0442 1", - "matrix_user_id": matrix_user_id, - "space_id": space_id, - }) - - # 6. Welcome message in chat room - welcome = ( - f"\u041f\u0440\u0438\u0432\u0435\u0442, {user.display_name or matrix_user_id}! \u041f\u0438\u0448\u0438 \u2014 \u044f \u0437\u0434\u0435\u0441\u044c.\n\n" - "\u041a\u043e\u043c\u0430\u043d\u0434\u044b: !new \u00b7 !chats \u00b7 !rename \u00b7 !archive \u00b7 !skills \u00b7 !soul \u00b7 !safety \u00b7 !settings" - ) - await client.room_send(chat_room_id, "m.room.message", {"msgtype": "m.text", "body": welcome}) -``` - -IMPORTANT: Use the actual Cyrillic characters in strings, not unicode escapes. The unicode escapes above are just for plan encoding safety. The actual file must have readable Russian text: "Чат 1", "Привет, ...", "Команды: ..." etc. - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.handlers.auth import handle_invite; print('OK')" - - -- `adapter/matrix/handlers/auth.py` does NOT contain the string `"chat_id": "C1"` (hardcode removed) -- `adapter/matrix/handlers/auth.py` contains the string `space=True` -- `adapter/matrix/handlers/auth.py` contains the string `room_put_state` -- `adapter/matrix/handlers/auth.py` contains the string `next_chat_id` -- `adapter/matrix/handlers/auth.py` contains the string `get_user_meta` -- `adapter/matrix/handlers/auth.py` imports from `nio.responses` (specifically `RoomCreateError`) -- `adapter/matrix/handlers/auth.py` contains `room_invite` (invites user to Space and chat room) -- `adapter/matrix/handlers/auth.py` contains `m.space.child` string - - handle_invite creates Space + chat room, stores space_id, uses next_chat_id, is idempotent on user_meta - - - - Task 3: Update room_router.py for space-aware resolve - adapter/matrix/room_router.py - adapter/matrix/room_router.py, adapter/matrix/store.py - -The current `resolve_chat_id` in `adapter/matrix/room_router.py` auto-creates room_meta with a new chat_id if none exists. This is problematic in the Space model because rooms should only be created through `handle_invite` or `!new`. Update the fallback behavior: - -Replace the entire `adapter/matrix/room_router.py` with: - -```python -from __future__ import annotations - -import structlog - -from adapter.matrix.store import get_room_meta -from core.store import StateStore - -logger = structlog.get_logger(__name__) - - -async def resolve_chat_id(store: StateStore, room_id: str, matrix_user_id: str) -> str: - meta = await get_room_meta(store, room_id) - if meta and meta.get("chat_id"): - return meta["chat_id"] - - # Room not registered — this can happen if the bot receives a message - # in a room it didn't create (e.g., a DM). Return a fallback chat_id - # based on room_id to avoid crashing, but don't auto-register. - logger.warning("unregistered_room", room_id=room_id, user=matrix_user_id) - return f"unregistered:{room_id}" -``` - -Key changes: -- Remove `next_chat_id` and `set_room_meta` imports (no longer auto-creating) -- Remove auto-creation of room_meta for unknown rooms -- Return `f"unregistered:{room_id}"` as fallback so messages from unregistered rooms don't crash but are identifiable -- Add structlog warning for debugging - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.room_router import resolve_chat_id; print('OK')" - - -- `adapter/matrix/room_router.py` does NOT contain `next_chat_id` -- `adapter/matrix/room_router.py` does NOT contain `set_room_meta` -- `adapter/matrix/room_router.py` contains `unregistered:{room_id}` or `f"unregistered:{room_id}"` -- `adapter/matrix/room_router.py` contains `get_room_meta` -- `adapter/matrix/room_router.py` contains `logger.warning` - - resolve_chat_id no longer auto-creates rooms, returns fallback for unregistered rooms - - - - - -After all 3 tasks: -- `python -c "from adapter.matrix.handlers.auth import handle_invite; from adapter.matrix.store import get_pending_confirm, set_pending_confirm, clear_pending_confirm; from adapter.matrix.room_router import resolve_chat_id; print('ALL IMPORTS OK')"` -- `pytest tests/adapter/matrix/test_store.py -x -q` passes (existing store tests still green) - - - -- handle_invite creates Space (space=True) + chat room + room_put_state link -- No hardcoded "C1" in auth.py -- pending_confirm helpers available in store.py -- room_router doesn't auto-create rooms -- Existing store tests pass - - - -After completion, create `.planning/phases/01-matrix-qa-polish/01-01-SUMMARY.md` - diff --git a/.planning/phases/01-matrix-qa-polish/01-01-SUMMARY.md b/.planning/phases/01-matrix-qa-polish/01-01-SUMMARY.md deleted file mode 100644 index e684351..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-01-SUMMARY.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -phase: 01-matrix-qa-polish -plan: 01 -subsystem: matrix -tags: [matrix, matrix-nio, spaces, sqlite] -requires: - - phase: 00-foundation - provides: Matrix adapter baseline with room metadata helpers -provides: - - Matrix pending-confirm store helpers keyed by room id - - Space-first invite flow with user space metadata and dynamic chat ids - - Space-aware room routing fallback for unregistered rooms -affects: [matrix invite flow, matrix chat creation, matrix confirmation flow] -tech-stack: - added: [] - patterns: [space-first Matrix onboarding, room metadata without implicit auto-registration] -key-files: - created: [] - modified: - - adapter/matrix/store.py - - adapter/matrix/handlers/auth.py - - adapter/matrix/room_router.py -key-decisions: - - "Invite idempotency now keys off user_meta.space_id instead of invite-room metadata." - - "Unknown Matrix rooms return an explicit unregistered chat id instead of silently creating room metadata." -patterns-established: - - "Matrix Space bootstrap creates a private Space, first chat room, and m.space.child link before welcoming the user." - - "Per-room pending confirmation state is stored under a dedicated store prefix." -requirements-completed: [] -duration: 1 min -completed: 2026-04-02 ---- - -# Phase 01 Plan 01: Space+rooms infrastructure Summary - -**Matrix Space-first onboarding now creates a private Space, seeds the first chat room, and stores pending confirmations by room id.** - -## Performance - -- **Duration:** 1 min -- **Started:** 2026-04-02T19:49:25Z -- **Completed:** 2026-04-02T19:50:50Z -- **Tasks:** 3 -- **Files modified:** 3 - -## Accomplishments -- Added `pending_confirm` storage helpers without changing existing Matrix store behavior. -- Replaced the DM-first invite flow with Space creation, first-room linking, user invites, and dynamic `C*` chat ids. -- Stopped `resolve_chat_id` from auto-registering unknown rooms and made the fallback explicit in logs and returned ids. - -## Task Commits - -Each task was committed atomically: - -1. **Task 1: Add pending_confirm helpers to store.py** - `9123401` (feat) -2. **Task 2: Rewrite handle_invite for Space+rooms** - `c2e29cc` (feat) -3. **Task 3: Update room_router.py for space-aware resolve** - `c8770da` (fix) - -## Files Created/Modified -- `adapter/matrix/store.py` - Adds `PENDING_CONFIRM_PREFIX` plus get/set/clear helpers for confirmation state. -- `adapter/matrix/handlers/auth.py` - Rewrites invite handling to create a Space and first chat room, invite the user, and persist `space_id`. -- `adapter/matrix/room_router.py` - Resolves known chat ids from stored metadata only and warns on unregistered rooms. - -## Decisions Made -- Used `user_meta.space_id` as the idempotency gate so repeated invites do not depend on whichever DM room triggered the event. -- Preserved the initial DM `join` before Space creation so the bot still accepts the invite room and keeps nio tracking consistent. -- Returned `unregistered:{room_id}` for unknown rooms instead of mutating store state from the router. - -## Deviations from Plan - -### Auto-fixed Issues - -**1. [Rule 3 - Blocking] Updated planning state artifacts manually** -- **Found during:** Post-task metadata updates -- **Issue:** `gsd-tools state advance-plan` could not parse the repository's existing `STATE.md` schema, which blocked the required state update flow. -- **Fix:** Updated `STATE.md` and `ROADMAP.md` manually to reflect plan completion while preserving existing content. -- **Files modified:** `.planning/STATE.md`, `.planning/ROADMAP.md` -- **Verification:** Re-read both files after editing to confirm plan progress and decisions were recorded correctly. -- **Committed in:** metadata commit - ---- - -**Total deviations:** 1 auto-fixed (1 blocking) -**Impact on plan:** No product scope change. The deviation only affected GSD metadata bookkeeping. - -## Issues Encountered - -- `gsd-tools state advance-plan` failed because the current `STATE.md` format does not include the fields the tool expects. Metadata was updated manually so execution could complete cleanly. - -## User Setup Required - -None - no external service configuration required. - -## Next Phase Readiness - -- Ready for `01-02-PLAN.md`, which can now rely on `space_id` in `user_meta` and non-mutating room resolution. -- No blockers introduced by this plan. - -## Self-Check: PASSED - -- Found `.planning/phases/01-matrix-qa-polish/01-01-SUMMARY.md` on disk. -- Verified task commits `9123401`, `c2e29cc`, and `c8770da` in `git log`. diff --git a/.planning/phases/01-matrix-qa-polish/01-02-PLAN.md b/.planning/phases/01-matrix-qa-polish/01-02-PLAN.md deleted file mode 100644 index d924add..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-02-PLAN.md +++ /dev/null @@ -1,409 +0,0 @@ ---- -phase: 01-matrix-qa-polish -plan: 02 -type: execute -wave: 1 -depends_on: [] -files_modified: - - adapter/matrix/handlers/chat.py -autonomous: true -requirements: [] - -must_haves: - truths: - - "!new creates a room and adds it to the user's Space via room_put_state" - - "!new without space_id returns an error message (not a crash)" - - "!archive archives the chat via chat_mgr.archive; Space child removal (room_put_state) deferred to Phase 2 — requires reverse room_id lookup not available" - - "!rename calls client.room_set_name if client available" - - "RoomCreateError is handled gracefully with user-facing message" - artifacts: - - path: "adapter/matrix/handlers/chat.py" - provides: "Space-aware chat commands" - contains: "room_put_state" - key_links: - - from: "adapter/matrix/handlers/chat.py" - to: "adapter/matrix/store.py" - via: "get_user_meta for space_id lookup" - pattern: "get_user_meta" - - from: "adapter/matrix/handlers/chat.py" - to: "client.room_put_state" - via: "m.space.child state event" - pattern: "m.space.child" ---- - - -Rewrite chat command handlers (!new, !archive, !rename) to work with Space+rooms architecture. - -Purpose: Per D-03/D-04, !new must create rooms inside the user's Space, !archive must remove rooms from Space (not delete). Currently !new creates standalone rooms without Space linkage, and !archive has no Space awareness. - -Output: make_handle_new_chat, handle_archive, handle_rename all Space-aware with proper error handling. - - - -@$HOME/.claude/get-shit-done/workflows/execute-plan.md -@$HOME/.claude/get-shit-done/templates/summary.md - - - -@.planning/PROJECT.md -@.planning/ROADMAP.md -@.planning/phases/01-matrix-qa-polish/01-CONTEXT.md -@.planning/phases/01-matrix-qa-polish/01-RESEARCH.md - -@adapter/matrix/handlers/chat.py -@adapter/matrix/store.py -@adapter/matrix/room_router.py -@core/protocol.py - - - - -```python -async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None -async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None -async def get_room_meta(store: StateStore, room_id: str) -> dict | None -async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None -async def next_chat_id(store: StateStore, matrix_user_id: str) -> str -``` - - - -```python -def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=None) -> None: - dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store)) - dispatcher.register(IncomingCommand, "archive", handle_archive) - dispatcher.register(IncomingCommand, "rename", handle_rename) -``` - -Note: `make_handle_new_chat(client, store)` is a closure factory. `handle_archive` and `handle_rename` are plain async functions — they do NOT receive `client` or `store` directly. To give archive/rename access to `client` and `store`, either: -(a) Convert them to closure factories like `make_handle_new_chat`, OR -(b) Pass client/store through the existing `register_matrix_handlers` pattern. - -Recommended: Convert `handle_archive` to `make_handle_archive(client, store)` and `handle_rename` to `make_handle_rename(client, store)` following the same pattern as `make_handle_new_chat`. Then update `adapter/matrix/handlers/__init__.py` registrations. - - - -```python -@dataclass -class IncomingCommand: - user_id: str - platform: str - chat_id: str - command: str - args: list[str] = field(default_factory=list) - -@dataclass -class OutgoingMessage: - chat_id: str - text: str -``` - - - -```python -from nio.responses import RoomCreateError, RoomPutStateError -``` - - - - - - - Task 1: Rewrite make_handle_new_chat for Space (per D-03) - adapter/matrix/handlers/chat.py - adapter/matrix/handlers/chat.py, adapter/matrix/store.py, adapter/matrix/handlers/__init__.py, core/protocol.py - -Rewrite `make_handle_new_chat` in `adapter/matrix/handlers/chat.py`. The function signature stays the same (closure factory receiving `client` and `store`), but the inner logic changes: - -```python -def make_handle_new_chat( - client: Any | None, - store: Any | None, -) -> Callable[..., Awaitable[list]]: - async def handle_new_chat( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list: - if client is None or store is None: - return await _fallback_new_chat(event, auth_mgr, platform, chat_mgr, settings_mgr) - - if not await auth_mgr.is_authenticated(event.user_id): - return [OutgoingMessage(chat_id=event.chat_id, text="Сначала примите приглашение бота.")] - - # Get user's space_id - user_meta = await get_user_meta(store, event.user_id) - space_id = (user_meta or {}).get("space_id") - if not space_id: - return [OutgoingMessage(chat_id=event.chat_id, text="Ошибка: Space не найден. Примите приглашение бота заново.")] - - name = " ".join(event.args).strip() if event.args else "" - chat_id = await next_chat_id(store, event.user_id) - room_name = name or f"Чат {chat_id}" - - # Create room - resp = await client.room_create(name=room_name, visibility="private", is_direct=False) - if isinstance(resp, RoomCreateError): - logger.error("room_create failed", user=event.user_id, error=getattr(resp, "status_code", None)) - return [OutgoingMessage(chat_id=event.chat_id, text="Не удалось создать комнату.")] - room_id = resp.room_id - - # Add room to Space - homeserver = event.user_id.split(":")[-1] - await client.room_put_state( - room_id=space_id, - event_type="m.space.child", - content={"via": [homeserver]}, - state_key=room_id, - ) - - # Invite user - await client.room_invite(room_id, event.user_id) - - # Store room metadata - await set_room_meta(store, room_id, { - "room_type": "chat", - "chat_id": chat_id, - "display_name": room_name, - "matrix_user_id": event.user_id, - "space_id": space_id, - }) - - # Register in core ChatManager - ctx = await chat_mgr.get_or_create( - user_id=event.user_id, - chat_id=chat_id, - platform=event.platform, - surface_ref=room_id, - name=room_name, - ) - return [ - OutgoingMessage( - chat_id=event.chat_id, - text=f"Создан чат: {ctx.display_name} ({ctx.chat_id})", - ) - ] - - return handle_new_chat -``` - -Add required imports at top of file: - -```python -import structlog -from nio.responses import RoomCreateError -from adapter.matrix.store import get_user_meta, set_room_meta, next_chat_id -``` - -Keep `_fallback_new_chat` as-is (it works without client). - -Also update `_fallback_new_chat` to use `next_chat_id` from store instead of counting chats: - -Replace the line `chat_id = f"C{len(chats) + 1}"` with a call to `next_chat_id` if store is available. Actually, `_fallback_new_chat` doesn't have store access, so keep it as-is — it's only used when client/store are None. - -Add `logger = structlog.get_logger(__name__)` after imports. - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.handlers.chat import make_handle_new_chat; print('OK')" - - -- `adapter/matrix/handlers/chat.py` contains `get_user_meta` -- `adapter/matrix/handlers/chat.py` contains `room_put_state` -- `adapter/matrix/handlers/chat.py` contains `m.space.child` -- `adapter/matrix/handlers/chat.py` contains `RoomCreateError` -- `adapter/matrix/handlers/chat.py` contains `space_id` -- `adapter/matrix/handlers/chat.py` contains `next_chat_id` -- `adapter/matrix/handlers/chat.py` contains `room_invite` - - make_handle_new_chat creates rooms inside user's Space, handles errors gracefully - - - - Task 2: Convert handle_archive and handle_rename to Space-aware closures (per D-04) - adapter/matrix/handlers/chat.py, adapter/matrix/handlers/__init__.py - adapter/matrix/handlers/chat.py, adapter/matrix/handlers/__init__.py - -**Part A: Convert handle_archive to make_handle_archive(client, store)** - -Replace the current `handle_archive` function with a closure factory: - -```python -def make_handle_archive( - client: Any | None, - store: Any | None, -) -> Callable[..., Awaitable[list]]: - async def handle_archive( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list: - await chat_mgr.archive(event.chat_id, user_id=event.user_id) - - # Remove room from Space if client and store available - if client is not None and store is not None: - room_meta = await get_room_meta(store, event.chat_id) - space_id = (room_meta or {}).get("space_id") - if space_id: - # Find the matrix room_id — event.chat_id is the core chat_id (e.g. "C1"), - # but we need the matrix room_id for room_put_state. - # Actually, in Matrix adapter, event.chat_id IS the core chat_id resolved - # by room_router. We need the actual room_id. - # The room_id is the key used in room_meta store. We need to find which - # room_id maps to this chat_id. For now, check if event has surface info. - # - # IMPORTANT: In the Matrix adapter, commands are dispatched with chat_id - # from resolve_chat_id (e.g. "C1"). The actual room_id is available in - # the MatrixBot.on_room_message where room.room_id is known. - # Since handle_archive doesn't receive room_id, we need to find it. - # - # Solution: Store the room_id in the event's chat_id field. - # Actually, re-examining the flow: - # MatrixBot.on_room_message gets room.room_id, resolves to chat_id, - # then dispatches with chat_id. We lose room_id. - # - # Practical approach: iterate store isn't possible. - # Better approach: room_meta stores "room_id" -> meta with "chat_id". - # We can't reverse-lookup efficiently. - # - # Simplest fix: Store room_id in room_meta keyed by chat_id too, - # OR pass room_id through the event somehow. - # - # For Phase 1, use a pragmatic approach: the archive command responds - # with a message, but the Space child removal requires knowing the - # matrix room_id. Since we don't have it here, log a warning. - # The room will still be archived in core (chat_mgr.archive). - pass - - return [OutgoingMessage(chat_id=event.chat_id, text="Чат архивирован.")] - - return handle_archive -``` - -WAIT — the above approach has a problem. Let me reconsider. - -Actually, looking at the flow more carefully: -- `MatrixBot.on_room_message(room, event)` has `room.room_id` -- It calls `resolve_chat_id(store, room.room_id, sender)` to get chat_id like "C1" -- Then dispatches with that chat_id -- So `event.chat_id` in the handler is "C1", not the matrix room_id - -We need the matrix room_id for `room_put_state`. The cleanest Phase 1 solution: - -In `make_handle_archive(client, store)`, scan room_meta by iterating. But InMemoryStore and SQLiteStore don't have a scan/list method. - -**Better solution:** Change `room_router.resolve_chat_id` to store a reverse mapping `chat_id -> room_id` in room_meta. But that's in Plan 01's scope. - -**Simplest solution for Phase 1:** Use the fact that `get_room_meta` stores room_id as key. We need a helper that finds room_id by chat_id and user_id. Add to `adapter/matrix/store.py`: - -Actually, the simplest approach: the archive handler can look up user_meta to get space_id, and then we need the room_id. Since we only have chat_id ("C1") and user_id, we can't efficiently look up the room_id without a reverse index. - -**FINAL DECISION:** For Phase 1, `handle_archive` archives in core only (via chat_mgr.archive) and does NOT call room_put_state. This is acceptable because: -1. The room still exists, it's just marked archived in core -2. The user sees "Чат архивирован" message -3. Space child removal is a nice-to-have for Phase 1 (the room stays visible in Space but is archived logically) -4. Full Space child removal can be added when we add a reverse-lookup index - -So keep handle_archive simple: - -```python -def make_handle_archive( - client: Any | None, - store: Any | None, -) -> Callable[..., Awaitable[list]]: - async def handle_archive( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list: - await chat_mgr.archive(event.chat_id, user_id=event.user_id) - return [OutgoingMessage(chat_id=event.chat_id, text="Чат архивирован.")] - - return handle_archive -``` - -**Part B: Convert handle_rename to make_handle_rename(client, store)** - -```python -def make_handle_rename( - client: Any | None, - store: Any | None, -) -> Callable[..., Awaitable[list]]: - async def handle_rename( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list: - if not event.args: - return [OutgoingMessage(chat_id=event.chat_id, text="Укажите название: !rename Название")] - new_name = " ".join(event.args) - ctx = await chat_mgr.rename(event.chat_id, new_name, user_id=event.user_id) - return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")] - - return handle_rename -``` - -**Part C: Update `adapter/matrix/handlers/__init__.py`** - -Change the imports and registrations: - -Old imports: -```python -from adapter.matrix.handlers.chat import ( - handle_archive, - handle_list_chats, - make_handle_new_chat, - handle_rename, -) -``` - -New imports: -```python -from adapter.matrix.handlers.chat import ( - make_handle_archive, - handle_list_chats, - make_handle_new_chat, - make_handle_rename, -) -``` - -Old registrations: -```python -dispatcher.register(IncomingCommand, "archive", handle_archive) -dispatcher.register(IncomingCommand, "rename", handle_rename) -``` - -New registrations: -```python -dispatcher.register(IncomingCommand, "archive", make_handle_archive(client, store)) -dispatcher.register(IncomingCommand, "rename", make_handle_rename(client, store)) -``` - -Also keep the existing exports in `chat.py` module-level for backwards compatibility: add `handle_archive = make_handle_archive(None, None)` etc. at module bottom. Actually NO — just export the factory functions. Update __init__.py imports as shown above. - -Make sure `handle_list_chats` remains a plain function (no closure needed, it doesn't use client or store). - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.handlers.chat import make_handle_archive, make_handle_rename, make_handle_new_chat, handle_list_chats; print('OK')" && python -c "from adapter.matrix.handlers import register_matrix_handlers; print('OK')" - - -- `adapter/matrix/handlers/chat.py` contains `def make_handle_archive(` -- `adapter/matrix/handlers/chat.py` contains `def make_handle_rename(` -- `adapter/matrix/handlers/chat.py` does NOT contain `async def handle_archive(` as a top-level function (it's inside the closure now) -- `adapter/matrix/handlers/__init__.py` contains `make_handle_archive(client, store)` -- `adapter/matrix/handlers/__init__.py` contains `make_handle_rename(client, store)` -- `python -c "from adapter.matrix.handlers import register_matrix_handlers"` succeeds - - handle_archive and handle_rename are closure factories; __init__.py registrations updated - - - - - -After both tasks: -- `python -c "from adapter.matrix.handlers import register_matrix_handlers; print('OK')"` succeeds -- `python -c "from adapter.matrix.handlers.chat import make_handle_new_chat, make_handle_archive, make_handle_rename, handle_list_chats; print('OK')"` succeeds - - - -- make_handle_new_chat creates rooms inside Space with room_put_state -- make_handle_archive is a closure factory (Phase 1: core archive only, no Space child removal) -- make_handle_rename is a closure factory -- __init__.py updated to use factory calls -- All imports resolve cleanly - - - -After completion, create `.planning/phases/01-matrix-qa-polish/01-02-SUMMARY.md` - diff --git a/.planning/phases/01-matrix-qa-polish/01-02-SUMMARY.md b/.planning/phases/01-matrix-qa-polish/01-02-SUMMARY.md deleted file mode 100644 index 26acc44..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-02-SUMMARY.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -phase: 01-matrix-qa-polish -plan: 02 -subsystem: api -tags: [matrix, nio, handlers, spaces] -requires: - - phase: 01-matrix-qa-polish - provides: space-aware invite flow and room metadata -provides: - - Matrix `!new` creates chat rooms inside a user's Space - - Matrix `!rename` updates both core chat metadata and Matrix room names - - Matrix `!archive` uses closure-based handlers aligned with client/store injection -affects: [matrix handlers, matrix bot, phase-01-04-tests] -tech-stack: - added: [] - patterns: [closure-based Matrix command handlers, Space child linking via `m.space.child`] -key-files: - created: [.planning/phases/01-matrix-qa-polish/01-02-SUMMARY.md] - modified: [adapter/matrix/handlers/chat.py, adapter/matrix/handlers/__init__.py] -key-decisions: - - "Use `ChatContext.surface_ref` as the Matrix room identifier for `!rename` updates." - - "Keep `!archive` limited to core archive state in Phase 1; Space child removal remains deferred." -patterns-established: - - "Matrix handlers that need transport dependencies are registered as closure factories." - - "`!new` creates rooms by linking the child room into the user's Space before inviting the user." -requirements-completed: [] -duration: 1min -completed: 2026-04-02 ---- - -# Phase 1 Plan 02: Chat command handlers Summary - -**Matrix chat commands now create Space-linked rooms, rename underlying Matrix rooms through stored surface refs, and archive chats through client-aware handler factories.** - -## Performance - -- **Duration:** 1 min -- **Started:** 2026-04-02T19:51:20Z -- **Completed:** 2026-04-02T19:51:30Z -- **Tasks:** 2 -- **Files modified:** 2 - -## Accomplishments -- Rewrote `make_handle_new_chat` to require a stored `space_id`, allocate chat IDs via `next_chat_id`, create Matrix rooms, attach them to the Space, and invite the user. -- Added graceful `RoomCreateError` handling with user-facing messages and structured logging in the Matrix chat handler. -- Converted `!archive` and `!rename` into closure factories and updated registration to inject `client`/`store`. - -## Task Commits - -Each task was committed atomically: - -1. **Task 1: Rewrite make_handle_new_chat for Space** - `84111ca` (feat) -2. **Task 2: Convert handle_archive and handle_rename to Space-aware closures** - `b7a04b6` (feat) - -## Files Created/Modified -- `adapter/matrix/handlers/chat.py` - Space-aware `!new` flow plus closure-based `!archive` and `!rename`. -- `adapter/matrix/handlers/__init__.py` - Registers Matrix archive and rename handlers through factory calls. -- `.planning/phases/01-matrix-qa-polish/01-02-SUMMARY.md` - Execution summary for plan 01-02. - -## Decisions Made - -- Used `get_user_meta(...).space_id` as the gate for Matrix `!new`, returning a user-facing error instead of crashing when invite setup is incomplete. -- Used `ChatManager.rename(...).surface_ref` to call `client.room_set_name(...)` without adding a new reverse room lookup mechanism. -- Kept Space child removal out of `!archive` for Phase 1 because the plan explicitly defers reverse lookup work. - -## Deviations from Plan - -None - plan executed exactly as written. - -## Issues Encountered - -None. - -## User Setup Required - -None - no external service configuration required. - -## Next Phase Readiness - -Matrix chat command handlers are aligned with the Space+rooms model and ready for the Phase 1 test plan. -`!archive` still defers Space child removal by design; Phase 2 or later will need reverse room lookup if that behavior is required. - -## Self-Check: PASSED diff --git a/.planning/phases/01-matrix-qa-polish/01-03-PLAN.md b/.planning/phases/01-matrix-qa-polish/01-03-PLAN.md deleted file mode 100644 index 949e4e4..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-03-PLAN.md +++ /dev/null @@ -1,542 +0,0 @@ ---- -phase: 01-matrix-qa-polish -plan: 03 -type: execute -wave: 2 -depends_on: ["01-01", "01-02"] -files_modified: - - adapter/matrix/bot.py - - adapter/matrix/reactions.py - - adapter/matrix/handlers/confirm.py - - adapter/matrix/handlers/settings.py -autonomous: true -requirements: [] - -must_haves: - truths: - - "OutgoingUI renders as text + '!yes / !no' hint, no m.reaction events sent" - - "_button_action_to_reaction function is removed from bot.py" - - "on_reaction callback is removed from bot.py" - - "ReactionEvent import is removed from bot.py" - - "build_skills_text no longer mentions reactions 1-9" - - "build_confirmation_text uses !yes/!no instead of reaction emojis" - - "!yes reads pending_confirm from store and returns action description" - - "!no clears pending_confirm and returns cancellation message" - - "!settings returns a read-only dashboard with skills/soul/safety/chats status" - artifacts: - - path: "adapter/matrix/bot.py" - provides: "Clean send_outgoing without reactions, pending_confirm storage on OutgoingUI" - contains: "!yes" - - path: "adapter/matrix/reactions.py" - provides: "Updated text builders without reaction references" - - path: "adapter/matrix/handlers/confirm.py" - provides: "!yes/!no handlers reading pending_confirm" - contains: "get_pending_confirm" - - path: "adapter/matrix/handlers/settings.py" - provides: "Read-only dashboard for !settings" - key_links: - - from: "adapter/matrix/bot.py" - to: "adapter/matrix/store.py" - via: "set_pending_confirm on OutgoingUI send" - pattern: "set_pending_confirm" - - from: "adapter/matrix/handlers/confirm.py" - to: "adapter/matrix/store.py" - via: "get_pending_confirm / clear_pending_confirm" - pattern: "get_pending_confirm" ---- - - -Remove all reaction-based UX from the Matrix adapter and replace with text-based !yes/!no confirmation. Update settings dashboard to read-only format. - -Purpose: Per D-06/D-07/D-08, reactions are removed entirely. OutgoingUI renders as plain text with !yes/!no hint. Per D-12, !settings becomes a read-only dashboard. - -Output: Clean bot.py without reactions, working !yes/!no confirmation flow, updated text builders, read-only settings dashboard. - - - -@$HOME/.claude/get-shit-done/workflows/execute-plan.md -@$HOME/.claude/get-shit-done/templates/summary.md - - - -@.planning/PROJECT.md -@.planning/ROADMAP.md -@.planning/phases/01-matrix-qa-polish/01-CONTEXT.md -@.planning/phases/01-matrix-qa-polish/01-RESEARCH.md - -@adapter/matrix/bot.py -@adapter/matrix/reactions.py -@adapter/matrix/handlers/confirm.py -@adapter/matrix/handlers/settings.py -@adapter/matrix/store.py -@adapter/matrix/converter.py -@core/protocol.py - - - - -```python -PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:" - -async def get_pending_confirm(store: StateStore, room_id: str) -> dict | None -async def set_pending_confirm(store: StateStore, room_id: str, meta: dict) -> None -async def clear_pending_confirm(store: StateStore, room_id: str) -> None -``` - - - -```python -@dataclass -class UIButton: - label: str - action: str - payload: dict = field(default_factory=dict) - style: str = "secondary" - -@dataclass -class OutgoingUI: - chat_id: str - text: str - buttons: list[UIButton] = field(default_factory=list) - -@dataclass -class IncomingCallback: - user_id: str - platform: str - chat_id: str - action: str - payload: dict = field(default_factory=dict) -``` - - - -```python -# In from_command(): -if command in {"yes", "no"}: - action = "confirm" if command == "yes" else "cancel" - return IncomingCallback( - user_id=sender, - platform=PLATFORM, - chat_id=chat_id, - action=action, - payload={"source": "command", "command": command}, - ) -``` - - - -```python -dispatcher.register(IncomingCallback, "confirm", handle_confirm) -dispatcher.register(IncomingCallback, "cancel", handle_cancel) -``` - - - -```python -@dataclass -class UserSettings: - skills: dict - connectors: dict - soul: dict - safety: dict - plan: dict -``` - - - - - - - Task 1: Remove reactions from bot.py, update send_outgoing for !yes/!no (per D-06, D-07) - adapter/matrix/bot.py - adapter/matrix/bot.py, adapter/matrix/store.py, core/protocol.py - -Modify `adapter/matrix/bot.py` with these specific changes: - -**1. Remove ReactionEvent import (line 14):** -Change the nio import block from: -```python -from nio import ( - AsyncClient, - AsyncClientConfig, - InviteMemberEvent, - MatrixRoom, - ReactionEvent, - RoomMemberEvent, - RoomMessageText, -) -``` -to: -```python -from nio import ( - AsyncClient, - AsyncClientConfig, - InviteMemberEvent, - MatrixRoom, - RoomMemberEvent, - RoomMessageText, -) -``` - -**2. Remove `from_reaction` import (line 20):** -Change: -```python -from adapter.matrix.converter import from_reaction, from_room_event -``` -to: -```python -from adapter.matrix.converter import from_room_event -``` - -**3. Add store import for pending_confirm:** -Add this import: -```python -from adapter.matrix.store import set_pending_confirm -``` - -**4. Delete the entire `on_reaction` method from MatrixBot class (lines 106-114).** - -**5. Delete the entire `_button_action_to_reaction` function (lines 135-140).** - -**6. Rewrite the OutgoingUI block in `send_outgoing` function.** -Replace the existing `if isinstance(event, OutgoingUI):` block (lines 154-180) with: - -```python - if isinstance(event, OutgoingUI): - lines = [event.text] - if event.buttons: - lines.append("") - for btn in event.buttons: - lines.append(f" {btn.label}") - lines.append("") - lines.append("Ответьте !yes для подтверждения или !no для отмены.") - body = "\n".join(lines) - await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) - # Store pending confirmation for !yes/!no handler - if event.buttons: - action_id = event.buttons[0].action if event.buttons else "unknown" - payload = event.buttons[0].payload if event.buttons else {} - await set_pending_confirm(store, room_id, { - "action_id": action_id, - "description": event.text, - "payload": payload, - }) - return -``` - -**PROBLEM:** `send_outgoing` is a module-level function with signature `async def send_outgoing(client, room_id, event)`. It doesn't receive `store`. We need to pass `store` to it. - -**Solution:** Change `send_outgoing` signature to include `store`: -```python -async def send_outgoing(client: AsyncClient, room_id: str, event: OutgoingEvent, store: StateStore | None = None) -> None: -``` - -And update `MatrixBot._send_all` to pass store: -```python - async def _send_all(self, room_id: str, outgoing: list[OutgoingEvent]) -> None: - for event in outgoing: - await send_outgoing(self.client, room_id, event, store=self.runtime.store) -``` - -**7. In `main()`, remove the on_reaction callback registration.** -Delete this line: -```python - client.add_event_callback(bot.on_reaction, ReactionEvent) -``` - -**8. Add StateStore import at top:** -```python -from core.store import InMemoryStore, SQLiteStore, StateStore -``` -(StateStore is already imported on line 37 — verify it's there.) - -The `set_pending_confirm` call in the OutgoingUI handler should guard against store being None: -```python - if event.buttons and store is not None: - action_id = event.buttons[0].action - payload = event.buttons[0].payload - await set_pending_confirm(store, room_id, { - "action_id": action_id, - "description": event.text, - "payload": payload, - }) -``` - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.bot import send_outgoing, MatrixBot, build_runtime; print('OK')" && python -c "import ast; tree = ast.parse(open('adapter/matrix/bot.py').read()); names = [n.name for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]; assert '_button_action_to_reaction' not in names, 'reaction helper still exists'; assert 'on_reaction' not in names, 'on_reaction still exists'; print('REACTION CODE REMOVED')" - - -- `adapter/matrix/bot.py` does NOT contain the string `_button_action_to_reaction` -- `adapter/matrix/bot.py` does NOT contain the string `on_reaction` -- `adapter/matrix/bot.py` does NOT contain `ReactionEvent` -- `adapter/matrix/bot.py` does NOT contain `from_reaction` -- `adapter/matrix/bot.py` does NOT contain `m.reaction` -- `adapter/matrix/bot.py` contains `Ответьте !yes для подтверждения или !no для отмены.` -- `adapter/matrix/bot.py` contains `set_pending_confirm` -- `send_outgoing` function signature includes `store` parameter - - bot.py has no reaction code; OutgoingUI renders text + !yes/!no; pending_confirm stored on OutgoingUI send - - - - Task 2: Update reactions.py text builders + confirm.py handlers + settings.py dashboard (per D-06, D-07, D-08, D-12) - adapter/matrix/reactions.py, adapter/matrix/handlers/confirm.py, adapter/matrix/handlers/settings.py - adapter/matrix/reactions.py, adapter/matrix/handlers/confirm.py, adapter/matrix/handlers/settings.py, adapter/matrix/store.py - -**Part A: Update adapter/matrix/reactions.py** - -1. Update `build_skills_text` — replace the last line "Реакции 1-9 переключают навыки." with instruction for text commands: - -Replace: -```python - lines.append("Реакции 1️⃣-9️⃣ переключают навыки.") -``` -With: -```python - lines.append("!skill on/off <название> — переключить навык.") -``` - -2. Update `build_confirmation_text` — remove reaction emojis, use only !yes/!no: - -Replace the entire function with: -```python -def build_confirmation_text(description: str) -> str: - return "\n".join( - [ - "Lambda", - description, - "", - "Ответьте !yes для подтверждения или !no для отмены.", - ] - ) -``` - -3. Remove `add_reaction` and `remove_reaction` functions entirely (they send m.reaction events which are no longer used). - -4. Keep `CONFIRM_REACTION`, `CANCEL_REACTION`, `SKILL_REACTIONS`, `REACTION_TO_INDEX`, `reaction_to_skill_index` — they are still imported by `converter.py` for `from_reaction`. Even though `from_reaction` is no longer called from bot.py, converter.py still exports it and removing would break imports. Keep for backwards compat. - -Actually, check: `from_reaction` is imported in `converter.py` definition, not as an external import. And `bot.py` no longer imports `from_reaction`. But `converter.py` imports `CANCEL_REACTION, CONFIRM_REACTION, reaction_to_skill_index` from `reactions.py`. So those constants MUST stay. - -Keep: `CONFIRM_REACTION`, `CANCEL_REACTION`, `SKILL_REACTIONS`, `REACTION_TO_INDEX`, `reaction_to_skill_index`, `build_skills_text`, `build_confirmation_text`. -Remove: `add_reaction`, `remove_reaction`. -Remove the `AsyncClient` import since add_reaction/remove_reaction used it and nothing else does. - -Updated file should look like: -```python -from __future__ import annotations - -from sdk.interface import UserSettings - -CONFIRM_REACTION = "👍" -CANCEL_REACTION = "❌" -SKILL_REACTIONS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"] -REACTION_TO_INDEX = {emoji: idx + 1 for idx, emoji in enumerate(SKILL_REACTIONS)} - - -def build_skills_text(settings: UserSettings) -> str: - lines: list[str] = ["Скиллы"] - for idx, (name, enabled) in enumerate(settings.skills.items(), start=1): - state = "on" if enabled else "off" - emoji = SKILL_REACTIONS[idx - 1] if idx - 1 < len(SKILL_REACTIONS) else f"{idx}." - lines.append(f" {state} {emoji} {name}") - lines.append("") - lines.append("!skill on/off <название> — переключить навык.") - return "\n".join(lines) - - -def build_confirmation_text(description: str) -> str: - return "\n".join( - [ - "Lambda", - description, - "", - "Ответьте !yes для подтверждения или !no для отмены.", - ] - ) - - -def reaction_to_skill_index(key: str) -> int | None: - return REACTION_TO_INDEX.get(key) -``` - -**Part B: Update adapter/matrix/handlers/confirm.py** - -Rewrite to read pending_confirm from store. The handlers receive the standard signature `(event, auth_mgr, platform, chat_mgr, settings_mgr)` but need access to `store`. Since they're registered in `__init__.py` as plain functions (not closures), convert them to closure factories. - -Replace entire file: - -```python -from __future__ import annotations - -from adapter.matrix.store import get_pending_confirm, clear_pending_confirm -from core.protocol import IncomingCallback, OutgoingMessage - - -def make_handle_confirm(store=None): - async def handle_confirm( - event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list: - if store is None: - return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")] - - pending = await get_pending_confirm(store, event.chat_id) - if not pending: - return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")] - - description = pending.get("description", "действие") - action_id = pending.get("action_id", "unknown") - await clear_pending_confirm(store, event.chat_id) - - return [ - OutgoingMessage( - chat_id=event.chat_id, - text=f"Подтверждено: {description}", - ) - ] - - return handle_confirm - - -def make_handle_cancel(store=None): - async def handle_cancel( - event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list: - if store is None: - return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")] - - pending = await get_pending_confirm(store, event.chat_id) - if not pending: - return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")] - - await clear_pending_confirm(store, event.chat_id) - - return [ - OutgoingMessage( - chat_id=event.chat_id, - text="Действие отменено.", - ) - ] - - return handle_cancel -``` - -**Part C: Update adapter/matrix/handlers/__init__.py for new confirm imports** - -Change confirm imports from: -```python -from adapter.matrix.handlers.confirm import handle_cancel, handle_confirm -``` -to: -```python -from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_confirm -``` - -Change registrations from: -```python - dispatcher.register(IncomingCallback, "confirm", handle_confirm) - dispatcher.register(IncomingCallback, "cancel", handle_cancel) -``` -to: -```python - dispatcher.register(IncomingCallback, "confirm", make_handle_confirm(store)) - dispatcher.register(IncomingCallback, "cancel", make_handle_cancel(store)) -``` - -**Part D: Update adapter/matrix/handlers/settings.py — handle_settings becomes read-only dashboard (per D-12)** - -Replace the `handle_settings` function body. Keep ALL other functions unchanged. - -```python -async def handle_settings( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr -) -> list: - settings = await settings_mgr.get(event.user_id) - chats = await chat_mgr.list_active(event.user_id) - - # Skills section - skills_lines = [] - for name, enabled in settings.skills.items(): - state = "on" if enabled else "off" - skills_lines.append(f" {state} {name}") - skills_text = "\n".join(skills_lines) if skills_lines else " нет навыков" - - # Soul section - soul_lines = [] - for key, value in (settings.soul or {}).items(): - soul_lines.append(f" {key}: {value}") - soul_text = "\n".join(soul_lines) if soul_lines else " по умолчанию" - - # Safety section - safety_lines = [] - for key, value in (settings.safety or {}).items(): - state = "on" if value else "off" - safety_lines.append(f" {state} {key}") - safety_text = "\n".join(safety_lines) if safety_lines else " по умолчанию" - - # Chats section - chat_lines = [f" {c.display_name} ({c.chat_id})" for c in chats] - chats_text = "\n".join(chat_lines) if chat_lines else " нет активных чатов" - - dashboard = "\n".join([ - "Настройки", - "", - "Скиллы:", - skills_text, - "", - "Личность:", - soul_text, - "", - "Безопасность:", - safety_text, - "", - f"Активные чаты ({len(chats)}):", - chats_text, - "", - "Изменить: !skills, !soul, !safety", - ]) - - return [OutgoingMessage(chat_id=event.chat_id, text=dashboard)] -``` - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.reactions import build_skills_text, build_confirmation_text; from adapter.matrix.handlers.confirm import make_handle_confirm, make_handle_cancel; from adapter.matrix.handlers.settings import handle_settings; print('OK')" && python -c "from adapter.matrix.handlers import register_matrix_handlers; print('OK')" - - -- `adapter/matrix/reactions.py` does NOT contain `add_reaction` -- `adapter/matrix/reactions.py` does NOT contain `remove_reaction` -- `adapter/matrix/reactions.py` does NOT contain the string `Реакции 1` -- `adapter/matrix/reactions.py` contains `!skill on/off` -- `adapter/matrix/reactions.py` contains `!yes` in build_confirmation_text -- `adapter/matrix/handlers/confirm.py` contains `get_pending_confirm` -- `adapter/matrix/handlers/confirm.py` contains `clear_pending_confirm` -- `adapter/matrix/handlers/confirm.py` contains `def make_handle_confirm(` -- `adapter/matrix/handlers/confirm.py` contains `def make_handle_cancel(` -- `adapter/matrix/handlers/__init__.py` contains `make_handle_confirm(store)` -- `adapter/matrix/handlers/__init__.py` contains `make_handle_cancel(store)` -- `adapter/matrix/handlers/settings.py` `handle_settings` function contains the string `Настройки` and `Скиллы:` and `Изменить:` -- `adapter/matrix/handlers/settings.py` `handle_settings` does NOT contain `!connectors` or `!plan` or `!status` or `!whoami` - - Reactions removed from text builders; !yes/!no handlers read pending_confirm; !settings is read-only dashboard - - - - - -After both tasks: -- `python -c "from adapter.matrix.bot import send_outgoing, MatrixBot; from adapter.matrix.reactions import build_skills_text; from adapter.matrix.handlers.confirm import make_handle_confirm; from adapter.matrix.handlers import register_matrix_handlers; print('ALL OK')"` -- No string `m.reaction` in `adapter/matrix/bot.py` -- No string `_button_action_to_reaction` in `adapter/matrix/bot.py` -- No string `Реакции 1` in `adapter/matrix/reactions.py` - - - -- bot.py: no reaction code, OutgoingUI renders text + !yes/!no, stores pending_confirm -- reactions.py: build_skills_text says "!skill on/off", build_confirmation_text says "!yes/!no" -- confirm.py: !yes reads pending_confirm and confirms, !no clears and cancels -- settings.py: !settings returns read-only dashboard -- All imports resolve - - - -After completion, create `.planning/phases/01-matrix-qa-polish/01-03-SUMMARY.md` - diff --git a/.planning/phases/01-matrix-qa-polish/01-03-SUMMARY.md b/.planning/phases/01-matrix-qa-polish/01-03-SUMMARY.md deleted file mode 100644 index e7a9301..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-03-SUMMARY.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -phase: 01-matrix-qa-polish -plan: 03 -subsystem: matrix -tags: [matrix, confirmations, settings, text-ui] -requires: - - phase: 01-matrix-qa-polish - provides: Space-aware Matrix store and handler wiring from plans 01-01 and 01-02 -provides: - - Text-only Matrix confirmation flow via `!yes` and `!no` - - Pending confirmation persistence on `OutgoingUI` send - - Read-only Matrix `!settings` dashboard -affects: [matrix-adapter, matrix-tests, confirmation-flow] -tech-stack: - added: [] - patterns: [Matrix confirmation state stored per room, read-only settings dashboard rendering] -key-files: - created: [.planning/phases/01-matrix-qa-polish/01-03-SUMMARY.md] - modified: - - adapter/matrix/bot.py - - adapter/matrix/reactions.py - - adapter/matrix/handlers/confirm.py - - adapter/matrix/handlers/settings.py - - adapter/matrix/handlers/__init__.py -key-decisions: - - "Matrix OutgoingUI no longer emits reactions; confirmation state is persisted and resumed via `!yes` / `!no`." - - "`!settings` now renders a dashboard snapshot instead of advertising mutable subcommands." -patterns-established: - - "Matrix adapter keeps transport UX text-based when callback events are unavailable or unreliable." - - "Confirmation handlers are registered as closures when adapter state access is required." -requirements-completed: [] -duration: 3 min -completed: 2026-04-02 ---- - -# Phase 01 Plan 03: Reaction Removal Summary - -**Matrix confirmation prompts now render as plain text, persist pending state per room, and resolve through `!yes` / `!no` alongside a read-only settings dashboard.** - -## Performance - -- **Duration:** 3 min -- **Started:** 2026-04-02T19:53:30Z -- **Completed:** 2026-04-02T19:56:30Z -- **Tasks:** 2 -- **Files modified:** 5 - -## Accomplishments - -- Removed Matrix reaction event handling and reaction emission from the adapter send path. -- Stored pending confirmation metadata when `OutgoingUI` sends buttons, then resolved it through `!yes` / `!no`. -- Replaced the `!settings` command menu with a read-only dashboard showing skills, soul, safety, and active chats. - -## Task Commits - -Each task was committed atomically: - -1. **Task 1: Remove reactions from bot.py, update send_outgoing for !yes/!no** - `8a6a33a` (feat) -2. **Task 2: Update reactions.py text builders + confirm.py handlers + settings.py dashboard** - `01610ef` (feat) - -## Files Created/Modified - -- `adapter/matrix/bot.py` - Removed reaction callbacks and switched `OutgoingUI` delivery to text plus pending confirmation storage. -- `adapter/matrix/reactions.py` - Updated helper text to `!skill` and `!yes` / `!no`, removed reaction send helpers. -- `adapter/matrix/handlers/confirm.py` - Added closure-based confirm and cancel handlers backed by pending confirmation state. -- `adapter/matrix/handlers/settings.py` - Replaced the command list response with a read-only dashboard summary. -- `adapter/matrix/handlers/__init__.py` - Registered confirm and cancel handlers through store-aware factories. - -## Decisions Made - -- Removed Matrix reaction UX completely from adapter send and receive paths to match the phase requirement for command-driven confirmations. -- Kept confirmation state in the Matrix adapter store keyed by room so `!yes` and `!no` can work without protocol changes. -- Left the deeper settings subcommands in place, but made `!settings` itself a read-only overview as required by D-12. - -## Deviations from Plan - -None - plan executed exactly as written. - -## Issues Encountered - -None. - -## User Setup Required - -None - no external service configuration required. - -## Next Phase Readiness - -Plan `01-04` can now focus on Matrix test updates against the text-only confirmation and dashboard behavior. - -## Self-Check: PASSED - -- Found `.planning/phases/01-matrix-qa-polish/01-03-SUMMARY.md` -- Found commit `8a6a33a` -- Found commit `01610ef` - ---- -*Phase: 01-matrix-qa-polish* -*Completed: 2026-04-02* diff --git a/.planning/phases/01-matrix-qa-polish/01-04-PLAN.md b/.planning/phases/01-matrix-qa-polish/01-04-PLAN.md deleted file mode 100644 index 969beb3..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-04-PLAN.md +++ /dev/null @@ -1,825 +0,0 @@ ---- -phase: 01-matrix-qa-polish -plan: 04 -type: execute -wave: 3 -depends_on: ["01-01", "01-02", "01-03"] -files_modified: - - tests/adapter/matrix/test_dispatcher.py - - tests/adapter/matrix/test_reactions.py - - tests/adapter/matrix/test_store.py - - tests/adapter/matrix/test_invite_space.py - - tests/adapter/matrix/test_chat_space.py - - tests/adapter/matrix/test_send_outgoing.py - - tests/adapter/matrix/test_confirm.py -autonomous: true -requirements: [] - -must_haves: - truths: - - "All 4 previously-broken tests are fixed and green" - - "12 new tests (MAT-01..MAT-12) are implemented and green" - - "pytest tests/ -q shows 96+ tests passing" - - "No test uses hardcoded 'C1' assumption from old DM flow" - artifacts: - - path: "tests/adapter/matrix/test_invite_space.py" - provides: "MAT-01, MAT-02, MAT-03 tests" - contains: "space=True" - - path: "tests/adapter/matrix/test_chat_space.py" - provides: "MAT-04, MAT-05, MAT-10, MAT-12 tests" - contains: "room_put_state" - - path: "tests/adapter/matrix/test_send_outgoing.py" - provides: "MAT-06, MAT-07 tests" - contains: "!yes" - - path: "tests/adapter/matrix/test_confirm.py" - provides: "MAT-09 test" - contains: "get_pending_confirm" - - path: "tests/adapter/matrix/test_dispatcher.py" - provides: "Fixed broken tests + MAT-11" - - path: "tests/adapter/matrix/test_reactions.py" - provides: "Fixed broken tests" - - path: "tests/adapter/matrix/test_store.py" - provides: "MAT-08 pending_confirm roundtrip test" - contains: "pending_confirm" - key_links: - - from: "tests/adapter/matrix/test_invite_space.py" - to: "adapter/matrix/handlers/auth.py" - via: "tests handle_invite" - pattern: "handle_invite" - - from: "tests/adapter/matrix/test_chat_space.py" - to: "adapter/matrix/handlers/chat.py" - via: "tests make_handle_new_chat" - pattern: "make_handle_new_chat" ---- - - -Fix all broken tests and implement 12 new test cases (MAT-01..MAT-12) covering the Space+rooms refactor. - -Purpose: Achieve 96+ green tests as required by Phase 1 deliverables. Currently 97 pass; 4 will break from Plans 01-03 changes. This plan fixes those 4 and adds 12 new, targeting ~109 total. - -Output: Full green test suite with comprehensive Space+rooms coverage. - - - -@$HOME/.claude/get-shit-done/workflows/execute-plan.md -@$HOME/.claude/get-shit-done/templates/summary.md - - - -@.planning/PROJECT.md -@.planning/ROADMAP.md -@.planning/phases/01-matrix-qa-polish/01-CONTEXT.md -@.planning/phases/01-matrix-qa-polish/01-RESEARCH.md -@.planning/phases/01-matrix-qa-polish/01-VALIDATION.md - -@tests/adapter/matrix/test_dispatcher.py -@tests/adapter/matrix/test_reactions.py -@tests/adapter/matrix/test_store.py -@adapter/matrix/handlers/auth.py -@adapter/matrix/handlers/chat.py -@adapter/matrix/handlers/confirm.py -@adapter/matrix/handlers/settings.py -@adapter/matrix/bot.py -@adapter/matrix/store.py -@adapter/matrix/reactions.py -@adapter/matrix/converter.py -@core/protocol.py - - - - -```python -# adapter/matrix/handlers/auth.py -async def handle_invite(client, room, event, platform, store, auth_mgr) -> None -# Creates Space (space=True), chat room, room_put_state, room_invite x2, stores user_meta+room_meta - -# adapter/matrix/store.py -async def get_pending_confirm(store: StateStore, room_id: str) -> dict | None -async def set_pending_confirm(store: StateStore, room_id: str, meta: dict) -> None -async def clear_pending_confirm(store: StateStore, room_id: str) -> None - -# adapter/matrix/handlers/chat.py -def make_handle_new_chat(client, store) -> Callable # closure factory -def make_handle_archive(client, store) -> Callable # closure factory -def make_handle_rename(client, store) -> Callable # closure factory - -# adapter/matrix/handlers/confirm.py -def make_handle_confirm(store=None) -> Callable # closure factory -def make_handle_cancel(store=None) -> Callable # closure factory - -# adapter/matrix/bot.py -async def send_outgoing(client, room_id, event, store=None) -> None -# For OutgoingUI: renders text + "!yes/!no", calls set_pending_confirm - -# adapter/matrix/reactions.py -def build_skills_text(settings) -> str # No longer mentions "Реакции 1-9" -def build_confirmation_text(description) -> str # Uses "!yes/!no" not emojis -``` - - - -```python -class InMemoryStore: - async def get(key) -> Any - async def set(key, value) -> None - async def delete(key) -> None # Check if exists; if not, use set(key, None) -``` - - - -```python -class MockPlatformClient: - # Provides get_or_create_user, get_settings, etc. -``` - - - -```python -@dataclass -class UserSettings: - skills: dict - connectors: dict - soul: dict - safety: dict - plan: dict -``` - - - - - - - Task 1: Fix 4 broken tests in test_dispatcher.py and test_reactions.py - tests/adapter/matrix/test_dispatcher.py, tests/adapter/matrix/test_reactions.py - tests/adapter/matrix/test_dispatcher.py, tests/adapter/matrix/test_reactions.py, adapter/matrix/handlers/auth.py, adapter/matrix/handlers/chat.py, adapter/matrix/bot.py, adapter/matrix/reactions.py, adapter/matrix/store.py - -**Fix 1: test_dispatcher.py::test_invite_event_creates_dm_room_and_sends_welcome** - -The old test checks `client.join` and `meta["chat_id"] == "C1"` via room_meta on the DM room. After refactor, handle_invite creates a Space + chat room, so the test needs different mocks and assertions. - -Replace the entire test function with: - -```python -async def test_invite_event_creates_space_and_chat_room(): - from adapter.matrix.store import get_user_meta, get_room_meta - - runtime = build_runtime(platform=MockPlatformClient()) - # Mock client with room_create, room_put_state, room_invite, room_send, join - space_resp = SimpleNamespace(room_id="!space:example.org") - chat_resp = SimpleNamespace(room_id="!chat1:example.org") - client = SimpleNamespace( - join=AsyncMock(), - room_create=AsyncMock(side_effect=[space_resp, chat_resp]), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - room_send=AsyncMock(), - ) - room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice") - event = SimpleNamespace(sender="@alice:example.org", membership="invite") - - await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr) - - # Verify Space created with space=True - 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) - - # Verify room_put_state called to add child to Space - client.room_put_state.assert_awaited_once() - put_state_call = client.room_put_state.call_args - assert put_state_call.kwargs.get("event_type") == "m.space.child" or put_state_call.args[1] == "m.space.child" - - # Verify user_meta has space_id - user_meta = await get_user_meta(runtime.store, "@alice:example.org") - assert user_meta is not None - assert user_meta.get("space_id") == "!space:example.org" - - # Verify room_meta for chat room - room_meta = await get_room_meta(runtime.store, "!chat1:example.org") - assert room_meta is not None - assert room_meta["chat_id"] == "C1" - assert room_meta["space_id"] == "!space:example.org" - - # Verify auth confirmed - assert await runtime.auth_mgr.is_authenticated("@alice:example.org") is True - - # Verify welcome message sent - client.room_send.assert_awaited_once() -``` - -Also add import at top if not present: -```python -from adapter.matrix.store import get_user_meta, get_room_meta -``` -(get_room_meta is already imported) - -**Fix 2: test_dispatcher.py::test_invite_event_is_idempotent_per_room** - -This test now needs to check idempotency on user_meta (not room_meta). Replace with: - -```python -async def test_invite_event_is_idempotent_per_user(): - runtime = build_runtime(platform=MockPlatformClient()) - space_resp = SimpleNamespace(room_id="!space:example.org") - chat_resp = SimpleNamespace(room_id="!chat1:example.org") - client = SimpleNamespace( - join=AsyncMock(), - room_create=AsyncMock(side_effect=[space_resp, chat_resp]), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - room_send=AsyncMock(), - ) - room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice") - event = SimpleNamespace(sender="@alice:example.org", membership="invite") - - await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr) - # Second call should be a no-op (user already has space_id) - await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr) - - # room_create called only twice (once for Space, once for chat room) — not 4 times - assert client.room_create.await_count == 2 -``` - -**Fix 3: test_dispatcher.py::test_new_chat_creates_real_matrix_room_when_client_available** - -After refactor, make_handle_new_chat needs space_id in user_meta and calls room_put_state. Update: - -```python -async def test_new_chat_creates_real_matrix_room_when_client_available(): - from adapter.matrix.store import set_user_meta - - client = SimpleNamespace( - room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - ) - runtime = build_runtime(platform=MockPlatformClient(), client=client) - - # Pre-populate user_meta with space_id (as if invite flow already ran) - await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 1}) - - start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start") - await runtime.dispatcher.dispatch(start) - - new = IncomingCommand( - user_id="u1", - platform="matrix", - chat_id="C1", - command="new", - args=["Research"], - ) - result = await runtime.dispatcher.dispatch(new) - - client.room_create.assert_awaited_once_with(name="Research", visibility="private", is_direct=False) - client.room_put_state.assert_awaited_once() - # Verify room_put_state adds child to space - put_call = client.room_put_state.call_args - assert put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example" - - assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result) -``` - -**Fix 4: test_dispatcher.py::test_matrix_dispatcher_registers_custom_handlers** - -This test checks `"Реакции 1️⃣-9️⃣" in r.text` on line 39. After reactions removal, this string no longer appears. Update: - -Change line 39 from: -```python - assert any(isinstance(r, OutgoingMessage) and "Реакции 1️⃣-9️⃣" in r.text for r in result) -``` -to: -```python - assert any(isinstance(r, OutgoingMessage) and "!skill on/off" in r.text for r in result) -``` - -**Fix 5: test_reactions.py::test_build_skills_text** - -Change assertion from: -```python - assert "Реакции 1️⃣-9️⃣" in text -``` -to: -```python - assert "!skill on/off" in text -``` - -**Fix 6: test_reactions.py::test_build_confirmation_text** - -The old test checks for "подтвердить" which may still be in the text. Update to check for new format: - -```python -def test_build_confirmation_text(): - text = build_confirmation_text("Отправить письмо?") - assert "Отправить письмо?" in text - assert "!yes" in text - assert "!no" in text -``` - -Also make sure the `get_room_meta` import and `get_user_meta` import are present in test_dispatcher.py. Add `from adapter.matrix.store import get_user_meta, set_user_meta` if not already imported. - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_reactions.py -x -q 2>&1 | tail -5 - - -- `test_dispatcher.py` does NOT contain `test_invite_event_creates_dm_room_and_sends_welcome` (renamed to `test_invite_event_creates_space_and_chat_room`) -- `test_dispatcher.py` contains `test_invite_event_creates_space_and_chat_room` -- `test_dispatcher.py` contains `space=True` in assertions -- `test_dispatcher.py` contains `room_put_state` in assertions -- `test_reactions.py` contains `!skill on/off` instead of `Реакции 1` -- `test_reactions.py` contains `!yes` in confirmation text test -- `pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_reactions.py -x -q` passes - - All 4 previously-broken tests fixed and passing (renamed/updated for Space+rooms) - - - - Task 2: Create new test files and implement MAT-01..MAT-12 - tests/adapter/matrix/test_invite_space.py, tests/adapter/matrix/test_chat_space.py, tests/adapter/matrix/test_send_outgoing.py, tests/adapter/matrix/test_confirm.py, tests/adapter/matrix/test_store.py, tests/adapter/matrix/test_dispatcher.py - adapter/matrix/handlers/auth.py, adapter/matrix/handlers/chat.py, adapter/matrix/bot.py, adapter/matrix/handlers/confirm.py, adapter/matrix/handlers/settings.py, adapter/matrix/store.py, tests/adapter/matrix/test_store.py, tests/adapter/matrix/test_dispatcher.py - -Create 4 new test files and extend 2 existing ones. All tests use `pytest-asyncio` (async test functions are auto-detected). - -**File 1: tests/adapter/matrix/test_invite_space.py (MAT-01, MAT-02, MAT-03)** - -```python -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from adapter.matrix.handlers.auth import handle_invite -from adapter.matrix.store import get_user_meta, get_room_meta -from adapter.matrix.bot import build_runtime -from sdk.mock import MockPlatformClient - - -def _make_client(): - """Helper: create mock client with Space+room creation responses.""" - space_resp = SimpleNamespace(room_id="!space:example.org") - chat_resp = SimpleNamespace(room_id="!chat1:example.org") - return SimpleNamespace( - join=AsyncMock(), - room_create=AsyncMock(side_effect=[space_resp, chat_resp]), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - room_send=AsyncMock(), - ) - - -async def test_mat01_invite_creates_space_and_chat1(): - """MAT-01: handle_invite creates Space + Чат 1, saves space_id in user_meta.""" - runtime = build_runtime(platform=MockPlatformClient()) - client = _make_client() - room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice") - event = SimpleNamespace(sender="@alice:example.org", membership="invite") - - await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr) - - # Space created with space=True - first_call = client.room_create.call_args_list[0] - assert first_call.kwargs.get("space") is True - - # Chat room created - assert client.room_create.await_count == 2 - - # room_put_state links child to Space - client.room_put_state.assert_awaited_once() - ps_kwargs = client.room_put_state.call_args.kwargs - assert ps_kwargs.get("event_type") == "m.space.child" - assert ps_kwargs.get("state_key") == "!chat1:example.org" - assert ps_kwargs.get("room_id") == "!space:example.org" - - # user_meta stores space_id - user_meta = await get_user_meta(runtime.store, "@alice:example.org") - assert user_meta is not None - assert user_meta["space_id"] == "!space:example.org" - - # room_meta stores chat metadata - room_meta = await get_room_meta(runtime.store, "!chat1:example.org") - assert room_meta is not None - assert room_meta["chat_id"] == "C1" - assert room_meta["space_id"] == "!space:example.org" - - -async def test_mat02_invite_idempotent(): - """MAT-02: Repeated invite does not create second Space.""" - runtime = build_runtime(platform=MockPlatformClient()) - client = _make_client() - room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice") - event = SimpleNamespace(sender="@alice:example.org", membership="invite") - - await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr) - - # Reset side_effect for potential second call - client.room_create.side_effect = None - client.room_create.return_value = SimpleNamespace(room_id="!should-not-exist:example.org") - - await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr) - - # Still only 2 room_create calls (from first invite) - assert client.room_create.await_count == 2 - - -async def test_mat03_no_hardcoded_c1(): - """MAT-03: handle_invite uses next_chat_id, not hardcoded 'C1'.""" - import ast - import inspect - source = inspect.getsource(handle_invite) - # Check that the literal string '"C1"' or "'C1'" does not appear as a value assignment - assert '"C1"' not in source or "chat_id" not in source.split('"C1"')[0].split("\n")[-1] - # More robust: verify via actual behavior — chat_id comes from next_chat_id - runtime = build_runtime(platform=MockPlatformClient()) - client = _make_client() - room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice") - event = SimpleNamespace(sender="@alice:example.org", membership="invite") - - await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr) - - room_meta = await get_room_meta(runtime.store, "!chat1:example.org") - # C1 is correct for first user, but it came from next_chat_id (not hardcode) - assert room_meta["chat_id"] == "C1" - - # Verify next_chat_index was incremented (proves next_chat_id was used) - user_meta = await get_user_meta(runtime.store, "@alice:example.org") - assert user_meta["next_chat_index"] == 2 # Incremented from 1 to 2 -``` - -**File 2: tests/adapter/matrix/test_chat_space.py (MAT-04, MAT-05, MAT-10, MAT-12)** - -```python -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from nio.responses import RoomCreateError - -from adapter.matrix.handlers.chat import make_handle_new_chat, make_handle_archive -from adapter.matrix.store import set_user_meta -from core.protocol import IncomingCommand, OutgoingMessage -from core.store import InMemoryStore -from core.chat import ChatManager -from core.auth import AuthManager -from core.settings import SettingsManager -from sdk.mock import MockPlatformClient - - -async def _setup(): - """Helper: create platform, store, managers, authenticate user.""" - platform = MockPlatformClient() - store = InMemoryStore() - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - await auth_mgr.confirm("@alice:example.org") - return platform, store, chat_mgr, auth_mgr, settings_mgr - - -async def test_mat04_new_chat_calls_room_put_state_with_space_id(): - """MAT-04: !new calls room_put_state to add room to Space.""" - platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() - await set_user_meta(store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2}) - - client = SimpleNamespace( - room_create=AsyncMock(return_value=SimpleNamespace(room_id="!newroom:ex")), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - ) - handler = make_handle_new_chat(client, store) - event = IncomingCommand( - user_id="@alice:example.org", platform="matrix", chat_id="C1", command="new", args=["Test"] - ) - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - client.room_put_state.assert_awaited_once() - ps_kwargs = client.room_put_state.call_args.kwargs - assert ps_kwargs.get("room_id") == "!space:ex" - assert ps_kwargs.get("event_type") == "m.space.child" - assert ps_kwargs.get("state_key") == "!newroom:ex" - assert any(isinstance(r, OutgoingMessage) and "Test" in r.text for r in result) - - -async def test_mat05_new_chat_without_space_id_returns_error(): - """MAT-05: !new without space_id in user_meta returns error message.""" - platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() - # user_meta exists but no space_id - await set_user_meta(store, "@alice:example.org", {"next_chat_index": 1}) - - client = SimpleNamespace( - room_create=AsyncMock(), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - ) - handler = make_handle_new_chat(client, store) - event = IncomingCommand( - user_id="@alice:example.org", platform="matrix", chat_id="C1", command="new" - ) - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - # Should return error, not crash - assert len(result) == 1 - assert isinstance(result[0], OutgoingMessage) - assert "Space" in result[0].text or "ошибка" in result[0].text.lower() or "Ошибка" in result[0].text - # room_create should NOT have been called - client.room_create.assert_not_awaited() - - -async def test_mat10_archive_calls_chat_mgr_archive(): - """MAT-10: !archive archives chat via chat_mgr.archive (Space removal deferred).""" - platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() - - handler = make_handle_archive(None, store) - event = IncomingCommand( - user_id="@alice:example.org", platform="matrix", chat_id="C1", command="archive" - ) - # Create a chat first so archive has something to work with - await chat_mgr.get_or_create( - user_id="@alice:example.org", chat_id="C1", platform="matrix", - surface_ref="!room:ex", name="Test" - ) - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - assert len(result) == 1 - assert "архивирован" in result[0].text - - -async def test_mat12_room_create_error_returns_user_message(): - """MAT-12: RoomCreateError is handled gracefully with user-facing message.""" - platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() - await set_user_meta(store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2}) - - # Simulate RoomCreateError - error_resp = RoomCreateError(message="rate limited", status_code="429") - client = SimpleNamespace( - room_create=AsyncMock(return_value=error_resp), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - ) - handler = make_handle_new_chat(client, store) - event = IncomingCommand( - user_id="@alice:example.org", platform="matrix", chat_id="C1", command="new", args=["Fail"] - ) - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - assert len(result) == 1 - assert isinstance(result[0], OutgoingMessage) - assert "Не удалось" in result[0].text or "не удалось" in result[0].text - # room_put_state should NOT have been called (room creation failed) - client.room_put_state.assert_not_awaited() -``` - -NOTE: For MAT-12, `RoomCreateError` constructor signature may differ. Check the actual nio source. It might be `RoomCreateError(message="...", status_code="...")` or just `RoomCreateError(message="...")`. If the constructor fails, create a mock: -```python -error_resp = SimpleNamespace(status_code="429") # Duck-typing: no room_id attr -``` -and rely on `isinstance(resp, RoomCreateError)` check in the handler. If isinstance check is used, the SimpleNamespace won't match — so use the real class or mock it. Actually, the handler uses `isinstance(resp, RoomCreateError)` so we MUST use a real `RoomCreateError` instance or the check won't match. Try both approaches: -- First: `RoomCreateError(message="error")` -- If that fails: mock the isinstance check by making room_create return an object where `hasattr(resp, 'room_id')` is False - -Read `nio/responses.py` source to find the exact constructor if `RoomCreateError(message="error")` fails during test execution. - -**File 3: tests/adapter/matrix/test_send_outgoing.py (MAT-06, MAT-07)** - -```python -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from adapter.matrix.bot import send_outgoing -from adapter.matrix.store import get_pending_confirm -from core.protocol import OutgoingUI, UIButton -from core.store import InMemoryStore - - -async def test_mat06_outgoing_ui_renders_text_with_yes_no(): - """MAT-06: OutgoingUI renders as text + '!yes / !no' hint.""" - client = SimpleNamespace(room_send=AsyncMock()) - store = InMemoryStore() - event = OutgoingUI( - chat_id="C1", - text="Удалить файл?", - buttons=[UIButton(label="Подтвердить", action="confirm")], - ) - - await send_outgoing(client, "!room:ex", event, store=store) - - client.room_send.assert_awaited_once() - call_args = client.room_send.call_args - body = call_args.args[2]["body"] if len(call_args.args) > 2 else call_args.kwargs.get("content", {}).get("body", "") - assert "Удалить файл?" in body - assert "!yes" in body - assert "!no" in body - assert "Подтвердить" in body - - -async def test_mat07_outgoing_ui_no_reaction_sent(): - """MAT-07: OutgoingUI does NOT send m.reaction event.""" - client = SimpleNamespace(room_send=AsyncMock()) - store = InMemoryStore() - event = OutgoingUI( - chat_id="C1", - text="Confirm action?", - buttons=[UIButton(label="OK", action="confirm")], - ) - - await send_outgoing(client, "!room:ex", event, store=store) - - # Only one room_send call (the text message), no m.reaction - assert client.room_send.await_count == 1 - call_args = client.room_send.call_args - msg_type = call_args.args[1] if len(call_args.args) > 1 else "" - assert msg_type == "m.room.message" - # Verify no m.reaction calls - for call in client.room_send.call_args_list: - assert call.args[1] != "m.reaction" -``` - -**File 4: tests/adapter/matrix/test_confirm.py (MAT-09)** - -```python -from __future__ import annotations - -from adapter.matrix.handlers.confirm import make_handle_confirm, make_handle_cancel -from adapter.matrix.store import set_pending_confirm, get_pending_confirm -from core.protocol import IncomingCallback, OutgoingMessage -from core.store import InMemoryStore -from sdk.mock import MockPlatformClient -from core.chat import ChatManager -from core.auth import AuthManager -from core.settings import SettingsManager - - -async def test_mat09_yes_reads_pending_confirm(): - """MAT-09: !yes reads pending_confirm and returns action description.""" - store = InMemoryStore() - platform = MockPlatformClient() - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - - # Set up pending confirmation - await set_pending_confirm(store, "C1", { - "action_id": "delete_file", - "description": "Удалить файл config.yaml", - "payload": {}, - }) - - handler = make_handle_confirm(store) - event = IncomingCallback( - user_id="@alice:example.org", - platform="matrix", - chat_id="C1", - action="confirm", - payload={"source": "command", "command": "yes"}, - ) - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - assert len(result) == 1 - assert isinstance(result[0], OutgoingMessage) - assert "Удалить файл config.yaml" in result[0].text - - # pending_confirm should be cleared after confirmation - pending = await get_pending_confirm(store, "C1") - assert pending is None - - -async def test_no_clears_pending_confirm(): - """!no clears pending_confirm and returns cancellation.""" - store = InMemoryStore() - platform = MockPlatformClient() - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - - await set_pending_confirm(store, "C1", { - "action_id": "delete_file", - "description": "Удалить файл", - "payload": {}, - }) - - handler = make_handle_cancel(store) - event = IncomingCallback( - user_id="@alice:example.org", - platform="matrix", - chat_id="C1", - action="cancel", - payload={"source": "command", "command": "no"}, - ) - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - assert len(result) == 1 - assert "отменено" in result[0].text.lower() - - pending = await get_pending_confirm(store, "C1") - assert pending is None - - -async def test_yes_without_pending_returns_no_pending(): - """!yes with no pending confirmation returns 'no pending' message.""" - store = InMemoryStore() - platform = MockPlatformClient() - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - - handler = make_handle_confirm(store) - event = IncomingCallback( - user_id="@alice:example.org", - platform="matrix", - chat_id="C1", - action="confirm", - payload={}, - ) - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - assert len(result) == 1 - assert "Нет ожидающих" in result[0].text -``` - -**File 5: Extend tests/adapter/matrix/test_store.py (MAT-08)** - -Add at the end of the existing file: - -```python -async def test_pending_confirm_roundtrip(store: InMemoryStore): - """MAT-08: get/set/clear_pending_confirm roundtrip.""" - from adapter.matrix.store import get_pending_confirm, set_pending_confirm, clear_pending_confirm - - # Initially None - assert await get_pending_confirm(store, "!room:m.org") is None - - # Set - meta = {"action_id": "test", "description": "Do thing"} - await set_pending_confirm(store, "!room:m.org", meta) - assert await get_pending_confirm(store, "!room:m.org") == meta - - # Clear - await clear_pending_confirm(store, "!room:m.org") - assert await get_pending_confirm(store, "!room:m.org") is None -``` - -**File 6: Extend tests/adapter/matrix/test_dispatcher.py (MAT-11)** - -Add at the end of test_dispatcher.py: - -```python -async def test_mat11_settings_returns_dashboard(): - """MAT-11: !settings returns a read-only dashboard with status info.""" - runtime = build_runtime(platform=MockPlatformClient()) - - # Authenticate user first - start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start") - await runtime.dispatcher.dispatch(start) - - settings_cmd = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="settings") - result = await runtime.dispatcher.dispatch(settings_cmd) - - assert len(result) >= 1 - text = result[0].text - # Dashboard should contain section headers - assert "Скиллы" in text or "скиллы" in text.lower() - assert "Изменить" in text or "!skills" in text - # Should NOT be the old command list format - assert "!connectors" not in text - assert "!whoami" not in text -``` - -IMPORTANT: Check that `core/store.py` InMemoryStore has a `delete` method. If it does NOT, the `clear_pending_confirm` function will fail. Read `core/store.py` and if `delete` is missing, implement `clear_pending_confirm` using `store.set(key, None)` instead and update the test accordingly. - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/ -x -q 2>&1 | tail -10 - - -- File `tests/adapter/matrix/test_invite_space.py` exists and contains `test_mat01`, `test_mat02`, `test_mat03` -- File `tests/adapter/matrix/test_chat_space.py` exists and contains `test_mat04`, `test_mat05`, `test_mat10`, `test_mat12` -- File `tests/adapter/matrix/test_send_outgoing.py` exists and contains `test_mat06`, `test_mat07` -- File `tests/adapter/matrix/test_confirm.py` exists and contains `test_mat09` -- `tests/adapter/matrix/test_store.py` contains `test_pending_confirm_roundtrip` -- `tests/adapter/matrix/test_dispatcher.py` contains `test_mat11_settings_returns_dashboard` -- `pytest tests/adapter/matrix/ -x -q` passes with 0 failures -- `pytest tests/ -q` shows 96+ tests passing - - All 12 new tests (MAT-01..MAT-12) implemented and green; 4 broken tests fixed; total 96+ passing - - - - - -After both tasks: -- `pytest tests/ -q` shows 96+ tests passing, 0 failures -- `pytest tests/adapter/matrix/ -q` shows all Matrix tests passing -- New test files exist: test_invite_space.py, test_chat_space.py, test_send_outgoing.py, test_confirm.py - - - -- 96+ tests passing in full suite -- 4 broken tests fixed (renamed/updated for Space model) -- 12 new tests implemented covering MAT-01..MAT-12 -- No test references hardcoded "C1" from old DM flow -- All test files importable and runnable - - - -After completion, create `.planning/phases/01-matrix-qa-polish/01-04-SUMMARY.md` - diff --git a/.planning/phases/01-matrix-qa-polish/01-04-SUMMARY.md b/.planning/phases/01-matrix-qa-polish/01-04-SUMMARY.md deleted file mode 100644 index 65f964d..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-04-SUMMARY.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -phase: 01-matrix-qa-polish -plan: 04 -subsystem: testing -tags: [pytest, matrix, matrix-nio, regression-testing] -requires: - - phase: 01-01 - provides: Matrix store helpers and invite flow for Space rooms - - phase: 01-02 - provides: Space-aware chat handlers for !new, !archive, and !rename - - phase: 01-03 - provides: Text confirmation flow and settings dashboard behavior -provides: - - Matrix regression coverage for Space invite, chat creation, confirmation, and settings flows - - Updated dispatcher and reaction assertions aligned to !yes/!no behavior - - Full green pytest suite above the 96-test phase threshold -affects: [phase-02-sdk-integration, matrix-adapter, qa] -tech-stack: - added: [] - patterns: [pytest-asyncio matrix handler tests, room/state store roundtrip assertions] -key-files: - created: - - tests/adapter/matrix/test_invite_space.py - - tests/adapter/matrix/test_chat_space.py - - tests/adapter/matrix/test_send_outgoing.py - - tests/adapter/matrix/test_confirm.py - modified: - - tests/adapter/matrix/test_dispatcher.py - - tests/adapter/matrix/test_reactions.py - - tests/adapter/matrix/test_store.py -key-decisions: - - "Split Matrix regression coverage into dedicated invite/chat/send_outgoing/confirm modules to keep each Space behavior isolated." - - "Validated current confirmation handlers at the unit level without widening plan scope into production-code changes." -patterns-established: - - "Matrix adapter regressions should assert Space linkage via room_put_state and stored space_id metadata." - - "OutgoingUI confirmation coverage should verify both rendered !yes/!no text and pending_confirm persistence." -requirements-completed: [] -duration: 3 min -completed: 2026-04-02 ---- - -# Phase 1 Plan 4: Test Suite Summary - -**Matrix Space-room regression coverage with 12 MAT tests, fixed dispatcher/reaction expectations, and 111 green pytest cases** - -## Performance - -- **Duration:** 3 min -- **Started:** 2026-04-02T20:00:50Z -- **Completed:** 2026-04-02T20:03:38Z -- **Tasks:** 2 -- **Files modified:** 7 - -## Accomplishments - -- Rewrote the broken Matrix dispatcher and reaction tests for the Space-based invite flow and text confirmation UX. -- Added dedicated MAT coverage for invite, chat room creation, outgoing UI, confirmation, pending-confirm storage, and settings dashboard behavior. -- Verified both the Matrix-only suite and the full repository suite, ending at `111 passed`. - -## Task Commits - -Each task was committed atomically: - -1. **Task 1: Fix 4 broken tests in test_dispatcher.py and test_reactions.py** - `6f1bdb4` (fix) -2. **Task 2: Create new test files and implement MAT-01..MAT-12** - `97a3dc3` (test) - -## Files Created/Modified - -- `tests/adapter/matrix/test_dispatcher.py` - updated broken dispatcher expectations and added MAT-11 dashboard coverage. -- `tests/adapter/matrix/test_reactions.py` - aligned text assertions with `!skill on/off` and `!yes/!no`. -- `tests/adapter/matrix/test_store.py` - added pending confirmation roundtrip coverage. -- `tests/adapter/matrix/test_invite_space.py` - added MAT-01..MAT-03 invite-flow regression tests. -- `tests/adapter/matrix/test_chat_space.py` - added MAT-04, MAT-05, MAT-10, and MAT-12 chat handler tests. -- `tests/adapter/matrix/test_send_outgoing.py` - added MAT-06 and MAT-07 outgoing UI rendering tests. -- `tests/adapter/matrix/test_confirm.py` - added MAT-09 confirmation handler tests. - -## Decisions Made - -- Split the new Matrix regression scenarios into focused files so each handler/store contract can be asserted without shared fixture noise. -- Kept the plan scoped to test coverage; no production-code changes were introduced outside the owned Matrix test files. - -## Deviations from Plan - -None - plan executed exactly as written. - -## Issues Encountered - -- The plan examples assume a slightly more integrated pending-confirm flow than the current implementation exposes. The tests were adjusted to validate the existing handler/store contracts directly while keeping the suite green. - -## User Setup Required - -None - no external service configuration required. - -## Next Phase Readiness - -- Phase 1 now has the required green test coverage and exceeds the 96-test target. -- The Matrix adapter is ready for downstream verification and Phase 2 planning against a stable test baseline. - -## Self-Check: PASSED - -- Verified `.planning/phases/01-matrix-qa-polish/01-04-SUMMARY.md` exists on disk. -- Verified task commits `6f1bdb4` and `97a3dc3` exist in git history. diff --git a/.planning/phases/01-matrix-qa-polish/01-05-PLAN.md b/.planning/phases/01-matrix-qa-polish/01-05-PLAN.md deleted file mode 100644 index 1bdf3b4..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-05-PLAN.md +++ /dev/null @@ -1,250 +0,0 @@ ---- -phase: 01-matrix-qa-polish -plan: 05 -type: execute -wave: 1 -depends_on: [] -files_modified: - - adapter/matrix/bot.py - - adapter/matrix/converter.py - - adapter/matrix/handlers/confirm.py - - adapter/matrix/store.py - - tests/adapter/matrix/test_converter.py - - tests/adapter/matrix/test_confirm.py - - tests/adapter/matrix/test_send_outgoing.py -autonomous: true -gap_closure: true -requirements: [] - -must_haves: - truths: - - "A Matrix user can confirm an action in the same room where Lambda requested confirmation, even when the logical chat id differs from the Matrix room id." - - "A Matrix user can cancel an action in the same room where Lambda requested confirmation without affecting another user's pending state." - - "Confirmation state survives the Matrix adapter send/receive round trip using D-08's `(user_id, room_id)` scope." - artifacts: - - path: "adapter/matrix/store.py" - provides: "Pending-confirm helpers keyed by Matrix user id plus room id." - - path: "adapter/matrix/converter.py" - provides: "Command callback payloads that retain Matrix room context." - - path: "adapter/matrix/handlers/confirm.py" - provides: "User-and-room-aware confirm and cancel handlers." - - path: "tests/adapter/matrix/test_send_outgoing.py" - provides: "Adapter-level send_outgoing -> !yes/!no regression coverage." - key_links: - - from: "adapter/matrix/bot.py" - to: "adapter/matrix/handlers/confirm.py" - via: "pending_confirm keyed by Matrix user id plus room id, with room_id carried through IncomingCallback payload" - pattern: "matrix_user_id|room_id" - - from: "tests/adapter/matrix/test_send_outgoing.py" - to: "adapter/matrix/bot.py" - via: "send_outgoing stores pending state before confirm handler resolves it" - pattern: "set_pending_confirm|make_handle_confirm|make_handle_cancel" ---- - - -Close the blocker where Matrix `send_outgoing` and the runtime `!yes` / `!no` path do not agree on the D-08 confirmation scope. - -Purpose: Per D-06/D-08 and the verification blocker, Phase 01 is not complete until the text-confirmation flow works end-to-end in the real adapter path using confirmation state scoped per `(user_id, room_id)`, not only in unit tests seeded with `C1`. -Output: A user-and-room-aware callback contract across `send_outgoing`, command conversion, store helpers, and confirm handlers, plus regression tests that exercise `OutgoingUI` -> `!yes` / `!no`. - - - -@/Users/a/.codex/get-shit-done/workflows/execute-plan.md -@/Users/a/.codex/get-shit-done/templates/summary.md - - - -@.planning/STATE.md -@.planning/ROADMAP.md -@.planning/phases/01-matrix-qa-polish/01-CONTEXT.md -@.planning/phases/01-matrix-qa-polish/01-RESEARCH.md -@.planning/phases/01-matrix-qa-polish/01-VERIFICATION.md -@.planning/phases/01-matrix-qa-polish/01-03-SUMMARY.md -@.planning/phases/01-matrix-qa-polish/01-04-SUMMARY.md -@adapter/matrix/bot.py -@adapter/matrix/converter.py -@adapter/matrix/handlers/confirm.py -@adapter/matrix/store.py -@tests/adapter/matrix/test_confirm.py -@tests/adapter/matrix/test_send_outgoing.py - - -From `adapter/matrix/bot.py`: - -```python -async def send_outgoing( - client: AsyncClient, - room_id: str, - event: OutgoingEvent, - store: StateStore | None = None, -) -> None -``` - -From `adapter/matrix/store.py`: - -```python -async def get_room_meta(store: StateStore, room_id: str) -> dict | None -async def get_pending_confirm(...) -> dict | None -async def set_pending_confirm(...) -> None -async def clear_pending_confirm(...) -> None -``` - -From `adapter/matrix/converter.py`: - -```python -def from_command(body: str, sender: str, chat_id: str) -> IncomingEvent -def from_room_event(event: Any, room_id: str, chat_id: str) -> IncomingEvent | None -``` - -From `core/protocol.py`: - -```python -@dataclass -class IncomingCallback: - user_id: str - platform: str - chat_id: str - action: str - payload: dict[str, Any] = field(default_factory=dict) -``` - - - - - - - Task 1: Preserve Matrix user-and-room identity through the `!yes` / `!no` callback path - adapter/matrix/converter.py, adapter/matrix/handlers/confirm.py, adapter/matrix/bot.py, adapter/matrix/store.py - adapter/matrix/converter.py, adapter/matrix/handlers/confirm.py, adapter/matrix/bot.py, adapter/matrix/store.py, .planning/phases/01-matrix-qa-polish/01-VERIFICATION.md - - - Test 1: `from_room_event(..., room_id=\"!room:example\", chat_id=\"C7\")` for `!yes` or `!no` preserves the core `chat_id` and adds `payload["room_id"] == "!room:example"`. - - Test 2: `send_outgoing` derives the Matrix user dimension from stored room metadata such as `room_meta["matrix_user_id"]` and persists confirmation state under `(user_id, room_id)`. - - Test 3: `make_handle_confirm` and `make_handle_cancel` resolve pending state by `(event.user_id, payload["room_id"])`, so a stored confirmation under `("@alice:example.org", "!room:example")` is found even when `event.chat_id` is `C7`. - - Test 4: If a legacy caller does not provide `payload["room_id"]`, handlers keep the current fallback behavior instead of crashing, while the Matrix adapter path uses the D-08 composite key. - - -Implement a single stable `(user_id, room_id)` key across the runtime flow per D-08. Update the Matrix pending-confirm store helpers to accept both `user_id` and `room_id`. Update `from_command` / `from_room_event` so Matrix command callbacks carry the originating `room_id` in `IncomingCallback.payload`. Update `send_outgoing` to derive the user dimension before persisting confirmation state; use stored room metadata such as `get_room_meta(store, room_id)["matrix_user_id"]` because `send_outgoing` currently receives only `room_id`, not `user_id`. Update `make_handle_confirm` and `make_handle_cancel` to read and clear pending confirmations by `(event.user_id, payload["room_id"])` first, with a compatibility fallback only where needed for non-Matrix or older tests. - -Do not widen this task into protocol changes, new core event types, or reaction support restoration. The only contract change should be the Matrix adapter adding room context into callback payloads and consuming the D-08 composite key consistently. - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && python - <<'PY' -from types import SimpleNamespace - -from adapter.matrix.bot import send_outgoing -from adapter.matrix.converter import from_room_event -from adapter.matrix.handlers.confirm import make_handle_confirm -from adapter.matrix.store import get_pending_confirm, set_room_meta -from core.auth import AuthManager -from core.chat import ChatManager -from core.protocol import IncomingCallback, OutgoingUI, UIButton -from core.settings import SettingsManager -from core.store import InMemoryStore -from sdk.mock import MockPlatformClient - - -async def main(): - callback = from_room_event( - SimpleNamespace( - sender="@alice:example.org", - body="!yes", - event_id="$e1", - msgtype="m.text", - replyto_event_id=None, - ), - room_id="!room:example.org", - chat_id="C7", - ) - assert isinstance(callback, IncomingCallback) - assert callback.chat_id == "C7" - assert callback.payload["room_id"] == "!room:example.org" - - store = InMemoryStore() - await set_room_meta( - store, - "!room:example.org", - {"matrix_user_id": "@alice:example.org", "chat_id": "C7", "space_id": "!space:example.org"}, - ) - platform = MockPlatformClient() - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - async def room_send(*args, **kwargs): - return None - client = SimpleNamespace(room_send=room_send) - await send_outgoing( - client, - "!room:example.org", - OutgoingUI( - chat_id="C7", - text="Archive room", - buttons=[UIButton(label="Confirm", action="archive", payload={})], - ), - store=store, - ) - pending = await get_pending_confirm(store, "@alice:example.org", "!room:example.org") - assert pending is not None - handler = make_handle_confirm(store) - result = await handler(callback, auth_mgr, platform, chat_mgr, settings_mgr) - assert "Archive room" in result[0].text - assert await get_pending_confirm(store, "@alice:example.org", "!room:example.org") is None - - -import asyncio -asyncio.run(main()) -print("OK") -PY - - -- `adapter/matrix/converter.py` passes the Matrix `room_id` into `IncomingCallback.payload` for `!yes` and `!no`. -- `adapter/matrix/store.py` exposes pending-confirm helpers keyed by both `user_id` and `room_id`. -- `adapter/matrix/handlers/confirm.py` uses `(event.user_id, Matrix room_id)` as the primary pending-confirm lookup key. -- `adapter/matrix/bot.py` derives the Matrix user dimension from stored room metadata before persisting pending confirmations. -- No code path reintroduces reaction callbacks or room-only/chat-id-only persistence for Matrix confirmations on the Matrix adapter path. - - Matrix confirmation state is keyed consistently across send, confirm, and cancel runtime flow using the D-08 `(user_id, room_id)` scope. - - - - Task 2: Add end-to-end adapter regression tests for `send_outgoing` -> `!yes` / `!no` - tests/adapter/matrix/test_converter.py, tests/adapter/matrix/test_send_outgoing.py, tests/adapter/matrix/test_confirm.py - tests/adapter/matrix/test_converter.py, tests/adapter/matrix/test_send_outgoing.py, tests/adapter/matrix/test_confirm.py, adapter/matrix/bot.py, adapter/matrix/converter.py, adapter/matrix/handlers/confirm.py - - - Test 1: `test_converter.py` asserts that Matrix `!yes` / `!no` callbacks preserve `chat_id` but also carry `payload["room_id"]`. - - Test 2: Sending an `OutgoingUI` with buttons stores pending confirmation under `(user_id, room_id)`, then a converted `!yes` callback resolves it and clears the store for that user in that room. - - Test 3: The same setup followed by `!no` clears the store and returns the cancellation message for that user in that room. - - Test 4: The regression tests use distinct room ids and core chat ids so they fail if the implementation falls back to brittle `C1` assumptions. - - -Extend the Matrix regression suite with adapter-level tests that exercise the real Phase 01 flow instead of seeding store state directly under `C1`. Add explicit converter assertions in `tests/adapter/matrix/test_converter.py` for `payload["room_id"]`, then use `send_outgoing(...)` to create the pending confirmation, `from_room_event(...)` to convert `!yes` / `!no` from a real Matrix room event, and `make_handle_confirm` / `make_handle_cancel` to resolve the callback. Seed the tests with mismatched values such as `room_id="!confirm:example.org"` and `chat_id="C7"` so the regression proves room-based behavior. The tests must also prove that storage is scoped by `event.user_id` plus `room_id`, not by room alone. - -Keep the tests isolated to adapter modules; do not route through unrelated core handlers or introduce brittle mocks of `StateStore`, `ChatManager`, or `SettingsManager`. - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_send_outgoing.py tests/adapter/matrix/test_confirm.py -q - - -- `tests/adapter/matrix/test_converter.py` contains explicit assertions for `payload["room_id"]` on Matrix `!yes` / `!no`. -- `tests/adapter/matrix/test_send_outgoing.py` contains at least one regression test covering `OutgoingUI` -> `!yes` with pending state stored under `(user_id, room_id)`. -- `tests/adapter/matrix/test_send_outgoing.py` contains at least one regression test covering `OutgoingUI` -> `!no` with pending state stored under `(user_id, room_id)`. -- `tests/adapter/matrix/test_confirm.py` no longer seeds or asserts the primary confirmation path under hardcoded `C1`. -- The new tests fail if `payload["room_id"]` is dropped from Matrix command conversion. - - The Matrix suite contains a true adapter-level confirmation regression that covers both confirm and cancel commands under the D-08 user-and-room scope. - - - - - -Run `pytest tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_send_outgoing.py tests/adapter/matrix/test_confirm.py -q` and confirm the converter and both user-and-room-scoped regression paths pass. - - - -- `send_outgoing` -> `!yes` resolves a stored confirmation for the same Matrix user in the same Matrix room. -- `send_outgoing` -> `!no` clears a stored confirmation for the same Matrix user in the same Matrix room. -- The adapter path no longer drifts away from D-08's `(user_id, room_id)` confirmation scope. - - - -After completion, create `.planning/phases/01-matrix-qa-polish/01-05-SUMMARY.md` - diff --git a/.planning/phases/01-matrix-qa-polish/01-05-SUMMARY.md b/.planning/phases/01-matrix-qa-polish/01-05-SUMMARY.md deleted file mode 100644 index 542f774..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-05-SUMMARY.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -phase: 01-matrix-qa-polish -plan: 05 -subsystem: matrix -tags: [matrix, confirmations, regression-testing, adapter] -requires: - - phase: 01-matrix-qa-polish - provides: Text confirmation flow and Matrix regression baseline from plans 01-03 and 01-04 -provides: - - Stable Matrix pending-confirm storage scoped by user id and room id - - Matrix command callbacks that retain originating room context - - Adapter-level confirm and cancel regressions covering send_outgoing round trips -affects: [matrix-adapter, matrix-tests, phase-01-closeout] -tech-stack: - added: [] - patterns: [Matrix callback payloads carry room context, pending confirmations are keyed by user id plus room id] -key-files: - created: - - .planning/phases/01-matrix-qa-polish/01-05-SUMMARY.md - modified: - - adapter/matrix/bot.py - - adapter/matrix/converter.py - - adapter/matrix/handlers/confirm.py - - adapter/matrix/store.py - - tests/adapter/matrix/test_converter.py - - tests/adapter/matrix/test_confirm.py - - tests/adapter/matrix/test_send_outgoing.py -key-decisions: - - "Matrix command callbacks now include room_id in payload for !yes and !no so confirm handlers can resolve runtime state without changing core protocol types." - - "Pending confirmations are stored under the D-08 composite key of matrix user id plus room id, with a narrow legacy fallback only for callers that omit room context." -patterns-established: - - "Matrix adapter send paths must derive transport-specific identity from room metadata before writing adapter-local state." - - "Adapter regressions should use mismatched Matrix room ids and logical chat ids to catch scope drift." -requirements-completed: [] -duration: 2 min -completed: 2026-04-03 ---- - -# Phase 01 Plan 05: Matrix Confirmation Scope Summary - -**Matrix confirmations now survive the real send_outgoing -> !yes/!no adapter round trip by keeping pending state scoped to the Matrix user and Matrix room.** - -## Performance - -- **Duration:** 2 min -- **Started:** 2026-04-03T09:26:32Z -- **Completed:** 2026-04-03T09:27:55Z -- **Tasks:** 2 -- **Files modified:** 7 - -## Accomplishments - -- Aligned the Matrix adapter runtime so command callbacks keep room context and pending confirmation state uses the D-08 `(user_id, room_id)` scope. -- Added a compatibility fallback in confirm handlers for legacy callers that do not send `payload["room_id"]`. -- Added adapter-level regressions for `OutgoingUI` -> `!yes` and `OutgoingUI` -> `!no` using distinct Matrix room ids and logical chat ids. - -## Task Commits - -Each task was committed atomically: - -1. **Task 1: Preserve Matrix user-and-room identity through the `!yes` / `!no` callback path** - `35695e0` (fix) -2. **Task 2: Add end-to-end adapter regression tests for `send_outgoing` -> `!yes` / `!no`** - `716dec5` (test) - -## Files Created/Modified - -- `adapter/matrix/bot.py` - derives the Matrix user id from room metadata before persisting pending confirmations. -- `adapter/matrix/converter.py` - carries Matrix `room_id` in `IncomingCallback.payload` for `!yes` and `!no`. -- `adapter/matrix/handlers/confirm.py` - resolves pending confirmations by `(event.user_id, payload["room_id"])` with legacy fallback behavior. -- `adapter/matrix/store.py` - supports composite pending-confirm keys while remaining compatible with older single-key callers. -- `tests/adapter/matrix/test_converter.py` - asserts Matrix callbacks preserve logical `chat_id` and include `payload["room_id"]`. -- `tests/adapter/matrix/test_confirm.py` - validates composite-key confirm/cancel behavior and the legacy fallback path. -- `tests/adapter/matrix/test_send_outgoing.py` - exercises `send_outgoing` to confirm/cancel round trips under user-and-room scope. - -## Decisions Made - -- Kept the contract change inside the Matrix adapter by extending callback payloads instead of changing `core.protocol.IncomingCallback`. -- Preserved the old chat-id-only lookup only as a fallback path for older tests or non-room-aware callers. - -## Deviations from Plan - -None - plan executed exactly as written. - -## Issues Encountered - -None. - -## User Setup Required - -None - no external service configuration required. - -## Next Phase Readiness - -- The Phase 01 confirmation blocker from `01-VERIFICATION.md` is closed for the Matrix adapter runtime path. -- Phase 01 still needs the remaining plan work outside `01-05`, but this gap no longer blocks end-to-end `!yes` / `!no` behavior. - -## Self-Check: PASSED - -- Found `.planning/phases/01-matrix-qa-polish/01-05-SUMMARY.md` -- Found commit `35695e0` -- Found commit `716dec5` diff --git a/.planning/phases/01-matrix-qa-polish/01-06-PLAN.md b/.planning/phases/01-matrix-qa-polish/01-06-PLAN.md deleted file mode 100644 index cf161de..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-06-PLAN.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -phase: 01-matrix-qa-polish -plan: 06 -type: execute -wave: 2 -depends_on: ["01-05"] -files_modified: - - adapter/matrix/reactions.py - - adapter/matrix/converter.py - - adapter/matrix/handlers/settings.py - - tests/adapter/matrix/test_converter.py - - tests/adapter/matrix/test_reactions.py - - tests/adapter/matrix/test_dispatcher.py - - tests/adapter/matrix/test_invite_space.py -autonomous: true -gap_closure: true -requirements: [] - -must_haves: - truths: - - "Matrix adapter no longer presents or parses reaction-era UX for confirmations or skill toggles." - - "A Matrix user who opens `!settings` sees a strict read-only snapshot without mutation prompts." - - "Matrix room behavior remains correct when chat ids are allocated dynamically instead of assuming legacy `C1` transport identity." - artifacts: - - path: "adapter/matrix/reactions.py" - provides: "Command-only Matrix helper text with no reaction numbering." - - path: "adapter/matrix/converter.py" - provides: "Matrix command conversion without reaction callback support." - - path: "tests/adapter/matrix/test_dispatcher.py" - provides: "Settings and invite regressions aligned to room-based Matrix behavior." - key_links: - - from: "adapter/matrix/reactions.py" - to: "tests/adapter/matrix/test_reactions.py" - via: "command-only skills/help text" - pattern: "!skill on/off" - - from: "adapter/matrix/handlers/settings.py" - to: "tests/adapter/matrix/test_dispatcher.py" - via: "strict read-only dashboard assertions" - pattern: "Изменить" ---- - - -Remove the remaining reaction-era Matrix UX, make `!settings` strictly read-only, and harden Matrix tests so they stop hiding dynamic or room-based behavior behind legacy `C1` assumptions. - -Purpose: Verification still found user-facing reaction remnants and brittle tests that can pass while the actual adapter contract is wrong. This plan cleans those leftovers without rewriting Phase 01 history. -Output: Command-only Matrix adapter helpers, strict `!settings` snapshot output, and updated Matrix regressions aligned with room ids and dynamic chat allocation. - - - -@/Users/a/.codex/get-shit-done/workflows/execute-plan.md -@/Users/a/.codex/get-shit-done/templates/summary.md - - - -@.planning/STATE.md -@.planning/ROADMAP.md -@.planning/phases/01-matrix-qa-polish/01-CONTEXT.md -@.planning/phases/01-matrix-qa-polish/01-VERIFICATION.md -@.planning/phases/01-matrix-qa-polish/01-03-SUMMARY.md -@.planning/phases/01-matrix-qa-polish/01-04-SUMMARY.md -@.planning/phases/01-matrix-qa-polish/01-05-PLAN.md -@adapter/matrix/reactions.py -@adapter/matrix/converter.py -@adapter/matrix/handlers/settings.py -@tests/adapter/matrix/test_converter.py -@tests/adapter/matrix/test_reactions.py -@tests/adapter/matrix/test_dispatcher.py -@tests/adapter/matrix/test_invite_space.py - - -From `adapter/matrix/reactions.py`: - -```python -def build_skills_text(settings: UserSettings) -> str -def build_confirmation_text(description: str) -> str -``` - -From `adapter/matrix/converter.py`: - -```python -def from_room_event(event: Any, room_id: str, chat_id: str) -> IncomingEvent | None -``` - -From `adapter/matrix/handlers/settings.py`: - -```python -async def handle_settings( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr -) -> list -``` - - - - - - - Task 1: Remove reaction-era Matrix UX and update the immediately affected regressions - adapter/matrix/reactions.py, adapter/matrix/converter.py, adapter/matrix/handlers/settings.py, tests/adapter/matrix/test_reactions.py, tests/adapter/matrix/test_converter.py, tests/adapter/matrix/test_dispatcher.py - adapter/matrix/reactions.py, adapter/matrix/converter.py, adapter/matrix/handlers/settings.py, tests/adapter/matrix/test_reactions.py, tests/adapter/matrix/test_converter.py, tests/adapter/matrix/test_dispatcher.py, .planning/phases/01-matrix-qa-polish/01-CONTEXT.md - - - Test 1: `build_skills_text` renders only command-driven guidance and never mentions `1️⃣..9️⃣`, `👍`, `❌`, or reaction lookup. - - Test 2: `converter.py` no longer treats Matrix reaction events as supported callbacks. - - Test 3: `handle_settings` returns a dashboard snapshot with skills/soul/safety/chats status and does not advertise `Изменить: !skills, !soul, !safety`. - - -Finish the cleanup promised by D-06, D-12, and the verification report, and rewrite the tests that would otherwise block the task from being executable. Remove reaction-only constants and lookup helpers from `adapter/matrix/reactions.py` if they are no longer needed, or reduce the module to text-formatting helpers only. Remove `from_reaction` support from `adapter/matrix/converter.py` and any imports that only exist for reaction handling. Update `handle_settings` so the primary dashboard is a strict read-only snapshot; it may still show current skills, soul, safety, and active chats, but it must not tell the user to mutate settings from that surface. - -In the same task, update `tests/adapter/matrix/test_reactions.py`, `tests/adapter/matrix/test_converter.py`, and the `!settings` assertion in `tests/adapter/matrix/test_dispatcher.py` so the verify command matches the code you just changed. Do not leave those test rewrites for Task 2. - -Do not remove the dedicated mutable subcommands themselves (`!skills`, `!soul`, `!safety`) because D-13 and D-14 explicitly keep them. The restriction applies only to the `!settings` dashboard copy. - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_reactions.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py -q - - -- `adapter/matrix/reactions.py` contains no reaction-number skill labels or reaction lookup helpers in user-facing output. -- `adapter/matrix/converter.py` no longer exports or relies on `from_reaction`. -- `adapter/matrix/handlers/settings.py` no longer renders the mutation prompt in the `!settings` dashboard. -- `tests/adapter/matrix/test_reactions.py`, `tests/adapter/matrix/test_converter.py`, and the dashboard assertion in `tests/adapter/matrix/test_dispatcher.py` are updated in the same task. -- Mutable settings subcommands remain implemented outside the `!settings` snapshot. - - Matrix adapter surfaces are command-only and `!settings` is strictly read-only. - - - - Task 2: Remove the remaining brittle `C1` assumptions from room-based Matrix regressions - tests/adapter/matrix/test_dispatcher.py, tests/adapter/matrix/test_invite_space.py - tests/adapter/matrix/test_dispatcher.py, tests/adapter/matrix/test_invite_space.py, .planning/phases/01-matrix-qa-polish/01-VERIFICATION.md - - - Test 1: Invite tests assert dynamic chat allocation or stored metadata progression instead of assuming the canonical Matrix identifier is always `C1`. - - Test 2: Dispatcher regressions distinguish Matrix room ids from logical core chat ids and avoid using `C1` as a proxy for transport identity. - - Test 3: The full Matrix suite stays green after those room-based assertions are tightened. - - -Update the remaining Matrix regressions so they match the intended room-based adapter behavior. In invite and dispatcher tests, stop using `C1` as a stand-in for Matrix room identity where that hides dynamic behavior; instead assert against stored `room_meta`, `next_chat_index`, chat lists returned by the manager, or explicit non-`C1` setup values. Keep any remaining `C1` use only where the core chat manager contract itself is under test and not acting as a proxy for Matrix room ids. - -Prefer small, explicit fixtures over broad rewrites. The tests should make it obvious which identifier is the Matrix `room_id` and which is the logical core `chat_id`. This task should only clean up the residual room-vs-chat assumptions that remain after Task 1's reaction/settings rewrites. - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix -q - - -- `tests/adapter/matrix/test_dispatcher.py` distinguishes room ids from chat ids in its Matrix-facing assertions. -- `tests/adapter/matrix/test_invite_space.py` validates dynamic chat metadata progression without hardcoding the phase outcome as `C1`. -- `pytest tests/adapter/matrix -q` passes after the updates. - - The Matrix regression suite enforces command-only, room-based behavior and no longer masks defects with legacy assumptions. - - - - - -Run `pytest tests/adapter/matrix -q` and confirm the full Matrix suite is green with no reaction-era behavior covered as supported flow. -Run `pytest tests/ -q` after the wave completes, per `01-VALIDATION.md`, and confirm the full repository suite remains green. - - - -- No Matrix adapter code parses or advertises reaction-era skill/confirmation UX. -- `!settings` is a strict snapshot surface. -- The full repository suite stays green after the Matrix gap-closure wave. - - - -After completion, create `.planning/phases/01-matrix-qa-polish/01-06-SUMMARY.md` - diff --git a/.planning/phases/01-matrix-qa-polish/01-06-SUMMARY.md b/.planning/phases/01-matrix-qa-polish/01-06-SUMMARY.md deleted file mode 100644 index 6ae34c9..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-06-SUMMARY.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -phase: 01-matrix-qa-polish -plan: 06 -subsystem: testing -tags: [matrix, pytest, settings, reactions, room-routing] -requires: - - phase: 01-matrix-qa-polish - provides: 01-05 room-scoped confirmation flow and Matrix callback payload updates -provides: - - Matrix adapter helpers and converter paths no longer advertise or parse reaction-era UX - - Matrix `!settings` renders a strict read-only dashboard snapshot - - Matrix regressions distinguish room ids from logical chat ids and dynamic chat allocation -affects: [adapter/matrix, matrix verification, future Matrix QA] -tech-stack: - added: [] - patterns: [command-only Matrix helper text, explicit room-id-vs-chat-id assertions] -key-files: - created: [] - modified: - - adapter/matrix/reactions.py - - adapter/matrix/converter.py - - adapter/matrix/handlers/settings.py - - tests/adapter/matrix/test_converter.py - - tests/adapter/matrix/test_reactions.py - - tests/adapter/matrix/test_dispatcher.py - - tests/adapter/matrix/test_invite_space.py -key-decisions: - - "Removed Matrix reaction conversion entirely and kept command callbacks limited to !yes/!no." - - "Kept !settings as a pure snapshot surface while preserving mutable subcommands outside the dashboard." - - "Seeded invite and dispatcher tests with explicit next_chat_index and room ids instead of treating C1 as Matrix transport identity." -patterns-established: - - "Matrix adapter tests should assert room_id separately from logical chat_id whenever Matrix rooms are involved." - - "Matrix user-facing helper text should describe only supported command flows, never deprecated reaction UX." -requirements-completed: [] -duration: 4 min -completed: 2026-04-03 ---- - -# Phase 1 Plan 06: Matrix reaction cleanup and room-aware regressions Summary - -**Matrix helper text and conversion are command-only, `!settings` is snapshot-only, and Matrix regressions now enforce room-aware chat allocation instead of legacy `C1` shortcuts.** - -## Performance - -- **Duration:** 4 min -- **Started:** 2026-04-03T09:32:21Z -- **Completed:** 2026-04-03T09:35:39Z -- **Tasks:** 2 -- **Files modified:** 7 - -## Accomplishments -- Removed remaining reaction-era Matrix UX from adapter helper text and conversion paths. -- Tightened the `!settings` dashboard so it reports state without mutation prompts. -- Rewrote Matrix regressions to assert dynamic chat allocation and room-id separation explicitly. - -## Task Commits - -Each task was committed atomically: - -1. **Task 1: Remove reaction-era Matrix UX and update the immediately affected regressions** - `974935c` (test), `3e06a67` (feat) -2. **Task 2: Remove the remaining brittle `C1` assumptions from room-based Matrix regressions** - `9cdb611` (test) - -## Files Created/Modified -- `adapter/matrix/reactions.py` - Reduced the module to command-only text builders. -- `adapter/matrix/converter.py` - Removed exported reaction callback conversion support. -- `adapter/matrix/handlers/settings.py` - Removed mutation prompts from the Matrix settings dashboard. -- `tests/adapter/matrix/test_reactions.py` - Locked helper text expectations to command-only output. -- `tests/adapter/matrix/test_converter.py` - Replaced reaction callback coverage with a regression asserting the converter no longer exports that path. -- `tests/adapter/matrix/test_dispatcher.py` - Separated current chat context from allocated logical chat ids in Matrix-facing assertions. -- `tests/adapter/matrix/test_invite_space.py` - Seeded invite metadata to verify dynamic `next_chat_index` progression. - -## Decisions Made -- Removed `from_reaction` instead of leaving a deprecated no-op path, so supported Matrix interactions are unambiguous. -- Left mutable Matrix settings subcommands outside `!settings`; only the dashboard copy was tightened in this plan. -- Treated the pre-existing missing singular `!skill` command wiring as out of scope for this plan because the acceptance criteria only required preserving `!skills`, `!soul`, and `!safety` subcommands and the reaction/settings cleanup. - -## Deviations from Plan - -None - plan executed exactly as written. - -## Issues Encountered - -- Task 2's red phase did not fail after tightening the assertions because the runtime already honored dynamic chat allocation; the work reduced to test cleanup and suite verification. - -## User Setup Required - -None - no external service configuration required. - -## Next Phase Readiness - -- Matrix Phase 01 gap-closure work is verified against both the Matrix suite and the full repository suite. -- Remaining manual verification is still limited to real Matrix client UX in Element and similar clients. - -## Self-Check: PASSED - -- FOUND: `.planning/phases/01-matrix-qa-polish/01-06-SUMMARY.md` -- FOUND: `974935c` -- FOUND: `3e06a67` -- FOUND: `9cdb611` diff --git a/.planning/phases/01-matrix-qa-polish/01-CONTEXT.md b/.planning/phases/01-matrix-qa-polish/01-CONTEXT.md deleted file mode 100644 index 69fb2d2..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-CONTEXT.md +++ /dev/null @@ -1,123 +0,0 @@ -# Phase 1: Matrix QA & Polish — Context - -**Gathered:** 2026-04-02 -**Status:** Ready for planning - - -## Phase Boundary - -Переработать и довести Matrix адаптер до уровня "приемлемо работает" как Telegram: -- Переход с DM-first на Space+rooms архитектуру -- Убрать реакции как механизм подтверждения — заменить текстовыми командами -- Реализовать все команды управления (`!new`, `!chats`, `!rename`, `!archive`, `!skills`, `!soul`, `!safety`, `!settings`) -- Подтвердить работу ручным тестированием (бот уже запускался) - -Новые возможности (коннекторы, E2EE, Space discovery) — вне scope. - - - - -## Implementation Decisions - -### Архитектура: Space + rooms - -- **D-01:** Space+rooms — единственная поддерживаемая модель. DM-first убрать. -- **D-02:** При первом invite бот создаёт Space `Lambda — {display_name}`, внутри — первую комнату `Чат 1`. Приглашает пользователя. -- **D-03:** `!new [name]` создаёт новую комнату внутри Space пользователя, приглашает его туда. -- **D-04:** `!archive` выводит комнату из Space (не удаляет). -- **D-05:** Маппинг `room_id → space_id` + `room_id → chat_id` хранится в SQLite через `adapter/matrix/store.py`. - -### Подтверждение действий - -- **D-06:** Реакции (`👍`/`❌`) — убрать полностью. `OutgoingUI` с кнопками рендерится как текст + `!yes` / `!no`. -- **D-07:** Когда агент запрашивает подтверждение, бот пишет текстовое сообщение с описанием и подсказкой: `Ответьте !yes для подтверждения или !no для отмены.` -- **D-08:** `!yes` / `!no` работают в текущей комнате. Состояние ожидания подтверждения хранится per (user_id, room_id). - -### Команды - -- **D-09:** Все команды работают из любой комнаты Space — нет выделенной комнаты «Настройки». -- **D-10:** Команды: `!new [name]`, `!chats`, `!rename `, `!archive`, `!skills`, `!soul`, `!safety`, `!settings`, `!yes`, `!no`. -- **D-11:** `!start` — не нужен, онбординг через invite flow. - -### Настройки (Вариант D) - -- **D-12:** `!settings` — read-only дашборд: одно сообщение со статусом всего (скиллы, soul, safety, активные чаты). Ничего не меняет. -- **D-13:** Изменения через субкоманды: - - `!skills` — показать список; `!skill on/off ` — переключить - - `!soul` — показать профиль; `!soul name/style/priority/reset ` — изменить - - `!safety` — показать статус; `!safety on/off ` — переключить -- **D-14:** Каждая команда без аргументов (`!skills`, `!soul`, `!safety`) выводит текущее состояние + подсказку как менять. - -### Claude's Discretion - -- Формат текста в Matrix (plain text vs markdown) — на усмотрение, Matrix клиенты рендерят markdown -- Структура invite-сообщения в новом Space — на усмотрение, главное: приветствие + список команд -- Обработка ошибок matrix-nio (room_create fail, join fail) — логировать + ответить пользователю - - - - -## Canonical References - -**Downstream agents MUST read these before planning or implementing.** - -### Архитектурные документы -- `docs/matrix-prototype.md` — описание Space+rooms структуры, FSM состояний, команд (ВНИМАНИЕ: секция "Реакции как действия" устарела — заменена D-06..D-08) -- `bot-examples/matrix_bot_rooms.py` — reference реализация Space+rooms на matrix-nio (другая архитектура поверх, но паттерны работы с Space/rooms актуальны) - -### Текущая реализация (требует переработки) -- `adapter/matrix/bot.py` — точка входа, `send_outgoing` (реакции убрать), `MatrixBot`, `MatrixRuntime` -- `adapter/matrix/handlers/auth.py` — `handle_invite` (сейчас создаёт DM без Space — переписать) -- `adapter/matrix/handlers/chat.py` — `make_handle_new_chat` (сейчас не добавляет комнату в Space — переписать) -- `adapter/matrix/store.py` — хранилище метаданных комнат (расширить для space_id) -- `adapter/matrix/room_router.py` — маршрутизация room_id → chat_id - -### Протокол -- `core/protocol.py` — `IncomingCommand`, `OutgoingUI`, `OutgoingMessage` — типы не менять -- `adapter/matrix/converter.py` — маппинг nio events → IncomingEvent - - - - -## Existing Code Insights - -### Reusable Assets -- `adapter/matrix/store.py`: `get_room_meta` / `set_room_meta` — переиспользовать, добавить поля `space_id` -- `adapter/matrix/room_router.py`: `resolve_chat_id` — переиспользовать, возможно расширить -- `core/handlers/`: все обработчики команд уже зарегистрированы через `register_all` -- `adapter/matrix/handlers/settings.py`, `confirm.py` — проверить, возможно переиспользовать/обновить - -### Known Bugs (из анализа кода) -- `auth.py:27`: `"chat_id": "C1"` захардкожен — у каждого нового пользователя будет коллизия -- `bot.py:167`: `_button_action_to_reaction` — убрать целиком -- `handlers/chat.py:50`: `room_create` не добавляет комнату в Space (`space_id` не указан) - -### Integration Points -- `AsyncClient.room_create(space=True)` — создание Space через matrix-nio -- `AsyncClient.room_put_state(room_id, "m.space.child", ...)` — добавление комнаты в Space -- Оба метода есть в `bot-examples/matrix_bot_rooms.py` - - - - -## Specific Ideas - -- Подтверждение: бот пишет `Ответьте !yes для подтверждения или !no для отмены.` — явно, без двусмысленности -- `!settings` — один дашборд-блок, не несколько сообщений - - - - -## Deferred Ideas - -- Комната «Настройки» как отдельная закреплённая комната — решили не делать в Phase 1 -- E2EE / python-olm — инфраструктурный трек, вне scope -- Space discovery (бот находит существующий Space при повторном invite) — Phase 2+ -- Attachment handling (m.file, m.image, m.audio) — Phase 2+ - - - ---- - -*Phase: 01-matrix-qa-polish* -*Context gathered: 2026-04-02* diff --git a/.planning/phases/01-matrix-qa-polish/01-DISCUSSION-LOG.md b/.planning/phases/01-matrix-qa-polish/01-DISCUSSION-LOG.md deleted file mode 100644 index ffb35f0..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-DISCUSSION-LOG.md +++ /dev/null @@ -1,54 +0,0 @@ -# Phase 1: Matrix QA & Polish — Discussion Log - -> **Audit trail only.** Do not use as input to planning, research, or execution agents. - -**Date:** 2026-04-02 -**Participants:** User, Claude - ---- - -## Gray Areas Discussed - -### 1. Архитектура: DM-first vs Space+rooms - -**Q:** Текущая реализация — DM-first (invite → одна комната). Prototype docs описывают Space+rooms. Какой вариант финальный? - -**A:** Space+rooms — единственный поддерживаемый режим. DM-first убрать. Реализация через `bot-examples/` как reference. - ---- - -### 2. Реакции как подтверждение - -**Q:** `bot.py` использует `👍`/`❌` реакции для OutgoingUI кнопок. Оставить? - -**A:** Нет. Реакции убрать полностью. Вместо них — текстовые команды `!yes` / `!no`. - ---- - -### 3. Комната «Настройки» vs команды везде - -**Q:** Прототип описывает специальную комнату «Настройки» где работают `!skills`, `!soul`, `!safety`. Нужна? - -**A:** Нет отдельной комнаты. Все команды работают из любой комнаты Space. - ---- - -### 4. Интерфейс настроек - -**Q:** В Telegram — inline keyboards. В Matrix без реакций как отображать настройки? - -**Предложенные варианты:** -- A: Команды без меню (богатый текст + команды изменения) -- B: Нумерованное меню с FSM-состоянием -- C: Субкоманды с аргументами (CLI-стиль) -- D: `!settings` как read-only дашборд + субкоманды для изменений - -**A:** Вариант D — `!settings` как read-only обзор, изменения через субкоманды. - ---- - -### 5. Тестирование - -**Q:** Как тестировать — живой сервер или автотесты? - -**A:** Ручное тестирование на живом сервере (пользователь уже запускал бота). diff --git a/.planning/phases/01-matrix-qa-polish/01-HUMAN-UAT.md b/.planning/phases/01-matrix-qa-polish/01-HUMAN-UAT.md deleted file mode 100644 index 56ce31e..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-HUMAN-UAT.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -status: partial -phase: 01-matrix-qa-polish -source: [01-VERIFICATION.md] -started: 2026-04-03T09:41:18Z -updated: 2026-04-03T09:41:18Z ---- - -## Current Test - -awaiting human testing - -## Tests - -### 1. Matrix client Space UX -expected: First invite creates a visible Space with Chat 1, !new creates a child room under that Space, and !archive / !yes / !no feel correct in a real Matrix client. -result: pending - -## Summary - -total: 1 -passed: 0 -issues: 0 -pending: 1 -skipped: 0 -blocked: 0 - -## Gaps diff --git a/.planning/phases/01-matrix-qa-polish/01-RESEARCH.md b/.planning/phases/01-matrix-qa-polish/01-RESEARCH.md deleted file mode 100644 index 3ac72d2..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-RESEARCH.md +++ /dev/null @@ -1,528 +0,0 @@ -# Phase 1: Matrix QA & Polish — Research - -**Researched:** 2026-04-02 -**Domain:** matrix-nio AsyncClient — Space+rooms architecture, OutgoingUI text rendering, !yes/!no confirmation flow -**Confidence:** HIGH (all critical APIs verified against the installed library) - ---- - - -## User Constraints (from CONTEXT.md) - -### Locked Decisions - -- **D-01:** Space+rooms — единственная поддерживаемая модель. DM-first убрать. -- **D-02:** При первом invite бот создаёт Space `Lambda — {display_name}`, внутри — первую комнату `Чат 1`. Приглашает пользователя. -- **D-03:** `!new [name]` создаёт новую комнату внутри Space пользователя, приглашает его туда. -- **D-04:** `!archive` выводит комнату из Space (не удаляет). -- **D-05:** Маппинг `room_id → space_id` + `room_id → chat_id` хранится в SQLite через `adapter/matrix/store.py`. -- **D-06:** Реакции (`👍`/`❌`) — убрать полностью. `OutgoingUI` с кнопками рендерится как текст + `!yes` / `!no`. -- **D-07:** Когда агент запрашивает подтверждение, бот пишет текстовое сообщение с описанием и подсказкой: `Ответьте !yes для подтверждения или !no для отмены.` -- **D-08:** `!yes` / `!no` работают в текущей комнате. Состояние ожидания подтверждения хранится per (user_id, room_id). -- **D-09:** Все команды работают из любой комнаты Space — нет выделенной комнаты «Настройки». -- **D-10:** Команды: `!new [name]`, `!chats`, `!rename `, `!archive`, `!skills`, `!soul`, `!safety`, `!settings`, `!yes`, `!no`. -- **D-11:** `!start` — не нужен, онбординг через invite flow. -- **D-12:** `!settings` — read-only дашборд: одно сообщение со статусом всего (скиллы, soul, safety, активные чаты). Ничего не меняет. -- **D-13:** Изменения через субкоманды: `!skills`, `!skill on/off `, `!soul`, `!soul name/style/priority/reset `, `!safety`, `!safety on/off `. -- **D-14:** Каждая команда без аргументов (`!skills`, `!soul`, `!safety`) выводит текущее состояние + подсказку как менять. - -### Claude's Discretion - -- Формат текста в Matrix (plain text vs markdown) — на усмотрение, Matrix клиенты рендерят markdown -- Структура invite-сообщения в новом Space — на усмотрение, главное: приветствие + список команд -- Обработка ошибок matrix-nio (room_create fail, join fail) — логировать + ответить пользователю - -### Deferred Ideas (OUT OF SCOPE) - -- Комната «Настройки» как отдельная закреплённая комната — решили не делать в Phase 1 -- E2EE / python-olm — инфраструктурный трек, вне scope -- Space discovery (бот находит существующий Space при повторном invite) — Phase 2+ -- Attachment handling (m.file, m.image, m.audio) — Phase 2+ - - ---- - -## Summary - -Phase 1 переписывает Matrix адаптер с DM-first на Space+rooms модель, убирает реакции в пользу `!yes`/`!no`, и реализует все команды управления. Большая часть бизнес-логики уже работает через `core/handlers/` и `adapter/matrix/handlers/settings.py`. Главная работа — в трёх точках: `handle_invite` (создание Space + двух комнат), `make_handle_new_chat` (добавление комнаты в Space), и `send_outgoing` (убрать реакции, добавить pending-state для `!yes`/`!no`). - -Текущее состояние: 97 тестов зелёные. Для "96+ зелёных" после рефакторинга нужно обновить 3 существующих теста (они проверяют DM-поведение и реакции) и добавить ~12 новых тестов на Space-сценарии. Итого целевой range — 106–110 тестов. - -Критическая деталь: `AsyncClient.room_create` принимает `space=True` (булевый параметр, не `room_type="m.space"`) для создания Space. Добавление дочерней комнаты — через `room_put_state` на Space с event_type `m.space.child` и state_key = child room_id. Это проверено против установленной версии matrix-nio. - -**Primary recommendation:** Реализовать в трёх независимых задачах Codex: (1) invite flow — Space+rooms creation, (2) send_outgoing — убрать реакции, добавить pending-confirm store, (3) обновить тесты под новое поведение. - ---- - -## Standard Stack - -### Core -| Library | Version | Purpose | Why Standard | -|---------|---------|---------|--------------| -| matrix-nio | установлена (проверено: `space=True` параметр присутствует) | Matrix async клиент — room_create, room_put_state, room_invite, join | Единственный maintained async Python Matrix клиент | -| structlog | уже используется | Логирование | Уже в проекте | -| pytest-asyncio | уже используется | Async тесты | Уже в проекте | - -**Версию matrix-nio не нужно менять.** Установленная версия поддерживает `space=True` в `room_create` и `room_put_state` для state events. - ---- - -## Architecture Patterns - -### Паттерн 1: Создание Space + первой комнаты (invite flow) - -**Что:** При первом invite бот делает 5 последовательных API вызовов — создание Space, создание chat-комнаты, линковка child→Space, приглашение пользователя в обе, запись в store. - -**Verified API** (из installed matrix-nio): - -```python -# 1. Создать Space -space_resp = await client.room_create( - name=f"Lambda — {display_name}", - space=True, # <-- булевый флаг, не room_type - visibility="private", - is_direct=False, -) -# space_resp.room_id — строка - -# 2. Создать первую chat-комнату -chat_resp = await client.room_create( - name="Чат 1", - visibility="private", - is_direct=False, -) -# chat_resp.room_id — строка - -# 3. Добавить комнату в Space как child -# state_key = room_id дочерней комнаты -await client.room_put_state( - room_id=space_resp.room_id, - event_type="m.space.child", - content={ - "via": [homeserver_domain], # например "matrix.org" - }, - state_key=chat_resp.room_id, -) - -# 4. Пригласить пользователя в Space и в chat-комнату -await client.room_invite(space_resp.room_id, matrix_user_id) -await client.room_invite(chat_resp.room_id, matrix_user_id) - -# 5. Записать в store -await set_user_meta(store, matrix_user_id, { - "space_id": space_resp.room_id, - "next_chat_index": 2, # C1 уже занят -}) -await set_room_meta(store, chat_resp.room_id, { - "room_type": "chat", - "chat_id": "C1", - "display_name": "Чат 1", - "matrix_user_id": matrix_user_id, - "space_id": space_resp.room_id, -}) -``` - -**Важный gotcha:** Бот сам не вступает в Space (join). Он создаёт Space как владелец, поэтому уже является членом. `join` нужен только для входящей DM-комнаты (invite в существующую комнату). В новом flow: бот создаёт комнаты сам, поэтому `join` для Space и chat-комнаты не нужен. - -### Паттерн 2: Добавление новой комнаты (!new) - -```python -async def handle_new_chat(...): - user_meta = await get_user_meta(store, event.user_id) or {} - space_id = user_meta.get("space_id") - if not space_id: - # Пользователь не прошёл invite flow — не должно случиться, но guard нужен - return [OutgoingMessage(chat_id=event.chat_id, text="Ошибка: Space не найден.")] - - chat_id = await next_chat_id(store, event.user_id) - room_name = " ".join(event.args).strip() or f"Чат {chat_id}" - - resp = await client.room_create(name=room_name, visibility="private", is_direct=False) - room_id = resp.room_id - - homeserver = event.user_id.split(":")[1] # "@user:matrix.org" → "matrix.org" - await client.room_put_state( - room_id=space_id, - event_type="m.space.child", - content={"via": [homeserver]}, - state_key=room_id, - ) - await client.room_invite(room_id, event.user_id) - await set_room_meta(store, room_id, { - "room_type": "chat", - "chat_id": chat_id, - "display_name": room_name, - "matrix_user_id": event.user_id, - "space_id": space_id, - }) -``` - -### Паттерн 3: Archive (!archive) — убрать из Space - -```python -# Убрать child: поставить пустой content (или content без 'via') -# Matrix spec: отправить m.space.child с пустым {} или без 'via' удаляет связь -await client.room_put_state( - room_id=space_id, - event_type="m.space.child", - content={}, # пустой content = удалить child relationship - state_key=room_id, # room_id архивируемой комнаты -) -``` - -Confidence: MEDIUM — Matrix spec говорит что пустой content убирает child, но поведение Element может варьироваться. Альтернатива: оставить room_put_state с `{"via": []}` (пустой массив). - -### Паттерн 4: OutgoingUI → текст + !yes/!no (без реакций) - -**Что убрать:** -- `_button_action_to_reaction` в `bot.py` — удалить целиком -- Блок `for button in event.buttons: reaction = _button_action_to_reaction(...)` — удалить -- `ReactionEvent` callback (`on_reaction` + `client.add_event_callback`) — удалить -- `from_reaction` в converter — оставить (используется для skill-reactions), но skill-reaction инфраструктура тоже под вопросом (D-06 убирает реакции полностью) - -**Что добавить в `send_outgoing` для `OutgoingUI`:** -```python -if isinstance(event, OutgoingUI): - lines = [event.text, ""] - for button in event.buttons: - lines.append(f"• {button.label}") - lines += ["", "Ответьте !yes для подтверждения или !no для отмены."] - body = "\n".join(lines) - await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) - # Сохранить pending state per (user_id, room_id) - await set_pending_confirm(store, user_id=???, room_id=room_id, action_id=???) -``` - -**Проблема:** `send_outgoing` сейчас не знает `user_id` — только `room_id`. Для сохранения pending state нужен либо рефакторинг сигнатуры, либо хранение pending по `room_id` (без user_id — достаточно, т.к. room_id уникален для конкретного пользователя в Space модели). - -### Паттерн 5: Pending confirm state - -```python -# Новые helpers в adapter/matrix/store.py -PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:" - -async def get_pending_confirm(store, room_id: str) -> dict | None: - return await store.get(f"{PENDING_CONFIRM_PREFIX}{room_id}") - -async def set_pending_confirm(store, room_id: str, meta: dict) -> None: - await store.set(f"{PENDING_CONFIRM_PREFIX}{room_id}", meta) - -async def clear_pending_confirm(store, room_id: str) -> None: - await store.delete(f"{PENDING_CONFIRM_PREFIX}{room_id}") -``` - -`!yes`/`!no` уже конвертируются в `IncomingCallback(action="confirm"/"cancel")` в `converter.py`. Нужно обновить `handle_confirm`/`handle_cancel` в `adapter/matrix/handlers/confirm.py` чтобы читать pending state и возвращать осмысленный ответ. - -### Паттерн 6: Hardcoded "C1" bug fix - -```python -# auth.py:27 — СЕЙЧАС (баг): -"chat_id": "C1" - -# ДОЛЖНО БЫТЬ: -chat_id = await next_chat_id(store, matrix_user_id) # возвращает "C1" для первого пользователя -``` - -`next_chat_id` уже существует в `store.py` и правильно инкрементирует per-user. Нужно просто использовать его в `handle_invite` вместо хардкода. - -### Рекомендуемая структура store после рефакторинга - -Текущие ключи в store: -- `matrix_room:{room_id}` → `{room_type, chat_id, display_name, matrix_user_id}` — **добавить `space_id`** -- `matrix_user:{user_id}` → `{next_chat_index, ...}` — **добавить `space_id`** -- `matrix_state:{room_id}` → `{state}` — оставить как есть -- `matrix_skills_msg:{room_id}` → `{event_id}` — оставить (или убрать если реакции полностью уходят) - -Новые ключи: -- `matrix_pending_confirm:{room_id}` → `{action_id, description, expires_at}` — для !yes/!no - ---- - -## Don't Hand-Roll - -| Problem | Don't Build | Use Instead | Why | -|---------|-------------|-------------|-----| -| Space creation | Кастомный HTTP запрос к Matrix API | `AsyncClient.room_create(space=True)` | Встроено в matrix-nio, управляет session state | -| Adding child room to Space | Кастомный state event builder | `AsyncClient.room_put_state(room_id, "m.space.child", ...)` | Правильный Content-Type, auth headers автоматически | -| User invite | Прямой HTTP PUT | `AsyncClient.room_invite(room_id, user_id)` | Обрабатывает ошибки M_FORBIDDEN, already-joined | -| Error detection | Проверка статус-кодов | `isinstance(resp, RoomCreateError)` / `isinstance(resp, RoomPutStateError)` | matrix-nio возвращает типизированные error-объекты | - ---- - -## Common Pitfalls - -### Pitfall 1: `room_create(space=True)` vs `room_type="m.space"` - -**What goes wrong:** Передача `room_type="m.space"` как отдельный параметр — работает, но `space=True` — это удобный shortcut в matrix-nio, который внутри устанавливает тот же `room_type`. Оба варианта корректны, но `space=True` проще читается. - -**Проверено:** `room_create` signature в installed matrix-nio имеет `space: bool = False`. Нет отдельного `is_space` параметра. - -**How to avoid:** Использовать `space=True`, не `room_type="m.space"`. - -### Pitfall 2: `room_id` из RoomCreateResponse — не `getattr` - -**What goes wrong:** Текущий код в `handlers/chat.py:55`: `room_id = getattr(response, "room_id", None)`. Это работает для RoomCreateResponse, но молча возвращает None если пришёл RoomCreateError (у которого нет `room_id`). - -**How to avoid:** -```python -from nio.responses import RoomCreateError -resp = await client.room_create(...) -if isinstance(resp, RoomCreateError): - logger.error("room_create failed", status_code=resp.status_code) - return [OutgoingMessage(..., text="Не удалось создать комнату.")] -room_id = resp.room_id # прямой доступ, не getattr -``` - -### Pitfall 3: `m.space.child` — state_key это room_id дочерней комнаты, не пустая строка - -**What goes wrong:** `room_put_state` по умолчанию `state_key=""`. Для `m.space.child` state_key ДОЛЖЕН быть room_id дочерней комнаты — иначе Space создастся некорректно. - -**How to avoid:** Всегда передавать `state_key=child_room_id` явно. - -### Pitfall 4: Бот должен быть в Space чтобы добавлять children - -**What goes wrong:** Бот создаёт Space (становится владельцем), потом пытается сделать `room_put_state` на Space. Это работает т.к. создатель автоматически имеет power level 100. Но если бот потерял membership (kicked out), `room_put_state` вернёт `M_FORBIDDEN`. - -**How to avoid:** Логировать ошибку и сообщать пользователю. Не ретраить молча. - -### Pitfall 5: Дублирование invite flow (идемпотентность) - -**What goes wrong:** Текущий `handle_invite` проверяет `get_room_meta(store, room.room_id)` чтобы не запускать flow дважды. После рефакторинга на Space+rooms нужно проверять `get_user_meta(store, matrix_user_id)` — потому что invite может прийти повторно в разные комнаты Space, а Space создаётся один раз per user. - -**How to avoid:** Idempotency check переносится на уровень user_meta: `if user_meta.get("space_id"): return`. - -### Pitfall 6: `skills_message` реакции — остаток от старого UX - -**What goes wrong:** `adapter/matrix/reactions.py` и `build_skills_text` до сих пор рендерят "Реакции 1️⃣-9️⃣ переключают навыки." По D-06 реакции убраны полностью. `build_skills_text` нужно обновить чтобы убрать эту строку и заменить инструкцией `!skill on/off `. - -**How to avoid:** Обновить `build_skills_text` + тест `test_reactions.py::test_build_skills_text`. - -### Pitfall 7: `on_reaction` callback остаётся зарегистрированным - -**What goes wrong:** В `main()` есть `client.add_event_callback(bot.on_reaction, ReactionEvent)`. Если убрать реакции но оставить этот callback — matrix-nio будет продолжать обрабатывать реакции и вызывать `on_reaction`. Нужно удалить и callback-регистрацию, и импорт `ReactionEvent`. - ---- - -## Gaps between Current Implementation and Target - -| File | Current State | Target State | Action | -|------|--------------|-------------|--------| -| `adapter/matrix/handlers/auth.py` | DM join + hardcoded C1 | Space creation + C1 from next_chat_id | Переписать `handle_invite` | -| `adapter/matrix/handlers/chat.py` | room_create без Space | room_create + room_put_state в Space | Обновить `make_handle_new_chat` | -| `adapter/matrix/bot.py` | `on_reaction` + `_button_action_to_reaction` | Без реакций, pending-state для !yes/!no | Убрать reaction code; обновить `send_outgoing` | -| `adapter/matrix/store.py` | Нет `space_id`, нет pending_confirm | `space_id` в room_meta + user_meta; `pending_confirm` helpers | Добавить поля и helpers | -| `adapter/matrix/reactions.py` | `build_skills_text` упоминает реакции | `build_skills_text` без реакций, с `!skill on/off` | Обновить текст | -| `adapter/matrix/handlers/confirm.py` | Заглушка без state | Читает pending_confirm, даёт реальный ответ | Обновить handlers | -| `adapter/matrix/handlers/settings.py` | `handle_settings` — список команд | `handle_settings` — read-only дашборд (D-12) | Обновить до дашборда со статусом | -| `adapter/matrix/converter.py` | `from_reaction` используется для skill toggle | Skill toggle через реакции убирается | `from_reaction` можно оставить или удалить | - ---- - -## Code Examples - -### Создание Space + child room (verified API) - -```python -# Source: matrix-nio installed version — inspect.signature(AsyncClient.room_create) -from nio.responses import RoomCreateError, RoomPutStateError - -async def create_user_space(client, display_name: str, matrix_user_id: str, store): - homeserver = matrix_user_id.split(":")[-1] # "@user:matrix.org" → "matrix.org" - - # Step 1: Create Space - space_resp = await client.room_create( - name=f"Lambda — {display_name}", - space=True, - visibility="private", - ) - if isinstance(space_resp, RoomCreateError): - return None, None - space_id = space_resp.room_id - - # Step 2: Create first chat room - chat_resp = await client.room_create( - name="Чат 1", - visibility="private", - is_direct=False, - ) - if isinstance(chat_resp, RoomCreateError): - return space_id, None - chat_room_id = chat_resp.room_id - - # Step 3: Link child room into Space (state_key = child's room_id) - await client.room_put_state( - room_id=space_id, - event_type="m.space.child", - content={"via": [homeserver]}, - state_key=chat_room_id, - ) - - # Step 4: Invite user to Space and to chat room - await client.room_invite(space_id, matrix_user_id) - await client.room_invite(chat_room_id, matrix_user_id) - - return space_id, chat_room_id -``` - -### send_outgoing для OutgoingUI (без реакций) - -```python -if isinstance(event, OutgoingUI): - lines = [event.text] - if event.buttons: - lines.append("") - for btn in event.buttons: - lines.append(f"• {btn.label}") - lines.append("") - lines.append("Ответьте !yes для подтверждения или !no для отмены.") - body = "\n".join(lines) - await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) -``` - -### Проверка ошибок matrix-nio - -```python -from nio.responses import RoomCreateError, RoomPutStateError, RoomInviteError - -resp = await client.room_create(...) -if isinstance(resp, RoomCreateError): - logger.error("room_create failed", status_code=resp.status_code) - # resp не имеет room_id — безопасный ранний возврат - return [OutgoingMessage(chat_id=event.chat_id, text="Не удалось создать комнату.")] -room_id = resp.room_id # str, гарантированно присутствует -``` - ---- - -## Validation Architecture - -nyquist_validation = true в config.json — раздел обязателен. - -### Test Framework -| Property | Value | -|----------|-------| -| Framework | pytest + pytest-asyncio | -| Config file | pytest.ini или pyproject.toml (проверить наличие) | -| Quick run command | `pytest tests/adapter/matrix/ -q` | -| Full suite command | `pytest tests/ -q` | -| Current count | 97 passed | - -### Существующие тесты Matrix, требующие обновления - -Эти тесты написаны под DM/reaction-based поведение и сломаются после рефакторинга: - -| Test | Текущее поведение | После рефакторинга | Действие | -|------|------------------|-------------------|---------| -| `test_dispatcher.py::test_invite_event_creates_dm_room_and_sends_welcome` | Проверяет `chat_id == "C1"` через hardcode, join DM | Должен проверять Space creation + chat room creation | Переписать | -| `test_dispatcher.py::test_new_chat_creates_real_matrix_room_when_client_available` | Проверяет `room_create` без Space | Должен проверять `room_create` + `room_put_state` | Обновить mock + assertions | -| `test_reactions.py::test_build_skills_text` | Ожидает "Реакции 1️⃣-9️⃣" в тексте | После удаления реакций эта строка исчезнет | Обновить assertion | -| `test_reactions.py::test_build_confirmation_text` | Проверяет `CONFIRM_REACTION` + "подтвердить" | Если `build_confirmation_text` обновится под D-07 | Обновить | - -### Новые тесты, необходимые для покрытия Space+rooms - -| ID | Behavior | Test Type | File | Command | -|----|----------|-----------|------|---------| -| MAT-01 | handle_invite создаёт Space + Чат 1, сохраняет space_id в user_meta | unit | `tests/adapter/matrix/test_invite_space.py` | `pytest tests/adapter/matrix/test_invite_space.py -x` | -| MAT-02 | handle_invite идемпотентен: повторный вызов не создаёт второй Space | unit | `tests/adapter/matrix/test_invite_space.py` | `pytest tests/adapter/matrix/test_invite_space.py -x` | -| MAT-03 | handle_invite использует next_chat_id, не хардкод "C1" | unit | `tests/adapter/matrix/test_invite_space.py` | `pytest tests/adapter/matrix/test_invite_space.py -x` | -| MAT-04 | make_handle_new_chat вызывает room_put_state с space_id из user_meta | unit | `tests/adapter/matrix/test_chat_space.py` | `pytest tests/adapter/matrix/test_chat_space.py -x` | -| MAT-05 | make_handle_new_chat без space_id возвращает error message | unit | `tests/adapter/matrix/test_chat_space.py` | `pytest tests/adapter/matrix/test_chat_space.py -x` | -| MAT-06 | send_outgoing для OutgoingUI рендерит текст + "!yes / !no", без реакций | unit | `tests/adapter/matrix/test_send_outgoing.py` | `pytest tests/adapter/matrix/test_send_outgoing.py -x` | -| MAT-07 | send_outgoing для OutgoingUI НЕ отправляет m.reaction event | unit | `tests/adapter/matrix/test_send_outgoing.py` | `pytest tests/adapter/matrix/test_send_outgoing.py -x` | -| MAT-08 | get/set/clear_pending_confirm roundtrip в store | unit | `tests/adapter/matrix/test_store.py` (extend) | `pytest tests/adapter/matrix/test_store.py -x` | -| MAT-09 | handle_confirm читает pending_confirm и возвращает описание действия | unit | `tests/adapter/matrix/test_confirm.py` | `pytest tests/adapter/matrix/test_confirm.py -x` | -| MAT-10 | handle_archive вызывает room_put_state с пустым content | unit | `tests/adapter/matrix/test_chat_space.py` | `pytest tests/adapter/matrix/test_chat_space.py -x` | -| MAT-11 | !settings возвращает дашборд со статусом (не список команд) | unit | `tests/adapter/matrix/test_dispatcher.py` (extend) | `pytest tests/adapter/matrix/test_dispatcher.py -x` | -| MAT-12 | RoomCreateError обрабатывается корректно (нет crash, есть user message) | unit | `tests/adapter/matrix/test_chat_space.py` | `pytest tests/adapter/matrix/test_chat_space.py -x` | - -### Wave 0 Gaps (новые файлы) - -- [ ] `tests/adapter/matrix/test_invite_space.py` — покрывает MAT-01, MAT-02, MAT-03 -- [ ] `tests/adapter/matrix/test_chat_space.py` — покрывает MAT-04, MAT-05, MAT-10, MAT-12 -- [ ] `tests/adapter/matrix/test_send_outgoing.py` — покрывает MAT-06, MAT-07 -- [ ] `tests/adapter/matrix/test_confirm.py` — покрывает MAT-09 - -### Sampling Rate - -- **Per task commit:** `pytest tests/adapter/matrix/ -q` -- **Per wave merge:** `pytest tests/ -q` -- **Phase gate:** All 97+ tests green (целевой диапазон 106–110 после добавления новых) - -### Численный ориентир для "96+ зелёных" - -- Сейчас: 97 тестов, все зелёные -- После рефакторинга без добавления тестов: 4 теста сломаются (3 dispatcher + 1 reactions) → ~93 зелёных -- После обновления сломанных: 97 зелёных -- После добавления 12 новых: ~109 зелёных -- **Итого: требование "96+" выполнено с запасом** - ---- - -## Environment Availability - -| Dependency | Required By | Available | Version | Fallback | -|------------|------------|-----------|---------|----------| -| matrix-nio | All Matrix API calls | ✓ | установлена, space=True присутствует | — | -| pytest + pytest-asyncio | Test suite | ✓ | работает (97 passed) | — | -| SQLite | SQLiteStore | ✓ | встроен в Python | — | -| Matrix homeserver | Manual QA только | не проверялось | — | Без homeserver — только unit тесты | - -**Missing dependencies with no fallback:** Нет (homeserver нужен только для ручного QA, не для автотестов). - ---- - -## Project Constraints (from CLAUDE.md) - -| Directive | Impact on Phase | -|-----------|----------------| -| `core/protocol.py` — типы не менять | `IncomingCommand`, `OutgoingUI`, `UIButton` используем as-is | -| Все вызовы платформы через `platform/interface.py` | MockPlatformClient остаётся, SDK не трогать | -| Хотфиксы < 20 строк → Claude Code напрямую | Небольшие правки реакций-в-текст могут идти напрямую | -| Реализацию делает Codex | Три задачи — три параллельных Codex запуска | -| Blueprint перед реализацией | Плану нужны blueprint-документы для каждой задачи | -| Порядок зависимостей: core/ → platform/ → adapters/ | Все изменения только в adapter/matrix/, core/ не трогаем | - ---- - -## Open Questions - -1. **Стоит ли полностью убирать `from_reaction` и `reactions.py`?** - - D-06 говорит "убрать реакции полностью" - - `reactions.py` содержит `build_confirmation_text` и `build_skills_text` — они нужны после рефакторинга - - Рекомендация: оставить `reactions.py`, удалить `CONFIRM_REACTION`/`CANCEL_REACTION`/`add_reaction`/`remove_reaction`, переименовать в `formatting.py` — но это необязательно для Phase 1. - -2. **Нужен ли `m.space.parent` event в дочерних комнатах?** - - Matrix spec позволяет устанавливать `m.space.parent` в дочерней комнате, чтобы Element показывал ссылку "назад к Space" - - Не является обязательным — `m.space.child` в Space достаточно для включения комнаты в Space - - Рекомендация: не добавлять в Phase 1, отложить если понадобится. - -3. **`via` в `m.space.child` — один сервер или несколько?** - - Для single-homeserver деплоя: `["homeserver_domain"]` достаточно - - Для федерации: нужны несколько серверов - - Рекомендация: парсить из `matrix_user_id.split(":")[-1]` — достаточно для текущего использования. - ---- - -## Sources - -### Primary (HIGH confidence) -- matrix-nio installed package — `AsyncClient.room_create`, `room_put_state`, `room_invite`, `join` — сигнатуры и docstrings проверены через `inspect.signature` и `help()` -- `nio.responses.RoomCreateResponse`, `RoomCreateError`, `RoomPutStateResponse`, `RoomPutStateError` — поля проверены через `inspect.getsource` -- Весь codebase прочитан напрямую - -### Secondary (MEDIUM confidence) -- Matrix Spec v1.x — `m.space.child` event format (content `{"via": [...]}`, state_key = child room_id) — стандартное поведение, описано в Matrix spec - ---- - -## Metadata - -**Confidence breakdown:** -- matrix-nio API: HIGH — проверено против installed package через Python introspection -- Space creation pattern: HIGH — `space=True` параметр подтверждён в room_create signature -- `m.space.child` content format: MEDIUM — стандарт Matrix spec, не проверен против конкретного homeserver -- Archive via empty content: MEDIUM — Matrix spec behaviour, может зависеть от homeserver version -- Тест-план: HIGH — основан на прямом анализе существующих тестов - -**Research date:** 2026-04-02 -**Valid until:** 2026-05-02 (matrix-nio обновляется редко, Space API стабилен с Matrix v1.2) diff --git a/.planning/phases/01-matrix-qa-polish/01-VALIDATION.md b/.planning/phases/01-matrix-qa-polish/01-VALIDATION.md deleted file mode 100644 index 7f94024..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-VALIDATION.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -phase: 1 -slug: matrix-qa-polish -status: draft -nyquist_compliant: false -wave_0_complete: false -created: 2026-04-02 ---- - -# Phase 1 — Validation Strategy - -> Per-phase validation contract for feedback sampling during execution. - ---- - -## Test Infrastructure - -| Property | Value | -|----------|-------| -| **Framework** | pytest + pytest-asyncio | -| **Config file** | `pyproject.toml` | -| **Quick run command** | `pytest tests/adapter/matrix/ -q` | -| **Full suite command** | `pytest tests/ -q` | -| **Estimated runtime** | ~10 seconds | - ---- - -## Sampling Rate - -- **After every task commit:** Run `pytest tests/adapter/matrix/ -q` -- **After every plan wave:** Run `pytest tests/ -q` -- **Before `/gsd:verify-work`:** Full suite must be green (96+ tests) -- **Max feedback latency:** 15 seconds - ---- - -## Per-Task Verification Map - -| Task ID | Plan | Wave | Behavior | Test Type | Automated Command | Status | -|---------|------|------|----------|-----------|-------------------|--------| -| MAT-01 | 01 | 1 | handle_invite creates Space + Чат 1 | unit | `pytest tests/adapter/matrix/test_invite_space.py -x -q` | ⬜ pending | -| MAT-02 | 01 | 1 | handle_invite idempotent | unit | `pytest tests/adapter/matrix/test_invite_space.py -x -q` | ⬜ pending | -| MAT-03 | 01 | 1 | no hardcoded C1 | unit | `pytest tests/adapter/matrix/test_invite_space.py -x -q` | ⬜ pending | -| MAT-04 | 02 | 1 | !new adds room to Space | unit | `pytest tests/adapter/matrix/test_chat_space.py -x -q` | ⬜ pending | -| MAT-05 | 02 | 1 | !new without space_id returns error | unit | `pytest tests/adapter/matrix/test_chat_space.py -x -q` | ⬜ pending | -| MAT-06 | 03 | 1 | OutgoingUI renders text + !yes/!no | unit | `pytest tests/adapter/matrix/test_send_outgoing.py -x -q` | ⬜ pending | -| MAT-07 | 03 | 1 | OutgoingUI does NOT send m.reaction | unit | `pytest tests/adapter/matrix/test_send_outgoing.py -x -q` | ⬜ pending | -| MAT-08 | 03 | 1 | pending_confirm store roundtrip | unit | `pytest tests/adapter/matrix/test_store.py -x -q` | ⬜ pending | -| MAT-09 | 03 | 2 | !yes/!no reads pending_confirm | unit | `pytest tests/adapter/matrix/test_confirm.py -x -q` | ⬜ pending | -| MAT-10 | 02 | 2 | !archive archives chat via chat_mgr.archive (Space removal deferred) | unit | `pytest tests/adapter/matrix/test_chat_space.py -x -q` | ⬜ pending | -| MAT-11 | 04 | 2 | !settings returns dashboard | unit | `pytest tests/adapter/matrix/test_dispatcher.py -x -q` | ⬜ pending | -| MAT-12 | 02 | 1 | RoomCreateError → user message | unit | `pytest tests/adapter/matrix/test_chat_space.py -x -q` | ⬜ pending | - -*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* - ---- - -## Wave 0 Requirements - -- [ ] `tests/adapter/matrix/test_invite_space.py` — stubs for MAT-01..03 -- [ ] `tests/adapter/matrix/test_chat_space.py` — stubs for MAT-04..05, MAT-10, MAT-12 -- [ ] `tests/adapter/matrix/test_send_outgoing.py` — stubs for MAT-06..07 -- [ ] `tests/adapter/matrix/test_confirm.py` — stubs for MAT-09 - -Existing files to update (not create): -- `tests/adapter/matrix/test_store.py` — add MAT-08 -- `tests/adapter/matrix/test_dispatcher.py` — add MAT-11, update broken DM-based tests - ---- - -## Broken Tests (Must Fix) - -These pass today but will break after the Space+rooms refactor: - -| Test | Why it breaks | Fix | -|------|--------------|-----| -| `test_dispatcher.py::test_invite_event_creates_dm_room_and_sends_welcome` | Asserts `chat_id == "C1"` hardcode, DM join | Rewrite for Space creation | -| `test_dispatcher.py::test_new_chat_creates_real_matrix_room_when_client_available` | No `room_put_state` in mock assertions | Update mock + assertions | -| `test_reactions.py::test_build_skills_text` | Expects "Реакции 1️⃣-9️⃣" in text | Update assertion | -| `test_reactions.py::test_build_confirmation_text` | Expects `CONFIRM_REACTION` | Update for !yes/!no | - ---- - -## Manual-Only Verifications - -| Behavior | Why Manual | Test Instructions | -|----------|------------|-------------------| -| First invite creates visible Space in Element | Element client rendering | Invite bot, check Space appears in sidebar | -| !new creates room inside Space (not standalone) | Space membership UI | Run !new, verify room appears under Space | -| !archive removes room from Space sidebar | Element room list | Run !archive, verify room disappears from Space | - ---- - -## Validation Sign-Off - -- [ ] All tasks have `` verify or Wave 0 dependencies -- [ ] Sampling continuity: no 3 consecutive tasks without automated verify -- [ ] Wave 0 covers all MISSING test files -- [ ] No watch-mode flags -- [ ] Feedback latency < 15s -- [ ] `nyquist_compliant: true` set in frontmatter - -**Approval:** pending diff --git a/.planning/phases/01-matrix-qa-polish/01-VERIFICATION.md b/.planning/phases/01-matrix-qa-polish/01-VERIFICATION.md deleted file mode 100644 index af0ffa9..0000000 --- a/.planning/phases/01-matrix-qa-polish/01-VERIFICATION.md +++ /dev/null @@ -1,138 +0,0 @@ ---- -phase: 01-matrix-qa-polish -verified: 2026-04-03T09:39:38Z -status: human_needed -score: 24/24 must-haves verified -re_verification: - previous_status: gaps_found - previous_score: 19/24 - gaps_closed: - - "!yes reads pending_confirm from store and returns action description" - - "build_skills_text no longer mentions reactions 1-9" - - "!settings returns a read-only dashboard with skills/soul/safety/chats status" - - "No Matrix tests rely on hardcoded legacy C1 assumptions from the old DM flow" - gaps_remaining: [] - regressions: [] -human_verification: - - test: "Matrix client Space UX" - expected: "First invite creates a visible Space with Chat 1, !new creates a child room under that Space, and !archive / !yes / !no feel correct in a real Matrix client." - why_human: "Element or another Matrix client must render Space membership, room hierarchy, and invite UX; this cannot be proven from repository-only checks." ---- - -# Phase 1: Matrix QA & Polish Verification Report - -**Phase Goal:** Переработать Matrix адаптер с DM-first на Space+rooms, убрать реакции в пользу !yes/!no, довести до уровня "приемлемо работает" как Telegram. -**Verified:** 2026-04-03T09:39:38Z -**Status:** human_needed -**Re-verification:** Yes — after gap closure - -## Goal Achievement - -### Observable Truths - -| # | Truth | Status | Evidence | -| --- | --- | --- | --- | -| 1 | Bot creates a Space on first invite | ✓ VERIFIED | `handle_invite` creates a private Space with `space=True` in `adapter/matrix/handlers/auth.py:37`. | -| 2 | Bot creates first chat room inside that Space | ✓ VERIFIED | `handle_invite` creates `Чат 1`, links it via `m.space.child`, and stores room metadata in `adapter/matrix/handlers/auth.py:51`. | -| 3 | Bot invites user to both Space and chat room | ✓ VERIFIED | `client.room_invite(space_id, ...)` and `client.room_invite(chat_room_id, ...)` in `adapter/matrix/handlers/auth.py:72`. | -| 4 | `space_id` is stored in `user_meta` | ✓ VERIFIED | `user_meta["space_id"] = space_id` in `adapter/matrix/handlers/auth.py:77`. | -| 5 | Repeated invite is idempotent | ✓ VERIFIED | Existing `user_meta.space_id` short-circuits invite flow in `adapter/matrix/handlers/auth.py:22`; covered by `tests/adapter/matrix/test_invite_space.py:54`. | -| 6 | Initial chat id comes from `next_chat_id` | ✓ VERIFIED | `chat_id = await next_chat_id(...)` in `adapter/matrix/handlers/auth.py:75`; dynamic progression asserted in `tests/adapter/matrix/test_invite_space.py:66`. | -| 7 | `!new` creates a room and links it into the user's Space | ✓ VERIFIED | `make_handle_new_chat` calls `room_create`, `room_put_state`, and `room_invite` in `adapter/matrix/handlers/chat.py`; covered by `tests/adapter/matrix/test_chat_space.py:25`. | -| 8 | `!new` without `space_id` returns a user-facing error | ✓ VERIFIED | Handler returns `"Ошибка: Space не найден..."` in `adapter/matrix/handlers/chat.py:39`; covered by `tests/adapter/matrix/test_chat_space.py:52`. | -| 9 | `!archive` archives chat state without Space-child removal | ✓ VERIFIED | `make_handle_archive` delegates only to `chat_mgr.archive` in `adapter/matrix/handlers/chat.py:119`; covered by `tests/adapter/matrix/test_chat_space.py:76`. | -| 10 | `!rename` updates Matrix room name when client is available | ✓ VERIFIED | `client.room_set_name(ctx.surface_ref, new_name)` in `adapter/matrix/handlers/chat.py:106`. | -| 11 | `RoomCreateError` from `!new` is handled gracefully | ✓ VERIFIED | User-facing `"Не удалось создать комнату."` in `adapter/matrix/handlers/chat.py:66`; covered by `tests/adapter/matrix/test_chat_space.py:97`. | -| 12 | Outgoing UI sends plain text with `!yes / !no`, no reactions | ✓ VERIFIED | `send_outgoing` emits only `m.room.message` and appends the command hint in `adapter/matrix/bot.py:140`; covered by `tests/adapter/matrix/test_send_outgoing.py:18`. | -| 13 | `_button_action_to_reaction` is removed | ✓ VERIFIED | No such symbol exists in `adapter/matrix/bot.py`; reaction path is absent. | -| 14 | `on_reaction` callback is removed | ✓ VERIFIED | `MatrixBot` registers only message and member callbacks in `adapter/matrix/bot.py:200`. | -| 15 | `ReactionEvent` import is removed | ✓ VERIFIED | `adapter/matrix/bot.py` imports no reaction event types. | -| 16 | `build_skills_text` no longer mentions reactions `1-9` | ✓ VERIFIED | `build_skills_text` renders only command help in `adapter/matrix/reactions.py:6`; enforced by `tests/adapter/matrix/test_reactions.py:10`. | -| 17 | `build_confirmation_text` uses `!yes/!no` | ✓ VERIFIED | `build_confirmation_text` returns the command-only prompt in `adapter/matrix/reactions.py:16`. | -| 18 | `!yes` resolves pending confirmation | ✓ VERIFIED | `make_handle_confirm` reads `(event.user_id, payload.room_id)` in `adapter/matrix/handlers/confirm.py:14`; adapter round-trip covered by `tests/adapter/matrix/test_send_outgoing.py:63` and a fresh inline spot-check returned `Подтверждено: Archive room`. | -| 19 | `!no` clears pending confirmation | ✓ VERIFIED | `make_handle_cancel` clears the same scoped key in `adapter/matrix/handlers/confirm.py:41`; covered by `tests/adapter/matrix/test_send_outgoing.py:112` and a fresh inline spot-check returned `Действие отменено.` | -| 20 | `!settings` is a read-only dashboard | ✓ VERIFIED | Dashboard output in `adapter/matrix/handlers/settings.py:48` contains snapshot sections only; `tests/adapter/matrix/test_dispatcher.py:161` and a fresh spot-check confirm `Изменить` is absent. | -| 21 | Previously broken Matrix tests are green | ✓ VERIFIED | `pytest tests/adapter/matrix/ -q` passed with `39 passed in 0.75s`. | -| 22 | MAT-01..MAT-12 tests exist and are green | ✓ VERIFIED | Dedicated invite/chat/send_outgoing/confirm coverage exists in `tests/adapter/matrix/` and passed in the Matrix suite. | -| 23 | Full test suite exceeds 96 passing tests | ✓ VERIFIED | `pytest tests/ -q` passed with `112 passed in 3.48s`. | -| 24 | No Matrix tests rely on hardcoded legacy `C1` assumptions from the old DM flow | ✓ VERIFIED | Room-aware regressions now assert dynamic chat allocation and room-id separation in `tests/adapter/matrix/test_invite_space.py:66`, `tests/adapter/matrix/test_dispatcher.py:54`, and `tests/adapter/matrix/test_send_outgoing.py:63`. Remaining `C1` literals are generic sample chat ids, not DM-flow assumptions. | - -**Score:** 24/24 truths verified - -### Required Artifacts - -| Artifact | Expected | Status | Details | -| --- | --- | --- | --- | -| `adapter/matrix/store.py` | pending-confirm helpers and metadata helpers | ✓ VERIFIED | Composite pending-confirm keys exist and are used by bot and confirm handlers. | -| `adapter/matrix/handlers/auth.py` | Space+rooms invite flow | ✓ VERIFIED | Creates Space, links `Чат 1`, stores metadata, invites the user, and sends welcome text. | -| `adapter/matrix/room_router.py` | room-aware chat resolution without auto-registration | ✓ VERIFIED | Returns stored `chat_id` or explicit `unregistered:{room_id}` fallback. | -| `adapter/matrix/handlers/chat.py` | Space-aware `!new`, `!archive`, `!rename` | ✓ VERIFIED | Wired via handler registration and covered by chat-space tests. | -| `adapter/matrix/bot.py` | reaction-free send path and pending-confirm persistence | ✓ VERIFIED | `OutgoingUI` persists confirmations under `(matrix_user_id, room_id)` before `!yes/!no` resolution. | -| `adapter/matrix/converter.py` | command-only Matrix callback conversion | ✓ VERIFIED | `!yes` and `!no` carry `room_id`; no `from_reaction` export remains. | -| `adapter/matrix/reactions.py` | command-only helper text | ✓ VERIFIED | Skill and confirmation text mention commands, not reactions. | -| `adapter/matrix/handlers/confirm.py` | `!yes/!no` handlers using pending confirmations | ✓ VERIFIED | Runtime and legacy fallback paths both behave correctly. | -| `adapter/matrix/handlers/settings.py` | read-only `!settings` dashboard | ✓ VERIFIED | Snapshot-only dashboard is wired and tested. | -| `tests/adapter/matrix/test_invite_space.py` | invite-flow regression coverage | ✓ VERIFIED | Covers Space creation, idempotency, and non-hardcoded chat allocation. | -| `tests/adapter/matrix/test_chat_space.py` | Space-aware chat command coverage | ✓ VERIFIED | Covers `!new`, missing `space_id`, archive, and `RoomCreateError`. | -| `tests/adapter/matrix/test_send_outgoing.py` | outgoing UI and confirm round-trip coverage | ✓ VERIFIED | Covers send path, no reactions, and scoped confirm/cancel round trips. | -| `tests/adapter/matrix/test_confirm.py` | confirm handler coverage | ✓ VERIFIED | Covers scoped confirmation, cancel, no-pending behavior, and legacy fallback. | - -### Key Link Verification - -| From | To | Via | Status | Details | -| --- | --- | --- | --- | --- | -| `adapter/matrix/handlers/auth.py` | `adapter/matrix/store.py` | `set_user_meta(...space_id...)` | ✓ WIRED | `space_id` is persisted immediately after invite flow. | -| `adapter/matrix/handlers/auth.py` | `adapter/matrix/store.py` | `next_chat_id` | ✓ WIRED | Initial chat ids are allocated dynamically, not hardcoded. | -| `adapter/matrix/handlers/chat.py` | `adapter/matrix/store.py` | `get_user_meta` for `space_id` | ✓ WIRED | `!new` refuses to proceed without stored Space metadata. | -| `adapter/matrix/handlers/chat.py` | Matrix API | `m.space.child` | ✓ WIRED | New rooms are linked into the user Space with `room_put_state`. | -| `adapter/matrix/bot.py` | `adapter/matrix/store.py` | `set_pending_confirm(store, matrix_user_id, room_id, ...)` | ✓ WIRED | Confirm state is stored under runtime Matrix identity. | -| `adapter/matrix/handlers/confirm.py` | `adapter/matrix/store.py` | `get_pending_confirm` / `clear_pending_confirm` | ✓ WIRED | Confirm handlers resolve and clear the same scoped key as the sender path. | -| `adapter/matrix/converter.py` | `adapter/matrix/handlers/confirm.py` | callback payload carries `room_id` | ✓ WIRED | `!yes/!no` callbacks preserve room context across dispatch. | - -### Data-Flow Trace (Level 4) - -| Artifact | Data Variable | Source | Produces Real Data | Status | -| --- | --- | --- | --- | --- | -| `adapter/matrix/handlers/auth.py` | `space_id`, `chat_id` | `client.room_create(...)`, `next_chat_id(...)` | Yes | ✓ FLOWING | -| `adapter/matrix/handlers/chat.py` | `space_id` | `get_user_meta(store, event.user_id)` | Yes | ✓ FLOWING | -| `adapter/matrix/bot.py` + `adapter/matrix/handlers/confirm.py` | pending confirmation | `set_pending_confirm(store, matrix_user_id, room_id, ...)` -> `get_pending_confirm(store, event.user_id, room_id)` | Yes | ✓ FLOWING | -| `adapter/matrix/handlers/settings.py` | dashboard sections | `settings_mgr.get(...)`, `chat_mgr.list_active(...)` | Yes | ✓ FLOWING | - -### Behavioral Spot-Checks - -| Behavior | Command | Result | Status | -| --- | --- | --- | --- | -| Matrix-only tests | `pytest tests/adapter/matrix/ -q` | `39 passed in 0.75s` | ✓ PASS | -| Full test suite | `pytest tests/ -q` | `112 passed in 3.48s` | ✓ PASS | -| Real `send_outgoing` -> `!yes` path | inline Python spot-check | Returned `Подтверждено: Archive room`; pending entry cleared | ✓ PASS | -| Real `send_outgoing` -> `!no` path | inline Python spot-check | Returned `Действие отменено.`; pending entry cleared | ✓ PASS | -| `!settings` output | inline Python spot-check | Snapshot dashboard rendered; `Изменить` absent | ✓ PASS | - -### Requirements Coverage - -| Requirement | Source Plan | Description | Status | Evidence | -| --- | --- | --- | --- | --- | -| none | 01-01..01-06 | No explicit `requirements:` IDs declared in phase plans or roadmap | ✓ N/A | Verification performed against previous must-haves, locked decisions from `01-CONTEXT.md`, and current codebase behavior. | - -### Anti-Patterns Found - -| File | Line | Pattern | Severity | Impact | -| --- | --- | --- | --- | --- | -| none | - | No blocker or warning-level stub patterns detected in the phase artifacts re-checked for gap closure. | ℹ️ Info | Remaining `C1` literals are benign sample values in tests, not evidence of DM-first wiring. | - -### Human Verification Required - -### 1. Matrix Client Space UX - -**Test:** Invite the bot from a real Matrix account, accept the Space and room invites, run `!new`, then exercise a confirmation flow that requires `!yes` and `!no`. -**Expected:** The Space should appear in the client sidebar, new rooms should appear as Space children, and confirmations should resolve cleanly without falling back to `Нет ожидающих подтверждений.` -**Why human:** Repository checks cannot validate Element or other Matrix-client rendering, invite visibility, or perceived UX quality. - -### Gaps Summary - -Automated re-verification closed all four previously reported gaps. Phase 01 now satisfies the code-level must-haves and locked decisions: Space+rooms invite flow is wired, reaction UX is removed, `!yes/!no` works end-to-end on scoped pending state, `!settings` is snapshot-only, and the full test suite is green at 112 tests. The only remaining work is manual client-side verification of Matrix UX. - ---- - -_Verified: 2026-04-03T09:39:38Z_ -_Verifier: Claude (gsd-verifier)_ diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/.gitkeep b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-PLAN.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-PLAN.md deleted file mode 100644 index a9a712b..0000000 --- a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-PLAN.md +++ /dev/null @@ -1,626 +0,0 @@ ---- -phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma -plan: 01 -type: execute -wave: 1 -depends_on: [] -files_modified: - - sdk/agent_api_wrapper.py - - sdk/agent_session.py - - sdk/real.py - - adapter/matrix/bot.py - - tests/platform/test_agent_session.py - - tests/platform/test_real.py - - tests/adapter/matrix/test_dispatcher.py -autonomous: true -requirements: - - Replace AgentSessionClient with AgentApi - - Wire AgentApi lifecycle into MatrixBot - -must_haves: - truths: - - "RealPlatformClient uses AgentApiWrapper (wraps AgentApi), not AgentSessionClient" - - "AgentApiWrapper is connected before sync_forever and closed in finally block of main()" - - "build_thread_key and AgentSessionClient are gone from sdk/" - - "stream_message() yields MessageChunk objects including a final chunk with tokens_used from last_tokens_used" - - "AGENT_WS_URL is used unchanged (no thread_id query param)" - - "MATRIX_PLATFORM_BACKEND=real still works end-to-end without test crash" - - "All existing tests pass after the swap" - artifacts: - - path: "sdk/agent_api_wrapper.py" - provides: "AgentApiWrapper subclass of AgentApi with last_tokens_used tracking" - contains: "AgentApiWrapper" - - path: "sdk/real.py" - provides: "RealPlatformClient wrapping AgentApiWrapper" - contains: "AgentApiWrapper" - - path: "adapter/matrix/bot.py" - provides: "main() awaits agent_api.connect() and agent_api.close()" - contains: "agent_api.connect" - - path: "tests/platform/test_real.py" - provides: "Updated tests using FakeAgentApi instead of FakeAgentSessionClient" - key_links: - - from: "adapter/matrix/bot.py main()" - to: "RealPlatformClient._agent_api" - via: "runtime.platform.agent_api property" - pattern: "agent_api\\.connect" - - from: "sdk/real.py stream_message()" - to: "agent_api.last_tokens_used" - via: "attribute read after async-for loop" - pattern: "last_tokens_used" ---- - - -Replace the custom per-request AgentSessionClient with a thin AgentApiWrapper that -subclasses AgentApi from lambda_agent_api and adds last_tokens_used tracking. Remove -build_thread_key and AgentSessionClient entirely. Wire AgentApiWrapper connect/close -into bot.py main(). Update all tests that referenced the old client. - -Do NOT modify any file under external/. The external/ directory is managed by the -platform team. All customisation goes in sdk/agent_api_wrapper.py. - -Purpose: The existing AgentSessionClient creates a new WebSocket per message and -injects thread_id into the URL — both incompatible with origin/main platform-agent. -AgentApi maintains a single persistent WS connection managed via connect()/close() -and exposes send_message() as an AsyncIterator. We capture tokens_used in a thin -subclass so sdk/real.py can include it in the final MessageChunk without touching -the upstream library. - -Output: sdk/agent_api_wrapper.py (new), sdk/real.py (rewritten), sdk/agent_session.py -(stubbed), adapter/matrix/bot.py updated, tests green. - - - -@$HOME/.claude/get-shit-done/workflows/execute-plan.md -@$HOME/.claude/get-shit-done/templates/summary.md - - - -@.planning/PROJECT.md -@.planning/ROADMAP.md -@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md -@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md - - - - - - -From external/platform-agent_api/lambda_agent_api/agent_api.py (READ ONLY): -```python -class AgentApi: - def __init__(self, agent_id: str, url: str, - callback=None, on_disconnect=None): ... - async def connect(self) -> None: ... # opens WS, awaits MsgStatus, starts _listen task - async def close(self) -> None: ... # cancels _listen, closes WS+session - async def send_message(self, text: str) -> AsyncIterator[AgentEventUnion]: - # yields MsgEventTextChunk only; breaks on MsgEventEnd (does NOT yield it) - # MsgEventEnd.tokens_used is consumed internally at the break point - ... - async def _listen(self) -> None: - # internal task: receives WS frames, puts AgentEventUnion into self._queue - # on MsgEventEnd: puts it in queue then breaks - ... - # AgentEventUnion = Union[MsgEventTextChunk, MsgEventEnd] per server.py -``` - -From external/platform-agent_api/lambda_agent_api/server.py (READ ONLY): -```python -class MsgEventTextChunk(BaseModel): - type: Literal[EServerMessage.AGENT_EVENT_TEXT_CHUNK] - text: str - -class MsgEventEnd(BaseModel): - type: Literal[EServerMessage.AGENT_EVENT_END] - tokens_used: int -``` - -New file to create — sdk/agent_api_wrapper.py: -```python -class AgentApiWrapper(AgentApi): - """Thin subclass of AgentApi that captures tokens_used from MsgEventEnd. - - AgentApi.send_message() yields only MsgEventTextChunk and breaks silently - on MsgEventEnd without storing tokens_used. This wrapper overrides _listen() - to intercept MsgEventEnd and store tokens_used before it is discarded. - """ - last_tokens_used: int = 0 - - async def _listen(self) -> None: - # Override: same as parent, but capture MsgEventEnd.tokens_used - ... -``` - -From sdk/interface.py (unchanged): -```python -class MessageChunk(BaseModel): - message_id: str - delta: str - finished: bool - tokens_used: int = 0 - -class PlatformClient(Protocol): - async def send_message(self, user_id, chat_id, text, attachments=None) -> MessageResponse: ... - async def stream_message(self, user_id, chat_id, text, attachments=None) -> AsyncIterator[MessageChunk]: ... -``` - - - - - - Task 1: Create sdk/agent_api_wrapper.py; rewrite sdk/real.py; stub sdk/agent_session.py - - - - sdk/real.py (full file — being replaced) - - sdk/agent_session.py (full file — being stubbed) - - external/platform-agent_api/lambda_agent_api/agent_api.py (full file — READ ONLY, inspect _listen and send_message to understand override point) - - external/platform-agent_api/lambda_agent_api/server.py (full file — READ ONLY, MsgEventEnd.tokens_used) - - sdk/interface.py (MessageChunk, PlatformClient Protocol) - - - sdk/agent_api_wrapper.py, sdk/real.py, sdk/agent_session.py - - - - Create sdk/agent_api_wrapper.py with class AgentApiWrapper(AgentApi): - - __init__: calls super().__init__(...) and adds self.last_tokens_used: int = 0 - - Override _listen(): copy the parent _listen() logic verbatim, then at the point where MsgEventEnd is received (before or as it is put into the queue), set self.last_tokens_used = chunk.tokens_used - - Do NOT modify agent_api.py in external/ — subclass only - - RealPlatformClient.__init__ accepts agent_api: AgentApiWrapper, prototype_state: PrototypeStateStore, platform: str = "matrix" - - RealPlatformClient exposes agent_api as property self.agent_api so bot.py main() can call connect/close - - stream_message() iterates agent_api.send_message(text) yielding MessageChunk per MsgEventTextChunk chunk; after loop yields final MessageChunk(finished=True, delta="", tokens_used=agent_api.last_tokens_used) - - send_message() collects all chunks from stream_message() and returns MessageResponse - - No thread_key, no build_thread_key references anywhere in sdk/real.py - - sdk/agent_session.py: replace file contents with single comment stub (keep file to avoid import errors until tests are updated in Task 2) - - - -1. Read external/platform-agent_api/lambda_agent_api/agent_api.py fully to find the exact _listen() implementation and the line where MsgEventEnd is handled. - -2. Create sdk/agent_api_wrapper.py: -```python -from __future__ import annotations - -import sys -from pathlib import Path - -# Ensure lambda_agent_api is importable (same sys.path trick as bot.py) -_api_root = Path(__file__).resolve().parents[1] / "external" / "platform-agent_api" -if str(_api_root) not in sys.path: - sys.path.insert(0, str(_api_root)) - -from lambda_agent_api.agent_api import AgentApi -from lambda_agent_api.server import MsgEventEnd - - -class AgentApiWrapper(AgentApi): - """Thin subclass of AgentApi that captures tokens_used from MsgEventEnd. - - AgentApi.send_message() yields MsgEventTextChunk events and breaks on - MsgEventEnd without storing tokens_used. This wrapper overrides _listen() - to intercept MsgEventEnd and set self.last_tokens_used before the event - is discarded, so RealPlatformClient can include it in the final MessageChunk. - - Do NOT modify external/platform-agent_api — subclass only. - """ - - def __init__(self, agent_id: str, url: str, **kwargs) -> None: - super().__init__(agent_id=agent_id, url=url, **kwargs) - self.last_tokens_used: int = 0 - - async def _listen(self) -> None: - # Copy parent _listen() logic. - # Read external/platform-agent_api/lambda_agent_api/agent_api.py _listen() - # and reproduce it here, adding: - # if isinstance(event, MsgEventEnd): - # self.last_tokens_used = event.tokens_used - # at the point where MsgEventEnd is processed. - # - # IMPORTANT: after reading agent_api.py, replace this entire method body - # with the exact parent implementation + the tokens_used capture line. - # Do not call super()._listen() — the parent creates a task; we need the - # override to run in the same task context. - raise NotImplementedError( - "Executor: replace this body with the copied _listen() from AgentApi " - "plus `self.last_tokens_used = event.tokens_used` at the MsgEventEnd branch." - ) -``` - - IMPORTANT NOTE FOR EXECUTOR: The `_listen()` body above is a placeholder. - After reading agent_api.py, copy the actual _listen() implementation from AgentApi - into AgentApiWrapper._listen() and insert `self.last_tokens_used = event.tokens_used` - at the MsgEventEnd branch. The final file must NOT contain the NotImplementedError. - -3. Rewrite sdk/real.py entirely: -```python -from __future__ import annotations - -from typing import TYPE_CHECKING, AsyncIterator - -from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformClient, User, UserSettings -from sdk.prototype_state import PrototypeStateStore - -if TYPE_CHECKING: - from sdk.agent_api_wrapper import AgentApiWrapper - - -class RealPlatformClient(PlatformClient): - def __init__( - self, - agent_api: "AgentApiWrapper", - prototype_state: PrototypeStateStore, - platform: str = "matrix", - ) -> None: - self._agent_api = agent_api - self._prototype_state = prototype_state - self._platform = platform - - @property - def agent_api(self) -> "AgentApiWrapper": - return self._agent_api - - async def get_or_create_user( - self, - external_id: str, - platform: str, - display_name: str | None = None, - ) -> User: - return await self._prototype_state.get_or_create_user( - external_id=external_id, - platform=platform, - display_name=display_name, - ) - - async def send_message( - self, - user_id: str, - chat_id: str, - text: str, - attachments: list[Attachment] | None = None, - ) -> MessageResponse: - parts: list[str] = [] - tokens_used = 0 - async for chunk in self.stream_message(user_id, chat_id, text, attachments): - if chunk.delta: - parts.append(chunk.delta) - if chunk.finished: - tokens_used = chunk.tokens_used - return MessageResponse( - message_id=user_id, - response="".join(parts), - tokens_used=tokens_used, - finished=True, - ) - - async def stream_message( - self, - user_id: str, - chat_id: str, - text: str, - attachments: list[Attachment] | None = None, - ) -> AsyncIterator[MessageChunk]: - from lambda_agent_api.server import MsgEventTextChunk - async for event in self._agent_api.send_message(text): - if isinstance(event, MsgEventTextChunk): - yield MessageChunk( - message_id=user_id, - delta=event.text, - finished=False, - ) - yield MessageChunk( - message_id=user_id, - delta="", - finished=True, - tokens_used=self._agent_api.last_tokens_used, - ) - - async def get_settings(self, user_id: str) -> UserSettings: - return await self._prototype_state.get_settings(user_id) - - async def update_settings(self, user_id: str, action) -> None: - await self._prototype_state.update_settings(user_id, action) -``` - -4. Replace sdk/agent_session.py content with: -```python -# Deleted in Phase 4 — replaced by AgentApiWrapper from sdk/agent_api_wrapper.py -# File kept as stub to avoid import errors during migration; remove after test_agent_session.py is updated. -``` - - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from sdk.real import RealPlatformClient; print('import ok')" - - - - - sdk/agent_api_wrapper.py exists with AgentApiWrapper(AgentApi), __init__ sets self.last_tokens_used = 0, _listen() override captures MsgEventEnd.tokens_used - - sdk/real.py imports AgentApiWrapper (not AgentSessionClient or AgentApi directly), exposes self.agent_api property - - sdk/real.py stream_message yields final chunk with tokens_used from agent_api.last_tokens_used - - external/ directory has NO modifications - - sdk/agent_session.py contains only a comment stub (no class definitions) - - `python -c "from sdk.real import RealPlatformClient"` exits 0 - - `grep "AgentApiWrapper" sdk/real.py` returns a match - - `grep "last_tokens_used" sdk/agent_api_wrapper.py` returns a match - - - - - Task 2: Wire AgentApiWrapper lifecycle into bot.py main(); update all broken tests - - - - adapter/matrix/bot.py (full file — _build_platform_from_env and main() need changes) - - tests/platform/test_agent_session.py (full file — delete or rewrite) - - tests/platform/test_real.py (full file — FakeAgentSessionClient → FakeAgentApi) - - tests/adapter/matrix/test_dispatcher.py (test_build_runtime_uses_real_platform — needs update) - - - adapter/matrix/bot.py, tests/platform/test_agent_session.py, tests/platform/test_real.py, tests/adapter/matrix/test_dispatcher.py - - - - _build_platform_from_env() returns a RealPlatformClient with an unconnected AgentApiWrapper (connect() NOT called here — called in main()) - - main() calls await runtime.platform.agent_api.connect() after build_runtime() (only when backend is "real"; mock has no agent_api); wrap in `if hasattr(runtime.platform, "agent_api")` guard - - main() finally block: await agent_api.close() before await client.close() - - AGENT_WS_URL env var is passed unchanged to AgentApiWrapper(url=ws_url) — no query param manipulation - - test_agent_session.py: completely rewritten — remove all build_thread_key tests, remove AgentSessionClient tests; replace with 2 tests: (1) import check for lambda_agent_api module, (2) stub test that documents the deletion - - test_real.py: FakeAgentSessionClient replaced with FakeAgentApi that has send_message(text: str) -> AsyncIterator and last_tokens_used: int = 0; tests updated to construct RealPlatformClient(agent_api=FakeAgentApi(), prototype_state=PrototypeStateStore()); test_send_message no longer checks thread_key in message_id (now uses user_id); test_stream_message checks final chunk tokens_used comes from FakeAgentApi.last_tokens_used - - test_dispatcher.py: test_build_runtime_uses_real_platform_when_matrix_backend_is_real must NOT call agent_api.connect() (build_runtime only constructs, does not connect); update test to mock AgentApiWrapper so it does not attempt a real WS connection; assert isinstance(runtime.platform, RealPlatformClient) still passes - - - -1. Edit adapter/matrix/bot.py: - - a. Remove imports: `from sdk.agent_session import AgentSessionClient, AgentSessionConfig` - - b. In _build_platform_from_env(), use AgentApiWrapper with lazy import: - ```python - def _build_platform_from_env() -> PlatformClient: - backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower() - if backend == "real": - import sys - _api_root = Path(__file__).resolve().parents[2] / "external" / "platform-agent_api" - if str(_api_root) not in sys.path: - sys.path.insert(0, str(_api_root)) - from sdk.agent_api_wrapper import AgentApiWrapper - ws_url = os.environ["AGENT_WS_URL"] - agent_api = AgentApiWrapper(agent_id="matrix-bot", url=ws_url) - return RealPlatformClient( - agent_api=agent_api, - prototype_state=PrototypeStateStore(), - platform="matrix", - ) - return MockPlatformClient() - ``` - - c. In main(), after `runtime = build_runtime(store=SQLiteStore(db_path), client=client)`, add: - ```python - if hasattr(runtime.platform, "agent_api"): - await runtime.platform.agent_api.connect() - ``` - - d. In main() finally block, add before `await client.close()`: - ```python - if hasattr(runtime.platform, "agent_api"): - await runtime.platform.agent_api.close() - ``` - -2. Rewrite tests/platform/test_agent_session.py: -```python -""" -test_agent_session.py — stub after Phase 4 migration. - -AgentSessionClient and build_thread_key were removed in Phase 4. -The platform client is now AgentApiWrapper wrapping AgentApi from lambda_agent_api. -See tests/platform/test_real.py for RealPlatformClient tests. -""" -import sys -from pathlib import Path - -_api_root = Path(__file__).resolve().parents[2] / "external" / "platform-agent_api" -if str(_api_root) not in sys.path: - sys.path.insert(0, str(_api_root)) - - -def test_lambda_agent_api_module_importable(): - from lambda_agent_api.agent_api import AgentApi # noqa: F401 - from lambda_agent_api.server import MsgEventTextChunk, MsgEventEnd # noqa: F401 - assert True - - -def test_agent_session_module_is_stub(): - """Ensure old module no longer exposes AgentSessionClient or build_thread_key.""" - import sdk.agent_session as mod - assert not hasattr(mod, "AgentSessionClient"), "AgentSessionClient should be removed" - assert not hasattr(mod, "build_thread_key"), "build_thread_key should be removed" -``` - -3. Rewrite tests/platform/test_real.py: -```python -from __future__ import annotations - -import sys -from pathlib import Path -from typing import AsyncIterator - -import pytest - -from core.protocol import SettingsAction -from sdk.interface import MessageChunk, MessageResponse, UserSettings -from sdk.prototype_state import PrototypeStateStore -from sdk.real import RealPlatformClient - -_api_root = Path(__file__).resolve().parents[2] / "external" / "platform-agent_api" -if str(_api_root) not in sys.path: - sys.path.insert(0, str(_api_root)) - -from lambda_agent_api.server import MsgEventTextChunk, EServerMessage # noqa: E402 - - -class FakeAgentApi: - """Minimal fake for AgentApiWrapper — no real WebSocket.""" - def __init__(self) -> None: - self.last_tokens_used: int = 0 - self.send_calls: list[str] = [] - - async def send_message(self, text: str) -> AsyncIterator[MsgEventTextChunk]: - self.send_calls.append(text) - self.last_tokens_used = 7 - yield MsgEventTextChunk(type=EServerMessage.AGENT_EVENT_TEXT_CHUNK, text=text[:2]) - yield MsgEventTextChunk(type=EServerMessage.AGENT_EVENT_TEXT_CHUNK, text=text[2:]) - # send_message() in real AgentApi breaks on MsgEventEnd without yielding it; - # FakeAgentApi mirrors this by not yielding MsgEventEnd — last_tokens_used is set directly. - - -@pytest.mark.asyncio -async def test_real_platform_client_get_or_create_user_uses_local_state(): - client = RealPlatformClient( - agent_api=FakeAgentApi(), - prototype_state=PrototypeStateStore(), - ) - first = await client.get_or_create_user("u1", "matrix", "Alice") - second = await client.get_or_create_user("u1", "matrix") - - assert first.user_id == "usr-matrix-u1" - assert first.is_new is True - assert second.user_id == first.user_id - assert second.is_new is False - assert second.display_name == "Alice" - - -@pytest.mark.asyncio -async def test_real_platform_client_send_message_calls_agent_with_text(): - fake = FakeAgentApi() - client = RealPlatformClient(agent_api=fake, prototype_state=PrototypeStateStore()) - - result = await client.send_message("@alice:example.org", "C1", "hello") - - assert result.response == "hello" - assert result.tokens_used == 7 - assert fake.send_calls == ["hello"] - - -@pytest.mark.asyncio -async def test_real_platform_client_stream_message_yields_chunks_and_final_with_tokens(): - fake = FakeAgentApi() - client = RealPlatformClient(agent_api=fake, prototype_state=PrototypeStateStore()) - - chunks = [] - async for chunk in client.stream_message("@alice:example.org", "C1", "hello"): - chunks.append(chunk) - - assert chunks[-1].finished is True - assert chunks[-1].tokens_used == 7 - assert "".join(c.delta for c in chunks) == "hello" - - -@pytest.mark.asyncio -async def test_real_platform_client_settings_are_local(): - client = RealPlatformClient( - agent_api=FakeAgentApi(), - prototype_state=PrototypeStateStore(), - ) - await client.update_settings( - "usr-matrix-u1", - SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}), - ) - settings = await client.get_settings("usr-matrix-u1") - assert isinstance(settings, UserSettings) - assert settings.skills["browser"] is True -``` - -4. Edit tests/adapter/matrix/test_dispatcher.py — update `test_build_runtime_uses_real_platform_when_matrix_backend_is_real`: - - Add sys.path setup for lambda_agent_api (same pattern as above) - - Mock AgentApiWrapper so it does not open a real WS: - ```python - async def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monkeypatch): - import sys - from pathlib import Path - _api_root = Path(__file__).resolve().parents[3] / "external" / "platform-agent_api" - if str(_api_root) not in sys.path: - sys.path.insert(0, str(_api_root)) - - monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real") - monkeypatch.setenv("AGENT_WS_URL", "ws://agent.example/agent_ws/") - - # Patch AgentApiWrapper to avoid real WS connection during build_runtime - import sdk.agent_api_wrapper as _mod - class _FakeAgentApiWrapper: - def __init__(self, agent_id, url, **kw): - self.last_tokens_used = 0 - async def connect(self): pass - async def close(self): pass - async def send_message(self, text): - return; yield # empty async generator - monkeypatch.setattr(_mod, "AgentApiWrapper", _FakeAgentApiWrapper) - - from adapter.matrix.bot import build_runtime - from sdk.real import RealPlatformClient - runtime = build_runtime() - assert isinstance(runtime.platform, RealPlatformClient) - ``` - - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/platform/test_agent_session.py tests/platform/test_real.py tests/adapter/matrix/test_dispatcher.py -v 2>&1 | tail -20 - - - - - All tests in test_agent_session.py, test_real.py, test_dispatcher.py pass - - main() in bot.py has agent_api.connect() call guarded by hasattr check - - main() finally block closes agent_api before matrix client - - grep confirms no "AgentSessionClient" or "build_thread_key" remain in sdk/real.py or adapter/matrix/bot.py - - grep confirms no modifications to any file under external/ - - - - - - -## Trust Boundaries - -| Boundary | Description | -|----------|-------------| -| bot → platform-agent WS | Outbound WS to agent service; input is user text | -| env vars → bot config | AGENT_WS_URL, MATRIX_PLATFORM_BACKEND read from environment | - -## STRIDE Threat Register - -| Threat ID | Category | Component | Disposition | Mitigation Plan | -|-----------|----------|-----------|-------------|-----------------| -| T-04-01-01 | Tampering | AgentApiWrapper.send_message() text | accept | Single-user prototype; text originates from authenticated Matrix user | -| T-04-01-02 | Denial of Service | AgentBusyException from concurrent sends | mitigate | AgentApi._request_lock already prevents concurrent sends; bot must surface error to user instead of crashing | -| T-04-01-03 | Information Disclosure | AGENT_WS_URL in env | accept | Internal service URL; not exposed to users | - - - -Run full test suite after both tasks complete: - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/ -v 2>&1 | tail -30 -``` - -Grep checks: -```bash -# No old imports should remain -grep -r "AgentSessionClient\|build_thread_key" sdk/ adapter/ tests/ --include="*.py" | grep -v "stub\|Deleted\|removed" - -# AgentApiWrapper wired in bot.py -grep "agent_api.connect\|agent_api.close" adapter/matrix/bot.py - -# last_tokens_used set in wrapper -grep "last_tokens_used" sdk/agent_api_wrapper.py - -# No external/ files modified -git diff --name-only external/ -``` - - - -- `pytest tests/platform/ tests/adapter/matrix/test_dispatcher.py -v` exits 0 with no failures -- `grep -r "AgentSessionClient" sdk/ adapter/` returns empty (or only the stub comment) -- `grep -r "build_thread_key" sdk/ adapter/` returns empty -- `grep "agent_api.connect" adapter/matrix/bot.py` returns a match -- `grep "last_tokens_used" sdk/agent_api_wrapper.py` returns the assignment line -- `git diff --name-only external/` returns empty (external/ untouched) - - - -After completion, create `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-SUMMARY.md` - diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-SUMMARY.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-SUMMARY.md deleted file mode 100644 index dcd6114..0000000 --- a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-SUMMARY.md +++ /dev/null @@ -1,29 +0,0 @@ -# 04-01 Summary - -## Outcome - -Replaced the Matrix real backend's custom `AgentSessionClient` path with a shared -`AgentApiWrapper` over upstream `lambda_agent_api.AgentApi`. - -## Changes - -- Added `sdk/agent_api_wrapper.py` to capture `MsgEventEnd.tokens_used` without - modifying `external/`. -- Rewrote `sdk/real.py` to use a shared `agent_api`, stream text chunks from - `AgentApi.send_message()`, and emit a final `MessageChunk` with - `last_tokens_used`. -- Updated `adapter/matrix/bot.py` to construct `RealPlatformClient` with - `AgentApiWrapper`, keep `AGENT_WS_URL` unchanged, and manage - `agent_api.connect()` / `agent_api.close()` around `sync_forever()`. -- Stubbed `sdk/agent_session.py` as a compatibility placeholder. -- Updated Matrix/runtime tests away from `thread_key` and per-request websocket - assumptions. - -## Verification - -- `pytest tests/platform/test_real.py -q` -- `pytest tests/adapter/matrix/test_dispatcher.py -q` -- `pytest tests/core/test_integration.py -q` -- `pytest tests/platform/test_agent_session.py -q` - -All listed commands passed locally. diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-PLAN.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-PLAN.md deleted file mode 100644 index 1b16918..0000000 --- a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-PLAN.md +++ /dev/null @@ -1,865 +0,0 @@ ---- -phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma -plan: 02 -type: execute -wave: 2 -depends_on: - - 04-01-PLAN.md -files_modified: - - sdk/prototype_state.py - - adapter/matrix/store.py - - adapter/matrix/handlers/__init__.py - - adapter/matrix/handlers/context_commands.py - - adapter/matrix/bot.py - - tests/adapter/matrix/test_context_commands.py - - tests/platform/test_prototype_state.py -autonomous: true -requirements: - - Implement !save, !load, !reset, !context commands - - PrototypeStateStore saved sessions storage - - !load pending state in Matrix store - - !reset pending state in Matrix store - - Numeric input interception for !load - -must_haves: - truths: - - "!save sends a save prompt to the agent and records session name in PrototypeStateStore" - - "!load shows a numbered list of saved sessions; numeric reply selects a session" - - "!reset shows a confirmation dialog; !yes calls POST /reset; !no cancels" - - "!context returns current session name, last tokens_used, and list of saved sessions" - - "Numeric input intercepted in on_room_message before dispatcher.dispatch when load_pending is set" - - "!yes in reset_pending context calls POST {AGENT_BASE_URL}/reset and reports unavailable on 404" - - "All context command tests pass" - artifacts: - - path: "adapter/matrix/handlers/context_commands.py" - provides: "make_handle_save, make_handle_load, make_handle_reset, make_handle_context" - - path: "adapter/matrix/store.py" - provides: "get_load_pending, set_load_pending, clear_load_pending, get_reset_pending, set_reset_pending, clear_reset_pending" - - path: "sdk/prototype_state.py" - provides: "add_saved_session, list_saved_sessions, get_last_tokens_used, set_last_tokens_used" - - path: "tests/adapter/matrix/test_context_commands.py" - provides: "tests for all four commands" - key_links: - - from: "adapter/matrix/bot.py on_room_message()" - to: "adapter/matrix/store.get_load_pending()" - via: "check before dispatcher.dispatch" - pattern: "get_load_pending" - - from: "adapter/matrix/handlers/context_commands.py make_handle_reset" - to: "httpx.AsyncClient.post(AGENT_BASE_URL + '/reset')" - via: "!yes handler inside reset_pending flow" - pattern: "httpx" - - from: "sdk/real.py stream_message()" - to: "prototype_state.set_last_tokens_used()" - via: "call after final chunk" - pattern: "set_last_tokens_used" ---- - - -Add four context management commands to the Matrix bot: !save, !load, !reset, !context. -Extend PrototypeStateStore with saved sessions and last_tokens_used tracking. Add -load_pending and reset_pending state keys to Matrix store. Wire numeric input -interception in on_room_message. Register all handlers. - -Purpose: Users need to save, load, and reset agent context, and inspect current context -state — essential for a shared-context MVP where one agent container persists across -Matrix sessions. - -Output: context_commands.py handler module, store.py extensions, prototype_state.py -extensions, bot.py updated, full test coverage. - - - -@$HOME/.claude/get-shit-done/workflows/execute-plan.md -@$HOME/.claude/get-shit-done/templates/summary.md - - - -@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md -@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md -@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-01-SUMMARY.md - - - - - -From adapter/matrix/store.py (existing pattern): -```python -PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:" - -def _pending_confirm_key(user_id: str, room_id: str | None = None) -> str: ... -async def get_pending_confirm(store, user_id, room_id=None) -> dict | None: ... -async def set_pending_confirm(store, user_id, room_id, meta) -> None: ... -async def clear_pending_confirm(store, user_id, room_id=None) -> None: ... -``` - -New store keys to add (same pattern): -```python -LOAD_PENDING_PREFIX = "matrix_load_pending:" -RESET_PENDING_PREFIX = "matrix_reset_pending:" - -# Keys: f"{PREFIX}{user_id}:{room_id}" -# load_pending data: {"saves": [{"name": str, "created_at": str}, ...], "display": str} -# reset_pending data: {"active": True} -``` - -From adapter/matrix/handlers/__init__.py (existing registration): -```python -def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=None) -> None: - dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store)) - ... -``` - -Handler closure signature (all existing handlers follow this): -```python -async def handle_X(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list[OutgoingEvent]: -``` - -New handlers use make_handle_X(agent_api, store, prototype_state) closures: -```python -async def _inner(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list[OutgoingEvent]: - ... -return _inner -``` - -From sdk/prototype_state.py (PrototypeStateStore to extend): -```python -class PrototypeStateStore: - def __init__(self) -> None: - self._users: dict[str, User] = {} - self._settings: dict[str, dict[str, Any]] = {} - # Add: - # self._saved_sessions: dict[str, list[dict]] = {} - # self._last_tokens_used: dict[str, int] = {} -``` - -From core/protocol.py: -```python -@dataclass -class IncomingCommand: - user_id: str; platform: str; chat_id: str; command: str; args: list[str] - -@dataclass -class OutgoingMessage: - chat_id: str; text: str - -@dataclass -class OutgoingUI: - chat_id: str; text: str; buttons: list[UIButton] -``` - -From sdk/real.py (after Plan 01): -```python -class RealPlatformClient: - async def stream_message(self, user_id, chat_id, text, ...) -> AsyncIterator[MessageChunk]: - # yields chunks; last chunk has finished=True, tokens_used=agent_api.last_tokens_used -``` - -SAVE_PROMPT template (Claude's Discretion): -```python -SAVE_PROMPT = ( - "Summarize our conversation and save to /workspace/contexts/{name}.md. " - "Reply only with: Saved: {name}" -) - -LOAD_PROMPT = ( - "Load context from /workspace/contexts/{name}.md and use it as background " - "for our conversation. Reply: Loaded: {name}" -) -``` - -Auto-name format for !save without args: `context-{YYYYMMDD-HHMMSS}` UTC. -HTTP client for POST /reset: httpx.AsyncClient (already in pyproject.toml deps). -AGENT_BASE_URL env var: `os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000")` - - - - - - Task 1: Extend PrototypeStateStore and Matrix store with pending state helpers - - - - sdk/prototype_state.py (full file — adding saved_sessions and last_tokens_used) - - adapter/matrix/store.py (full file — adding load_pending and reset_pending helpers) - - tests/platform/test_prototype_state.py (full file — adding new test cases) - - - sdk/prototype_state.py, adapter/matrix/store.py, tests/platform/test_prototype_state.py - - - - PrototypeStateStore.__init__ adds: self._saved_sessions: dict[str, list[dict]] = {} and self._last_tokens_used: dict[str, int] = {} - - add_saved_session(user_id: str, name: str) -> None: appends {"name": name, "created_at": datetime.now(UTC).isoformat()} to _saved_sessions[user_id] - - list_saved_sessions(user_id: str) -> list[dict]: returns copy of _saved_sessions.get(user_id, []) - - get_last_tokens_used(user_id: str) -> int: returns _last_tokens_used.get(user_id, 0) - - set_last_tokens_used(user_id: str, tokens: int) -> None: sets _last_tokens_used[user_id] = tokens - - adapter/matrix/store.py adds LOAD_PENDING_PREFIX and RESET_PENDING_PREFIX constants - - get_load_pending(store, user_id, room_id) -> dict | None - - set_load_pending(store, user_id, room_id, data: dict) -> None - - clear_load_pending(store, user_id, room_id) -> None - - get_reset_pending(store, user_id, room_id) -> dict | None - - set_reset_pending(store, user_id, room_id, data: dict) -> None - - clear_reset_pending(store, user_id, room_id) -> None - - test_prototype_state.py gets 4 new tests: add/list saved sessions, last_tokens_used get/set - - - -1. Edit sdk/prototype_state.py — add to __init__ and add four new async methods: - -In __init__ after existing attributes: -```python - self._saved_sessions: dict[str, list[dict]] = {} - self._last_tokens_used: dict[str, int] = {} -``` - -After update_settings() method, add: -```python - async def add_saved_session(self, user_id: str, name: str) -> None: - sessions = self._saved_sessions.setdefault(user_id, []) - sessions.append({"name": name, "created_at": datetime.now(UTC).isoformat()}) - - async def list_saved_sessions(self, user_id: str) -> list[dict]: - return list(self._saved_sessions.get(user_id, [])) - - async def get_last_tokens_used(self, user_id: str) -> int: - return self._last_tokens_used.get(user_id, 0) - - async def set_last_tokens_used(self, user_id: str, tokens: int) -> None: - self._last_tokens_used[user_id] = tokens -``` - -2. Edit adapter/matrix/store.py — add after existing constants and helpers: - -After PENDING_CONFIRM_PREFIX line, add: -```python -LOAD_PENDING_PREFIX = "matrix_load_pending:" -RESET_PENDING_PREFIX = "matrix_reset_pending:" -``` - -After clear_pending_confirm(), add: -```python -def _load_pending_key(user_id: str, room_id: str) -> str: - return f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}" - -async def get_load_pending(store: StateStore, user_id: str, room_id: str) -> dict | None: - return await store.get(_load_pending_key(user_id, room_id)) - -async def set_load_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None: - await store.set(_load_pending_key(user_id, room_id), data) - -async def clear_load_pending(store: StateStore, user_id: str, room_id: str) -> None: - await store.delete(_load_pending_key(user_id, room_id)) - - -def _reset_pending_key(user_id: str, room_id: str) -> str: - return f"{RESET_PENDING_PREFIX}{user_id}:{room_id}" - -async def get_reset_pending(store: StateStore, user_id: str, room_id: str) -> dict | None: - return await store.get(_reset_pending_key(user_id, room_id)) - -async def set_reset_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None: - await store.set(_reset_pending_key(user_id, room_id), data) - -async def clear_reset_pending(store: StateStore, user_id: str, room_id: str) -> None: - await store.delete(_reset_pending_key(user_id, room_id)) -``` - -3. Edit tests/platform/test_prototype_state.py — append four new tests: - -```python -@pytest.mark.asyncio -async def test_saved_sessions_add_and_list(): - store = PrototypeStateStore() - await store.add_saved_session("u1", "my-save") - await store.add_saved_session("u1", "another-save") - sessions = await store.list_saved_sessions("u1") - assert len(sessions) == 2 - assert sessions[0]["name"] == "my-save" - assert "created_at" in sessions[0] - assert sessions[1]["name"] == "another-save" - - -@pytest.mark.asyncio -async def test_saved_sessions_list_returns_copy(): - store = PrototypeStateStore() - await store.add_saved_session("u1", "my-save") - sessions = await store.list_saved_sessions("u1") - sessions.append({"name": "injected"}) - sessions2 = await store.list_saved_sessions("u1") - assert len(sessions2) == 1 - - -@pytest.mark.asyncio -async def test_last_tokens_used_default_zero(): - store = PrototypeStateStore() - assert await store.get_last_tokens_used("u1") == 0 - - -@pytest.mark.asyncio -async def test_last_tokens_used_set_and_get(): - store = PrototypeStateStore() - await store.set_last_tokens_used("u1", 42) - assert await store.get_last_tokens_used("u1") == 42 -``` - - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/platform/test_prototype_state.py -v 2>&1 | tail -15 - - - - - PrototypeStateStore has add_saved_session, list_saved_sessions, get_last_tokens_used, set_last_tokens_used - - adapter/matrix/store.py has LOAD_PENDING_PREFIX, RESET_PENDING_PREFIX, and 6 new helper functions - - All test_prototype_state.py tests pass (including 4 new ones) - - `grep "add_saved_session\|list_saved_sessions" sdk/prototype_state.py` returns matches - - `grep "LOAD_PENDING_PREFIX\|RESET_PENDING_PREFIX" adapter/matrix/store.py` returns matches - - - - - Task 2: Implement context_commands.py handlers, wire into __init__.py and bot.py, update tokens_used tracking in real.py - - - - adapter/matrix/handlers/__init__.py (full file — adding registrations) - - adapter/matrix/handlers/confirm.py (full file — example of make_handle_X closure pattern with store) - - adapter/matrix/bot.py (full file — on_room_message and build_runtime need changes) - - sdk/real.py (after Plan 01 — add set_last_tokens_used call after stream_message) - - adapter/matrix/store.py (after Task 1 — load_pending/reset_pending helpers now available) - - sdk/prototype_state.py (after Task 1 — saved_sessions methods available) - - - - adapter/matrix/handlers/context_commands.py, - adapter/matrix/handlers/__init__.py, - adapter/matrix/bot.py, - sdk/real.py, - tests/adapter/matrix/test_context_commands.py - - - - - context_commands.py exports: make_handle_save, make_handle_load, make_handle_reset, make_handle_context - - make_handle_save(agent_api, store, prototype_state) -> handler: - !save with no args: auto-name = f"context-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}" - !save [name]: use args[0] as name - sends SAVE_PROMPT via platform.send_message (NOT stream — simple blocking send) - calls prototype_state.add_saved_session(event.user_id, name) - returns [OutgoingMessage(chat_id=event.chat_id, text=f"Сохранение запущено: {name}")] - - make_handle_load(agent_api, store, prototype_state) -> handler: - !load: fetches sessions = await prototype_state.list_saved_sessions(event.user_id) - if empty: returns [OutgoingMessage(chat_id=..., text="Нет сохранённых сессий. Используй !save [имя].")] - else: builds numbered display text, stores load_pending via set_load_pending(store, event.user_id, room_id, {"saves": sessions}) - room_id is in event.chat_id (in Matrix adapter, chat_id == room_id for commands) - returns [OutgoingMessage(chat_id=..., text=display_text + "\nВведи номер или 0 / !cancel для отмены.")] - - Numeric input interception in MatrixBot.on_room_message(): - Before dispatcher.dispatch, check load_pending = await get_load_pending(runtime.store, sender, room_id) - If load_pending and msg text is digit: handle_load_selection(pending, selection, ...) - handle_load_selection: if text == "0" or "!cancel" → clear_load_pending, return [OutgoingMessage("Отменено")] - if valid index → clear_load_pending, send LOAD_PROMPT via platform.send_message, add session as current_session in prototype_state (store in dict), return [OutgoingMessage("Загрузка: {name}")] - if invalid index → return [OutgoingMessage("Неверный номер. Введи от 1 до N или 0 для отмены.")] - - make_handle_reset(store, agent_base_url) -> handler: - !reset: set reset_pending, return [OutgoingMessage with text: - "Сбросить контекст агента? Выбери:\n !yes — сбросить\n !save [имя] — сохранить и сбросить\n !no — отмена")] - !yes in reset_pending: call POST {AGENT_BASE_URL}/reset via httpx; if 404 or connection error → "Reset endpoint недоступен. Обратитесь к администратору."; else "Контекст сброшен."; clear reset_pending - !no in reset_pending: clear reset_pending, return [OutgoingMessage("Отменено.")] - !save имя in reset_pending: delegate to save logic, then POST /reset (same fallback) - Reset_pending check must happen BEFORE pending_confirm in handler priority — implement by checking reset_pending in the !yes and !no handlers (make_handle_confirm must check reset_pending first) - - make_handle_context(store, prototype_state) -> handler: - reads current_session from prototype_state._current_session dict (keyed by user_id) if it exists - reads tokens = await prototype_state.get_last_tokens_used(event.user_id) - reads sessions = await prototype_state.list_saved_sessions(event.user_id) - formats: "Контекст:\n Сессия: {name or 'не загружена'}\n Токены (последний ответ): {tokens}\n Сохранения ({len}):\n {list}" - returns [OutgoingMessage(chat_id=..., text=formatted)] - - sdk/real.py: after the final yield in stream_message, call await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used) — needs prototype_state reference already present in RealPlatformClient - - PrototypeStateStore gets one more dict: self._current_session: dict[str, str] = {} and methods get_current_session(user_id) -> str | None, set_current_session(user_id, name) -> None - - register_matrix_handlers() updated to accept agent_api and agent_base_url params; registers save/load/reset/context - - - -1. Add to sdk/prototype_state.py __init__: `self._current_session: dict[str, str] = {}` - Add methods: - ```python - async def get_current_session(self, user_id: str) -> str | None: - return self._current_session.get(user_id) - - async def set_current_session(self, user_id: str, name: str) -> None: - self._current_session[user_id] = name - ``` - -2. Create adapter/matrix/handlers/context_commands.py: - -```python -from __future__ import annotations - -import os -from datetime import UTC, datetime -from typing import TYPE_CHECKING - -import httpx -import structlog - -from core.protocol import IncomingCommand, OutgoingEvent, OutgoingMessage - -if TYPE_CHECKING: - from lambda_agent_api.agent_api import AgentApi - from sdk.prototype_state import PrototypeStateStore - from core.store import StateStore - -logger = structlog.get_logger(__name__) - -SAVE_PROMPT = ( - "Summarize our conversation and save to /workspace/contexts/{name}.md. " - "Reply only with: Saved: {name}" -) - -LOAD_PROMPT = ( - "Load context from /workspace/contexts/{name}.md and use it as background " - "for our conversation. Reply: Loaded: {name}" -) - - -def make_handle_save(agent_api: "AgentApi", store: "StateStore", prototype_state: "PrototypeStateStore"): - async def handle_save( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list[OutgoingEvent]: - if event.args: - name = event.args[0] - else: - name = f"context-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}" - - prompt = SAVE_PROMPT.format(name=name) - try: - await platform.send_message(event.user_id, event.chat_id, prompt) - except Exception as exc: - logger.warning("save_agent_call_failed", error=str(exc)) - return [OutgoingMessage(chat_id=event.chat_id, text=f"Ошибка при сохранении: {exc}")] - - await prototype_state.add_saved_session(event.user_id, name) - return [OutgoingMessage(chat_id=event.chat_id, text=f"Сохранение запущено: {name}")] - - return handle_save - - -def make_handle_load(store: "StateStore", prototype_state: "PrototypeStateStore"): - async def handle_load( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list[OutgoingEvent]: - from adapter.matrix.store import set_load_pending - - sessions = await prototype_state.list_saved_sessions(event.user_id) - if not sessions: - return [OutgoingMessage( - chat_id=event.chat_id, - text="Нет сохранённых сессий. Используй !save [имя].", - )] - - lines = ["Сохранённые сессии:"] - for i, s in enumerate(sessions, start=1): - created = s.get("created_at", "")[:10] - lines.append(f" {i}. {s['name']} ({created})") - lines.append("\nВведи номер или 0 / !cancel для отмены.") - display = "\n".join(lines) - - await set_load_pending(store, event.user_id, event.chat_id, {"saves": sessions}) - return [OutgoingMessage(chat_id=event.chat_id, text=display)] - - return handle_load - - -def make_handle_reset(store: "StateStore", agent_base_url: str): - async def handle_reset( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list[OutgoingEvent]: - from adapter.matrix.store import set_reset_pending - - await set_reset_pending(store, event.user_id, event.chat_id, {"active": True}) - text = ( - "Сбросить контекст агента? Выбери:\n" - " !yes — сбросить\n" - " !save [имя] — сохранить и сбросить\n" - " !no — отмена" - ) - return [OutgoingMessage(chat_id=event.chat_id, text=text)] - - return handle_reset - - -async def _call_reset_endpoint(agent_base_url: str, chat_id: str) -> list[OutgoingEvent]: - try: - async with httpx.AsyncClient() as http: - resp = await http.post(f"{agent_base_url}/reset", timeout=5.0) - if resp.status_code == 404: - return [OutgoingMessage(chat_id=chat_id, text="Reset endpoint недоступен. Обратитесь к администратору.")] - return [OutgoingMessage(chat_id=chat_id, text="Контекст сброшен.")] - except (httpx.ConnectError, httpx.TimeoutException) as exc: - logger.warning("reset_endpoint_unreachable", error=str(exc)) - return [OutgoingMessage(chat_id=chat_id, text="Reset endpoint недоступен. Обратитесь к администратору.")] - - -def make_handle_context(store: "StateStore", prototype_state: "PrototypeStateStore"): - async def handle_context( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list[OutgoingEvent]: - session_name = await prototype_state.get_current_session(event.user_id) or "не загружена" - tokens = await prototype_state.get_last_tokens_used(event.user_id) - sessions = await prototype_state.list_saved_sessions(event.user_id) - - lines = [ - "Контекст:", - f" Сессия: {session_name}", - f" Токены (последний ответ): {tokens}", - f" Сохранения ({len(sessions)}):", - ] - for s in sessions: - created = s.get("created_at", "")[:10] - lines.append(f" • {s['name']} ({created})") - if not sessions: - lines.append(" (нет)") - - return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] - - return handle_context -``` - -3. Edit adapter/matrix/handlers/__init__.py: - - Add import at top: `from adapter.matrix.handlers.context_commands import make_handle_save, make_handle_load, make_handle_reset, make_handle_context` - - Change signature: `def register_matrix_handlers(dispatcher, client=None, store=None, agent_api=None, prototype_state=None, agent_base_url="http://127.0.0.1:8000") -> None:` - - Add at bottom of function before the last line: - ```python - if agent_api is not None and prototype_state is not None: - dispatcher.register(IncomingCommand, "save", make_handle_save(agent_api, store, prototype_state)) - dispatcher.register(IncomingCommand, "load", make_handle_load(store, prototype_state)) - dispatcher.register(IncomingCommand, "reset", make_handle_reset(store, agent_base_url)) - dispatcher.register(IncomingCommand, "context", make_handle_context(store, prototype_state)) - ``` - -4. Edit adapter/matrix/bot.py: - a. Add imports: `from adapter.matrix.store import get_load_pending, clear_load_pending, get_reset_pending, clear_reset_pending` - b. In build_event_dispatcher() and build_runtime(), extract prototype_state from platform if RealPlatformClient, otherwise create new one: - In build_runtime() after creating platform: - ```python - prototype_state = getattr(platform, "_prototype_state", None) - agent_api = getattr(platform, "_agent_api", None) - agent_base_url = os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000") - ``` - Pass these to register_matrix_handlers: - ```python - register_matrix_handlers(dispatcher, client=client, store=store, - agent_api=agent_api, prototype_state=prototype_state, - agent_base_url=agent_base_url) - ``` - c. In MatrixBot.on_room_message(), before `incoming = from_room_event(...)`: - ```python - sender = getattr(event, "sender", None) - # !load numeric interception - load_pending = await get_load_pending(self.runtime.store, sender, room.room_id) - if load_pending is not None: - text = getattr(event, "body", "").strip() - if text.isdigit() or text == "0" or text == "!cancel": - outgoing = await self._handle_load_selection( - sender, room.room_id, text, load_pending - ) - await self._send_all(room.room_id, outgoing) - return - ``` - d. Add _handle_load_selection method to MatrixBot: - ```python - async def _handle_load_selection( - self, user_id: str, room_id: str, text: str, pending: dict - ) -> list[OutgoingEvent]: - from adapter.matrix.store import clear_load_pending - saves = pending.get("saves", []) - if text == "0" or text == "!cancel": - await clear_load_pending(self.runtime.store, user_id, room_id) - return [OutgoingMessage(chat_id=room_id, text="Отменено.")] - idx = int(text) - 1 - if idx < 0 or idx >= len(saves): - return [OutgoingMessage(chat_id=room_id, text=f"Неверный номер. Введи от 1 до {len(saves)} или 0 для отмены.")] - name = saves[idx]["name"] - await clear_load_pending(self.runtime.store, user_id, room_id) - prototype_state = getattr(self.runtime.platform, "_prototype_state", None) - if prototype_state is not None: - await prototype_state.set_current_session(user_id, name) - prompt = f"Load context from /workspace/contexts/{name}.md and use it as background for our conversation. Reply: Loaded: {name}" - try: - await self.runtime.platform.send_message(user_id, room_id, prompt) - except Exception as exc: - logger.warning("load_agent_call_failed", error=str(exc)) - return [OutgoingMessage(chat_id=room_id, text=f"Ошибка при загрузке: {exc}")] - return [OutgoingMessage(chat_id=room_id, text=f"Загрузка: {name}")] - ``` - e. In MatrixBot.on_room_message(), also add reset_pending check for !yes/!no/!save commands: - In the block after load_pending check, before calling dispatcher.dispatch: - ```python - # !reset pending interception for !yes, !no, !save commands - reset_pending = await get_reset_pending(self.runtime.store, sender, room.room_id) - if reset_pending is not None: - body = getattr(event, "body", "").strip() - if body == "!yes" or body.startswith("!save ") or body == "!no": - outgoing = await self._handle_reset_selection(sender, room.room_id, body) - await self._send_all(room.room_id, outgoing) - return - ``` - f. Add _handle_reset_selection method to MatrixBot: - ```python - async def _handle_reset_selection( - self, user_id: str, room_id: str, text: str - ) -> list[OutgoingEvent]: - from adapter.matrix.store import clear_reset_pending - from adapter.matrix.handlers.context_commands import _call_reset_endpoint - agent_base_url = os.environ.get("AGENT_BASE_URL", "http://127.0.0.1:8000") - await clear_reset_pending(self.runtime.store, user_id, room_id) - if text == "!no": - return [OutgoingMessage(chat_id=room_id, text="Отменено.")] - if text.startswith("!save "): - name = text[len("!save "):].strip() - prototype_state = getattr(self.runtime.platform, "_prototype_state", None) - prompt = f"Summarize our conversation and save to /workspace/contexts/{name}.md. Reply only with: Saved: {name}" - try: - await self.runtime.platform.send_message(user_id, room_id, prompt) - if prototype_state: - await prototype_state.add_saved_session(user_id, name) - except Exception as exc: - logger.warning("save_before_reset_failed", error=str(exc)) - return await _call_reset_endpoint(agent_base_url, room_id) - ``` - -5. Edit sdk/real.py — in stream_message(), after the final yield, add: - ```python - await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used) - ``` - (This must come after `yield MessageChunk(finished=True, ...)` — use a local variable to store tokens_used before yield, then call set_last_tokens_used after the generator resumes.) - Actually: put it before the final yield: - ```python - await self._prototype_state.set_last_tokens_used(user_id, self._agent_api.last_tokens_used) - yield MessageChunk( - message_id=user_id, - delta="", - finished=True, - tokens_used=self._agent_api.last_tokens_used, - ) - ``` - -6. Create tests/adapter/matrix/test_context_commands.py: - -```python -from __future__ import annotations - -from typing import AsyncIterator -from unittest.mock import AsyncMock, patch - -import pytest - -from adapter.matrix.bot import MatrixBot, build_runtime -from core.protocol import IncomingCommand, OutgoingMessage -from sdk.mock import MockPlatformClient -from sdk.prototype_state import PrototypeStateStore - - -def make_runtime_with_prototype_state(): - proto = PrototypeStateStore() - platform = MockPlatformClient() - # Inject prototype_state into platform so handlers can find it - platform._prototype_state = proto - runtime = build_runtime(platform=platform) - return runtime, proto - - -@pytest.mark.asyncio -async def test_save_command_auto_name_records_session(): - proto = PrototypeStateStore() - platform = MockPlatformClient() - platform._prototype_state = proto - - from adapter.matrix.handlers.context_commands import make_handle_save - from core.store import InMemoryStore - - store = InMemoryStore() - handler = make_handle_save(agent_api=None, store=store, prototype_state=proto) - - event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!room:example", command="save", args=[]) - - class FakePlatform: - async def send_message(self, *a, **kw): pass - - result = await handler(event, None, FakePlatform(), None, None) - assert any(isinstance(r, OutgoingMessage) and "Сохранение запущено" in r.text for r in result) - sessions = await proto.list_saved_sessions("u1") - assert len(sessions) == 1 - assert sessions[0]["name"].startswith("context-") - - -@pytest.mark.asyncio -async def test_save_command_with_name_uses_given_name(): - proto = PrototypeStateStore() - from adapter.matrix.handlers.context_commands import make_handle_save - from core.store import InMemoryStore - - store = InMemoryStore() - handler = make_handle_save(agent_api=None, store=store, prototype_state=proto) - - event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="save", args=["my-session"]) - - class FakePlatform: - async def send_message(self, *a, **kw): pass - - await handler(event, None, FakePlatform(), None, None) - sessions = await proto.list_saved_sessions("u1") - assert sessions[0]["name"] == "my-session" - - -@pytest.mark.asyncio -async def test_load_command_shows_numbered_list(): - proto = PrototypeStateStore() - await proto.add_saved_session("u1", "session-A") - await proto.add_saved_session("u1", "session-B") - - from adapter.matrix.handlers.context_commands import make_handle_load - from core.store import InMemoryStore - - store = InMemoryStore() - handler = make_handle_load(store=store, prototype_state=proto) - event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="load", args=[]) - - result = await handler(event, None, None, None, None) - assert len(result) == 1 - text = result[0].text - assert "1." in text and "session-A" in text - assert "2." in text and "session-B" in text - assert "0" in text - - -@pytest.mark.asyncio -async def test_load_command_empty_sessions(): - proto = PrototypeStateStore() - from adapter.matrix.handlers.context_commands import make_handle_load - from core.store import InMemoryStore - - store = InMemoryStore() - handler = make_handle_load(store=store, prototype_state=proto) - event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="load", args=[]) - - result = await handler(event, None, None, None, None) - assert "Нет сохранённых сессий" in result[0].text - - -@pytest.mark.asyncio -async def test_reset_command_shows_dialog(): - proto = PrototypeStateStore() - from adapter.matrix.handlers.context_commands import make_handle_reset - from core.store import InMemoryStore - - store = InMemoryStore() - handler = make_handle_reset(store=store, agent_base_url="http://127.0.0.1:8000") - event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="reset", args=[]) - - result = await handler(event, None, None, None, None) - text = result[0].text - assert "!yes" in text - assert "!save" in text - assert "!no" in text - - -@pytest.mark.asyncio -async def test_reset_yes_reports_unavailable_when_endpoint_missing(): - from adapter.matrix.handlers.context_commands import _call_reset_endpoint - - with patch("httpx.AsyncClient") as MockClient: - import httpx - MockClient.return_value.__aenter__ = AsyncMock(return_value=MockClient.return_value) - MockClient.return_value.__aexit__ = AsyncMock(return_value=False) - MockClient.return_value.post = AsyncMock(side_effect=httpx.ConnectError("refused")) - - result = await _call_reset_endpoint("http://127.0.0.1:8000", "!r:e") - assert "недоступен" in result[0].text - - -@pytest.mark.asyncio -async def test_context_command_shows_snapshot(): - proto = PrototypeStateStore() - await proto.set_last_tokens_used("u1", 99) - await proto.add_saved_session("u1", "my-save") - - from adapter.matrix.handlers.context_commands import make_handle_context - from core.store import InMemoryStore - - store = InMemoryStore() - handler = make_handle_context(store=store, prototype_state=proto) - event = IncomingCommand(user_id="u1", platform="matrix", chat_id="!r:e", command="context", args=[]) - - result = await handler(event, None, None, None, None) - text = result[0].text - assert "99" in text - assert "my-save" in text - assert "не загружена" in text -``` - - - - cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/adapter/matrix/test_context_commands.py tests/platform/test_prototype_state.py -v 2>&1 | tail -20 - - - - - adapter/matrix/handlers/context_commands.py exists with make_handle_save, make_handle_load, make_handle_reset, make_handle_context, _call_reset_endpoint - - register_matrix_handlers() signature accepts agent_api, prototype_state, agent_base_url; registers save/load/reset/context handlers when agent_api is not None - - MatrixBot.on_room_message() checks load_pending and reset_pending before dispatcher.dispatch - - sdk/real.py calls set_last_tokens_used before final yield - - All tests in test_context_commands.py pass - - Full test suite still passes: `pytest tests/ -v` exits 0 - - - - - - -## Trust Boundaries - -| Boundary | Description | -|----------|-------------| -| Matrix user → command args | !save [name] arg is user-controlled; used in file paths | -| bot → agent (save/load prompts) | Prompt text contains user-supplied name | -| bot → POST /reset endpoint | HTTP call to AGENT_BASE_URL (internal service) | - -## STRIDE Threat Register - -| Threat ID | Category | Component | Disposition | Mitigation Plan | -|-----------|----------|-----------|-------------|-----------------| -| T-04-02-01 | Tampering | !save name arg used in /workspace/contexts/{name}.md path | mitigate | Sanitize name: only allow [a-zA-Z0-9_-] characters; reject path traversal attempts (names containing "/" or "..") | -| T-04-02-02 | Information Disclosure | !context exposes tokens_used and session names | accept | Single-user prototype; data is the user's own | -| T-04-02-03 | Denial of Service | !load numeric interception could loop if load_pending never clears | mitigate | clear_load_pending on selection (any) or disconnect; pending data is volatile in-memory | -| T-04-02-04 | Spoofing | !yes intercepted by reset_pending could conflict with pending_confirm | mitigate | Reset_pending check in on_room_message before dispatcher — takes priority; documented in code comment | -| T-04-02-05 | Tampering | POST /reset to AGENT_BASE_URL | accept | Internal service URL from env; timeout=5.0 prevents hanging | - - - -Run full suite after both tasks: - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -m pytest tests/ -v 2>&1 | tail -30 -``` - -Grep checks: -```bash -# Handlers registered -grep "save\|load\|reset\|context" adapter/matrix/handlers/__init__.py - -# Numeric interception in bot -grep "get_load_pending\|_handle_load_selection" adapter/matrix/bot.py - -# tokens tracking in real.py -grep "set_last_tokens_used" sdk/real.py - -# context_commands module -ls adapter/matrix/handlers/context_commands.py -``` - - - -- `pytest tests/adapter/matrix/test_context_commands.py -v` exits 0 with 7+ tests passing -- `pytest tests/platform/test_prototype_state.py -v` exits 0 (including 4 new tests) -- `pytest tests/ -v` exits 0 -- !save, !load, !reset, !context all registered in register_matrix_handlers -- load_pending and reset_pending helpers exist in adapter/matrix/store.py -- MatrixBot.on_room_message contains numeric interception for !load - - - -After completion, create `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md` - diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md deleted file mode 100644 index e6ccc76..0000000 --- a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md +++ /dev/null @@ -1,40 +0,0 @@ -# Phase 04 Plan 02: Matrix Context Commands Summary - -## Outcome - -Added Matrix context management for `!save`, `!load`, `!reset`, and `!context`, plus -pending-state interception in the Matrix bot and prototype-state tracking for saved -sessions, current session, and last token usage. - -## Commits - -- `2720ee2` `feat(04-02): extend prototype and matrix pending state` -- `b52fdc4` `feat(04-02): add matrix context management commands` - -## Verification - -- `pytest tests/platform/test_prototype_state.py -q` -- `pytest tests/adapter/matrix/test_context_commands.py tests/platform/test_prototype_state.py -q` -- `pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_confirm.py -q` - -## Deviations from Plan - -### Auto-fixed Issues - -1. `[Rule 2 - Critical functionality]` Sanitized save names before using them in save/load prompts. - This rejects names outside `[A-Za-z0-9_-]` and prevents path-like input from flowing into `/workspace/contexts/{name}.md`. - -2. `[Rule 2 - Critical functionality]` Cleared the active current-session marker on reset. - Without this, `!context` could report a stale loaded session after `!reset`. - -## Files Changed - -- `sdk/prototype_state.py` -- `adapter/matrix/store.py` -- `adapter/matrix/handlers/__init__.py` -- `adapter/matrix/handlers/context_commands.py` -- `adapter/matrix/bot.py` -- `tests/adapter/matrix/test_context_commands.py` -- `tests/platform/test_prototype_state.py` - -## Self-Check: PASSED diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-PLAN.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-PLAN.md deleted file mode 100644 index 7c6781b..0000000 --- a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-PLAN.md +++ /dev/null @@ -1,193 +0,0 @@ ---- -phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma -plan: 03 -type: execute -wave: 2 -depends_on: - - 04-01-PLAN.md -files_modified: - - Dockerfile - - docker-compose.yml - - .env.example -autonomous: true -requirements: - - Dockerfile for Matrix bot - - docker-compose.yml with matrix-bot service - - .env.example updated with AGENT_BASE_URL and MATRIX_PLATFORM_BACKEND - -must_haves: - truths: - - "Dockerfile builds successfully with python:3.11-slim base" - - "lambda_agent_api installed in container despite Python version constraint" - - "PYTHONPATH=/app set so adapter/matrix/bot.py is runnable as module" - - "docker-compose.yml defines matrix-bot service with env_file: .env" - - ".env.example contains AGENT_BASE_URL and MATRIX_PLATFORM_BACKEND=real" - - "CMD runs python -m adapter.matrix.bot" - artifacts: - - path: "Dockerfile" - provides: "Matrix bot container image" - contains: "python:3.11-slim" - - path: "docker-compose.yml" - provides: "Service definition for matrix-bot" - contains: "matrix-bot" - - path: ".env.example" - provides: "Updated env template" - contains: "AGENT_BASE_URL" - key_links: - - from: "Dockerfile" - to: "external/platform-agent_api" - via: "COPY + pip install with --ignore-requires-python" - pattern: "ignore-requires-python" ---- - - -Package the Matrix bot in a Docker container. Create Dockerfile using python:3.11-slim, -install lambda_agent_api from the local external/ directory (bypassing the Python 3.14 -version constraint), and define a docker-compose.yml for running the matrix-bot service. -Update .env.example with new variables. - -Purpose: Enable reproducible MVP deployment of the Matrix bot in a container alongside -the separately-run platform-agent. - -Output: Dockerfile, docker-compose.yml, updated .env.example. - - - -@$HOME/.claude/get-shit-done/workflows/execute-plan.md -@$HOME/.claude/get-shit-done/templates/summary.md - - - -@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md -@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md - - - - - - Task 1: Create Dockerfile and docker-compose.yml - - - - .env.example (full file — adding new vars) - - external/platform-agent_api/lambda_agent_api/ (ls — verify files to copy) - - pyproject.toml (verify uv is the package manager used) - - - Dockerfile, docker-compose.yml, .env.example - - -1. Check if pyproject.toml uses uv or pip. The project uses `uv sync` per CLAUDE.md. However, in the Docker container we can use pip for simplicity since uv's lockfile-based install may need the lockfile present. Use pip for the base install of surfaces-bot deps, and install lambda_agent_api separately. - - Actually: the project uses uv. Use uv in Docker to be consistent: - - Install uv via pip (pip install uv) - - Run uv sync to install project deps - - Install lambda_agent_api with pip --ignore-requires-python - -2. Create Dockerfile: - -```dockerfile -FROM python:3.11-slim - -WORKDIR /app - -# Install uv -RUN pip install --no-cache-dir uv - -# Copy dependency manifests first for layer caching -COPY pyproject.toml uv.lock* ./ - -# Install project dependencies via uv (no project install yet, just deps) -RUN uv sync --no-install-project --frozen 2>/dev/null || uv sync --no-install-project - -# Copy project source -COPY . . - -# Install the project itself -RUN uv sync --frozen 2>/dev/null || uv sync - -# Install lambda_agent_api, bypassing Python version constraint -RUN pip install --no-cache-dir --ignore-requires-python /app/external/platform-agent_api - -ENV PYTHONPATH=/app -ENV PYTHONUNBUFFERED=1 - -CMD ["python", "-m", "adapter.matrix.bot"] -``` - -3. Create docker-compose.yml: - -```yaml -services: - matrix-bot: - build: . - env_file: .env - restart: unless-stopped - # platform-agent runs separately — not included in this compose file -``` - -4. Read current .env.example, then append new variables. Current file likely has MATRIX_* vars. Add: - - AGENT_WS_URL=ws://127.0.0.1:8000/agent_ws/ - - AGENT_BASE_URL=http://127.0.0.1:8000 - - MATRIX_PLATFORM_BACKEND=real - - Read .env.example first to see what's there, then write the full updated file. - - - - - `grep "python:3.11-slim" Dockerfile` returns a match - - `grep "ignore-requires-python" Dockerfile` returns a match (lambda_agent_api install) - - `grep "PYTHONPATH=/app" Dockerfile` returns a match - - `grep "adapter.matrix.bot" Dockerfile` returns a match (CMD) - - `grep "matrix-bot" docker-compose.yml` returns a match - - `grep "env_file" docker-compose.yml` returns a match - - `grep "AGENT_BASE_URL" .env.example` returns a match - - `grep "MATRIX_PLATFORM_BACKEND" .env.example` returns a match - - Dockerfile exists with python:3.11-slim, uv install, lambda_agent_api pip install --ignore-requires-python, PYTHONPATH=/app, CMD python -m adapter.matrix.bot - - docker-compose.yml exists with matrix-bot service, env_file: .env, restart: unless-stopped - - .env.example contains AGENT_WS_URL, AGENT_BASE_URL, MATRIX_PLATFORM_BACKEND=real - - - - grep "python:3.11-slim" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile && grep "ignore-requires-python" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile && grep "AGENT_BASE_URL" /Users/a/MAI/sem2/lambda/surfaces-bot/.env.example && echo "All checks passed" - - - - - - -## Trust Boundaries - -| Boundary | Description | -|----------|-------------| -| container → host env | .env file mounts secrets into container | -| container → platform-agent | Network call to AGENT_WS_URL / AGENT_BASE_URL | - -## STRIDE Threat Register - -| Threat ID | Category | Component | Disposition | Mitigation Plan | -|-----------|----------|-----------|-------------|-----------------| -| T-04-03-01 | Information Disclosure | .env file with secrets mounted in container | mitigate | .env in .gitignore; .env.example committed with placeholder values only, never real secrets | -| T-04-03-02 | Tampering | lambda_agent_api installed from local path via --ignore-requires-python | accept | Local package under version control; no external supply chain risk | -| T-04-03-03 | Denial of Service | restart: unless-stopped could loop on crash | accept | Expected behavior; operator can `docker compose stop` | - - - -```bash -# Verify files exist and contain expected content -grep "python:3.11-slim" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile -grep "ignore-requires-python" /Users/a/MAI/sem2/lambda/surfaces-bot/Dockerfile -grep "AGENT_BASE_URL" /Users/a/MAI/sem2/lambda/surfaces-bot/.env.example -grep "matrix-bot" /Users/a/MAI/sem2/lambda/surfaces-bot/docker-compose.yml -``` - - - -- Dockerfile, docker-compose.yml, .env.example all exist in project root -- Dockerfile builds without errors when platform-agent_api dir is present (docker build . exits 0) -- .env.example contains AGENT_BASE_URL, AGENT_WS_URL, MATRIX_PLATFORM_BACKEND -- docker-compose.yml service named matrix-bot uses env_file: .env - - - -After completion, create `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-SUMMARY.md` - diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-SUMMARY.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-SUMMARY.md deleted file mode 100644 index 38957dd..0000000 --- a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-SUMMARY.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma -plan: 03 -subsystem: infra -tags: [docker, docker-compose, matrix, uv, lambda-agent-api] -requires: - - phase: 04-01 - provides: Matrix MVP runtime and environment model -provides: - - Matrix bot Docker image definition - - Single-service docker-compose setup for matrix-bot - - Env template entries for Agent API base URLs and real backend selection -affects: [deployment, matrix, local-dev] -tech-stack: - added: [Dockerfile, docker-compose] - patterns: [uv-managed container install with system Python runtime, local path install for lambda_agent_api] -key-files: - created: [Dockerfile, docker-compose.yml] - modified: [.env.example] -key-decisions: - - "Kept compose scoped to matrix-bot only; platform-agent remains external to this stack." - - "Set UV_PROJECT_ENVIRONMENT=/usr/local so uv-installed dependencies are available to CMD [\"python\", \"-m\", \"adapter.matrix.bot\"]." -patterns-established: - - "Install project dependencies with uv inside the container, then install lambda_agent_api from external/platform-agent_api via pip --ignore-requires-python." -requirements-completed: [Dockerfile for Matrix bot, docker-compose.yml with matrix-bot service, .env.example updated with AGENT_BASE_URL and MATRIX_PLATFORM_BACKEND] -duration: 6min -completed: 2026-04-17 ---- - -# Phase 4 Plan 03: Matrix Bot Containerization Summary - -**Python 3.11 Matrix bot container with uv-managed app dependencies, local lambda_agent_api install bypass, and a single-service compose entrypoint** - -## Performance - -- **Duration:** 6 min -- **Started:** 2026-04-17T13:01:00Z -- **Completed:** 2026-04-17T13:07:04Z -- **Tasks:** 1 -- **Files modified:** 4 - -## Accomplishments - -- Added a root `Dockerfile` for the Matrix bot using `python:3.11-slim`. -- Added `docker-compose.yml` with a single `matrix-bot` service using `env_file: .env`. -- Extended `.env.example` with `AGENT_WS_URL`, `AGENT_BASE_URL`, and `MATRIX_PLATFORM_BACKEND=real`. - -## Files Created/Modified - -- `Dockerfile` - Builds the Matrix bot image, installs project dependencies with `uv`, and installs `lambda_agent_api` from the local `external/` tree. -- `docker-compose.yml` - Defines the `matrix-bot` service with restart policy and `.env` loading. -- `.env.example` - Documents the agent WebSocket URL, agent HTTP base URL, and real backend selector. - -## Decisions Made - -- Kept the compose scope limited to the Matrix bot, matching the phase boundary and excluding platform services. -- Added `UV_PROJECT_ENVIRONMENT=/usr/local` as a correctness fix so `uv sync` installs are visible to the final `python` runtime. - -## Deviations from Plan - -### Auto-fixed Issues - -**1. [Rule 2 - Missing Critical] Ensured uv installs are available to the container runtime** -- **Found during:** Task 1 (Create Dockerfile and docker-compose.yml) -- **Issue:** The plan sketch used `uv sync` plus `CMD ["python", ...]`; by default, `uv sync` would install into a virtual environment that system `python` would not use. -- **Fix:** Set `UV_PROJECT_ENVIRONMENT=/usr/local` in the Dockerfile before running `uv sync`. -- **Files modified:** `Dockerfile` -- **Verification:** Required grep checks passed and the generated compose config remained valid. - ---- - -**Total deviations:** 1 auto-fixed (1 missing critical) -**Impact on plan:** Narrow correctness fix only. No scope expansion. - -## Issues Encountered - -- `docker compose config` resolved values from the local `.env`, so verification was kept to config rendering and grep-style checks rather than a full image build. - -## User Setup Required - -- Create `.env` from `.env.example` with real Matrix and agent values before running `docker compose up`. - -## Next Phase Readiness - -- Matrix bot container packaging is in place and ready for operator-supplied secrets plus an external platform-agent deployment. -- No code changes were made outside the allowed containerization files. - -## Verification - -- `grep 'python:3.11-slim' Dockerfile` -- `grep 'ignore-requires-python' Dockerfile` -- `grep 'PYTHONPATH=/app' Dockerfile` -- `grep 'adapter.matrix.bot' Dockerfile` -- `grep 'matrix-bot' docker-compose.yml` -- `grep 'env_file' docker-compose.yml` -- `grep 'AGENT_BASE_URL' .env.example` -- `grep 'AGENT_WS_URL' .env.example` -- `grep 'MATRIX_PLATFORM_BACKEND' .env.example` -- `docker compose -f docker-compose.yml config` - -## Self-Check: PASSED - -- Found `Dockerfile` -- Found `docker-compose.yml` -- Found updated `.env.example` -- Found `.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-SUMMARY.md` diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md deleted file mode 100644 index 5637a34..0000000 --- a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-CONTEXT.md +++ /dev/null @@ -1,136 +0,0 @@ -# Phase 4: Matrix MVP — Agent Context + Context Management — Context - -**Gathered:** 2026-04-16 -**Status:** Ready for planning -**Source:** Conversation context (2026-04-16 design session) - - -## Phase Boundary - -Привести Matrix-бот к рабочему состоянию для MVP-деплоя в контейнер: -- Убрать наш кастомный `AgentSessionClient` и thread_id патч из `platform-agent`, перейти на актуальный origin/main платформы с `AgentApi` из `lambda_agent_api` -- Добавить 4 команды управления контекстом агента: `!save`, `!load`, `!reset`, `!context` -- Упаковать Matrix-бот в Docker-контейнер - -НЕ входит в фазу: -- Изменения в platform-agent (это задача команды платформы) -- Telegram адаптер -- E2EE -- Skills system (ждём платформу) - - - - -## Implementation Decisions - -### Архитектура платформы (locked) - -- **Один контейнер = один чат**: `AgentService` с `thread_id = "default"` — намеренная архитектура. Изоляция на уровне контейнеров, не thread_id. Не менять. -- **Убрать thread_id патч**: наш коммит `1dca2c1` в `external/platform-agent` удаляем. Переходим на `origin/main` platform-agent. -- **Удалить `build_thread_key`**: функция больше не нужна. Убрать из `sdk/agent_session.py` и `sdk/real.py`. -- **Заменить `AgentSessionClient` на `AgentApi`**: использовать `AgentApi` из `external/platform-agent_api/lambda_agent_api/agent_api.py`. Он уже правильно обрабатывает все event-типы (неизвестные → `logger.warning`, без краша). - -### !save (locked) - -- Синтаксис: `!save` (автоимя по дате/времени) или `!save [имя]` -- Механизм: Matrix-бот посылает агенту текстовое сообщение "Summarize our conversation and save to /workspace/contexts/[name].md. Reply only with: Saved: [name]" -- Имена сохранений хранятся в `PrototypeStateStore` (список для `!load`) -- Агент сам пишет файл через свои инструменты (`write_file`) - -### !load (locked) - -- `!load` без аргументов → бот показывает нумерованный список сохранений -- Пользователь вводит **число** (1, 2, 3...) для выбора -- Выход из состояния: `0` или `!cancel` -- После выбора: бот посылает агенту "Load context from /workspace/contexts/[name].md and use it as background for our conversation. Reply: Loaded: [name]" -- Состояние ожидания выбора хранится в Matrix store (аналогично pending_confirm) - -### !reset (locked) - -- Показывает confirmation-диалог: - ``` - Сбросить контекст агента? Выбери: - !yes — сбросить - !save [имя] — сохранить и сбросить - !no — отмена - ``` -- `!yes` → вызвать `POST {AGENT_BASE_URL}/reset` (resets AgentService singleton) -- `!save имя` → сначала выполняется логика !save, затем POST /reset -- `!no` → отмена -- Fallback если `/reset` endpoint недоступен (404): вернуть пользователю "Reset endpoint недоступен. Обратитесь к администратору." -- `AGENT_BASE_URL` — новая env переменная (HTTP base URL агента, отдельно от `AGENT_WS_URL`) - -### !context (locked) - -- Показывает: имя текущей сессии (если загружали через `!load`), токены из последнего ответа (`tokens_used` из `MsgEventEnd`), список сохранений (имена + даты) -- Не делает никаких вызовов к агенту - -### Dockerfile + docker-compose (locked) - -- `Dockerfile` для Matrix-бота (`adapter/matrix/bot.py`) -- `docker-compose.yml` с сервисом `matrix-bot` -- Env переменные через `.env` файл -- Platform-agent запускается отдельно (не входит в compose этой фазы) - -### Claude's Discretion - -- Структура хранения saved sessions в PrototypeStateStore (dict name→timestamp) -- Формат автоимени для !save без аргументов -- HTTP клиент для POST /reset (aiohttp или httpx) -- Точный формат промптов к агенту для save/load - - - - -## Canonical References - -**Downstream agents MUST read these before planning or implementing.** - -### Platform клиент (заменяем) -- `sdk/agent_session.py` — текущий AgentSessionClient, УДАЛЯЕМ/ЗАМЕНЯЕМ -- `sdk/real.py` — RealPlatformClient, обновляем под AgentApi -- `external/platform-agent_api/lambda_agent_api/agent_api.py` — новый клиент AgentApi -- `external/platform-agent_api/lambda_agent_api/server.py` — типы сообщений (MsgStatus, MsgEventTextChunk, MsgEventEnd, etc.) -- `external/platform-agent_api/lambda_agent_api/client.py` — MsgUserMessage - -### Matrix адаптер (расширяем) -- `adapter/matrix/bot.py` — точка входа, MatrixBot, build_runtime -- `adapter/matrix/handlers/` — существующие обработчики команд -- `adapter/matrix/store.py` — get_room_meta, set_pending_confirm (паттерн для !load state) -- `sdk/prototype_state.py` — PrototypeStateStore, расширяем для saved sessions - -### Состояние платформы -- `.planning/threads/matrix-dev-prototype-agent-platform-state.md` — исследование от 2026-04-14 - -### Существующая архитектура команд -- `core/protocol.py` — IncomingCommand, OutgoingMessage, OutgoingUI -- `core/handlers/` — паттерны регистрации обработчиков - - - - -## Specific Ideas - -- `AgentApi` требует явного `connect()` при старте и `close()` при завершении — lifecycle нужно встроить в `MatrixBot` -- `AgentApi.send_message()` — AsyncIterator, возвращает `MsgEventTextChunk` чанки и `MsgEventEnd` -- Для `!load` состояние "ожидаем число" хранить по ключу `load_pending:{matrix_user_id}:{room_id}` в store (аналог pending_confirm) -- `AGENT_BASE_URL` — HTTP URL, например `http://127.0.0.1:8000`; `AGENT_WS_URL` = `ws://127.0.0.1:8000/agent_ws/` -- platform-agent origin/main: `POST /reset` эндпоинта нет — это нужно запросить у команды платформы. До тех пор `!reset` возвращает "Reset endpoint недоступен" - - - - -## Deferred Ideas - -- Замена `PrototypeStateStore` на реальный control-plane из platform-master (Phase 3) -- Skills интеграция через SkillsMiddleware (ждём платформу) -- E2EE для Matrix -- `!reset` через docker restart (заменяется на /reset endpoint когда платформа добавит) -- Суммаризация контекста (агент сам решает как писать в файл) - - - ---- - -*Phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma* -*Context gathered: 2026-04-16 via conversation design session* diff --git a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md b/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md deleted file mode 100644 index 4cf1b60..0000000 --- a/.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-RESEARCH.md +++ /dev/null @@ -1,546 +0,0 @@ -# Phase 4: Matrix MVP — Shared Agent Context + Context Management — Research - -**Researched:** 2026-04-16 -**Domain:** Matrix bot, AgentApi WebSocket client, context management commands, Docker packaging -**Confidence:** HIGH (all findings verified against actual source files in this repo) - ---- - - -## User Constraints (from CONTEXT.md) - -### Locked Decisions - -**Архитектура платформы:** -- Один контейнер = один чат: `AgentService` с `thread_id = "default"` — намеренная архитектура. Не менять. -- Убрать thread_id патч: наш коммит `1dca2c1` в `external/platform-agent` удаляем. Переходим на `origin/main` platform-agent. -- Удалить `build_thread_key`: функция больше не нужна. Убрать из `sdk/agent_session.py` и `sdk/real.py`. -- Заменить `AgentSessionClient` на `AgentApi`: использовать `AgentApi` из `external/platform-agent_api/lambda_agent_api/agent_api.py`. - -**!save:** Синтаксис `!save` (автоимя) или `!save [имя]`. Механизм: посылаем агенту текстовое сообщение. Имена сохранений хранятся в `PrototypeStateStore`. - -**!load:** `!load` без аргументов → нумерованный список. Пользователь вводит число. Выход: `0` или `!cancel`. После выбора — посылаем агенту текстовое сообщение. Состояние ожидания в Matrix store. - -**!reset:** Confirmation-диалог с `!yes`/`!save имя`/`!no`. `!yes` → `POST {AGENT_BASE_URL}/reset`. Fallback если 404: сообщение пользователю. - -**!context:** Показывает имя сессии, токены, список сохранений. Без вызовов агента. - -**Dockerfile + docker-compose:** Для Matrix-бота. Env через `.env`. Platform-agent — отдельно. - -### Claude's Discretion - -- Структура хранения saved sessions в PrototypeStateStore (dict name→timestamp) -- Формат автоимени для !save без аргументов -- HTTP клиент для POST /reset (aiohttp или httpx) -- Точный формат промптов к агенту для save/load - -### Deferred Ideas (OUT OF SCOPE) - -- Замена `PrototypeStateStore` на реальный control-plane из platform-master -- Skills интеграция через SkillsMiddleware -- E2EE для Matrix -- `!reset` через docker restart -- Суммаризация контекста - - ---- - -## Summary - -Phase 4 replaces the custom `AgentSessionClient` with the production `AgentApi` from `lambda_agent_api`, adds four context management commands to the Matrix bot, and packages it in Docker. All findings are verified directly against source files. - -**Primary recommendation:** Wire `AgentApi` as a persistent connection in `MatrixBot.__init__` (connect on start, close in finally block of `main()`). Expose it through `RealPlatformClient`. The four commands follow the existing handler registration pattern in `adapter/matrix/handlers/__init__.py`. - -The `platform-agent` at `origin/main` already works with `AgentApi` — it does NOT require `thread_id` query param. Our local patch (`1dca2c1`) must be discarded and `external/platform-agent` reset to `origin/main`. - ---- - -## Project Constraints (from CLAUDE.md) - -- **Tech stack:** matrix-nio for Matrix — do not change without discussion -- **Platform client:** connected only via `sdk/interface.py` Protocol — core/ and adapters untouched when swapping implementation -- **No E2EE** — matrix-nio without python-olm -- **Hotfixes < 20 lines** → Claude Code directly; implementation → Codex via GSD -- **MATRIX_PLATFORM_BACKEND env var** controls mock vs real - ---- - -## Standard Stack - -### Core (verified) -| Library | Version | Purpose | Source | -|---------|---------|---------|--------| -| `lambda_agent_api` | local (external/platform-agent_api) | AgentApi WebSocket client | [VERIFIED: file read] | -| `aiohttp` | >=3.9 (surfaces-bot), >=3.13.4 (agent_api) | WebSocket transport inside AgentApi | [VERIFIED: pyproject.toml] | -| `pydantic` | >=2.5 | Message serialization (MsgUserMessage, MsgEventEnd, etc.) | [VERIFIED: server.py/client.py] | -| `httpx` | >=0.27 | HTTP client for POST /reset (already in deps) | [VERIFIED: pyproject.toml] | -| `structlog` | >=24.1 | Logging (existing pattern) | [VERIFIED: pyproject.toml] | - -### Supporting -| Library | Version | Purpose | When to Use | -|---------|---------|---------|-------------| -| `aiohttp` | already a dep | Alternative HTTP for POST /reset | Could use instead of httpx — both available | - -**Installation:** No new packages needed. `lambda_agent_api` is installed as a local path package (currently accessed via `sys.path` injection in tests; for production use, add to pyproject.toml as path dep or install via `pip install -e external/platform-agent_api`). - -**Critical:** `lambda_agent_api` requires Python >=3.14 per its own `pyproject.toml`. The surfaces-bot requires Python >=3.11. [VERIFIED: pyproject.toml of both]. This is a **version mismatch** — see Pitfalls. - ---- - -## Architecture Patterns - -### AgentApi Constructor (verified) - -```python -# Source: external/platform-agent_api/lambda_agent_api/agent_api.py -AgentApi( - agent_id: str, # arbitrary string ID, used in logs - url: str, # WebSocket URL, e.g. "ws://127.0.0.1:8000/agent_ws/" - callback: Optional[Callable[[ServerMessage], None]] = None, # for orphaned msgs - on_disconnect: Optional[Callable[['AgentApi'], None]] = None # called on WS close -) -``` - -### AgentApi Lifecycle (verified) - -```python -# Source: external/platform-agent_api/lambda_agent_api/agent_api.py -agent = AgentApi(agent_id="matrix-bot", url=ws_url) -await agent.connect() # opens WS, waits for MsgStatus, starts _listen() task -# ... use agent ... -await agent.close() # cancels _listen task, closes WS and session -``` - -`connect()` blocks until `MsgStatus` is received from server (5s timeout). After `connect()`, a background `_listen()` asyncio task runs continuously, routing server messages to an internal `asyncio.Queue`. - -### AgentApi.send_message() semantics (verified) - -```python -# Source: external/platform-agent_api/lambda_agent_api/agent_api.py, line 134 -async def send_message(self, text: str) -> AsyncIterator[AgentEventUnion]: -``` - -- `AgentEventUnion = Union[MsgEventTextChunk, MsgEventEnd]` — **but** the generator `yield`s only `MsgEventTextChunk` chunks; it `break`s (stops) on `MsgEventEnd` without yielding it. -- `MsgEventEnd` carries `tokens_used: int` — to capture this, the caller must intercept the queue or handle `MsgEventEnd` in the `_listen` loop. **Currently `send_message` discards `tokens_used`.** This affects `!context` which needs tokens. - -**Resolution:** In `RealPlatformClient.stream_message()`, after iterating through `send_message()`, `tokens_used` won't be directly available. Options: -1. Store `tokens_used` in a shared attribute after each response (add `self._last_tokens_used` to `AgentApi` or a wrapper). -2. Use the `callback` parameter to capture `MsgEventEnd` events from the `_listen` loop. - -[ASSUMED] The simplest approach: wrap `AgentApi` in a thin `AgentApiAdapter` class that intercepts `_listen` output and exposes `last_tokens_used`. Or: store tokens in `PrototypeStateStore` after each message. - -### AgentApi concurrency constraint (verified) - -`AgentApi._request_lock` prevents parallel `send_message()` calls — second call raises `AgentBusyException`. In the single-user Matrix prototype this is acceptable. The bot must not dispatch two messages concurrently to the same agent. - -### Wiring AgentApi into MatrixBot (integration pattern) - -The `AgentApi` must be a persistent connection (not per-message connect/disconnect) because: -1. `_listen()` task runs in background and routes server push events. -2. Per-message connect/disconnect would recreate the aiohttp session each time and discard LangGraph thread state. - -**Recommended wiring:** - -```python -# adapter/matrix/bot.py — main() function -agent_api = AgentApi(agent_id="matrix-bot", url=ws_url) -await agent_api.connect() -runtime = build_runtime(store=SQLiteStore(db_path), client=client, agent_api=agent_api) -try: - await client.sync_forever(timeout=30000, since=since_token) -finally: - await client.close() - await agent_api.close() -``` - -`_build_platform_from_env()` currently instantiates everything synchronously. It must be refactored to `async` or split: construct `AgentApi` synchronously, call `connect()` in `main()` before starting sync loop. - -### RealPlatformClient updates - -`RealPlatformClient` currently imports `AgentSessionClient` and calls `build_thread_key`. Both are removed. The updated class: - -```python -class RealPlatformClient(PlatformClient): - def __init__( - self, - agent_api: AgentApi, # replaces agent_sessions: AgentSessionClient - prototype_state: PrototypeStateStore, - platform: str = "matrix", - ) -> None: -``` - -`send_message()` and `stream_message()` call `agent_api.send_message(text)` directly — no `thread_key` needed. - -### platform-agent origin/main: what changes (verified) - -Our patch `1dca2c1` added `thread_id` query param handling to `external/platform-agent/src/api/external.py`. On `origin/main`, the `process_message()` function does NOT use `thread_id` — it calls `agent_service.astream(msg.text)` without `thread_id`. The WS URL becomes simply `ws://host:port/agent_ws/` — no query params. - -### Existing command registration pattern (verified) - -```python -# adapter/matrix/handlers/__init__.py — register_matrix_handlers() -dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store)) -dispatcher.register(IncomingCommand, "settings", handle_settings) -dispatcher.register(IncomingCallback, "confirm", make_handle_confirm(store)) -``` - -Handler signature (all existing handlers follow this): -```python -async def handle_X( - event: IncomingCommand, - auth_mgr, - platform, - chat_mgr, - settings_mgr, -) -> list[OutgoingEvent]: -``` - -New context commands need access to `agent_api` (for `!save`, `!load`) and `store` (for `!context`, `!load` pending state). Pattern: use `make_handle_X(agent_api, store)` closures — same as `make_handle_new_chat(client, store)`. - -### !load pending state pattern (verified) - -Existing `PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"` in `adapter/matrix/store.py`. - -New key for load pending state: -```python -LOAD_PENDING_PREFIX = "matrix_load_pending:" - -def _load_pending_key(user_id: str, room_id: str) -> str: - return f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}" -``` - -Stored data structure: -```python -{ - "saves": [{"name": "my-save", "ts": "2026-04-16T12:00:00Z"}, ...], - "display": "1. my-save (2026-04-16)\n2. other..." -} -``` - -The numeric input `1`, `2`, etc. is intercepted in `MatrixBot.on_room_message()` BEFORE dispatching as `IncomingMessage` — check if `load_pending` exists for this user+room, resolve to save name, dispatch the load command internally. - -**Alternative (recommended):** Handle numeric input in the `IncomingMessage` handler via a pre-dispatch interceptor, or register a special numeric-input check in the dispatcher for messages that are pure integers. - -### !reset confirmation dialog pattern - -!reset reuses the `OutgoingUI` + `pending_confirm` mechanism or a simpler custom state. Since the dialog options are `!yes`, `!save имя`, `!no` (not just yes/no), it cannot reuse `pending_confirm` directly without extension. - -Simplest approach: store `reset_pending:{user_id}:{room_id}` key (boolean) and check for `!yes`/`!no`/`!save` commands from the `IncomingCommand` dispatcher when reset_pending is set. - -### saved sessions storage in PrototypeStateStore - -New dict attribute on `PrototypeStateStore`: -```python -self._saved_sessions: dict[str, list[dict]] = {} -# Key: matrix_user_id -# Value: [{"name": "my-save", "created_at": "2026-04-16T12:00:00Z"}, ...] -``` - -Methods to add: -```python -async def add_saved_session(self, user_id: str, name: str) -> None: ... -async def list_saved_sessions(self, user_id: str) -> list[dict]: ... -``` - -### !context tokens_used tracking - -`MsgEventEnd.tokens_used: int` is available from `server.py`. Since `AgentApi.send_message()` drops it, the planner must decide how to surface it. Recommended: store in `PrototypeStateStore` as `_last_tokens_used: dict[str, int]` keyed by user_id, updated after each successful agent response in `RealPlatformClient`. - -### Prompts for !save / !load (Claude's Discretion) - -```python -# !save -SAVE_PROMPT = ( - "Summarize our conversation and save to /workspace/contexts/{name}.md. " - "Reply only with: Saved: {name}" -) - -# !load -LOAD_PROMPT = ( - "Load context from /workspace/contexts/{name}.md and use it as background " - "for our conversation. Reply: Loaded: {name}" -) -``` - -Auto-name format (Claude's Discretion): `context-{YYYYMMDD-HHMMSS}` (UTC, no spaces, no special chars, safe as filename). - -### POST /reset endpoint - -Confirmed absent in `origin/main` platform-agent. Only endpoint is `GET /agent_ws/` (WebSocket). The `main.py` has no HTTP routes beyond what FastAPI provides by default (`/docs`, `/openapi.json`). - -`!reset` with `!yes` → `POST {AGENT_BASE_URL}/reset` → expect 404 → return "Reset endpoint недоступен. Обратитесь к администратору." - -HTTP client for this: **httpx** (already in `pyproject.toml`): -```python -import httpx -async with httpx.AsyncClient() as client: - response = await client.post(f"{agent_base_url}/reset", timeout=5.0) - if response.status_code == 404: - return [OutgoingMessage(chat_id=..., text="Reset endpoint недоступен...")] -``` - -### Dockerfile - -```dockerfile -FROM python:3.11-slim -WORKDIR /app -COPY pyproject.toml . -RUN pip install -e . -COPY . . -ENV PYTHONUNBUFFERED=1 -CMD ["python", "-m", "adapter.matrix.bot"] -``` - -`lambda_agent_api` must be installed in the container. Options: -1. `COPY external/platform-agent_api /app/external/platform-agent_api` + `pip install -e /app/external/platform-agent_api` -2. Include `lambda_agent_api` package directly in `surfaces-bot` package (copy source files) - -Option 1 is cleaner. - -### docker-compose.yml structure - -```yaml -services: - matrix-bot: - build: . - env_file: .env - restart: unless-stopped -``` - -Platform-agent runs separately — not in this compose file. - ---- - -## Don't Hand-Roll - -| Problem | Don't Build | Use Instead | Why | -|---------|-------------|-------------|-----| -| WebSocket lifecycle with reconnect | Custom WS manager | `AgentApi` from `lambda_agent_api` | Already handles connect/close/listen loop, error routing, queue management | -| Message deserialization | Custom JSON parsing | `ServerMessage.validate_json()` (Pydantic TypeAdapter) | Discriminated union handles all message types | -| HTTP async client | `aiohttp.ClientSession` directly | `httpx.AsyncClient` | Already in deps, cleaner API for one-shot POST | -| Concurrent request guard | Custom lock | `AgentApi._request_lock` | Already implemented, raises `AgentBusyException` | - ---- - -## Common Pitfalls - -### Pitfall 1: lambda_agent_api Python version mismatch - -**What goes wrong:** `lambda_agent_api/pyproject.toml` declares `requires-python = ">=3.14"`. The surfaces-bot runs on Python 3.11+. If `pip install -e external/platform-agent_api` is run with Python 3.11 it may fail or emit warnings. - -**Why it happens:** The `lambda_agent_api` was developed under Python 3.14 (seen in `.venv` path: `python3.14`). The code itself uses no 3.14-specific syntax — it is pure aiohttp + pydantic which run on 3.11. - -**How to avoid:** Change `requires-python = ">=3.11"` in `external/platform-agent_api/pyproject.toml` before building the Docker image, or install with `--ignore-requires-python`. Alternatively, copy the three source files directly into the surfaces-bot package. - -**Warning signs:** `pip install` failure with "requires Python >=3.14". - -### Pitfall 2: AgentApi.send_message() drops MsgEventEnd (tokens_used lost) - -**What goes wrong:** The generator yields only `MsgEventTextChunk` objects and breaks on `MsgEventEnd` without yielding it. Any downstream code that tries to get `tokens_used` from the iterator gets nothing. - -**Why it happens:** The generator `break`s on `MsgEventEnd` (line 172 of agent_api.py) without yielding it. This is intentional for streaming UX but loses token info. - -**How to avoid:** Before streaming, set `self._last_tokens_used = 0`. In `_listen()`, `MsgEventEnd` is put into `_current_queue` (line 241). The `send_message()` generator reads from that queue but does `break` — the `MsgEventEnd` object is consumed but not returned to caller. The only way to capture it is to subclass `AgentApi` or read from `_current_queue` directly before the break. - -**Practical fix:** Add `self.last_tokens_used: int = 0` to `AgentApi` and intercept the queue in the `finally` block of `send_message()` — or store it in a wrapper class. - -### Pitfall 3: AgentApi persistent connection vs sync_forever loop - -**What goes wrong:** If `agent_api.connect()` is called inside `_build_platform_from_env()` (sync function), it creates an `asyncio.Task` for `_listen()` outside the event loop context. - -**Why it happens:** `_build_platform_from_env()` is called synchronously from `build_runtime()`. `connect()` is a coroutine. - -**How to avoid:** Do NOT call `agent_api.connect()` inside `_build_platform_from_env()`. Instead: -1. `_build_platform_from_env()` creates `RealPlatformClient` with an unconnected `AgentApi` -2. `main()` awaits `agent_api.connect()` explicitly after constructing runtime - -Expose `agent_api` from `RealPlatformClient` via a property so `main()` can call `connect()` on it. - -### Pitfall 4: !load numeric input interception - -**What goes wrong:** When user types `1` in response to `!load` menu, it is dispatched as `IncomingMessage` (not a command) and routed to the platform — the agent receives "1" as a user message. - -**Why it happens:** The Matrix converter (`from_room_event`) produces `IncomingMessage` for plain text, `IncomingCommand` only for `!`-prefixed text. - -**How to avoid:** In `MatrixBot.on_room_message()`, before calling `dispatcher.dispatch()`, check if `load_pending` state exists for this user+room. If yes and the message text is a digit (or `0`/`!cancel`), handle it as a load selection instead of routing to agent. - -### Pitfall 5: platform-agent thread_id removal breaks existing tests - -**What goes wrong:** `tests/platform/test_agent_session.py` imports `build_thread_key` and tests `process_message` with `thread_id` in query params. After the patch is removed, these tests will fail. - -**Why it happens:** Tests were written against our patched `external.py`. - -**How to avoid:** The plan must include updating `test_agent_session.py` — remove `build_thread_key` tests, update `process_message` tests to reflect origin/main signature (no `thread_id` param). - -### Pitfall 6: !reset dialog conflicts with existing !yes/!no flow - -**What goes wrong:** The existing `pending_confirm` flow uses `!yes`/`!no`. If both `reset_pending` and `pending_confirm` are active simultaneously, `!yes` could trigger the wrong handler. - -**Why it happens:** Both flows listen for the same commands. - -**How to avoid:** `!reset` dialog uses a separate state key `reset_pending:{user_id}:{room_id}`. The handler for `!yes` must check `reset_pending` first, then `pending_confirm`. Document priority in handler code. - ---- - -## Code Examples - -### Invoking AgentApi.send_message() in stream_message -```python -# Source: external/platform-agent_api/lambda_agent_api/agent_api.py -async def stream_message(self, user_id: str, chat_id: str, text: str, ...) -> AsyncIterator[MessageChunk]: - async for event in self._agent_api.send_message(text): - if isinstance(event, MsgEventTextChunk): - yield MessageChunk( - message_id=user_id, - delta=event.text, - finished=False, - ) - # After loop ends, MsgEventEnd was consumed internally - yield MessageChunk(message_id=user_id, delta="", finished=True, tokens_used=self._agent_api.last_tokens_used) -``` - -### Handler registration pattern -```python -# Source: adapter/matrix/handlers/__init__.py -def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=None, agent_api=None) -> None: - # existing... - dispatcher.register(IncomingCommand, "save", make_handle_save(agent_api, store)) - dispatcher.register(IncomingCommand, "load", make_handle_load(agent_api, store)) - dispatcher.register(IncomingCommand, "reset", make_handle_reset(store)) - dispatcher.register(IncomingCommand, "context", make_handle_context(store)) -``` - -### !load pending key -```python -# New in adapter/matrix/store.py -LOAD_PENDING_PREFIX = "matrix_load_pending:" - -async def get_load_pending(store: StateStore, user_id: str, room_id: str) -> dict | None: - return await store.get(f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}") - -async def set_load_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None: - await store.set(f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}", data) - -async def clear_load_pending(store: StateStore, user_id: str, room_id: str) -> None: - await store.delete(f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}") -``` - -### platform-agent origin/main process_message (no thread_id) -```python -# Source: git show origin/main:src/api/external.py in external/platform-agent -async def process_message(ws: WebSocket, msg, agent_service: AgentService): - match msg: - case MsgUserMessage(): - async for chunk in agent_service.astream(msg.text): # no thread_id arg - await ws.send_text(chunk.model_dump_json()) - await ws.send_text(MsgEventEnd(tokens_used=0).model_dump_json()) -``` - ---- - -## Assumptions Log - -| # | Claim | Section | Risk if Wrong | -|---|-------|---------|---------------| -| A1 | `tokens_used` can be captured by storing in `AgentApi.last_tokens_used` attribute during `_listen()` before it's queued | Architecture Patterns | If `_listen` timing means value is read before queue, token count would be wrong — low risk, easy to test | -| A2 | Python 3.11 can run `lambda_agent_api` despite `>=3.14` constraint in pyproject.toml | Standard Stack | If code uses 3.14-specific syntax, would fail at runtime — actual code inspected: no 3.14 syntax found | -| A3 | httpx is preferred over aiohttp for POST /reset (one-shot HTTP) | Standard Stack | Either works; httpx already in deps | - ---- - -## Open Questions - -1. **tokens_used capture from AgentApi** - - What we know: `MsgEventEnd.tokens_used` is put into `_current_queue` but consumed (not yielded) by `send_message()` generator - - What's unclear: Cleanest interception point without modifying `lambda_agent_api` source - - Recommendation: Add `last_tokens_used: int = 0` attribute to `AgentApi` and set it in `send_message()`'s `finally` block when draining orphan queue, OR set it in `_listen()` before putting `MsgEventEnd` in queue - -2. **!load numeric input dispatch** - - What we know: Plain text `1`, `2` arrives as `IncomingMessage`, not `IncomingCommand` - - What's unclear: Where to intercept — in `on_room_message()` (bot layer) or in dispatcher pre-hook - - Recommendation: Intercept in `MatrixBot.on_room_message()` before `dispatcher.dispatch()`. Keeps dispatcher clean. - -3. **lambda_agent_api install in Docker** - - What we know: It's a local package in `external/platform-agent_api/` - - What's unclear: Whether to install as editable or copy sources - - Recommendation: `COPY external/platform-agent_api /build/lambda_agent_api && pip install /build/lambda_agent_api` in Dockerfile - ---- - -## Environment Availability - -| Dependency | Required By | Available | Version | Fallback | -|------------|-------------|-----------|---------|----------| -| Python 3.11+ | All | ✓ | System | — | -| aiohttp | AgentApi WS | ✓ | >=3.9 in deps | — | -| httpx | POST /reset | ✓ | >=0.27 in deps | aiohttp | -| matrix-nio | Matrix bot | ✓ | >=0.21 in deps | — | -| lambda_agent_api | AgentApi | local only | 0.1.0 | — | -| Docker | Container build | [ASSUMED] standard dev env | — | — | -| platform-agent (running) | Integration test | local clone | origin/main needed | — | - ---- - -## Validation Architecture - -### Test Framework -| Property | Value | -|----------|-------| -| Framework | pytest + pytest-asyncio (asyncio_mode = "auto") | -| Config file | pyproject.toml `[tool.pytest.ini_options]` | -| Quick run command | `pytest tests/platform/test_real.py tests/adapter/matrix/test_dispatcher.py -v` | -| Full suite command | `pytest tests/ -v` | - -### Phase Requirements → Test Map - -| Req | Behavior | Test Type | File | -|-----|----------|-----------|------| -| Remove build_thread_key | Function gone from sdk/ | unit | `tests/platform/test_agent_session.py` — update/remove | -| AgentApi replaces AgentSessionClient | `RealPlatformClient` uses `AgentApi` | unit | `tests/platform/test_real.py` — update | -| !save sends prompt to agent | Command dispatches agent message | unit | `tests/adapter/matrix/test_dispatcher.py` — add | -| !load shows list | Command returns numbered list | unit | `tests/adapter/matrix/test_dispatcher.py` — add | -| !load numeric select | Bot intercepts digit, sends load prompt | unit | `tests/adapter/matrix/test_dispatcher.py` — add | -| !reset shows dialog | Command returns confirmation UI | unit | `tests/adapter/matrix/test_dispatcher.py` — add | -| !context returns snapshot | Command returns session info | unit | `tests/adapter/matrix/test_dispatcher.py` — add | -| PrototypeStateStore saved sessions | add/list saved sessions | unit | `tests/platform/test_prototype_state.py` — add | - -### Wave 0 Gaps -- [ ] `tests/platform/test_agent_api_integration.py` — unit tests for `RealPlatformClient` with mocked `AgentApi` -- [ ] `tests/adapter/matrix/test_context_commands.py` — dedicated module for !save/!load/!reset/!context handlers - ---- - -## Sources - -### Primary (HIGH confidence — verified by file read in this session) -- `external/platform-agent_api/lambda_agent_api/agent_api.py` — AgentApi constructor, connect/close/send_message, _listen loop -- `external/platform-agent_api/lambda_agent_api/server.py` — MsgEventTextChunk, MsgEventEnd, MsgStatus, AgentEventUnion types -- `external/platform-agent_api/lambda_agent_api/client.py` — MsgUserMessage type -- `external/platform-agent/src/api/external.py` — current (patched) and origin/main versions verified via git show -- `adapter/matrix/handlers/__init__.py` — handler registration pattern -- `adapter/matrix/store.py` — pending_confirm key pattern -- `adapter/matrix/bot.py` — MatrixBot, build_runtime, _build_platform_from_env -- `sdk/agent_session.py` — current AgentSessionClient (to be replaced) -- `sdk/real.py` — RealPlatformClient (to be updated) -- `sdk/prototype_state.py` — PrototypeStateStore (to be extended) -- `core/protocol.py` — IncomingCommand, OutgoingMessage types -- `pyproject.toml` — dependency versions -- `external/platform-agent_api/pyproject.toml` — Python version constraint - -### Tertiary (LOW confidence) -- Docker best practices for Python apps [ASSUMED] — standard industry pattern - ---- - -## Metadata - -**Confidence breakdown:** -- AgentApi interface: HIGH — read source directly -- platform-agent origin/main diff: HIGH — verified via `git show origin/main` -- handler registration pattern: HIGH — read all handler files -- pending_confirm key pattern: HIGH — read store.py directly -- tokens_used interception: MEDIUM — pattern clear but implementation needs care -- Docker/docker-compose: MEDIUM — standard pattern, not verified against specific matrix-nio requirements - -**Research date:** 2026-04-16 -**Valid until:** 2026-05-16 (lambda_agent_api is local — stable until platform team updates it) diff --git a/.planning/phases/05-mvp-deployment/05-01-PLAN.md b/.planning/phases/05-mvp-deployment/05-01-PLAN.md deleted file mode 100644 index 2320eda..0000000 --- a/.planning/phases/05-mvp-deployment/05-01-PLAN.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -phase: 05-mvp-deployment -plan: 01 -type: execute -wave: 1 -depends_on: [] -files_modified: - - adapter/matrix/reconciliation.py - - adapter/matrix/bot.py - - tests/adapter/matrix/test_reconciliation.py - - tests/adapter/matrix/test_restart_persistence.py -autonomous: true -requirements: - - PH05-01 - - PH05-03 -must_haves: - truths: - - "On restart, existing Matrix Space and child-room topology is rebuilt before live sync begins." - - "Restart recovery preserves Space+rooms UX instead of creating duplicate DM-style working rooms." - - "Recovered rooms regain user metadata, room metadata, and chat bindings needed for normal routing." - - "Legacy working rooms missing `platform_chat_id` are backfilled deterministically during startup before strict routing handles traffic." - artifacts: - - path: "adapter/matrix/reconciliation.py" - provides: "Authoritative restart reconciliation from Matrix topology into local metadata" - - path: "adapter/matrix/bot.py" - provides: "Startup wiring that runs reconciliation before sync_forever" - - path: "tests/adapter/matrix/test_reconciliation.py" - provides: "Regression coverage for startup recovery and idempotence" - key_links: - - from: "adapter/matrix/bot.py" - to: "adapter/matrix/reconciliation.py" - via: "startup bootstrap before sync_forever" - pattern: "reconcil" - - from: "adapter/matrix/reconciliation.py" - to: "core/chat.py" - via: "chat manager rebuild for recovered rooms" - pattern: "get_or_create" ---- - - -Rebuild Matrix-local routing state from authoritative Space topology before the bot processes live traffic. - -Purpose: Preserve the Phase 01 Space+rooms contract after restart even if SQLite metadata is partial or missing. -Output: A startup reconciliation module, bot wiring, and regression tests proving no DM-first duplication on restart. - - - -@/Users/a/.codex/get-shit-done/workflows/execute-plan.md -@/Users/a/.codex/get-shit-done/templates/summary.md - - - -@.planning/PROJECT.md -@.planning/ROADMAP.md -@.planning/STATE.md -@.planning/phases/05-mvp-deployment/05-RESEARCH.md -@.planning/phases/05-mvp-deployment/05-VALIDATION.md -@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md -@adapter/matrix/bot.py -@adapter/matrix/store.py -@adapter/matrix/handlers/auth.py -@tests/adapter/matrix/test_invite_space.py -@tests/adapter/matrix/test_chat_space.py -@tests/adapter/matrix/test_restart_persistence.py - - -From `adapter/matrix/bot.py`: - -```python -async def prepare_live_sync(client: AsyncClient) -> str | None: - response = await client.sync(timeout=0, full_state=True) - if isinstance(response, SyncResponse): - return response.next_batch - return None -``` - -```python -class MatrixBot: - async def _bootstrap_unregistered_room( - self, - room: MatrixRoom, - sender: str, - ) -> list[OutgoingEvent] | None: ... -``` - -From `adapter/matrix/store.py`: - -```python -async def get_room_meta(store: StateStore, room_id: str) -> dict | None: ... -async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None: ... -async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None: ... -async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None: ... -async def next_platform_chat_id(store: StateStore) -> str: ... -``` - - - - - - - Task 1: Add restart reconciliation regression coverage - tests/adapter/matrix/test_reconciliation.py, tests/adapter/matrix/test_restart_persistence.py - tests/adapter/matrix/test_invite_space.py, tests/adapter/matrix/test_chat_space.py, tests/adapter/matrix/test_restart_persistence.py, adapter/matrix/bot.py, adapter/matrix/handlers/auth.py, .planning/phases/05-mvp-deployment/05-RESEARCH.md - - - Test 1: startup recovery rebuilds user space metadata, room metadata, and chat bindings from Matrix topology without creating new working rooms (per D-Phase05-reset and PH05-01). - - Test 2: reconciliation is idempotent and safe when local SQLite state is already present. - - Test 3: reconciliation happens before lazy `_bootstrap_unregistered_room()` would run for existing rooms (per PH05-03). - - Test 4: legacy room metadata missing `platform_chat_id` is backfilled deterministically at startup and persisted before routed handling begins. - - - - `tests/adapter/matrix/test_reconciliation.py` exists and names reconciliation entrypoints explicitly. - - The new tests assert restored `space_id`, `chat_id`, `matrix_user_id`, and `platform_chat_id` values for recovered rooms. - - The regression slice also proves existing Space onboarding behavior still passes by running `test_invite_space.py` and `test_chat_space.py`. - - The automated command in `` fails before implementation or would fail if reconciliation is removed. - - Create a dedicated `tests/adapter/matrix/test_reconciliation.py` module and extend restart persistence coverage so Phase 05 has a real Wave 0 contract. Model the recovered topology after the Phase 01 Space+rooms onboarding tests, not a DM-first flow, and explicitly keep those onboarding regressions in the verification slice so restart hardening cannot break provisioning UX. Cover recovery of `user_meta`, `room_meta`, `ChatManager` bindings, and room-local routing fields from Matrix-side state before live callbacks begin, including deterministic backfill for legacy rooms that predate `platform_chat_id`. Keep temporary UX state out of scope, per research. - - pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py -v - - Phase 05 has failing-or-red-before-code tests that define authoritative restart reconciliation behavior and exclude duplicate room provisioning. - - - - Task 2: Implement authoritative startup reconciliation and wire it before live sync - adapter/matrix/reconciliation.py, adapter/matrix/bot.py - adapter/matrix/bot.py, adapter/matrix/store.py, adapter/matrix/handlers/auth.py, tests/adapter/matrix/test_reconciliation.py, tests/adapter/matrix/test_restart_persistence.py, .planning/phases/05-mvp-deployment/05-RESEARCH.md - - - Test 1: startup rebuild runs after login and initial full-state fetch, but before `sync_forever()` processes live events. - - Test 2: recovered rooms keep their existing Space+rooms identity and do not trigger `_bootstrap_unregistered_room()` unless the room is genuinely new. - - Test 3: local metadata can be rebuilt from Matrix topology when SQLite entries are missing, while existing valid metadata remains stable. - - Test 4: startup repair assigns a deterministic `platform_chat_id` to legacy rooms missing that field and persists it before routed platform calls can occur. - - - - `adapter/matrix/reconciliation.py` exports a focused reconciliation entrypoint used by startup code. - - `adapter/matrix/bot.py` invokes reconciliation before `client.sync_forever(...)`. - - Recovered room metadata includes `room_type`, `chat_id`, `space_id`, `matrix_user_id`, and `platform_chat_id` where available or rebuildable. - - Legacy rooms missing `platform_chat_id` follow one documented startup backfill path rather than ad hoc routing fallbacks. - - Implement a restart recovery module that treats Matrix topology as authoritative, per the Phase 05 reset and research notes. Rebuild missing local metadata for Space-owned working rooms, deterministically backfill missing `platform_chat_id` values for legacy rooms, and re-create `ChatManager` entries needed by routing, while keeping SQLite as a rebuildable cache rather than the source of truth. Wire the new reconciliation step into startup after the initial full-state sync and before live sync begins, and keep the onboarding regression slice green while doing it. Do not widen into timeline scraping, new storage backends, or DM-first fallbacks. - - pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py tests/adapter/matrix/test_dispatcher.py -v - - Restart recovery restores the minimum durable state for existing Space rooms before live traffic, and the guarded regression suite passes. - - - - - -Run the onboarding, reconciliation, restart-persistence, and Matrix dispatcher slices together. Confirm startup now has a deterministic pre-sync recovery and legacy-room backfill step instead of relying on lazy room bootstrap or routing-time fallbacks for existing topology. - - - -The bot can restart with partial or empty local room metadata, rebuild managed Space rooms before live sync, and continue handling those rooms without creating duplicate onboarding rooms. - - - -After completion, create `.planning/phases/05-mvp-deployment/05-01-SUMMARY.md` - diff --git a/.planning/phases/05-mvp-deployment/05-01-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-01-SUMMARY.md deleted file mode 100644 index c50f371..0000000 --- a/.planning/phases/05-mvp-deployment/05-01-SUMMARY.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -phase: 05-mvp-deployment -plan: 01 -subsystem: infra -tags: [matrix, reconciliation, sqlite, startup, testing] -requires: - - phase: 01-matrix-mvp - provides: Space+rooms onboarding, room metadata, and Matrix dispatcher behavior - - phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma - provides: durable platform_chat_id and restart persistence primitives -provides: - - authoritative startup reconciliation from Matrix room topology into local metadata - - pre-sync startup wiring that repairs managed rooms before live traffic - - restart regression coverage for reconciliation, idempotence, and legacy platform_chat_id backfill -affects: [matrix, startup, deployment, restart-persistence] -tech-stack: - added: [] - patterns: [matrix-topology-as-source-of-truth, sqlite-cache-rebuild, pre-sync-reconciliation] -key-files: - created: [adapter/matrix/reconciliation.py, tests/adapter/matrix/test_reconciliation.py] - modified: [adapter/matrix/bot.py, tests/adapter/matrix/test_restart_persistence.py] -key-decisions: - - "Treat synced Matrix parent/child topology as authoritative for managed room recovery; keep SQLite rebuildable." - - "Backfill missing platform_chat_id values during startup reconciliation instead of routing-time fallbacks." -patterns-established: - - "Startup runs full-state sync, then reconciliation, then sync_forever." - - "Recovered Matrix rooms rebuild user_meta, room_meta, auth state, and ChatManager bindings idempotently." -requirements-completed: [PH05-01, PH05-03] -duration: 8min -completed: 2026-04-27 ---- - -# Phase 05 Plan 01: Restart Reconciliation Summary - -**Matrix startup now rebuilds Space-owned working rooms into durable local routing state before live sync begins** - -## Performance - -- **Duration:** 8 min -- **Started:** 2026-04-27T22:00:47Z -- **Completed:** 2026-04-27T22:08:47Z -- **Tasks:** 2 -- **Files modified:** 4 - -## Accomplishments -- Added a dedicated reconciliation module that restores `user_meta`, `room_meta`, auth state, chat bindings, and missing `platform_chat_id` values from the synced Matrix room graph. -- Wired startup to run reconciliation immediately after the initial full-state sync and before `sync_forever()`. -- Added regression coverage for recovery, idempotence, pre-sync ordering, onboarding compatibility, and legacy restart backfill. - -## Task Commits - -Each task was committed atomically: - -1. **Task 1: Add restart reconciliation regression coverage** - `a75b26a` (test) -2. **Task 2: Implement authoritative startup reconciliation and wire it before live sync** - `8a80d00` (feat) - -## Files Created/Modified -- `adapter/matrix/reconciliation.py` - Startup recovery from Matrix topology into local room and user metadata. -- `adapter/matrix/bot.py` - Startup wiring that runs reconciliation after the bootstrap sync and before live sync. -- `tests/adapter/matrix/test_reconciliation.py` - Recovery, idempotence, and startup-order regression coverage. -- `tests/adapter/matrix/test_restart_persistence.py` - Legacy `platform_chat_id` backfill persistence coverage. - -## Decisions Made -- Used the synced Matrix room graph as the authoritative source for restart recovery, while preserving existing local metadata whenever it is already valid. -- Kept legacy `platform_chat_id` repair on a single startup path so routed handling never needs ad hoc fallback creation for existing rooms. - -## Deviations from Plan - -### Auto-fixed Issues - -**1. [Rule 3 - Blocking] Switched verification to a clean `uv run pytest` environment** -- **Found during:** Task 1 and Task 2 verification -- **Issue:** The default `pytest` path used a mismatched virtualenv without repo dependencies, and `.env` injected Matrix backend variables that polluted mock-mode tests. -- **Fix:** Ran the verification slice through `uv run pytest` with `UV_CACHE_DIR=/tmp/uv-cache-surfaces` and blank `MATRIX_AGENT_REGISTRY_PATH` / `MATRIX_PLATFORM_BACKEND` values to match the intended test environment. -- **Files modified:** None -- **Verification:** `uv run pytest` slice passed with 50/50 tests green -- **Committed in:** not applicable (verification-only adjustment) - ---- - -**Total deviations:** 1 auto-fixed (1 blocking) -**Impact on plan:** Verification needed an environment correction, but code scope stayed within the plan and owned files. - -## Issues Encountered -- The shell environment loaded deployment-oriented Matrix backend settings from `.env`; these had to be neutralized for the mock-mode regression slice. - -## User Setup Required - -None - no external service configuration required. - -## Next Phase Readiness -- Restart recovery is in place for existing Space rooms, including deterministic legacy `platform_chat_id` repair. -- Remaining Phase 05 plans can build on a stable pre-sync recovery path instead of lazy bootstrap for existing topology. - -## Self-Check: PASSED - ---- -*Phase: 05-mvp-deployment* -*Completed: 2026-04-27* diff --git a/.planning/phases/05-mvp-deployment/05-02-PLAN.md b/.planning/phases/05-mvp-deployment/05-02-PLAN.md deleted file mode 100644 index dc93cf0..0000000 --- a/.planning/phases/05-mvp-deployment/05-02-PLAN.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -phase: 05-mvp-deployment -plan: 02 -type: execute -wave: 2 -depends_on: - - 05-01 -files_modified: - - adapter/matrix/handlers/__init__.py - - adapter/matrix/handlers/context_commands.py - - adapter/matrix/routed_platform.py - - tests/adapter/matrix/test_context_commands.py - - tests/adapter/matrix/test_routed_platform.py -autonomous: true -requirements: - - PH05-02 -must_haves: - truths: - - "Each working Matrix room uses its own durable `platform_chat_id` as the real agent context boundary." - - "`!clear` resets only the current room by rotating its `platform_chat_id` and disconnecting the old upstream chat." - - "Save, load, context, and routed send paths resolve through room-local platform context, not shared user state." - - "Strict room routing assumes startup reconciliation has already repaired legacy rooms missing `platform_chat_id`." - artifacts: - - path: "adapter/matrix/handlers/context_commands.py" - provides: "Room-local `!clear`, save/load/context resolution, and upstream disconnect behavior" - - path: "adapter/matrix/routed_platform.py" - provides: "Strict room -> agent_id + platform_chat_id routing" - - path: "tests/adapter/matrix/test_context_commands.py" - provides: "Regression coverage for `!clear` and room-local context commands" - key_links: - - from: "adapter/matrix/handlers/__init__.py" - to: "adapter/matrix/handlers/context_commands.py" - via: "IncomingCommand registration for `clear`" - pattern: "\"clear\"" - - from: "adapter/matrix/routed_platform.py" - to: "adapter/matrix/store.py" - via: "room metadata lookup" - pattern: "platform_chat_id" ---- - - -Make room-local platform context explicit and user-facing by shipping real `!clear` semantics and strict per-room routing. - -Purpose: Phase 05 must preserve Space+rooms UX while giving each room a true upstream context boundary. -Output: Updated command wiring, room-local context reset behavior, and routing regressions tied to `platform_chat_id`. - - - -@/Users/a/.codex/get-shit-done/workflows/execute-plan.md -@/Users/a/.codex/get-shit-done/templates/summary.md - - - -@.planning/PROJECT.md -@.planning/ROADMAP.md -@.planning/STATE.md -@.planning/phases/05-mvp-deployment/05-RESEARCH.md -@.planning/phases/05-mvp-deployment/05-VALIDATION.md -@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-02-SUMMARY.md -@adapter/matrix/handlers/__init__.py -@adapter/matrix/handlers/context_commands.py -@adapter/matrix/routed_platform.py -@tests/adapter/matrix/test_context_commands.py -@tests/adapter/matrix/test_routed_platform.py - - -From `adapter/matrix/handlers/__init__.py`: - -```python -dispatcher.register( - IncomingCommand, - "reset", - make_handle_reset(store, prototype_state) - if prototype_state is not None - else handle_settings, -) -``` - -From `adapter/matrix/handlers/context_commands.py`: - -```python -async def _resolve_context_scope( - event: IncomingCommand, - store: StateStore, - chat_mgr, -) -> tuple[str, str | None]: ... -``` - -From `adapter/matrix/routed_platform.py`: - -```python -async def _resolve_delegate(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]: - ... -``` - - - - - - - Task 1: Expand room-local context and clear-command tests - tests/adapter/matrix/test_context_commands.py, tests/adapter/matrix/test_routed_platform.py - tests/adapter/matrix/test_context_commands.py, tests/adapter/matrix/test_routed_platform.py, adapter/matrix/handlers/__init__.py, adapter/matrix/handlers/context_commands.py, adapter/matrix/routed_platform.py, .planning/phases/05-mvp-deployment/05-VALIDATION.md - - - Test 1: `!clear` rotates only the current room's `platform_chat_id` and disconnects only the old upstream chat (per PH05-02). - - Test 2: `!clear` is the supported command name; `!reset` may remain as a compatibility alias but must not be the only registered path. - - Test 3: routed send/stream paths fail fast when room metadata lacks `agent_id` or `platform_chat_id` instead of silently sharing context. - - Test 4: routed behavior uses startup-repaired room metadata and does not introduce a second fallback path that invents `platform_chat_id` during message handling. - - - - Tests explicitly mention `clear` in command registration or command invocation. - - The context-command tests assert old and new `platform_chat_id` values and upstream disconnect behavior. - - The routed-platform tests assert room-local IDs are passed to delegates unchanged. - - Extend the current Matrix context-command and routed-platform regressions so Phase 05 has direct coverage for `!clear`, room-local `platform_chat_id` rotation, and fail-fast routing when room bindings are incomplete. Treat startup reconciliation from `05-01` as the only supported repair path for legacy rooms missing `platform_chat_id`; the routed path must consume repaired metadata, not synthesize new room identities on demand. Preserve the Phase 04 prototype-state behavior where it still fits, but anchor new checks on per-room context isolation rather than shared session assumptions. - - pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py -v - - The tests define the Phase 05 room-local contract for reset/clear and for routed upstream calls. - - - - Task 2: Ship real room-local `!clear` semantics and strict routing - adapter/matrix/handlers/__init__.py, adapter/matrix/handlers/context_commands.py, adapter/matrix/routed_platform.py - adapter/matrix/handlers/__init__.py, adapter/matrix/handlers/context_commands.py, adapter/matrix/routed_platform.py, tests/adapter/matrix/test_context_commands.py, tests/adapter/matrix/test_routed_platform.py, .planning/phases/05-mvp-deployment/05-RESEARCH.md - - - Test 1: command registration exposes `!clear` as the real context-reset entrypoint for Matrix rooms. - - Test 2: only the active room's `platform_chat_id` rotates, and only the old upstream chat session is disconnected. - - Test 3: all room-local context commands resolve through recovered room metadata instead of falling back to shared user scope. - - Test 4: strict routing stays strict at runtime because legacy-room repair was already handled at startup by `05-01`, not by hidden message-path fallbacks. - - - - `adapter/matrix/handlers/__init__.py` registers `clear`; if `reset` remains, it is clearly a compatibility alias. - - `adapter/matrix/handlers/context_commands.py` resolves and rotates room-local platform context without touching other rooms. - - `adapter/matrix/routed_platform.py` keeps explicit `MATRIX_ROUTE_INCOMPLETE` behavior when bindings are missing. - - Update the Matrix context command surface to match the Phase 05 contract: real `!clear`, room-local `platform_chat_id` rotation, and upstream disconnect scoped to the old room context. Keep `save`, `load`, and `context` anchored to the same room-local identity. Tighten routed-platform behavior only where needed to preserve fail-fast semantics after startup reconciliation has repaired legacy rooms; do not reintroduce shared chat state, user-level reset behavior, or message-time backfill of missing `platform_chat_id`. - - pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_dispatcher.py -v - - Users can clear one working room without affecting others, and all routed upstream calls stay bound to room-local platform context. - - - - - -Run the context and routed-platform slices plus Matrix dispatcher smoke coverage to confirm the exposed command name and room-local routing behavior are consistent. - - - -Every working Matrix room has an independent upstream context boundary, and `!clear` resets only the room where it is invoked. - - - -After completion, create `.planning/phases/05-mvp-deployment/05-02-SUMMARY.md` - diff --git a/.planning/phases/05-mvp-deployment/05-02-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-02-SUMMARY.md deleted file mode 100644 index fa4a48c..0000000 --- a/.planning/phases/05-mvp-deployment/05-02-SUMMARY.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -phase: 05-mvp-deployment -plan: 02 -subsystem: matrix -tags: [matrix, routing, context, platform-chat-id, testing] -requires: - - phase: 05-01 - provides: startup reconciliation for room metadata before live routing -provides: - - room-local `!clear` coverage and command registration - - strict room-local context resolution for save/context flows - - fail-fast routed-platform regressions for incomplete room bindings -affects: [matrix-dispatcher, routed-platform, startup-reconciliation] -tech-stack: - added: [] - patterns: [per-room platform context, compatibility alias registration, fail-fast routing] -key-files: - created: [] - modified: - - adapter/matrix/handlers/__init__.py - - adapter/matrix/handlers/context_commands.py - - tests/adapter/matrix/test_context_commands.py - - tests/adapter/matrix/test_routed_platform.py -key-decisions: - - "Expose `clear` only when prototype room-context support is available, while keeping `reset` as a compatibility alias." - - "Require recovered `platform_chat_id` for save/context/clear flows instead of falling back to shared local chat ids." -patterns-established: - - "Matrix room context commands resolve through room metadata repaired at startup, not message-time backfill." - - "Reset semantics rotate one room's upstream chat id and disconnect only that old upstream session." -requirements-completed: [PH05-02] -duration: 16 min -completed: 2026-04-27 ---- - -# Phase 05 Plan 02: Room-local clear and strict Matrix routing Summary - -**Room-local `!clear` command coverage, strict per-room `platform_chat_id` context resolution, and fail-fast Matrix routing regressions** - -## Performance - -- **Duration:** 16 min -- **Started:** 2026-04-27T22:00:00Z -- **Completed:** 2026-04-27T22:15:58Z -- **Tasks:** 2 -- **Files modified:** 4 - -## Accomplishments -- Added RED/GREEN regression coverage for `!clear`, room-local chat-id rotation, and strict routed-platform failure modes. -- Registered `clear` as the supported reset entrypoint when room-context support exists, while preserving `reset` as a compatibility alias. -- Removed shared-context fallbacks from save/context handling and fixed reset to clear the old upstream prototype session before rotation. - -## Task Commits - -Each task was committed atomically: - -1. **Task 1: Expand room-local context and clear-command tests** - `ae37476` (test) -2. **Task 2: Ship real room-local `!clear` semantics and strict routing** - `85e2fda` (feat) - -## Files Created/Modified -- `adapter/matrix/handlers/__init__.py` - registers `clear` for runtimes with prototype room-context support and keeps `reset` as the compatibility alias. -- `adapter/matrix/handlers/context_commands.py` - requires room-local `platform_chat_id` for save/context/clear flows and disconnects the old upstream context on clear. -- `tests/adapter/matrix/test_context_commands.py` - covers room-local clear rotation, upstream disconnect, and clear registration. -- `tests/adapter/matrix/test_routed_platform.py` - covers incomplete-room fail-fast behavior and repaired metadata routing. - -## Decisions Made -- Exposed `clear` only on runtimes that actually have prototype room-context support so Phase 05 semantics do not break older MVP smoke tests. -- Treated startup-repaired room metadata as the only supported source of `platform_chat_id` for context commands instead of falling back to local chat ids. - -## Deviations from Plan - -### Auto-fixed Issues - -**1. [Rule 1 - Bug] Clear path was wiping the new context instead of the old upstream session** -- **Found during:** Task 2 (Ship real room-local `!clear` semantics and strict routing) -- **Issue:** `make_handle_reset()` cleared prototype session state for the newly generated chat id, leaving the previous upstream context intact. -- **Fix:** Cleared prototype state for the old `platform_chat_id` before rotating to the new room-local id, and kept the new context empty as well. -- **Files modified:** `adapter/matrix/handlers/context_commands.py` -- **Verification:** `MATRIX_AGENT_REGISTRY_PATH='' MATRIX_PLATFORM_BACKEND='' UV_CACHE_DIR=/tmp/uv-cache-surfaces uv run pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_dispatcher.py -v` -- **Committed in:** `85e2fda` - ---- - -**Total deviations:** 1 auto-fixed (1 bug) -**Impact on plan:** The auto-fix was required for correct room-local clear behavior. No scope creep. - -## Issues Encountered -- Plain `pytest` used an environment without `PyYAML`; verification was switched to `uv run pytest` with `UV_CACHE_DIR=/tmp/uv-cache-surfaces`. -- Shared shell env exposed `MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml`, which broke unrelated Matrix smoke tests. Verification was run with `MATRIX_AGENT_REGISTRY_PATH='' MATRIX_PLATFORM_BACKEND=''` to match the intended mock-runtime test setup. - -## User Setup Required - -None - no external service configuration required. - -## Next Phase Readiness -- Matrix room-local clear semantics and routing contracts are now explicit and covered. -- Phase 05 follow-on work can assume startup reconciliation remains the only supported repair path for missing room routing metadata. - ---- -*Phase: 05-mvp-deployment* -*Completed: 2026-04-27* - -## Self-Check: PASSED - -- Found `.planning/phases/05-mvp-deployment/05-02-SUMMARY.md` -- Found commit `ae37476` -- Found commit `85e2fda` diff --git a/.planning/phases/05-mvp-deployment/05-03-PLAN.md b/.planning/phases/05-mvp-deployment/05-03-PLAN.md deleted file mode 100644 index 01023b3..0000000 --- a/.planning/phases/05-mvp-deployment/05-03-PLAN.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -phase: 05-mvp-deployment -plan: 03 -type: execute -wave: 1 -depends_on: [] -files_modified: - - adapter/matrix/files.py - - sdk/real.py - - tests/adapter/matrix/test_files.py - - tests/platform/test_real.py -autonomous: true -requirements: - - PH05-04 -must_haves: - truths: - - "Incoming Matrix attachments are written into a room-safe shared-volume path and passed upstream as relative workspace paths." - - "Agent-emitted files can be returned to Matrix users without inventing a separate file proxy." - - "The shared-volume contract works with the Phase 05 `/agents` deployment shape." - artifacts: - - path: "adapter/matrix/files.py" - provides: "Room-safe shared-volume path building and path resolution" - - path: "sdk/real.py" - provides: "Attachment path passthrough and send-file normalization" - - path: "tests/adapter/matrix/test_files.py" - provides: "Regression coverage for shared-volume path construction" - key_links: - - from: "adapter/matrix/files.py" - to: "sdk/real.py" - via: "relative `workspace_path` transport" - pattern: "workspace_path" - - from: "sdk/real.py" - to: "adapter/matrix/bot.py" - via: "OutgoingMessage attachments rendered back to Matrix" - pattern: "MsgEventSendFile" ---- - - -Harden the Matrix attachment path contract around the shared deployment volume instead of custom transport shims. - -Purpose: Phase 05 file handling must survive real deployment with room-safe paths and outbound file delivery through the existing shared-volume model. -Output: Attachment-path regressions and any targeted runtime fixes needed for `/agents`-backed shared volume behavior. - - - -@/Users/a/.codex/get-shit-done/workflows/execute-plan.md -@/Users/a/.codex/get-shit-done/templates/summary.md - - - -@.planning/PROJECT.md -@.planning/ROADMAP.md -@.planning/STATE.md -@.planning/phases/05-mvp-deployment/05-RESEARCH.md -@.planning/phases/05-mvp-deployment/05-VALIDATION.md -@docs/deploy-architecture.md -@docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md -@adapter/matrix/files.py -@sdk/real.py -@tests/adapter/matrix/test_files.py -@tests/platform/test_real.py - - -From `adapter/matrix/files.py`: - -```python -def build_workspace_attachment_path( - *, - workspace_root: Path, - matrix_user_id: str, - room_id: str, - filename: str, - timestamp: str | None = None, -) -> tuple[str, Path]: ... -``` - -From `sdk/real.py`: - -```python -@staticmethod -def _attachment_paths(attachments: list[Attachment] | None) -> list[str]: ... - -@staticmethod -def _attachment_from_send_file_event(event: MsgEventSendFile) -> Attachment: ... -``` - - - - - - - Task 1: Add shared-volume file contract tests for `/agents` deployment - tests/adapter/matrix/test_files.py, tests/platform/test_real.py - tests/adapter/matrix/test_files.py, tests/platform/test_real.py, adapter/matrix/files.py, sdk/real.py, docs/deploy-architecture.md, .planning/phases/05-mvp-deployment/05-RESEARCH.md - - - Test 1: incoming Matrix files land under a room-safe `surfaces/matrix/.../inbox/...` path that remains relative to the agent workspace contract. - - Test 2: upstream file events normalize `/workspace/...` and `/agents/...`-style absolute paths into relative `workspace_path` values. - - Test 3: attachment forwarding never switches to inline blobs or HTTP shim URLs (per PH05-04). - - - - `tests/adapter/matrix/test_files.py` asserts the path namespace includes sanitized user and room components. - - `tests/platform/test_real.py` contains explicit coverage for send-file path normalization. - - The automated test command in `` exercises both inbound and outbound sides of the shared-volume contract. - - Expand the file-flow regressions around the real deployment contract described in research and `docs/deploy-architecture.md`. Keep the tests centered on relative `workspace_path` transport and room-safe on-disk layout. Do not introduce proxy URLs, base64 payload transport, or new platform endpoints. - - pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py -v - - Phase 05 has direct test coverage for `/agents`-backed shared-volume behavior across inbound and outbound file paths. - - - - Task 2: Tighten attachment path handling for the shared volume contract - adapter/matrix/files.py, sdk/real.py - adapter/matrix/files.py, sdk/real.py, tests/adapter/matrix/test_files.py, tests/platform/test_real.py, docs/deploy-architecture.md - - - Test 1: inbound attachment helpers keep returning relative paths even when the bot writes into `/agents`. - - Test 2: outbound file normalization accepts absolute paths from the agent runtime but strips them back to relative workspace paths for Matrix rendering. - - Test 3: no code path emits non-relative attachment references to the upstream agent API. - - - - `sdk/real.py` only forwards relative attachment paths to the agent API. - - `sdk/real.py` normalizes both `/workspace` and `/agents` absolute roots if present in send-file events. - - `adapter/matrix/files.py` remains the single source of truth for room-safe attachment path construction. - - Implement only the minimum runtime changes needed to satisfy the shared-volume tests. Keep `adapter/matrix/files.py` as the single place that builds surface-owned attachment paths, and keep `sdk/real.py` responsible only for attachment passthrough and send-file normalization. Do not widen this plan into compose edits, registry redesign, or bot command changes. - - pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v - - Incoming and outgoing file references stay compatible with the real shared-volume deployment contract, and the targeted file/path regressions pass. - - - - - -Run the Matrix file helper, real platform client, and outgoing-send slices together so the shared-volume contract is validated from write path through return-to-user rendering. - - - -The Matrix bot and agent runtime can exchange file references through the shared volume using only relative workspace paths and room-safe storage layout. - - - -After completion, create `.planning/phases/05-mvp-deployment/05-03-SUMMARY.md` - diff --git a/.planning/phases/05-mvp-deployment/05-03-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-03-SUMMARY.md deleted file mode 100644 index 0745e7c..0000000 --- a/.planning/phases/05-mvp-deployment/05-03-SUMMARY.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -phase: 05-mvp-deployment -plan: 03 -subsystem: infra -tags: [matrix, attachments, shared-volume, agents, pytest] -requires: - - phase: 04-matrix-mvp-shared-agent-context-and-context-management-comma - provides: direct AgentApi integration and Matrix outgoing file rendering -provides: - - shared-volume attachment path regressions for /agents deployment - - relative workspace-path normalization for upstream attachment transport - - send-file event normalization for Matrix outbound file rendering -affects: [matrix, deployment, shared-volume, file-transfer] -tech-stack: - added: [] - patterns: [relative workspace_path transport, shared-volume root normalization] -key-files: - created: [] - modified: - - tests/adapter/matrix/test_files.py - - tests/platform/test_real.py - - sdk/real.py -key-decisions: - - "Keep adapter/matrix/files.py as the only path-construction source; sdk/real.py only normalizes attachment references crossing the shared-volume boundary." - - "Normalize both /workspace and /agents absolute paths back to relative workspace paths before forwarding to the agent API or rendering Matrix send-file events." -patterns-established: - - "Matrix attachment transport stays relative even when the bot sees absolute shared-volume paths." - - "Agent-emitted file paths are rendered back to Matrix without HTTP proxy shims or inline blobs." -requirements-completed: [PH05-04] -duration: 3 min -completed: 2026-04-27 ---- - -# Phase 05 Plan 03: Shared-volume attachment path hardening Summary - -**Room-safe Matrix inbox paths and relative shared-volume attachment normalization for `/agents` deployment** - -## Performance - -- **Duration:** 3 min -- **Started:** 2026-04-27T22:02:34Z -- **Completed:** 2026-04-27T22:05:41Z -- **Tasks:** 2 -- **Files modified:** 3 - -## Accomplishments -- Added regression coverage for room-safe `surfaces/matrix/.../inbox/...` attachment layout under `/agents` workspaces. -- Added explicit tests for `/workspace/...` and `/agents/...` attachment-path normalization on both upstream send and downstream send-file rendering. -- Tightened `RealPlatformClient` so only relative `workspace_path` values are forwarded to the agent API. - -## Task Commits - -Each task was committed atomically: - -1. **Task 1: Add shared-volume file contract tests for `/agents` deployment** - `cafb0ec` (test) -2. **Task 2: Tighten attachment path handling for the shared volume contract** - `9a03160` (fix) - -## Files Created/Modified -- `tests/adapter/matrix/test_files.py` - covers room-safe shared-volume inbox paths under an `/agents` workspace root. -- `tests/platform/test_real.py` - covers relative attachment forwarding and send-file normalization for `/workspace` and `/agents` paths. -- `sdk/real.py` - normalizes shared-volume roots before attachments cross the upstream or Matrix rendering boundary. - -## Decisions Made -- Kept `adapter/matrix/files.py` unchanged so path construction remains centralized there. -- Reused one normalization helper in `sdk/real.py` for both `_attachment_paths()` and `_attachment_from_send_file_event()` to keep inbound and outbound behavior aligned. - -## Deviations from Plan - -### Auto-fixed Issues - -**1. [Rule 3 - Blocking] Adjusted verification to use the project uv environment** -- **Found during:** Task 2 (Tighten attachment path handling for the shared volume contract) -- **Issue:** The plan's raw `pytest` command collected with an interpreter missing `PyYAML`, which blocked `tests/adapter/matrix/test_send_outgoing.py` before runtime assertions could execute. -- **Fix:** Re-ran verification with `UV_CACHE_DIR=/tmp/uv-cache uv run pytest ...`, matching the repo's `CLAUDE.md` guidance and the project `.venv`. -- **Files modified:** None -- **Verification:** `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v` -- **Committed in:** None (verification-environment adjustment only) - ---- - -**Total deviations:** 1 auto-fixed (1 blocking) -**Impact on plan:** No scope creep. The deviation only changed the verification entrypoint so the planned test slice could run in the correct environment. - -## Issues Encountered -- The ambient `pytest` resolved to a different virtualenv than the project `.venv`, so direct verification was unreliable for dependency-backed Matrix tests. - -## User Setup Required - -None - no external service configuration required. - -## Next Phase Readiness -- Shared-volume file references now stay relative across inbound bot downloads, agent API attachment transport, and outbound Matrix send-file rendering. -- Phase 05 compose and deployment work can assume the `/agents` contract without adding file proxy infrastructure. - -## Self-Check: PASSED - -- Found `.planning/phases/05-mvp-deployment/05-03-SUMMARY.md` -- Verified commit `cafb0ec` exists in git history -- Verified commit `9a03160` exists in git history - ---- -*Phase: 05-mvp-deployment* -*Completed: 2026-04-27* diff --git a/.planning/phases/05-mvp-deployment/05-04-PLAN.md b/.planning/phases/05-mvp-deployment/05-04-PLAN.md deleted file mode 100644 index 4fe2235..0000000 --- a/.planning/phases/05-mvp-deployment/05-04-PLAN.md +++ /dev/null @@ -1,128 +0,0 @@ ---- -phase: 05-mvp-deployment -plan: 04 -type: execute -wave: 2 -depends_on: - - 05-03 -files_modified: - - docker-compose.prod.yml - - docker-compose.fullstack.yml - - Dockerfile - - .env.example - - README.md - - docs/deploy-architecture.md -autonomous: true -requirements: - - PH05-05 -must_haves: - truths: - - "Production handoff uses a bot-only compose artifact instead of the internal full-stack harness." - - "Internal E2E compose brings up the bot, platform-agent, and shared volume with explicit health-gated startup." - - "Deployment docs and env examples match the split compose artifacts and shared `/agents` contract." - artifacts: - - path: "docker-compose.prod.yml" - provides: "Bot-only deployment handoff artifact" - - path: "docker-compose.fullstack.yml" - provides: "Internal E2E harness with shared volume and dependency gating" - - path: ".env.example" - provides: "Documented runtime contract for Phase 05 deployment" - key_links: - - from: "docker-compose.fullstack.yml" - to: "docker-compose.prod.yml" - via: "shared service definition or explicit duplication" - pattern: "matrix-bot" - - from: "docs/deploy-architecture.md" - to: "docker-compose.prod.yml" - via: "operator handoff instructions" - pattern: "prod" ---- - - -Split deployment artifacts by operational intent so operator handoff and internal E2E testing stop sharing the same compose contract. - -Purpose: Phase 05 needs an explicit bot-only production artifact and a separate full-stack compose harness aligned with the shared-volume design. -Output: `docker-compose.prod.yml`, `docker-compose.fullstack.yml`, and updated env/docs describing when to use each. - - - -@/Users/a/.codex/get-shit-done/workflows/execute-plan.md -@/Users/a/.codex/get-shit-done/templates/summary.md - - - -@.planning/PROJECT.md -@.planning/ROADMAP.md -@.planning/STATE.md -@.planning/phases/05-mvp-deployment/05-RESEARCH.md -@.planning/phases/05-mvp-deployment/05-VALIDATION.md -@.planning/phases/04-matrix-mvp-shared-agent-context-and-context-management-comma/04-03-SUMMARY.md -@docs/deploy-architecture.md -@docker-compose.yml -@Dockerfile -@.env.example - - -Current root compose contract: - -```yaml -services: - platform-agent: - ... - matrix-bot: - build: . - env_file: .env - environment: - AGENT_BASE_URL: http://platform-agent:8000 - SURFACES_WORKSPACE_DIR: /workspace -``` - - - - - - - Task 1: Create split prod and fullstack compose artifacts - docker-compose.prod.yml, docker-compose.fullstack.yml, Dockerfile, .env.example - docker-compose.yml, Dockerfile, .env.example, docs/deploy-architecture.md, .planning/phases/05-mvp-deployment/05-RESEARCH.md, .planning/phases/05-mvp-deployment/05-VALIDATION.md - - - `docker-compose.prod.yml` defines only the bot-side runtime required for operator handoff. - - `docker-compose.fullstack.yml` includes the internal platform-agent service, shared volume mounts, and health-gated startup rather than sleep-based sequencing. - - `.env.example` documents the Phase 05 env contract without requiring the reader to inspect the old root compose file. - - Create two explicit compose artifacts per PH05-05: a bot-only `docker-compose.prod.yml` for deployment handoff and a `docker-compose.fullstack.yml` for internal E2E runs. Align mounts and env values with the Phase 05 shared `/agents` volume direction from research. Reuse the existing Dockerfile unless a small compatibility edit is required. Do not keep the old single-file compose setup as the only documented runtime. - - docker compose -f docker-compose.prod.yml config > /tmp/phase05-prod-compose.yml && docker compose -f docker-compose.fullstack.yml config > /tmp/phase05-fullstack-compose.yml && rg -n "^services:$|^ matrix-bot:$|^volumes:$|/agents" /tmp/phase05-prod-compose.yml && test -z "$(rg -n "^ platform-agent:$" /tmp/phase05-prod-compose.yml)" && rg -n "^ matrix-bot:$|^ platform-agent:$|condition: service_healthy|healthcheck:|/agents" /tmp/phase05-fullstack-compose.yml - - Both compose files render successfully and express distinct operational roles: prod handoff vs internal full-stack testing. - - - - Task 2: Update deployment docs and operator guidance for the split artifacts - README.md, docs/deploy-architecture.md - README.md, docs/deploy-architecture.md, docker-compose.prod.yml, docker-compose.fullstack.yml, .env.example - - - README or deploy doc tells the operator exactly which compose file to use for production vs internal E2E. - - The docs describe the shared `/agents` volume behavior and reference the relevant env vars. - - The old root `docker-compose.yml` is no longer the primary documented deployment path. - - Update the repo docs so the Phase 05 deployment story is executable without inference: production handoff stays bot-only, full-stack compose is for internal E2E, and shared-volume file behavior is described in the same terms as the runtime artifacts. Keep documentation narrowly scoped to the shipped compose split; do not widen into platform-master or future storage design. - - rg -n "docker-compose\\.prod|docker-compose\\.fullstack|/agents|prod handoff|full-stack" README.md docs/deploy-architecture.md .env.example && rg -n "production|deploy" README.md docs/deploy-architecture.md | rg "docker-compose\\.prod" && test -z "$(rg -n "docker compose up|docker-compose\\.yml" README.md docs/deploy-architecture.md | rg "production|deploy")" - - The docs and env guidance match the new compose artifacts and no longer imply a single shared deployment file. - - - - - -Render both compose files, then grep the docs for the new artifact names and `/agents` references so the operator contract is explicit and consistent. - - - -An operator can deploy the Matrix bot with the bot-only compose file, while developers can run the internal end-to-end harness separately without reinterpreting the deployment docs. - - - -After completion, create `.planning/phases/05-mvp-deployment/05-04-SUMMARY.md` - diff --git a/.planning/phases/05-mvp-deployment/05-04-SUMMARY.md b/.planning/phases/05-mvp-deployment/05-04-SUMMARY.md deleted file mode 100644 index 68a62c6..0000000 --- a/.planning/phases/05-mvp-deployment/05-04-SUMMARY.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -phase: 05-mvp-deployment -plan: 04 -subsystem: infra -tags: [docker-compose, matrix, deployment, agents, docs] -requires: - - phase: 05-03 - provides: "Shared /agents attachment contract and path normalization for Matrix runtime" -provides: - - "docker-compose.prod.yml bot-only deployment handoff artifact" - - "docker-compose.fullstack.yml internal E2E harness with health-gated platform-agent startup" - - "README and deploy architecture docs aligned to the split compose contract" -affects: [mvp-deployment, operator-handoff, internal-e2e] -tech-stack: - added: [Docker Compose] - patterns: [split-compose-by-operational-intent, shared-agents-volume-contract] -key-files: - created: [docker-compose.prod.yml, docker-compose.fullstack.yml] - modified: [.env.example, README.md, docs/deploy-architecture.md] -key-decisions: - - "Split Compose artifacts by runtime intent: bot-only prod handoff vs internal full-stack verification." - - "Document /agents as the bot-side shared volume root while internal platform-agent keeps /workspace on the same volume." -patterns-established: - - "Production operators use docker-compose.prod.yml and provide an external AGENT_BASE_URL." - - "Internal verification uses docker-compose.fullstack.yml with service_healthy gating instead of sleep-based startup." -requirements-completed: [PH05-05] -duration: 3 min -completed: 2026-04-27 ---- - -# Phase 05 Plan 04: Split deployment artifacts Summary - -**Bot-only production compose handoff plus a separate health-gated full-stack harness aligned to the shared `/agents` volume contract** - -## Performance - -- **Duration:** 3 min -- **Started:** 2026-04-27T22:12:42Z -- **Completed:** 2026-04-27T22:16:09Z -- **Tasks:** 2 -- **Files modified:** 5 - -## Accomplishments -- Added `docker-compose.prod.yml` as the operator-facing bot-only runtime artifact. -- Added `docker-compose.fullstack.yml` for internal E2E runs with `platform-agent` health-gated startup. -- Updated env and deployment docs so `/agents` and the split compose flow are explicit without relying on the old root compose file. - -## Task Commits - -Each task was committed atomically: - -1. **Task 1: Create split prod and fullstack compose artifacts** - `df6d8bf` (feat) -2. **Task 2: Update deployment docs and operator guidance for the split artifacts** - `22a3a2b` (docs) - -**Plan metadata:** pending final docs commit after state updates - -## Files Created/Modified -- `docker-compose.prod.yml` - bot-only deployment handoff with `/agents` volume contract -- `docker-compose.fullstack.yml` - internal harness extending the bot service and adding health-checked `platform-agent` -- `.env.example` - Phase 05 runtime variables for prod handoff and internal full-stack defaults -- `README.md` - operator-facing instructions for choosing the correct compose artifact -- `docs/deploy-architecture.md` - deployment topology and shared-volume guidance aligned to the split artifacts - -## Decisions Made -- Split deployment packaging by operational intent instead of overloading one compose file for both prod handoff and internal testing. -- Kept the bot’s absolute shared path rooted at `/agents` in docs and env examples while letting the internal `platform-agent` consume the same named volume at `/workspace`. - -## Deviations from Plan - -None - plan executed exactly as written. - -## Issues Encountered - -- The docs verification grep treated `deploy` inside the filename `docs/deploy-architecture.md` as a false positive when root compose was still named explicitly. The fix was to remove the literal root compose filename from deploy-path wording while keeping the deprecation clear. - -## User Setup Required - -None - no external service configuration required beyond populating `.env` from `.env.example`. - -## Next Phase Readiness - -- Operators now have a bot-only compose artifact for handoff, and developers have a separate internal E2E harness. -- Remaining Phase 05 work can rely on the split runtime contract without reinterpreting deployment docs. - -## Self-Check: PASSED - -- Summary file exists at `.planning/phases/05-mvp-deployment/05-04-SUMMARY.md` -- Commit `df6d8bf` found in git history -- Commit `22a3a2b` found in git history - ---- -*Phase: 05-mvp-deployment* -*Completed: 2026-04-27* diff --git a/.planning/phases/05-mvp-deployment/05-RESEARCH.md b/.planning/phases/05-mvp-deployment/05-RESEARCH.md deleted file mode 100644 index 6ccb0cd..0000000 --- a/.planning/phases/05-mvp-deployment/05-RESEARCH.md +++ /dev/null @@ -1,411 +0,0 @@ -# Phase 05: MVP Deployment - Research - -**Researched:** 2026-04-28 -**Domain:** Matrix bot production deployment, restart reconciliation, per-room context isolation, shared-volume file transfer -**Confidence:** HIGH - -## Project Constraints (from CLAUDE.md) - -- All platform calls must stay behind `platform/interface.py` (`PlatformClient` protocol). -- Current platform implementation is a mock / replaceable adapter; architecture must not depend on unfinished upstream SDK. -- Keep architecture decisions inside this repo and document contracts locally. -- Prefer async, adapter/core separation, and do not bypass the existing `core/` and `adapter/` layering. -- Use `uv sync` for dependency installation. -- Use `pytest tests/ -v` and adapter-specific pytest slices for verification. -- Never commit `.env`. -- Dependency order remains fixed: `core/` first, `platform/` second, adapters after that. - -## Summary - -Phase 05 should not introduce a new stack. The established implementation path is to harden the existing `matrix-nio + SQLiteStore + RoutedPlatformClient + shared workspace volume` design so production restart behavior matches the current Space+rooms UX. The main architectural rule is: Matrix topology is authoritative for room existence, while local SQLite metadata is authoritative only after reconciliation has rebuilt it. - -The production-safe approach is to bind every working Matrix room to its own durable `platform_chat_id`, rotate only that identifier for `!clear`, and make restart recovery idempotent. Reconciliation should rebuild `user_meta`, `room_meta`, `ChatManager` entries, and missing routing fields from Matrix Space membership and room state before `sync_forever()` begins processing live traffic. Unknown rooms must be reconciled first, not silently converted into new chats. - -For files, keep the current shared-volume contract and relative `workspace_path` transport. Do not build HTTP file shims or embed file payloads in bot-side state. For deployment artifacts, split runtime intent explicitly: `docker-compose.prod.yml` is a bot-only handoff contract, while `docker-compose.fullstack.yml` is the internal E2E harness that brings up platform services and shared volumes together. - -**Primary recommendation:** Implement Phase 05 as a reconciliation-and-deploy hardening pass on the current Matrix stack, with Matrix Space state as source of truth and per-room `platform_chat_id` as the routing key. - -## Standard Stack - -### Core -| Library | Version | Purpose | Why Standard | -|---------|---------|---------|--------------| -| `matrix-nio` | 0.25.2 | Async Matrix client, Spaces, media upload/download, token login, sync loop | Already in repo; official docs confirm support for Spaces, token login, `room_put_state`, `upload`, `download`, and `sync_forever` | -| `sqlite3` / `SQLiteStore` | stdlib / repo-local | Durable bot metadata (`room_meta`, `user_meta`, routing state) | Small, local, restart-safe KV layer already used by runtime and tests | -| `PyYAML` | 6.0.3 | Agent registry / deployment config parsing | Current repo standard for `config/matrix-agents.yaml`-style artifacts | -| `httpx` | 0.28.1 | Async HTTP for auxiliary platform calls | Already used; fits async runtime and current codebase | -| Docker Compose | v2 spec; local install `v2.40.3` | Prod/fullstack topology, shared named volumes, health-gated startup | Officially supports multi-file overlays, named volumes, and `service_healthy` gating | - -### Supporting -| Library | Version | Purpose | When to Use | -|---------|---------|---------|-------------| -| `structlog` | 25.5.0 | Structured runtime logging | Use for reconciliation summaries, routing mismatches, and deploy diagnostics | -| `pydantic` | 2.13.3 | Typed config / payload validation | Use for any new deployment config or reconciliation report structures | -| `python-dotenv` | 1.2.2 | Local env loading | Keep for local and compose-driven runtime config | -| `pytest` | 9.0.3 | Test runner | Full phase verification and regression slices | -| `pytest-asyncio` | 1.3.0 | Async test execution | Required for reconciliation/runtime tests | - -### Alternatives Considered -| Instead of | Could Use | Tradeoff | -|------------|-----------|----------| -| `matrix-nio` | Synapse Admin / raw Matrix HTTP calls | Worse fit; repo already depends on nio abstractions and tests | -| repo-local `SQLiteStore` | Redis/Postgres | Unnecessary operational scope increase for MVP deployment | -| shared volume file flow | custom file proxy / presigned URLs | More moving parts, more auth/cleanup edge cases, no need for MVP | -| split compose files | one overloaded compose file with profiles | Harder operator handoff; less explicit prod vs internal-test intent | - -**Installation:** -```bash -uv sync -``` - -**Version verification:** Verified on 2026-04-28 from PyPI and local environment. - -| Package | Verified Version | Publish Date | Source | -|---------|------------------|--------------|--------| -| `matrix-nio` | 0.25.2 | 2024-10-04 | PyPI | -| `httpx` | 0.28.1 | 2024-12-06 | PyPI | -| `structlog` | 25.5.0 | 2025-10-27 | PyPI | -| `pydantic` | 2.13.3 | 2026-04-20 | PyPI | -| `aiohttp` | 3.13.5 | 2026-03-31 | PyPI | -| `PyYAML` | 6.0.3 | 2025-09-25 | PyPI | -| `python-dotenv` | 1.2.2 | 2026-03-01 | PyPI | -| `pytest` | 9.0.3 | 2026-04-07 | PyPI | -| `pytest-asyncio` | 1.3.0 | 2025-11-10 | PyPI | - -## Architecture Patterns - -### Recommended Project Structure -```text -adapter/matrix/ -├── bot.py # startup, sync bootstrap, live callbacks -├── reconciliation.py # new: restart recovery from Matrix state -├── files.py # shared-volume path building / materialization -├── routed_platform.py # room -> agent_id + platform_chat_id routing -├── store.py # room_meta/user_meta helpers and counters -└── handlers/ - ├── auth.py # Space + first room provisioning - ├── chat.py # !new / !archive / !rename - └── context_commands.py # !save / !load / !clear / !context - -deploy/ -├── docker-compose.prod.yml # bot-only handoff -└── docker-compose.fullstack.yml # internal E2E stack -``` - -### Pattern 1: Matrix Space State Is Canonical, SQLite Is Rebuildable -**What:** Treat Matrix Space membership and child-room state as the source of truth for room topology; use local SQLite metadata as a cached routing index that reconciliation can rebuild. -**When to use:** Startup, DB loss, stale local metadata, and any deployment where rooms may outlive the bot process. -**Example:** -```python -# Source: repo pattern from adapter/matrix/store.py + Matrix Space state -room_meta = { - "room_type": "chat", - "chat_id": "C7", - "display_name": "Research", - "matrix_user_id": "@alice:example.org", - "space_id": "!space:example.org", - "agent_id": "agent-1", - "platform_chat_id": "42", -} -await set_room_meta(store, room_id, room_meta) -await chat_mgr.get_or_create( - user_id=room_meta["matrix_user_id"], - chat_id=room_meta["chat_id"], - platform="matrix", - surface_ref=room_id, - name=room_meta["display_name"], -) -``` - -### Pattern 2: Per-Room `platform_chat_id` Is the Only Real Context Boundary -**What:** Route every working Matrix room to its own durable `platform_chat_id`. -**When to use:** Normal messaging, `!save`, `!load`, `!context`, `!clear`, restart restoration. -**Example:** -```python -# Source: adapter/matrix/routed_platform.py + adapter/matrix/handlers/context_commands.py -old_chat_id = room_meta["platform_chat_id"] -new_chat_id = await next_platform_chat_id(store) -await set_platform_chat_id(store, room_id, new_chat_id) - -disconnect = getattr(platform, "disconnect_chat", None) -if callable(disconnect): - await disconnect(old_chat_id) -``` - -### Pattern 3: `!clear` Means Chat-ID Rotation, Not Global Wipe -**What:** Implement real clear by rotating only the current room's `platform_chat_id` and disconnecting the old upstream chat session. -**When to use:** User-triggered context reset for one room. -**Example:** -```python -# Source: adapter/matrix/handlers/context_commands.py -room_id = await _resolve_room_id(event, chat_mgr) -old_chat_id = (room_meta or {}).get("platform_chat_id") or room_id -new_chat_id = await next_platform_chat_id(store) -await set_platform_chat_id(store, room_id, new_chat_id) -``` - -### Pattern 4: Shared-Volume File Handoff Uses Relative Workspace Paths -**What:** Persist incoming Matrix media into a room-scoped path under the shared workspace, and pass only relative paths to the agent. -**When to use:** User uploads, staged attachments, agent-emitted files. -**Example:** -```python -# Source: adapter/matrix/files.py -relative_path = ( - Path("surfaces") / "matrix" / safe_user / safe_room / "inbox" / f"{stamp}-{safe_name}" -) -return Attachment( - type=attachment.type, - url=attachment.url, - filename=filename, - mime_type=attachment.mime_type, - workspace_path=relative_path.as_posix(), -) -``` - -### Pattern 5: Compose Split By Operational Intent -**What:** Keep one compose artifact for operator handoff and one for internal full-stack testing. -**When to use:** Deployment packaging. -**Example:** -```yaml -# docker-compose.prod.yml -services: - matrix-bot: - image: surfaces-bot:latest - env_file: .env - volumes: - - agents:/agents - -# docker-compose.fullstack.yml -services: - matrix-bot: - extends: - file: docker-compose.prod.yml - service: matrix-bot - platform-agent: - ... -volumes: - agents: -``` - -### Anti-Patterns to Avoid -- **Lazy bootstrap as restart strategy:** `_bootstrap_unregistered_room()` is acceptable for first-contact repair, not as the primary restart recovery path in production. -- **Per-user context identity:** a user-level or DM-level chat id breaks Space+rooms isolation and makes `!clear` incorrect. -- **Global reset endpoint semantics:** `!clear` must not wipe other rooms or all agent state for a user. -- **Absolute attachment paths in platform payloads:** keep agent attachment references relative to its workspace contract. -- **Sleep-based service readiness:** use Compose healthchecks and dependency conditions, not shell `sleep`. - -## Don't Hand-Roll - -| Problem | Don't Build | Use Instead | Why | -|---------|-------------|-------------|-----| -| Matrix room/Space protocol | Raw custom HTTP wrappers for state events | `matrix-nio` `room_create`, `room_put_state`, `space_get_hierarchy`, `sync_forever`, `upload`, `download` | Official support already exists and repo tests are built around nio | -| Restart topology discovery | Ad hoc timeline scraping | Full-state sync plus room state / Space child reconciliation | Timeline replay is noisy and brittle; state is the stable source | -| File transfer bus | Base64 blobs or custom bot-side file API | Shared `/agents/` volume with relative `workspace_path` | Lower operational complexity and already matches upstream agent contract | -| Compose startup sequencing | Shell loops / sleeps | `healthcheck` + `depends_on: condition: service_healthy` | Official Compose behavior is deterministic and observable | -| Context reset | Deleting all SQLite rows or resetting the whole user | Rotate current room `platform_chat_id` and drop that room's live agent connection | Preserves other rooms and matches user expectation | - -**Key insight:** The deceptively hard problems in this phase are already solved by the current stack: Matrix room state, nio media handling, named volumes, and service health gating. Custom alternatives add more failure modes than value. - -## Common Pitfalls - -### Pitfall 1: Unknown room after restart creates a duplicate working chat -**What goes wrong:** The bot treats an existing room as unregistered and provisions a fresh room/tree. -**Why it happens:** Local SQLite metadata is missing, but Matrix topology still exists. -**How to avoid:** Run reconciliation before live sync callbacks; only allow lazy bootstrap for genuinely new first-contact rooms. -**Warning signs:** New `Чат N` rooms appear after restart without a matching user action. - -### Pitfall 2: `!clear` resets the wrong scope -**What goes wrong:** Clearing one room also clears another room, or does nothing because the upstream session key did not change. -**Why it happens:** Context is keyed by user or local `chat_id` instead of durable room-local `platform_chat_id`. -**How to avoid:** Always resolve room -> `platform_chat_id`, rotate it, and disconnect only the old upstream chat. -**Warning signs:** Two rooms share response history or `!context` reports the same platform context id. - -### Pitfall 3: Space child linkage is incomplete -**What goes wrong:** Rooms exist but do not appear correctly under the user's Space. -**Why it happens:** Missing or malformed `m.space.child` state, especially missing `via` data. -**How to avoid:** Persist `space_id`, write `m.space.child` with `state_key=room_id`, and reconcile child links on startup. -**Warning signs:** Element shows the room outside the Space, or not at all in the hierarchy. - -### Pitfall 4: Shared volume works locally but fails in deployment -**What goes wrong:** Agent-generated files cannot be read by the bot, or bot-downloaded files are unreadable by the agent. -**Why it happens:** Mount mismatch, wrong root (`/workspace` vs `/agents`), or container user/group permissions. -**How to avoid:** Standardize one shared root, keep relative workspace paths, and align container permissions with Compose volume configuration. -**Warning signs:** Attachment paths exist in metadata but not on disk inside the other container. - -### Pitfall 5: Compose `depends_on` starts too early -**What goes wrong:** Bot starts before dependent services are actually ready. -**Why it happens:** Short-form `depends_on` only waits for container start, not health. -**How to avoid:** Use healthchecks and long-form `depends_on` with `service_healthy` in the full-stack compose file. -**Warning signs:** First requests fail after fresh `docker compose up`, then succeed on retry. - -## Code Examples - -Verified patterns from official sources and current repo: - -### Create a Space with `matrix-nio` -```python -# Source: matrix-nio API docs -space_resp = await client.room_create( - name=f"Lambda — {display_name}", - visibility=RoomVisibility.private, - invite=[matrix_user_id], - space=True, -) -``` - -### Add a child room to a Space -```python -# Source: current repo pattern + Matrix spec -await client.room_put_state( - room_id=space_id, - event_type="m.space.child", - content={"via": [homeserver]}, - state_key=chat_room_id, -) -``` - -### Persist room-scoped attachment paths -```python -# Source: adapter/matrix/files.py -relative_path, absolute_path = build_workspace_attachment_path( - workspace_root=workspace_root, - matrix_user_id=matrix_user_id, - room_id=room_id, - filename=filename, -) -absolute_path.parent.mkdir(parents=True, exist_ok=True) -absolute_path.write_bytes(body) -``` - -### Health-gated startup in Compose -```yaml -# Source: Docker Compose docs -services: - matrix-bot: - depends_on: - platform-agent: - condition: service_healthy - - platform-agent: - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - interval: 10s - timeout: 5s - retries: 5 -``` - -## State of the Art - -| Old Approach | Current Approach | When Changed | Impact | -|--------------|------------------|--------------|--------| -| Per-user or single shared platform context | Per-room `platform_chat_id` | Repo direction corrected on 2026-04-28 | Enables true room isolation and correct `!clear` | -| Single overloaded compose runtime | Separate prod handoff and full-stack E2E compose files | Current Phase 05 scope | Reduces operator ambiguity | -| Unknown room auto-bootstrap as recovery | Explicit reconciliation before live traffic | Recommended for Phase 05 | Prevents duplicate chat trees after restart | -| File payloads treated as transport concern | Shared-volume relative path contract | Already present in repo | Keeps bot/platform contract simple and durable | - -**Deprecated/outdated:** -- Single-chat / DM-first deployment direction: explicitly discarded in Phase 05 reset. -- Global reset semantics for Matrix context commands: does not match Space+rooms UX. -- Using only local store as truth for restart recovery: unsafe once deployed rooms outlive the process. - -## Open Questions - -1. **What exact Matrix state should reconciliation trust for `chat_id` labels?** - - What we know: `room_meta.chat_id` is local and not derivable from Matrix protocol by default. - - What's unclear: whether chat labels should be reconstructed from room names, stored custom state, or cached local metadata when present. - - Recommendation: persist `chat_id` in local SQLite, but make reconciliation able to regenerate a stable fallback label and avoid blocking routing if the label is missing. - -2. **What readiness probe exists for `platform-agent` in the full-stack compose?** - - What we know: Compose health gating is the right pattern. - - What's unclear: whether upstream agent image already exposes a reliable health endpoint. - - Recommendation: inspect upstream container and add a bot-facing probe before finalizing `docker-compose.fullstack.yml`. - -3. **Should prod mount root remain `/workspace` or be renamed to `/agents` externally?** - - What we know: current code defaults to `SURFACES_WORKSPACE_DIR=/workspace`, while deployment docs describe shared `/agents/`. - - What's unclear: whether external handoff wants a host path named `/agents` while containers still use `/workspace`. - - Recommendation: keep one in-container canonical path and let host-side naming vary only in Compose mounts. - -## Environment Availability - -| Dependency | Required By | Available | Version | Fallback | -|------------|------------|-----------|---------|----------| -| Python | bot runtime | ✓ | 3.14.3 | — | -| `uv` | dependency install | ✓ | 0.9.30 | `pip` | -| `pytest` | validation | ✓ | 9.0.2 installed | `python -m pytest` | -| Docker Engine | deployment packaging / E2E compose | ✓ | 29.1.3 | none | -| Docker Compose | split runtime orchestration | ✓ | 2.40.3 | none | - -**Missing dependencies with no fallback:** -- None - -**Missing dependencies with fallback:** -- None - -## Validation Architecture - -### Test Framework -| Property | Value | -|----------|-------| -| Framework | `pytest` + `pytest-asyncio` | -| Config file | `pyproject.toml` | -| Quick run command | `pytest tests/adapter/matrix/test_restart_persistence.py -v` | -| Full suite command | `pytest tests/ -v` | - -### Phase Requirements → Test Map -| Req ID | Behavior | Test Type | Automated Command | File Exists? | -|--------|----------|-----------|-------------------|-------------| -| PH05-01 | Space+rooms onboarding remains primary UX | integration | `pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py -v` | ✅ | -| PH05-02 | Per-room `platform_chat_id` isolates routing and powers real clear | integration | `pytest tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_context_commands.py -v` | ✅ | -| PH05-03 | Restart reconciliation restores routing metadata | integration | `pytest tests/adapter/matrix/test_restart_persistence.py -v` | ❌ new reconciliation tests needed | -| PH05-04 | Shared-volume file transfer is room-safe | integration | `pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py -v` | ✅ partial | -| PH05-05 | Split prod/fullstack compose artifacts stay coherent | smoke | `docker compose -f docker-compose.prod.yml config && docker compose -f docker-compose.fullstack.yml config` | ❌ Wave 0 | - -### Sampling Rate -- **Per task commit:** `pytest tests/adapter/matrix/test_restart_persistence.py -v` -- **Per wave merge:** `pytest tests/adapter/matrix/ -v` -- **Phase gate:** `pytest tests/ -v` plus both compose files passing `docker compose ... config` - -### Wave 0 Gaps -- [ ] `tests/adapter/matrix/test_reconciliation.py` — startup recovery of user/room metadata from Matrix state -- [ ] `tests/adapter/matrix/test_context_commands.py` additions — `!clear` command contract and room-local rotation semantics -- [ ] `tests/adapter/matrix/test_compose_artifacts.py` or equivalent smoke command documentation — split compose validation -- [ ] `tests/adapter/matrix/test_files.py` additions — cross-room attachment path isolation and shared-root consistency - -## Sources - -### Primary (HIGH confidence) -- Local repo code and tests: - - `adapter/matrix/bot.py` - - `adapter/matrix/store.py` - - `adapter/matrix/files.py` - - `adapter/matrix/routed_platform.py` - - `adapter/matrix/handlers/auth.py` - - `adapter/matrix/handlers/context_commands.py` - - `tests/adapter/matrix/test_restart_persistence.py` - - `tests/adapter/matrix/test_files.py` - - `tests/platform/test_real.py` -- Matrix-nio API docs: https://matrix-nio.readthedocs.io/en/latest/nio.html -- Matrix-nio async client docs: https://matrix-nio.readthedocs.io/en/latest/_modules/nio/client/async_client.html -- Matrix-nio PyPI release page: https://pypi.org/project/matrix-nio/ -- Matrix spec Spaces / hierarchy: https://spec.matrix.org/v1.18/server-server-api/ -- Matrix spec changelog note on `via` for `m.space.child`: https://spec.matrix.org/v1.16/changelog/v1.9/ -- Docker Compose CLI reference: https://docs.docker.com/reference/cli/docker/compose/ -- Docker Compose services reference: https://docs.docker.com/reference/compose-file/services/ - -### Secondary (MEDIUM confidence) -- `docs/deploy-architecture.md` — repo-local deployment contract clarified on 2026-04-27 -- `docs/research/matrix-spaces.md` — prior internal research aligned with spec, but not treated as primary -- `README.md` runtime notes for current Matrix backend and shared workspace behavior - -### Tertiary (LOW confidence) -- None - -## Metadata - -**Confidence breakdown:** -- Standard stack: HIGH - current repo stack verified against official docs and package registries -- Architecture: HIGH - recommendations align with existing runtime boundaries and official Matrix / Compose behavior -- Pitfalls: HIGH - derived from current code paths, existing tests, and official protocol/runtime semantics - -**Research date:** 2026-04-28 -**Valid until:** 2026-05-28 diff --git a/.planning/phases/05-mvp-deployment/05-VALIDATION.md b/.planning/phases/05-mvp-deployment/05-VALIDATION.md deleted file mode 100644 index 6466df9..0000000 --- a/.planning/phases/05-mvp-deployment/05-VALIDATION.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -phase: 05 -slug: mvp-deployment -status: revised -nyquist_compliant: true -wave_0_complete: false -created: 2026-04-28 ---- - -# Phase 05 — Validation Strategy - -> Per-phase validation contract for feedback sampling during execution. - ---- - -## Test Infrastructure - -| Property | Value | -|----------|-------| -| **Framework** | `pytest` + `pytest-asyncio` | -| **Config file** | `pyproject.toml` | -| **Quick run command** | `pytest tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py -v` | -| **Full suite command** | `pytest tests/ -v` | -| **Estimated runtime** | targeted slices < 60 seconds each; full suite longer | - ---- - -## Sampling Rate - -- **After every task commit:** Run the exact `` command from the task that just changed -- **After every plan wave:** Run `pytest tests/adapter/matrix/ -v` -- **Before `$gsd-verify-work`:** Full suite must be green -- **Max feedback latency:** 60 seconds for task-level slices - ---- - -## Per-Task Verification Map - -| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status | -|---------|------|------|-------------|-----------|-------------------|-------------|--------| -| 05-01-01 | 01 | 1 | PH05-01 | integration | `pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py -v` | ❌ W0 | ⬜ pending | -| 05-01-02 | 01 | 1 | PH05-03 | integration | `pytest tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_reconciliation.py tests/adapter/matrix/test_restart_persistence.py tests/adapter/matrix/test_dispatcher.py -v` | ❌ W0 | ⬜ pending | -| 05-02-01 | 02 | 2 | PH05-02 | integration | `pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py -v` | ✅ partial | ⬜ pending | -| 05-02-02 | 02 | 2 | PH05-02 | integration | `pytest tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_dispatcher.py -v` | ✅ partial | ⬜ pending | -| 05-03-01 | 03 | 1 | PH05-04 | integration | `pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py -v` | ✅ partial | ⬜ pending | -| 05-03-02 | 03 | 1 | PH05-04 | integration | `pytest tests/adapter/matrix/test_files.py tests/platform/test_real.py tests/adapter/matrix/test_send_outgoing.py -v` | ✅ partial | ⬜ pending | -| 05-04-01 | 04 | 2 | PH05-05 | smoke | `docker compose -f docker-compose.prod.yml config && docker compose -f docker-compose.fullstack.yml config` | ❌ W0 | ⬜ pending | -| 05-04-02 | 04 | 2 | PH05-05 | docs smoke | `rg -n "docker-compose\\.prod|docker-compose\\.fullstack|/agents|prod handoff|full-stack" README.md docs/deploy-architecture.md .env.example` | ✅ | ⬜ pending | - -*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* - ---- - -## Wave 0 Requirements - -- [ ] `tests/adapter/matrix/test_reconciliation.py` — startup recovery of user and room metadata from Matrix state -- [ ] `tests/adapter/matrix/test_restart_persistence.py` additions — deterministic backfill for legacy rooms missing `platform_chat_id` -- [ ] `tests/adapter/matrix/test_context_commands.py` additions — room-local `!clear` rotation semantics -- [ ] `tests/adapter/matrix/test_files.py` additions — cross-room attachment isolation and shared-root consistency -- [ ] Compose smoke coverage or documented verification command for `docker-compose.prod.yml` and `docker-compose.fullstack.yml` - ---- - -## Manual-Only Verifications - -| Behavior | Requirement | Why Manual | Test Instructions | -|----------|-------------|------------|-------------------| -| Restart after real Matrix room topology exists | PH05-03 | Full recovery depends on live Space hierarchy and persisted homeserver state | Start the bot, provision a Space and chat rooms, stop the bot, remove local SQLite metadata, restart, confirm routing and room labels are rebuilt before live messages are handled | -| Shared `/agents` volume behavior across bot and platform containers | PH05-04 | Container mounts and permissions are environment-dependent | Run `docker compose -f docker-compose.fullstack.yml up`, upload a file in Matrix, confirm the agent sees the relative `workspace_path`, then confirm an agent-created file is readable back from the bot side | -| Operator handoff of prod compose | PH05-05 | Final deploy contract depends on real env files and target host conventions | Run `docker compose -f docker-compose.prod.yml config` on the target deployment checkout and confirm only bot services, required env vars, and shared volumes are present | - ---- - -## Validation Sign-Off - -- [ ] All tasks have `` verify or Wave 0 dependencies -- [ ] Sampling continuity: no 3 consecutive tasks without automated verify -- [ ] Wave 0 covers all MISSING references -- [ ] No watch-mode flags -- [x] Feedback latency target tightened to task slices under 60s -- [x] `nyquist_compliant: true` set in frontmatter - -**Approval:** pending diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index c04d98a..0000000 --- a/Dockerfile +++ /dev/null @@ -1,46 +0,0 @@ -FROM python:3.11-slim AS base - -WORKDIR /app -RUN useradd -u 1000 -m appuser -USER appuser - -ENV PYTHONUNBUFFERED=1 -ENV PYTHONPATH=/app -ENV UV_PROJECT_ENVIRONMENT=/usr/local - -# Install uv and git for reproducible platform SDK installation. -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates git \ - && rm -rf /var/lib/apt/lists/* \ - && pip install --no-cache-dir uv - -# Copy dependency manifests first for layer caching. -COPY pyproject.toml uv.lock* ./ - -# Install project dependencies into the system environment. -RUN uv sync --no-dev --no-install-project --frozen - -FROM base AS development - -COPY . . -RUN uv sync --no-dev --frozen - -# Local fullstack/dev builds can override the SDK with a checked-out agent_api -# build context, matching platform-agent's development Dockerfile pattern. -COPY --from=agent_api . /agent_api/ -RUN python -m pip install --no-cache-dir --ignore-requires-python -e /agent_api/ - -CMD ["python", "-m", "adapter.matrix.bot"] - -FROM base AS production - -COPY . . -RUN uv sync --no-dev --frozen - -# Production builds follow the platform-agent pattern: install the API SDK from -# the platform Git repository instead of relying on local external/ clones. -ARG LAMBDA_AGENT_API_REF=master -RUN python -m pip install --no-cache-dir --ignore-requires-python \ - "git+https://git.lambda.coredump.ru/platform/agent_api.git@${LAMBDA_AGENT_API_REF}" - -CMD ["python", "-m", "adapter.matrix.bot"] diff --git a/README.md b/README.md index 51e92f9..aedfc16 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,25 @@ # Lambda Lab 3.0 — Surfaces -Matrix-бот для взаимодействия пользователя с AI-агентом Lambda. - -## Интеграция для платформы - -Бот — это один Docker-контейнер (`matrix-bot`), который вы добавляете в свою инфраструктуру рядом с агентами. Production target — один surface container на 25-30 внешних agent containers/services. - -### Что бот ожидает от вас - -**1. HTTP-эндпоинт агента** -Бот подключается к агенту через `lambda_agent_api.AgentApi` по адресу `AGENT_BASE_URL`. -Протокол — WebSocket поверх HTTP, контракт описан в `docs/deploy-architecture.md`. - -**2. Shared volume с per-agent поддиректориями** -Shared volume монтируется в бот как `/agents`. Каждый агент видит свою поддиректорию. - -``` -Bot container Agent containers - /agents/0/ ←── volume ──→ agent_0: /workspace/ - /agents/1/ ←── volume ──→ agent_1: /workspace/ - /agents/N/ ←── volume ──→ agent_N: /workspace/ -``` - -- Бот сохраняет входящий файл прямо в `{workspace_path}/{file}` и передаёт агенту `attachments=["{file}"]` -- Если файл с таким именем уже есть, бот сохраняет следующий как `file (1).ext`, `file (2).ext`, как в Windows -- Агент пишет исходящий файл прямо в свой `/workspace/file`, бот читает его из `{workspace_path}/file` -- `workspace_path` для каждого агента задаётся в `config/matrix-agents.yaml` - -**3. Конфиг агентов** -Файл `config/matrix-agents.yaml` — маппинг Matrix-пользователей на агентов. Вы заполняете его под свою инфраструктуру. Пример в `config/matrix-agents.example.yaml`. - -### Что бот не делает - -- Не управляет lifecycle агент-контейнеров (запуск/остановка — на вашей стороне) -- Не хранит историю разговоров (это в памяти агента) -- Не обрабатывает аутентификацию пользователей — любой Matrix-пользователь, который пишет боту, получает доступ - -### Минимальный чеклист - -- [ ] Взять опубликованный image `SURFACES_BOT_IMAGE` или собрать production image из этого репозитория -- [ ] Заполнить `config/matrix-agents.yaml` — ID агентов, `base_url` каждого, `workspace_path`, маппинг пользователей -- [ ] Задать переменные окружения (см. `.env.example`): Matrix credentials, `MATRIX_PLATFORM_BACKEND=real`, `MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml` -- [ ] Смонтировать в бот-контейнер shared volume как `/agents` — каждый агент должен видеть свою поддиректорию как `/workspace` -- [ ] Добавить bot-only service в свой compose; агенты в этот compose не входят и управляются платформой - ---- +Команда поверхностей. Telegram и Matrix боты для взаимодействия пользователя с AI-агентом Lambda. ## Статус -Matrix MVP готов к деплою. Telegram — в отдельном worktree, не входит в этот handoff. +Прототип в разработке. SDK платформы ещё не готов — работаем через `MockPlatformClient`. + +| Поверхность | Статус | Описание | +|---|---|---| +| Telegram | 🔨 В разработке | Forum Topics: одна группа, чат = тема | +| Matrix | 🔨 В разработке | Space + комнаты: чат = отдельная комната | + +--- + +## Концепция + +Пользователь получает персонального AI-агента через привычный мессенджер. +Агент выполняет реальные задачи: разбирает почту, ищет информацию, работает с файлами, управляет календарём. + +**Поверхности** — тонкие клиенты. Вся бизнес-логика на стороне платформы. +Задача команды: сделать интерфейс удобным, надёжным и легко расширяемым. --- @@ -59,224 +30,101 @@ surfaces-bot/ core/ — общее ядро, не зависит от транспорта protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...) handler.py — EventDispatcher: IncomingEvent → OutgoingEvent + handlers/ — обработчики по типам событий store.py — StateStore Protocol + InMemoryStore + SQLiteStore - chat.py — ChatManager - auth.py — AuthManager - settings.py — SettingsManager + chat.py — ChatManager: метаданные чатов C1/C2/C3 + auth.py — AuthManager: аутентификация + settings.py — SettingsManager: коннекторы, скиллы, SOUL, безопасность adapter/ + telegram/ — aiogram 3.x адаптер matrix/ — matrix-nio адаптер - sdk/ + platform/ interface.py — PlatformClient Protocol (контракт к SDK) - real.py — RealPlatformClient (через AgentApi) - mock.py — MockPlatformClient (заглушка для тестов) - - config/ - matrix-agents.yaml — реестр агентов + mock.py — MockPlatformClient (заглушка) docs/ — документация + .claude/agents/ — агенты для Claude Code ``` -Подробнее: [`docs/surface-protocol.md`](docs/surface-protocol.md) +**Ключевой принцип:** добавить новую поверхность = написать один адаптер-конвертер. +Ядро (`core/`) не трогается. Подробнее: [`docs/surface-protocol.md`](docs/surface-protocol.md) --- -## Деплой +## Функционал прототипа -### Переменные окружения +### Telegram ([подробнее](docs/telegram-prototype.md)) -```bash -cp .env.example .env -``` +- **Чаты** — Forum Topics: бот создаёт личную группу пользователя, каждый чат = отдельная тема +- **Аутентификация** — привязка Telegram аккаунта к аккаунту платформы +- **Диалог** — typing indicator, передача файлов, подтверждение опасных действий через inline-кнопки +- **Настройки** через `/settings`: коннекторы (Gmail, GitHub, Notion...), скиллы, личность агента (SOUL), безопасность, подписка -| Переменная | Обязательна | Описание | -|---|---|---| -| `MATRIX_HOMESERVER` | ✓ | URL Matrix-сервера | -| `MATRIX_USER_ID` | ✓ | `@bot:example.org` | -| `MATRIX_PASSWORD` | ✓ | пароль (или `MATRIX_ACCESS_TOKEN`) | -| `MATRIX_PLATFORM_BACKEND` | ✓ | `real` для продакшна | -| `SURFACES_BOT_IMAGE` | ✓ | Docker image поверхности: `mput1/surfaces-bot:latest` | -| `AGENT_BASE_URL` | | Fallback URL агента если `base_url` не задан в `matrix-agents.yaml` | -| `MATRIX_AGENT_REGISTRY_PATH` | ✓ | путь к реестру внутри контейнера: `/app/config/matrix-agents.yaml` | -| `SURFACES_WORKSPACE_DIR` | | путь к shared volume в контейнере (по умолчанию `/agents`) | -| `SURFACES_SHARED_VOLUME` | | имя Docker volume (по умолчанию `surfaces-agents`) | +### Matrix ([подробнее](docs/matrix-prototype.md)) -### Реестр агентов - -`config/matrix-agents.yaml` — статический маппинг пользователей на агентов: - -```yaml -user_agents: - "@user0:matrix.lambda.coredump.ru": agent-0 - "@user1:matrix.lambda.coredump.ru": agent-1 - -agents: - - id: agent-0 - label: "Agent 0" - base_url: "http://lambda.coredump.ru:7000/agent_0/" - workspace_path: "/agents/0" - - id: agent-1 - label: "Agent 1" - base_url: "http://lambda.coredump.ru:7000/agent_1/" - workspace_path: "/agents/1" - - id: agent-2 - label: "Agent 2" - base_url: "http://lambda.coredump.ru:7000/agent_2/" - workspace_path: "/agents/2" -``` - -- `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент. -- `base_url` — HTTP URL агент-эндпоинта (path-based routing через reverse proxy). -- `workspace_path` — путь к воркспейсу агента внутри бот-контейнера на shared volume. - Бот сохраняет входящие файлы прямо в `{workspace_path}/`, агент пишет исходящие прямо в свой `/workspace/`. -- Для 25-30 агентов продолжайте тот же паттерн: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`. - -Полный пример с комментариями: `config/matrix-agents.example.yaml` - -### Production (bot-only) - -`docker-compose.prod.yml` — bot-only handoff через published image. Платформа добавляет этот сервис в свой compose рядом с agent containers, монтирует shared volume и задаёт переменные окружения. Этот compose не создаёт и не собирает агент-контейнеры. - -Перед redeploy можно проверить реальные agent routes из той же сети, где будет работать бот: -```bash -PYTHONPATH=. uv run python -m tools.check_matrix_agents \ - --config config/matrix-agents.yaml \ - --timeout 5 -``` - -Проверка открывает фактический WebSocket URL каждого агента (`.../v1/agent_ws/{chat_id}/`) и ждёт первый `STATUS`. Для проверки полного запроса к агенту добавьте `--message "ping"`. - -Для запуска опубликованного image: -```bash -export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest -docker compose --env-file .env -f docker-compose.prod.yml up -d -``` - -Опубликованный image: - -```text -mput1/surfaces-bot:latest -sha256:2f135f3535f7765d4377b440cdabe41195ad2efbc3e175def159ae4689ef90bd -``` - -Для сборки и публикации surface image: -```bash -docker login -export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest - -docker build --target production \ - --build-arg LAMBDA_AGENT_API_REF=master \ - -t "$SURFACES_BOT_IMAGE" . -docker push "$SURFACES_BOT_IMAGE" -``` - -Если push возвращает `insufficient_scope`, текущий Docker login не имеет доступа к namespace/repository. Создайте repository в нужном Docker Hub namespace или перетегируйте image в namespace пользователя, под которым выполнен `docker login`. - -### Fullstack E2E (bot + agent) - -```bash -docker compose --env-file .env -f docker-compose.fullstack.yml up --build -``` - -Поднимает `matrix-bot` вместе с локальным `platform-agent`. Это internal harness, не production topology на 25-30 агентов. `matrix-bot` собирается через Dockerfile target `development` и локальный `external/platform-agent_api` context, как в dev-сборке `platform-agent`. `AGENT_BASE_URL` перекрывается на `http://platform-agent:8000`. Shared volume виден как `/agents` в боте и `/workspace` в агенте. - -### Сброс состояния (локально) - -```bash -rm -f lambda_matrix.db && rm -rf matrix_store -``` +- **Чаты** — Space + комнаты: бот создаёт личное пространство, каждый чат = комната +- **Аутентификация** — привязка Matrix аккаунта к аккаунту платформы +- **Диалог** — typing, файлы, подтверждение действий через реакции 👍/❌, треды для долгих задач +- **Настройки** — отдельная комната «Настройки» с командами `!connectors`, `!skills`, `!soul`, `!safety`, `!status` --- -## Shared volume: передача файлов +## Замена SDK -``` -Bot (/agents) Agent (/workspace = /agents/N/) - /agents/0/report.pdf ←──── одно и то же хранилище ────→ /workspace/report.pdf - /agents/0/result.txt ←────────────────────────────────→ /workspace/result.txt +Вся работа с платформой идёт через `PlatformClient` Protocol: + +```python +class PlatformClient(Protocol): + async def get_or_create_user(self, external_id: str, platform: str, ...) -> User: ... + async def send_message(self, user_id: str, chat_id: str, text: str, ...) -> MessageResponse: ... + async def get_settings(self, user_id: str) -> UserSettings: ... + async def update_settings(self, user_id: str, action: Any) -> None: ... ``` -- **Входящий файл** (пользователь → агент): бот сохраняет в `{workspace_path}/{file}`, например `/agents/17/report.pdf`, и передаёт агенту `attachments=["report.pdf"]` -- **Коллизии имён**: если `/agents/17/report.pdf` уже существует, бот сохранит следующий файл как `/agents/17/report (1).pdf`, затем `/agents/17/report (2).pdf` -- **Исходящий файл** (агент → пользователь): агент пишет в `/workspace/file`, бот читает из `{workspace_path}/file`, например `/agents/17/result.txt`, и отправляет пользователю как Matrix file message -- `workspace_path` для каждого агента задаётся в `config/matrix-agents.yaml` +Бот не управляет lifecycle контейнеров — это делает Master (платформа). +Бот передаёт `user_id` + `chat_id` + сообщение; платформа сама решает нужно ли поднять контейнер. + +Сейчас: `MockPlatformClient` в `platform/mock.py`. +Когда SDK готов: добавляем `SdkPlatformClient`, меняем одну строку в DI. Адаптеры и ядро не трогаем. --- -## Онбординг пользователя - -1. Пользователь приглашает бота в личные сообщения (DM) на Matrix-сервере -2. Бот создаёт private Space `Lambda — {display_name}` и комнату `Чат 1` -3. Дальнейшее общение — в рабочих комнатах, не в DM - -**Требование:** незашифрованные комнаты. E2EE не поддержан. - ---- - -## Команды Matrix - -### Работающие - -| Команда | Действие | -|---|---| -| *(любое сообщение)* | Диалог с агентом, стриминг ответа | -| `!new [название]` | Создать новый чат | -| `!chats` | Список активных чатов | -| `!rename <название>` | Переименовать текущую комнату | -| `!archive` | Архивировать чат | -| `!clear` | Сбросить контекст текущего чата | -| `!yes` / `!no` | Подтвердить / отменить действие агента | -| `!list` | Файлы в очереди вложений | -| `!remove ` / `!remove all` | Удалить вложение из очереди | -| `!help` | Справка | - -### Не работают / заглушки - -| Команда | Статус | -|---|---| -| `!save` / `!load` / `!context` | Нестабильны: зависят от агента, сессии теряются при рестарте | -| `!settings` и подкоманды | Заглушки MVP, требуют готового SDK платформы | - ---- - -## Отправка файлов агенту - -Matrix-клиент отправляет файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь. - -``` -[отправил файл] -!list - 1. report.pdf - -прочитай и сделай summary ← файл уйдёт агенту вместе с этим текстом -``` - ---- - -## Известные ограничения - -| Проблема | Причина | -|---|---| -| История теряется при рестарте агента | `platform-agent` использует `MemorySaver` (in-memory) | -| E2EE | `python-olm` не собирается на macOS/ARM | - ---- - -## Разработка +## Быстрый старт ```bash -uv sync +# Зависимости +uv sync # или: pip install -e ".[dev]" + +# Тесты pytest tests/ -v -pytest tests/adapter/matrix/ -v # только Matrix + +# Запустить Telegram бота +cp .env.example .env # заполнить TELEGRAM_BOT_TOKEN +python -m adapter.telegram.bot + +# Запустить Matrix бота +cp .env.example .env # заполнить MATRIX_* переменные +python -m adapter.matrix.bot ``` +--- + ## Документация | Файл | Содержание | |---|---| -| [`docs/deploy-architecture.md`](docs/deploy-architecture.md) | **Читать первым.** Топология деплоя, volume-контракт, AgentApi, конфигурация | -| [`docs/matrix-prototype.md`](docs/matrix-prototype.md) | Команды бота, UX, передача файлов | -| [`docs/known-limitations.md`](docs/known-limitations.md) | Известные ограничения и обходные пути | -| [`docs/surface-protocol.md`](docs/surface-protocol.md) | Внутренний протокол событий (для расширения) | -| [`docs/new-surface-guide.md`](docs/new-surface-guide.md) | Руководство по созданию новой поверхности (Discord, Slack и др.) | +| [`docs/surface-protocol.md`](docs/surface-protocol.md) | Унификация поверхностей — все структуры, как добавить новую поверхность | +| [`docs/telegram-prototype.md`](docs/telegram-prototype.md) | Функционал Telegram прототипа | +| [`docs/matrix-prototype.md`](docs/matrix-prototype.md) | Функционал Matrix прототипа | +| [`docs/api-contract.md`](docs/api-contract.md) | Контракт к SDK платформы | +| [`docs/user-flow.md`](docs/user-flow.md) | FSM и user journey | +| [`docs/claude-code-guide.md`](docs/claude-code-guide.md) | Гайд по работе с Claude Code | + +--- + +## Команда + +Поверхности и интеграции +Lambda Lab 3.0, МАИ diff --git a/adapter/__init__.py b/adapter/__init__.py index 3ce55a6..e69de29 100644 --- a/adapter/__init__.py +++ b/adapter/__init__.py @@ -1,2 +0,0 @@ -from __future__ import annotations - diff --git a/adapter/matrix/__init__.py b/adapter/matrix/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/adapter/matrix/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/adapter/matrix/agent_registry.py b/adapter/matrix/agent_registry.py deleted file mode 100644 index bf02018..0000000 --- a/adapter/matrix/agent_registry.py +++ /dev/null @@ -1,125 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass, field -from pathlib import Path -from typing import Literal - -import yaml - - -class AgentRegistryError(ValueError): - pass - - -@dataclass(frozen=True) -class AgentDefinition: - agent_id: str - label: str - base_url: str = field(default="") - workspace_path: str = field(default="") - - -@dataclass(frozen=True) -class AgentAssignment: - agent_id: str | None - source: Literal["configured", "default", "none"] - - @property - def is_default(self) -> bool: - return self.source == "default" - - -class AgentRegistry: - def __init__( - self, - agents: list[AgentDefinition], - user_agents: Mapping[str, str] | None = None, - ) -> None: - self.agents = tuple(agents) - self._by_id = {agent.agent_id: agent for agent in self.agents} - self._user_agents: dict[str, str] = dict(user_agents or {}) - - def get(self, agent_id: str) -> AgentDefinition: - try: - return self._by_id[agent_id] - except KeyError as exc: - raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc - - def get_agent_id_for_user(self, matrix_user_id: str) -> str | None: - return self._user_agents.get(matrix_user_id) - - def resolve_agent_for_user(self, matrix_user_id: str) -> AgentAssignment: - agent_id = self.get_agent_id_for_user(matrix_user_id) - if agent_id is not None: - return AgentAssignment(agent_id=agent_id, source="configured") - if self.agents: - return AgentAssignment(agent_id=self.agents[0].agent_id, source="default") - return AgentAssignment(agent_id=None, source="none") - - -def _required_text(entry: Mapping[str, object], key: str) -> str: - value = entry.get(key) - if not isinstance(value, str): - raise AgentRegistryError("each agent entry requires id and label") - text = value.strip() - if not text: - raise AgentRegistryError("each agent entry requires id and label") - return text - - -def _optional_text(entry: Mapping[str, object], key: str) -> str: - value = entry.get(key) - if value is None: - return "" - if not isinstance(value, str): - raise AgentRegistryError(f"agent entry field '{key}' must be a string") - return value.strip() - - -def _load_registry_data(path: str | Path) -> dict[str, object]: - try: - raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) - except yaml.YAMLError as exc: - raise AgentRegistryError("invalid agent registry YAML") from exc - if raw is None: - return {} - if not isinstance(raw, Mapping): - raise AgentRegistryError("agent registry must be a mapping with an agents list") - return dict(raw) - - -def load_agent_registry(path: str | Path) -> AgentRegistry: - raw = _load_registry_data(path) - entries = raw.get("agents") - if not isinstance(entries, list) or not entries: - raise AgentRegistryError("agents registry must contain a non-empty agents list") - - agents: list[AgentDefinition] = [] - seen: set[str] = set() - for entry in entries: - if not isinstance(entry, Mapping): - raise AgentRegistryError("each agent entry requires id and label") - agent_id = _required_text(entry, "id") - label = _required_text(entry, "label") - base_url = _optional_text(entry, "base_url") - workspace_path = _optional_text(entry, "workspace_path") - if agent_id in seen: - raise AgentRegistryError(f"duplicate agent id: {agent_id}") - seen.add(agent_id) - agents.append( - AgentDefinition( - agent_id=agent_id, - label=label, - base_url=base_url, - workspace_path=workspace_path, - ) - ) - - user_agents = raw.get("user_agents") - if user_agents is not None: - if not isinstance(user_agents, Mapping): - raise AgentRegistryError("user_agents must be a mapping of user_id to agent_id") - user_agents = {str(k): str(v) for k, v in user_agents.items()} - - return AgentRegistry(agents, user_agents) diff --git a/adapter/matrix/bot.py b/adapter/matrix/bot.py deleted file mode 100644 index 411f037..0000000 --- a/adapter/matrix/bot.py +++ /dev/null @@ -1,971 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import os -import re -from dataclasses import dataclass -from pathlib import Path -from urllib.parse import urlsplit, urlunsplit - -import structlog -from dotenv import load_dotenv -from nio import ( - AsyncClient, - AsyncClientConfig, - InviteMemberEvent, - MatrixRoom, - RoomMemberEvent, - RoomMessage, - RoomMessageAudio, - RoomMessageFile, - RoomMessageImage, - RoomMessageText, - RoomMessageVideo, -) -from nio.responses import SyncResponse - -from adapter.matrix.agent_registry import AgentRegistry, AgentRegistryError, load_agent_registry -from adapter.matrix.converter import from_room_event -from adapter.matrix.files import ( - download_matrix_attachment, - matrix_msgtype_for_attachment, - resolve_workspace_attachment_path, -) -from adapter.matrix.handlers import register_matrix_handlers -from adapter.matrix.handlers.auth import ( - default_agent_notice, - handle_invite, - provision_workspace_chat, - restore_workspace_access, -) -from adapter.matrix.handlers.context_commands import ( - LOAD_PROMPT, -) -from adapter.matrix.reconciliation import reconcile_startup_state -from adapter.matrix.room_router import resolve_chat_id -from adapter.matrix.routed_platform import RoutedPlatformClient -from adapter.matrix.store import ( - add_staged_attachment, - clear_load_pending, - clear_staged_attachments, - get_load_pending, - get_room_meta, - get_staged_attachments, - next_platform_chat_id, - remove_staged_attachment_at, - set_pending_confirm, - set_platform_chat_id, - set_room_meta, -) -from core.auth import AuthManager -from core.chat import ChatManager -from core.handler import EventDispatcher -from core.handlers import register_all -from core.protocol import ( - Attachment, - IncomingCommand, - IncomingMessage, - OutgoingEvent, - OutgoingMessage, - OutgoingNotification, - OutgoingTyping, - OutgoingUI, -) -from core.settings import SettingsManager -from core.store import InMemoryStore, SQLiteStore, StateStore -from sdk.interface import PlatformClient, PlatformError -from sdk.mock import MockPlatformClient -from sdk.prototype_state import PrototypeStateStore -from sdk.real import RealPlatformClient - -logger = structlog.get_logger(__name__) - -load_dotenv(Path(__file__).resolve().parents[2] / ".env") - - -@dataclass -class MatrixRuntime: - platform: PlatformClient - store: StateStore - chat_mgr: ChatManager - auth_mgr: AuthManager - settings_mgr: SettingsManager - dispatcher: EventDispatcher - agent_routing_enabled: bool = False - registry: AgentRegistry | None = None - - -def build_event_dispatcher(platform: PlatformClient, store: StateStore) -> EventDispatcher: - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - prototype_state = getattr(platform, "_prototype_state", None) - agent_base_url = _agent_base_url_from_env() - registry = _load_agent_registry_from_env() - dispatcher = EventDispatcher( - platform=platform, chat_mgr=chat_mgr, auth_mgr=auth_mgr, settings_mgr=settings_mgr - ) - register_all(dispatcher) - register_matrix_handlers( - dispatcher, - store=store, - registry=registry, - prototype_state=prototype_state, - agent_base_url=agent_base_url, - ) - return dispatcher - - -def _normalize_agent_base_url(url: str) -> str: - parsed = urlsplit(url) - path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/")) - return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) - - -def _ws_debug_enabled() -> bool: - value = os.environ.get("SURFACES_DEBUG_WS", "") - return value.strip().lower() in {"1", "true", "yes", "on"} - - -def _configure_debug_logging() -> None: - if not _ws_debug_enabled(): - return - root_logger = logging.getLogger() - if not root_logger.handlers: - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)-8s] %(name)s %(message)s", - ) - elif root_logger.level > logging.INFO: - root_logger.setLevel(logging.INFO) - logging.getLogger("lambda_agent_api").setLevel(logging.INFO) - logging.getLogger("lambda_agent_api.agent_api").setLevel(logging.INFO) - - -def _agent_base_url_from_env() -> str: - if base_url := os.environ.get("AGENT_BASE_URL"): - return base_url - if ws_url := os.environ.get("AGENT_WS_URL"): - return _normalize_agent_base_url(ws_url) - return "http://127.0.0.1:8000" - - -def _load_agent_registry_from_env(required: bool = False) -> AgentRegistry | None: - registry_path = os.environ.get("MATRIX_AGENT_REGISTRY_PATH", "").strip() - if not registry_path: - if required: - raise RuntimeError( - "MATRIX_AGENT_REGISTRY_PATH is required when MATRIX_PLATFORM_BACKEND=real" - ) - return None - try: - registry = load_agent_registry(registry_path) - except (AgentRegistryError, OSError) as exc: - raise RuntimeError(f"failed to load matrix agent registry: {registry_path}") from exc - if _ws_debug_enabled(): - logger.warning( - "matrix_agent_registry_loaded", - registry_path=registry_path, - agent_count=len(registry.agents), - ) - for agent in registry.agents: - logger.warning( - "matrix_agent_registry_entry", - registry_path=registry_path, - agent_id=agent.agent_id, - label=agent.label, - configured_base_url=agent.base_url, - normalized_base_url=_normalize_agent_base_url(agent.base_url) - if agent.base_url - else "", - workspace_path=agent.workspace_path, - ) - return registry - - -def _build_platform_from_env(*, store: StateStore, chat_mgr: ChatManager) -> PlatformClient: - backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower() - if _ws_debug_enabled(): - logger.warning( - "matrix_platform_backend_selected", - backend=backend, - global_agent_base_url=_agent_base_url_from_env(), - registry_path=os.environ.get("MATRIX_AGENT_REGISTRY_PATH", "").strip(), - ) - if backend == "real": - prototype_state = PrototypeStateStore() - registry = _load_agent_registry_from_env(required=True) - assert registry is not None - global_base_url = _agent_base_url_from_env() - delegates = { - agent.agent_id: RealPlatformClient( - agent_id=agent.agent_id, - agent_base_url=agent.base_url or global_base_url, - prototype_state=prototype_state, - platform="matrix", - ) - for agent in registry.agents - } - return RoutedPlatformClient( - chat_mgr=chat_mgr, - store=store, - delegates=delegates, - ) - return MockPlatformClient() - - -def build_runtime( - platform: PlatformClient | None = None, - store: StateStore | None = None, - client: AsyncClient | None = None, -) -> MatrixRuntime: - store = store or InMemoryStore() - chat_mgr = ChatManager(platform, store) - platform = platform or _build_platform_from_env(store=store, chat_mgr=chat_mgr) - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - prototype_state = getattr(platform, "_prototype_state", None) - agent_base_url = _agent_base_url_from_env() - registry = _load_agent_registry_from_env() - dispatcher = EventDispatcher( - platform=platform, chat_mgr=chat_mgr, auth_mgr=auth_mgr, settings_mgr=settings_mgr - ) - register_all(dispatcher) - register_matrix_handlers( - dispatcher, - client=client, - store=store, - registry=registry, - prototype_state=prototype_state, - agent_base_url=agent_base_url, - ) - return MatrixRuntime( - platform=platform, - store=store, - chat_mgr=chat_mgr, - auth_mgr=auth_mgr, - settings_mgr=settings_mgr, - dispatcher=dispatcher, - agent_routing_enabled=isinstance(platform, RoutedPlatformClient), - registry=registry, - ) - - -class MatrixBot: - def __init__(self, client: AsyncClient, runtime: MatrixRuntime) -> None: - self.client = client - self.runtime = runtime - - async def _ensure_platform_chat_id(self, room_id: str, room_meta: dict | None) -> None: - if not room_meta: - return - if room_meta.get("redirect_room_id"): - return - if room_meta.get("platform_chat_id"): - return - await set_platform_chat_id( - self.runtime.store, - room_id, - await next_platform_chat_id(self.runtime.store), - ) - - async def _refresh_room_agent_assignment( - self, room_id: str, matrix_user_id: str, room_meta: dict | None - ) -> tuple[dict | None, bool]: - if not room_meta or room_meta.get("redirect_room_id") or self.runtime.registry is None: - return room_meta, False - - assignment = self.runtime.registry.resolve_agent_for_user(matrix_user_id) - updated = dict(room_meta) - should_warn_default = False - - if assignment.source == "configured" and ( - updated.get("agent_id") != assignment.agent_id - or updated.get("agent_assignment") != "configured" - ): - updated["agent_id"] = assignment.agent_id - updated["agent_assignment"] = "configured" - updated.pop("default_agent_notice_sent", None) - elif assignment.source == "default": - if not updated.get("agent_id"): - updated["agent_id"] = assignment.agent_id - if updated.get("agent_id") == assignment.agent_id: - updated["agent_assignment"] = "default" - should_warn_default = not updated.get("default_agent_notice_sent") - updated["default_agent_notice_sent"] = True - - if updated != room_meta: - await set_room_meta(self.runtime.store, room_id, updated) - return updated, should_warn_default - return room_meta, should_warn_default - - async def on_room_message(self, room: MatrixRoom, event: RoomMessageText) -> None: - if getattr(event, "sender", None) == self.client.user_id: - return - sender = getattr(event, "sender", None) - body = (getattr(event, "body", None) or "").strip() - room_meta = await get_room_meta(self.runtime.store, room.room_id) - if room_meta is not None and not room_meta.get("redirect_room_id"): - await self._ensure_platform_chat_id(room.room_id, room_meta) - room_meta, warn_default_agent = await self._refresh_room_agent_assignment( - room.room_id, sender, room_meta - ) - if warn_default_agent and not body.startswith("!"): - await self._send_all( - room.room_id, - [OutgoingMessage(chat_id=room.room_id, text=default_agent_notice())], - ) - - load_pending = await get_load_pending(self.runtime.store, sender, room.room_id) - if load_pending is not None and (body.isdigit() or body == "!cancel"): - outgoing = await self._handle_load_selection(sender, room.room_id, body, load_pending) - await self._send_all(room.room_id, outgoing) - return - - if room_meta is None: - outgoing = await self._bootstrap_unregistered_room(room, sender) - if outgoing: - await self._send_all(room.room_id, outgoing) - return - elif room_meta.get("redirect_room_id"): - display_name = getattr(room, "display_name", None) or sender - if body == "!new": - try: - created = await provision_workspace_chat( - self.client, - sender, - display_name, - self.runtime.platform, - self.runtime.store, - self.runtime.auth_mgr, - self.runtime.chat_mgr, - registry=self.runtime.registry, - ) - except Exception as exc: - logger.warning( - "matrix_entry_room_new_chat_failed", - room_id=room.room_id, - sender=sender, - error=str(exc), - ) - await self._send_all( - room.room_id, - [ - OutgoingMessage( - chat_id=room.room_id, - text="Не удалось создать новый рабочий чат.", - ) - ], - ) - return - - welcome = f"Создал новый рабочий чат {created['room_name']}." - if created.get("agent_assignment") == "default": - welcome = f"{welcome}\n\n{default_agent_notice()}" - await self.client.room_send( - created["chat_room_id"], - "m.room.message", - {"msgtype": "m.text", "body": welcome}, - ) - await set_room_meta( - self.runtime.store, - room.room_id, - { - **room_meta, - "redirect_room_id": created["chat_room_id"], - "redirect_chat_id": created["chat_id"], - }, - ) - await self._send_all( - room.room_id, - [ - OutgoingMessage( - chat_id=room.room_id, - text=( - f"Создал рабочий чат {created['room_name']} " - f"({created['chat_id']}) и отправил приглашение." - ), - ) - ], - ) - return - - restored = await restore_workspace_access( - self.client, - sender, - display_name, - self.runtime.platform, - self.runtime.store, - self.runtime.auth_mgr, - self.runtime.chat_mgr, - registry=self.runtime.registry, - ) - redirect_room_id = room_meta["redirect_room_id"] - redirect_chat_id = room_meta.get("redirect_chat_id", "рабочий чат") - if restored.get("created_new_chat"): - text = ( - f"Создал новый рабочий чат {restored['room_name']} " - f"({restored['chat_id']}) и отправил приглашение." - ) - else: - text = ( - f"Рабочий чат уже создан: {redirect_chat_id}. " - "Я повторно отправил приглашения в пространство Lambda и рабочие чаты. " - "Чтобы создать новый чат, напишите !new здесь." - ) - await self._send_all( - room.room_id, - [ - OutgoingMessage( - chat_id=room.room_id, - text=text, - ) - ], - ) - logger.info( - "matrix_redirect_entry_room", - room_id=room.room_id, - redirect_room_id=redirect_room_id, - user=sender, - ) - return - if not body.startswith("!") and self.runtime.agent_routing_enabled: - pass - - local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender) - incoming = from_room_event(event, room_id=room.room_id, chat_id=local_chat_id) - if incoming is None: - return - if isinstance(incoming, IncomingCommand) and incoming.command in { - "matrix_list_attachments", - "matrix_remove_attachment", - }: - outgoing = await self._handle_staged_attachment_command( - room.room_id, - sender, - incoming, - ) - await self._send_all(room.room_id, outgoing) - return - if self._is_file_only_event(event, incoming): - materialized = await self._materialize_incoming_attachments( - room.room_id, - sender, - incoming, - ) - await self._stage_attachments(room.room_id, sender, materialized.attachments) - return - if isinstance(incoming, IncomingMessage) and incoming.attachments: - incoming = await self._materialize_incoming_attachments( - room.room_id, - sender, - incoming, - ) - clear_staged_after_dispatch = False - if isinstance(incoming, IncomingMessage) and incoming.text: - incoming, clear_staged_after_dispatch = await self._merge_staged_attachments( - room.room_id, - sender, - incoming, - ) - agent_id = (room_meta or {}).get("agent_id") - if _ws_debug_enabled() and not body.startswith("!"): - logger.warning( - "matrix_incoming_message_route", - room_id=room.room_id, - sender=sender, - local_chat_id=local_chat_id, - agent_id=agent_id, - platform_chat_id=(room_meta or {}).get("platform_chat_id"), - ) - workspace_root = self._agent_workspace_root(agent_id) - try: - outgoing = await self.runtime.dispatcher.dispatch(incoming) - except PlatformError as exc: - logger.warning( - "matrix_message_platform_error", - room_id=room.room_id, - sender=getattr(event, "sender", None), - code=exc.code, - error=str(exc), - ) - outgoing = [ - OutgoingMessage( - chat_id=local_chat_id, - text="Сервис временно недоступен. Попробуйте ещё раз позже.", - ) - ] - else: - if clear_staged_after_dispatch: - await clear_staged_attachments(self.runtime.store, room.room_id, sender) - await self._send_all(room.room_id, outgoing, workspace_root=workspace_root) - - def _is_file_only_event( - self, event: RoomMessage, incoming: IncomingMessage | IncomingCommand - ) -> bool: - return ( - isinstance(incoming, IncomingMessage) - and bool(incoming.attachments) - and not isinstance(event, RoomMessageText) - ) - - async def _stage_attachments( - self, - room_id: str, - user_id: str, - attachments: list, - ) -> None: - for attachment in attachments: - await add_staged_attachment( - self.runtime.store, - room_id, - user_id, - { - "type": attachment.type, - "url": attachment.url, - "filename": attachment.filename, - "mime_type": attachment.mime_type, - "workspace_path": attachment.workspace_path, - }, - ) - - async def _format_staged_attachments( - self, - room_id: str, - user_id: str, - *, - include_hint: bool = False, - ) -> str: - attachments = await get_staged_attachments(self.runtime.store, room_id, user_id) - if not attachments: - return "Нет сохраненных вложений." - - lines = ["Вложения в очереди:"] - for index, attachment in enumerate(attachments, start=1): - lines.append(f"{index}. {attachment.get('filename') or 'attachment'}") - if include_hint: - lines.extend( - [ - "", - "Следующее сообщение отправит файлы агенту.", - "Команды: !list, !remove , !remove all", - ] - ) - return "\n".join(lines) - - async def _handle_staged_attachment_command( - self, - room_id: str, - user_id: str, - incoming: IncomingCommand, - ) -> list[OutgoingEvent]: - if incoming.command == "matrix_list_attachments": - return [ - OutgoingMessage( - chat_id=incoming.chat_id, - text=await self._format_staged_attachments(room_id, user_id), - ) - ] - - arg = incoming.args[0] if incoming.args else "" - if arg == "all": - await clear_staged_attachments(self.runtime.store, room_id, user_id) - return [OutgoingMessage(chat_id=incoming.chat_id, text="Все вложения удалены.")] - - try: - index = int(arg) - 1 - except ValueError: - return [OutgoingMessage(chat_id=incoming.chat_id, text="Нет такого вложения.")] - - removed = await remove_staged_attachment_at(self.runtime.store, room_id, user_id, index) - if removed is None: - return [OutgoingMessage(chat_id=incoming.chat_id, text="Нет такого вложения.")] - return [ - OutgoingMessage( - chat_id=incoming.chat_id, - text=await self._format_staged_attachments(room_id, user_id), - ) - ] - - async def _merge_staged_attachments( - self, - room_id: str, - user_id: str, - incoming: IncomingMessage, - ) -> tuple[IncomingMessage, bool]: - staged = await get_staged_attachments(self.runtime.store, room_id, user_id) - if not staged: - return incoming, False - attachments = [ - Attachment( - type=item.get("type", "document"), - url=item.get("url"), - filename=item.get("filename"), - mime_type=item.get("mime_type"), - workspace_path=item.get("workspace_path"), - ) - for item in staged - ] - return ( - IncomingMessage( - user_id=incoming.user_id, - platform=incoming.platform, - chat_id=incoming.chat_id, - text=incoming.text, - attachments=attachments, - reply_to=incoming.reply_to, - ), - True, - ) - - def _agent_workspace_root(self, agent_id: str | None) -> Path: - default = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace")) - if agent_id is None or self.runtime.registry is None: - return default - try: - agent = self.runtime.registry.get(agent_id) - if agent.workspace_path: - return Path(agent.workspace_path) - except Exception: - pass - return default - - async def _materialize_incoming_attachments( - self, - room_id: str, - matrix_user_id: str, - incoming: IncomingMessage, - ) -> IncomingMessage: - room_meta = await get_room_meta(self.runtime.store, room_id) - agent_id = (room_meta or {}).get("agent_id") - workspace_root = self._agent_workspace_root(agent_id) - materialized = [] - for attachment in incoming.attachments: - materialized.append( - await download_matrix_attachment( - client=self.client, - workspace_root=workspace_root, - matrix_user_id=matrix_user_id, - room_id=room_id, - attachment=attachment, - ) - ) - return IncomingMessage( - user_id=incoming.user_id, - platform=incoming.platform, - chat_id=incoming.chat_id, - text=incoming.text, - attachments=materialized, - reply_to=incoming.reply_to, - ) - - async def _bootstrap_unregistered_room( - self, - room: MatrixRoom, - sender: str, - ) -> list[OutgoingEvent] | None: - if not hasattr(self.client, "room_create") or not hasattr(self.client, "room_put_state"): - return None - display_name = getattr(room, "display_name", None) or sender - try: - created = await provision_workspace_chat( - self.client, - sender, - display_name, - self.runtime.platform, - self.runtime.store, - self.runtime.auth_mgr, - self.runtime.chat_mgr, - registry=self.runtime.registry, - ) - except Exception as exc: - logger.warning( - "matrix_unregistered_room_bootstrap_failed", - room_id=room.room_id, - sender=sender, - error=str(exc), - ) - return [ - OutgoingMessage( - chat_id=room.room_id, - text="Не удалось подготовить рабочий чат. Попробуйте ещё раз позже.", - ) - ] - - welcome = ( - f"Привет, {created['user'].display_name or sender}! Пиши — я здесь.\n\n" - "Команды: !new · !chats · !rename · !archive · !context · !save · !load · !help" - ) - if created.get("agent_assignment") == "default": - welcome = f"{welcome}\n\n{default_agent_notice()}" - await set_room_meta( - self.runtime.store, - room.room_id, - { - "matrix_user_id": sender, - "redirect_room_id": created["chat_room_id"], - "redirect_chat_id": created["chat_id"], - }, - ) - await self.client.room_send( - created["chat_room_id"], - "m.room.message", - {"msgtype": "m.text", "body": welcome}, - ) - return [ - OutgoingMessage( - chat_id=room.room_id, - text=( - f"Создал рабочий чат {created['room_name']} ({created['chat_id']}) " - "и добавил его в пространство Lambda. " - "Открой приглашённую комнату для продолжения." - ), - ) - ] - - async def _handle_load_selection( - self, - user_id: str, - room_id: str, - text: str, - pending: dict, - ) -> list[OutgoingEvent]: - saves = pending.get("saves", []) - if text in {"0", "!cancel"}: - await clear_load_pending(self.runtime.store, user_id, room_id) - return [OutgoingMessage(chat_id=room_id, text="Отменено.")] - - index = int(text) - 1 - if index < 0 or index >= len(saves): - return [ - OutgoingMessage( - chat_id=room_id, - text=f"Неверный номер. Введи от 1 до {len(saves)} или 0 для отмены.", - ) - ] - - name = saves[index]["name"] - await clear_load_pending(self.runtime.store, user_id, room_id) - prototype_state = getattr(self.runtime.platform, "_prototype_state", None) - if prototype_state is not None: - room_meta = await get_room_meta(self.runtime.store, room_id) - context_keys = [] - if room_meta is not None: - platform_chat_id = room_meta.get("platform_chat_id") - if platform_chat_id: - context_keys.append(platform_chat_id) - chat_id = room_meta.get("chat_id") - if chat_id: - context_keys.append(chat_id) - if not context_keys: - context_keys.append(room_id) - for context_key in dict.fromkeys(context_keys): - await prototype_state.set_current_session(context_key, name) - - try: - await self.runtime.platform.send_message( - user_id, - room_id, - LOAD_PROMPT.format(name=name), - ) - except Exception as exc: - logger.warning("load_agent_call_failed", error=str(exc)) - return [OutgoingMessage(chat_id=room_id, text=f"Ошибка при загрузке: {exc}")] - return [ - OutgoingMessage(chat_id=room_id, text=f"Запрос на загрузку отправлен агенту: {name}") - ] - - async def on_member(self, room: MatrixRoom, event: RoomMemberEvent) -> None: - if getattr(event, "sender", None) == self.client.user_id: - return - membership = getattr(event, "membership", None) - if membership == "invite": - await handle_invite( - self.client, - room, - event, - self.runtime.platform, - self.runtime.store, - self.runtime.auth_mgr, - self.runtime.chat_mgr, - self.runtime.registry, - ) - - async def _send_all( - self, - room_id: str, - outgoing: list[OutgoingEvent], - workspace_root: Path | None = None, - ) -> None: - for event in outgoing: - await send_outgoing( - self.client, - room_id, - event, - store=self.runtime.store, - workspace_root=workspace_root, - ) - - -async def prepare_live_sync(client: AsyncClient) -> str | None: - response = await client.sync(timeout=0, full_state=True) - if isinstance(response, SyncResponse): - return response.next_batch - return None - - -async def send_outgoing( - client: AsyncClient, - room_id: str, - event: OutgoingEvent, - store: StateStore | None = None, - workspace_root: Path | None = None, -) -> None: - if isinstance(event, OutgoingTyping): - await client.room_typing(room_id, event.is_typing, timeout=25000) - return - if isinstance(event, OutgoingNotification): - body = f"[{event.level.upper()}] {event.text}" - await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) - return - if isinstance(event, OutgoingMessage): - if event.text: - await client.room_send( - room_id, "m.room.message", {"msgtype": "m.text", "body": event.text} - ) - if event.attachments: - workspace_root = workspace_root or Path( - os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace") - ) - for attachment in event.attachments: - if not attachment.workspace_path: - continue - file_path = resolve_workspace_attachment_path( - workspace_root, attachment.workspace_path - ) - with file_path.open("rb") as handle: - upload_response, _ = await client.upload( - handle, - content_type=attachment.mime_type or "application/octet-stream", - filename=attachment.filename or file_path.name, - filesize=file_path.stat().st_size, - ) - content_uri = getattr(upload_response, "content_uri", None) - if not content_uri: - raise RuntimeError(f"Matrix upload failed for {file_path}") - await client.room_send( - room_id, - "m.room.message", - { - "msgtype": matrix_msgtype_for_attachment(attachment), - "body": attachment.filename or file_path.name, - "url": content_uri, - }, - ) - return - if isinstance(event, OutgoingUI): - lines = [event.text] - if event.buttons: - lines.append("") - for button in event.buttons: - lines.append(f" {button.label}") - lines.append("") - lines.append("Ответьте !yes для подтверждения или !no для отмены.") - body = "\n".join(lines) - await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) - if event.buttons and store is not None: - action_id = event.buttons[0].action - payload = event.buttons[0].payload - room_meta = await get_room_meta(store, room_id) - matrix_user_id = room_meta.get("matrix_user_id") if room_meta else None - if matrix_user_id: - await set_pending_confirm( - store, - matrix_user_id, - room_id, - { - "action_id": action_id, - "description": event.text, - "payload": payload, - }, - ) - return - - -async def main() -> None: - _configure_debug_logging() - homeserver = os.environ.get("MATRIX_HOMESERVER") - user_id = os.environ.get("MATRIX_USER_ID") - device_id = os.environ.get("MATRIX_DEVICE_ID", "") - password = os.environ.get("MATRIX_PASSWORD") - token = os.environ.get("MATRIX_ACCESS_TOKEN") - db_path = os.environ.get("MATRIX_DB_PATH", "lambda_matrix.db") - store_path = os.environ.get("MATRIX_STORE_PATH", "matrix_store") - if not homeserver or not user_id: - raise RuntimeError("MATRIX_HOMESERVER and MATRIX_USER_ID are required") - - client_config = AsyncClientConfig( - request_timeout=120, - max_timeouts=12, - max_limit_exceeded=20, - backoff_factor=0.5, - max_timeout_retry_wait_time=15, - ) - client = AsyncClient( - homeserver, - user=user_id, - device_id=device_id, - store_path=store_path, - config=client_config, - ) - runtime = build_runtime(store=SQLiteStore(db_path), client=client) - if token: - client.access_token = token - elif password: - await client.login(password=password, device_name="surfaces-bot") - - since_token = await prepare_live_sync(client) - await reconcile_startup_state(client, runtime) - - bot = MatrixBot(client, runtime) - client.add_event_callback( - bot.on_room_message, - ( - RoomMessageText, - RoomMessageFile, - RoomMessageImage, - RoomMessageVideo, - RoomMessageAudio, - ), - ) - client.add_event_callback(bot.on_member, (InviteMemberEvent, RoomMemberEvent)) - - logger.info( - "Matrix bot starting", - homeserver=homeserver, - user_id=user_id, - store_path=store_path, - request_timeout=client_config.request_timeout, - ) - if _ws_debug_enabled(): - logger.warning( - "matrix_ws_debug_enabled", - homeserver=homeserver, - user_id=user_id, - backend=os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower(), - global_agent_base_url=_agent_base_url_from_env(), - registry_path=os.environ.get("MATRIX_AGENT_REGISTRY_PATH", "").strip(), - ) - try: - await client.sync_forever(timeout=30000, since=since_token) - finally: - close = getattr(runtime.platform, "close", None) - if callable(close): - await close() - await client.close() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/adapter/matrix/converter.py b/adapter/matrix/converter.py deleted file mode 100644 index a19d8ea..0000000 --- a/adapter/matrix/converter.py +++ /dev/null @@ -1,138 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from core.protocol import ( - Attachment, - IncomingCallback, - IncomingCommand, - IncomingEvent, - IncomingMessage, -) - -PLATFORM = "matrix" - - -def extract_attachments(event: Any) -> list[Attachment]: - source = getattr(event, "source", {}) or {} - content = source.get("content", {}) or getattr(event, "content", {}) or {} - msgtype = getattr(event, "msgtype", None) - if msgtype is None: - msgtype = content.get("msgtype") - url = content.get("url") or getattr(event, "url", None) - filename = content.get("body") or getattr(event, "body", None) - mime_type = content.get("mimetype") or getattr(event, "mimetype", None) - if mime_type is None: - info = content.get("info") or {} - if isinstance(info, dict): - mime_type = info.get("mimetype") - - if msgtype == "m.image": - return [ - Attachment( - type="image", - url=url, - filename=filename, - mime_type=mime_type, - ) - ] - if msgtype == "m.file": - return [ - Attachment( - type="document", - url=url, - filename=filename, - mime_type=mime_type, - ) - ] - if msgtype == "m.audio": - return [ - Attachment( - type="audio", - url=url, - filename=filename, - mime_type=mime_type, - ) - ] - if msgtype == "m.video": - return [ - Attachment( - type="video", - url=url, - filename=filename, - mime_type=mime_type, - ) - ] - return [] - - -def from_command(body: str, sender: str, chat_id: str, room_id: str | None = None) -> IncomingEvent: - raw = body.lstrip("!").strip() - parts = raw.split() - command = parts[0].lower() if parts else "" - args = parts[1:] - - if command in {"yes", "no"}: - action = "confirm" if command == "yes" else "cancel" - return IncomingCallback( - user_id=sender, - platform=PLATFORM, - chat_id=chat_id, - action=action, - payload={ - "source": "command", - "command": command, - **({"room_id": room_id} if room_id is not None else {}), - }, - ) - - if command == "list" and not args: - return IncomingCommand( - user_id=sender, - platform=PLATFORM, - chat_id=chat_id, - command="matrix_list_attachments", - args=[], - ) - - if command == "remove" and len(args) == 1: - return IncomingCommand( - user_id=sender, - platform=PLATFORM, - chat_id=chat_id, - command="matrix_remove_attachment", - args=[args[0]], - ) - - aliases = { - "skills": "settings_skills", - "connectors": "settings_connectors", - "soul": "settings_soul", - "safety": "settings_safety", - "plan": "settings_plan", - "status": "settings_status", - "whoami": "settings_whoami", - } - command = aliases.get(command, command) - return IncomingCommand( - user_id=sender, - platform=PLATFORM, - chat_id=chat_id, - command=command, - args=args, - ) - - -def from_room_event(event: Any, room_id: str, chat_id: str) -> IncomingEvent | None: - body = (getattr(event, "body", None) or "").strip() - sender = getattr(event, "sender", "") - if body.startswith("!"): - return from_command(body, sender=sender, chat_id=chat_id, room_id=room_id) - return IncomingMessage( - user_id=sender, - platform=PLATFORM, - chat_id=chat_id, - text=body, - attachments=extract_attachments(event), - reply_to=getattr(event, "replyto_event_id", None), - ) diff --git a/adapter/matrix/files.py b/adapter/matrix/files.py deleted file mode 100644 index 0845684..0000000 --- a/adapter/matrix/files.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -import mimetypes -import re -from pathlib import Path, PurePosixPath - -from core.protocol import Attachment - - -def _sanitize_filename(value: str) -> str: - filename = PurePosixPath(str(value).replace("\\", "/")).name.strip() - cleaned = re.sub(r"[\x00-\x1f\x7f<>:\"/\\|?*]+", "_", filename) - cleaned = cleaned.strip(" .") - return cleaned or "attachment.bin" - - -def _default_filename(attachment: Attachment) -> str: - if attachment.filename: - return attachment.filename - - extension = mimetypes.guess_extension(attachment.mime_type or "") or "" - base = { - "image": "image", - "audio": "audio", - "video": "video", - "document": "attachment", - }.get(attachment.type, "attachment") - return f"{base}{extension}" - - -def _with_copy_index(filename: str, index: int) -> str: - path = Path(filename) - suffix = path.suffix - stem = path.stem if suffix else filename - return f"{stem} ({index}){suffix}" - - -def _unique_workspace_relative_path(workspace_root: Path, filename: str) -> tuple[str, Path]: - safe_name = _sanitize_filename(filename) - candidate = workspace_root / safe_name - if not candidate.exists(): - return safe_name, candidate - - index = 1 - while True: - indexed_name = _with_copy_index(safe_name, index) - candidate = workspace_root / indexed_name - if not candidate.exists(): - return indexed_name, candidate - index += 1 - - -def build_agent_workspace_path( - *, - workspace_root: Path, - filename: str, -) -> tuple[str, Path]: - """Saves user files directly to {workspace_root}/{filename}. - - The returned relative path is what gets passed to agent.send_message(attachments=[...]). - """ - return _unique_workspace_relative_path(workspace_root, filename) - - -async def download_matrix_attachment( - *, - client, - workspace_root: Path, - matrix_user_id: str, - room_id: str, - attachment: Attachment, - timestamp: str | None = None, -) -> Attachment: - if not attachment.url: - return attachment - - filename = _default_filename(attachment) - - del matrix_user_id, room_id, timestamp - relative_path, absolute_path = build_agent_workspace_path( - workspace_root=workspace_root, - filename=filename, - ) - - absolute_path.parent.mkdir(parents=True, exist_ok=True) - - response = await client.download(attachment.url) - body = getattr(response, "body", None) - if body is None: - raise RuntimeError(f"Matrix download response for {attachment.url} has no body") - absolute_path.write_bytes(body) - - return Attachment( - type=attachment.type, - url=attachment.url, - filename=filename, - mime_type=attachment.mime_type, - workspace_path=relative_path, - ) - - -def resolve_workspace_attachment_path(workspace_root: Path, workspace_path: str) -> Path: - path = Path(workspace_path) - if path.is_absolute(): - return path - return workspace_root / path - - -def matrix_msgtype_for_attachment(attachment: Attachment) -> str: - return { - "image": "m.image", - "audio": "m.audio", - "video": "m.video", - }.get(attachment.type, "m.file") diff --git a/adapter/matrix/handlers/__init__.py b/adapter/matrix/handlers/__init__.py deleted file mode 100644 index 30adf59..0000000 --- a/adapter/matrix/handlers/__init__.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -from adapter.matrix.handlers.chat import ( - handle_list_chats, - make_handle_archive, - make_handle_new_chat, - make_handle_rename, -) -from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_confirm -from adapter.matrix.handlers.context_commands import ( - make_handle_context, - make_handle_load, - make_handle_reset, - make_handle_save, -) -from adapter.matrix.handlers.settings import ( - handle_help, - handle_settings, - handle_settings_connectors, - handle_settings_plan, - handle_settings_safety, - handle_settings_skills, - handle_settings_soul, - handle_settings_status, - handle_settings_whoami, - handle_toggle_skill, - handle_unknown_command, -) -from core.handler import EventDispatcher -from core.protocol import IncomingCallback, IncomingCommand - - -def register_matrix_handlers( - dispatcher: EventDispatcher, - client=None, - store=None, - registry=None, - prototype_state=None, - agent_base_url: str = "http://127.0.0.1:8000", -) -> None: - dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store, registry)) - dispatcher.register(IncomingCommand, "chats", handle_list_chats) - dispatcher.register(IncomingCommand, "rename", make_handle_rename(client, store)) - dispatcher.register(IncomingCommand, "archive", make_handle_archive(client, store)) - dispatcher.register(IncomingCommand, "help", handle_help) - dispatcher.register(IncomingCommand, "settings", handle_settings) - if prototype_state is not None: - clear_handler = make_handle_reset(store, prototype_state) - dispatcher.register(IncomingCommand, "clear", clear_handler) - dispatcher.register(IncomingCommand, "reset", clear_handler) - else: - dispatcher.register(IncomingCommand, "reset", handle_settings) - dispatcher.register(IncomingCommand, "settings_skills", handle_settings_skills) - dispatcher.register(IncomingCommand, "settings_connectors", handle_settings_connectors) - dispatcher.register(IncomingCommand, "settings_soul", handle_settings_soul) - dispatcher.register(IncomingCommand, "settings_safety", handle_settings_safety) - dispatcher.register(IncomingCommand, "settings_plan", handle_settings_plan) - dispatcher.register(IncomingCommand, "settings_status", handle_settings_status) - dispatcher.register(IncomingCommand, "settings_whoami", handle_settings_whoami) - - dispatcher.register(IncomingCallback, "confirm", make_handle_confirm(store)) - dispatcher.register(IncomingCallback, "cancel", make_handle_cancel(store)) - dispatcher.register(IncomingCallback, "toggle_skill", handle_toggle_skill) - dispatcher.register(IncomingCommand, "*", handle_unknown_command) - - if prototype_state is not None: - dispatcher.register( - IncomingCommand, - "save", - make_handle_save(None, store, prototype_state), - ) - dispatcher.register(IncomingCommand, "load", make_handle_load(store, prototype_state)) - dispatcher.register(IncomingCommand, "context", make_handle_context(store, prototype_state)) diff --git a/adapter/matrix/handlers/auth.py b/adapter/matrix/handlers/auth.py deleted file mode 100644 index 064448d..0000000 --- a/adapter/matrix/handlers/auth.py +++ /dev/null @@ -1,285 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import structlog -from nio.api import RoomVisibility -from nio.responses import RoomCreateError - -from adapter.matrix.agent_registry import AgentRegistry -from adapter.matrix.store import ( - get_user_meta, - next_platform_chat_id, - set_room_meta, - set_user_meta, -) - -logger = structlog.get_logger(__name__) - - -def _default_room_name(chat_id: str) -> str: - suffix = chat_id[1:] if chat_id.startswith("C") else chat_id - return f"Чат {suffix}" - - -def default_agent_notice() -> str: - return ( - "Внимание: ваш Matrix ID не найден в конфиге агентов. " - "Пока используется агент по умолчанию. После добавления вас в конфиг " - "бот переключит существующие комнаты на назначенного агента." - ) - - -async def _invite_if_possible(client: Any, room_id: str, matrix_user_id: str) -> bool: - room_invite = getattr(client, "room_invite", None) - if not callable(room_invite): - return False - try: - await room_invite(room_id, matrix_user_id) - return True - except Exception as exc: - logger.warning( - "matrix_workspace_reinvite_failed", - room_id=room_id, - user=matrix_user_id, - error=str(exc), - ) - return False - - -async def provision_workspace_chat( - client: Any, - matrix_user_id: str, - display_name: str, - platform, - store, - auth_mgr, - chat_mgr, - room_name_override: str | None = None, - registry: AgentRegistry | None = None, -) -> dict: - user = await platform.get_or_create_user( - external_id=matrix_user_id, - platform="matrix", - display_name=display_name, - ) - await auth_mgr.confirm(matrix_user_id) - - homeserver = matrix_user_id.split(":")[-1] - user_meta = await get_user_meta(store, matrix_user_id) or {} - space_id = user_meta.get("space_id") - - if not space_id: - space_resp = await client.room_create( - name=f"Lambda — {display_name}", - space=True, - visibility=RoomVisibility.private, - invite=[matrix_user_id], - ) - if isinstance(space_resp, RoomCreateError): - logger.error( - "space creation failed", - user=matrix_user_id, - error=getattr(space_resp, "status_code", None), - ) - raise RuntimeError("Не удалось создать Space.") - space_id = space_resp.room_id - user_meta["space_id"] = space_id - await set_user_meta(store, matrix_user_id, user_meta) - - next_chat_index = int(user_meta.get("next_chat_index", 1)) - chat_id = f"C{next_chat_index}" - platform_chat_id = await next_platform_chat_id(store) - room_name = room_name_override or _default_room_name(chat_id) - - agent_id = None - agent_assignment = "none" - if registry is not None: - assignment = registry.resolve_agent_for_user(matrix_user_id) - agent_id = assignment.agent_id - agent_assignment = assignment.source - - chat_resp = await client.room_create( - name=room_name, - visibility=RoomVisibility.private, - is_direct=False, - invite=[matrix_user_id], - ) - if isinstance(chat_resp, RoomCreateError): - logger.error( - "chat room creation failed", - user=matrix_user_id, - error=getattr(chat_resp, "status_code", None), - ) - raise RuntimeError("Не удалось создать рабочий чат.") - chat_room_id = chat_resp.room_id - - await client.room_put_state( - room_id=space_id, - event_type="m.space.child", - content={"via": [homeserver]}, - state_key=chat_room_id, - ) - - user_meta["space_id"] = space_id - user_meta["next_chat_index"] = next_chat_index + 1 - await set_user_meta(store, matrix_user_id, user_meta) - - await set_room_meta( - store, - chat_room_id, - { - "room_type": "chat", - "chat_id": chat_id, - "display_name": room_name, - "matrix_user_id": matrix_user_id, - "space_id": space_id, - "platform_chat_id": platform_chat_id, - "agent_id": agent_id, - "agent_assignment": agent_assignment, - }, - ) - await chat_mgr.get_or_create( - user_id=matrix_user_id, - chat_id=chat_id, - platform="matrix", - surface_ref=chat_room_id, - name=room_name, - ) - - return { - "user": user, - "space_id": space_id, - "chat_room_id": chat_room_id, - "chat_id": chat_id, - "room_name": room_name, - "agent_assignment": agent_assignment, - "agent_id": agent_id, - } - - -async def restore_workspace_access( - client: Any, - matrix_user_id: str, - display_name: str, - platform, - store, - auth_mgr, - chat_mgr, - registry: AgentRegistry | None = None, -) -> dict: - user_meta = await get_user_meta(store, matrix_user_id) or {} - space_id = user_meta.get("space_id") - if not space_id: - created = await provision_workspace_chat( - client, - matrix_user_id, - display_name, - platform, - store, - auth_mgr, - chat_mgr, - room_name_override="Чат 1", - registry=registry, - ) - return {**created, "reinvited_rooms": [], "created_new_chat": True} - - await auth_mgr.confirm(matrix_user_id) - await _invite_if_possible(client, space_id, matrix_user_id) - - chats = await chat_mgr.list_active(matrix_user_id) - if not chats: - created = await provision_workspace_chat( - client, - matrix_user_id, - display_name, - platform, - store, - auth_mgr, - chat_mgr, - registry=registry, - ) - return {**created, "reinvited_rooms": [], "created_new_chat": True} - - reinvited_rooms = [] - for chat in chats: - if chat.surface_ref: - if await _invite_if_possible(client, chat.surface_ref, matrix_user_id): - reinvited_rooms.append(chat.surface_ref) - - return { - "space_id": space_id, - "reinvited_rooms": reinvited_rooms, - "created_new_chat": False, - } - - -async def handle_invite( - client: Any, - room: Any, - event: Any, - platform, - store, - auth_mgr, - chat_mgr, - registry: AgentRegistry | None = None, -) -> None: - matrix_user_id = getattr(event, "sender", "") - display_name = getattr(room, "display_name", None) or matrix_user_id - - await client.join(room.room_id) - - existing = await get_user_meta(store, matrix_user_id) - if existing and existing.get("space_id"): - restored = await restore_workspace_access( - client, - matrix_user_id, - display_name, - platform, - store, - auth_mgr, - chat_mgr, - registry=registry, - ) - body = "Я отправил повторные приглашения в пространство Lambda и рабочие чаты." - if restored.get("created_new_chat"): - body = ( - f"Создал новый рабочий чат {restored['room_name']} " - f"({restored['chat_id']}) и отправил приглашение." - ) - if restored.get("agent_assignment") == "default": - body = f"{body}\n\n{default_agent_notice()}" - await client.room_send( - room.room_id, - "m.room.message", - {"msgtype": "m.text", "body": body}, - ) - return - - try: - created = await provision_workspace_chat( - client, - matrix_user_id, - display_name, - platform, - store, - auth_mgr, - chat_mgr, - room_name_override="Чат 1", - registry=registry, - ) - except RuntimeError as exc: - logger.error("invite_workspace_provision_failed", user=matrix_user_id, error=str(exc)) - return - - welcome = ( - f"Привет, {created['user'].display_name or matrix_user_id}! Пиши — я здесь.\n\n" - "Команды: !new · !chats · !rename · !archive · !clear · !help" - ) - if created.get("agent_assignment") == "default": - welcome = f"{welcome}\n\n{default_agent_notice()}" - await client.room_send( - created["chat_room_id"], - "m.room.message", - {"msgtype": "m.text", "body": welcome}, - ) diff --git a/adapter/matrix/handlers/chat.py b/adapter/matrix/handlers/chat.py deleted file mode 100644 index 645e9cd..0000000 --- a/adapter/matrix/handlers/chat.py +++ /dev/null @@ -1,220 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from typing import Any - -import structlog -from nio.api import RoomVisibility -from nio.responses import RoomCreateError - -from adapter.matrix.agent_registry import AgentRegistry -from adapter.matrix.handlers.auth import default_agent_notice -from adapter.matrix.store import ( - get_user_meta, - next_chat_id, - next_platform_chat_id, - set_room_meta, -) -from core.protocol import IncomingCommand, OutgoingMessage - -logger = structlog.get_logger(__name__) - - -def _is_unregistered_chat_id(chat_id: str) -> bool: - return chat_id.startswith("unregistered:") - - -async def _fallback_new_chat( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr -) -> list: - if not await auth_mgr.is_authenticated(event.user_id): - return [OutgoingMessage(chat_id=event.chat_id, text="Введите !start чтобы начать.")] - - name = " ".join(event.args).strip() if event.args else "" - chats = await chat_mgr.list_active(event.user_id) - chat_id = f"C{len(chats) + 1}" - ctx = await chat_mgr.get_or_create( - user_id=event.user_id, - chat_id=chat_id, - platform=event.platform, - surface_ref=event.chat_id, - name=name or None, - ) - return [ - OutgoingMessage( - chat_id=event.chat_id, text=f"Создан чат: {ctx.display_name} ({ctx.chat_id})" - ) - ] - - -def make_handle_new_chat( - client: Any | None, - store: Any | None, - registry: AgentRegistry | None = None, -) -> Callable[..., Awaitable[list]]: - async def handle_new_chat( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list: - if client is None or store is None: - return await _fallback_new_chat(event, auth_mgr, platform, chat_mgr, settings_mgr) - - if not await auth_mgr.is_authenticated(event.user_id): - return [ - OutgoingMessage( - chat_id=event.chat_id, - text="Сначала примите приглашение бота.", - ) - ] - - user_meta = await get_user_meta(store, event.user_id) - space_id = (user_meta or {}).get("space_id") - if not space_id: - return [ - OutgoingMessage( - chat_id=event.chat_id, - text="Ошибка: Space не найден. Примите приглашение бота заново.", - ) - ] - - name = " ".join(event.args).strip() if event.args else "" - chat_id = await next_chat_id(store, event.user_id) - platform_chat_id = await next_platform_chat_id(store) - room_name = name or f"Чат {chat_id}" - - response = await client.room_create( - name=room_name, - visibility=RoomVisibility.private, - is_direct=False, - invite=[event.user_id], - ) - if isinstance(response, RoomCreateError): - logger.error( - "room_create failed", - user_id=event.user_id, - status_code=getattr(response, "status_code", None), - ) - return [OutgoingMessage(chat_id=event.chat_id, text="Не удалось создать комнату.")] - - room_id = getattr(response, "room_id", None) - if not room_id: - return [OutgoingMessage(chat_id=event.chat_id, text="Не удалось создать комнату.")] - - homeserver = event.user_id.split(":")[-1] - await client.room_put_state( - room_id=space_id, - event_type="m.space.child", - content={"via": [homeserver]}, - state_key=room_id, - ) - - agent_id = None - agent_assignment = "none" - if registry is not None: - assignment = registry.resolve_agent_for_user(event.user_id) - agent_id = assignment.agent_id - agent_assignment = assignment.source - - room_meta: dict = { - "room_type": "chat", - "chat_id": chat_id, - "display_name": room_name, - "matrix_user_id": event.user_id, - "space_id": space_id, - "platform_chat_id": platform_chat_id, - "agent_id": agent_id, - "agent_assignment": agent_assignment, - } - await set_room_meta(store, room_id, room_meta) - ctx = await chat_mgr.get_or_create( - user_id=event.user_id, - chat_id=chat_id, - platform=event.platform, - surface_ref=room_id, - name=room_name, - ) - text = f"Создан чат: {ctx.display_name} ({ctx.chat_id})" - if agent_assignment == "default": - text = f"{text}\n\n{default_agent_notice()}" - return [ - OutgoingMessage( - chat_id=event.chat_id, - text=text, - ) - ] - - return handle_new_chat - - -async def handle_list_chats( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr -) -> list: - chats = await chat_mgr.list_active(event.user_id) - if not chats: - return [OutgoingMessage(chat_id=event.chat_id, text="Нет активных чатов.")] - lines = [f"• {c.display_name} ({c.chat_id})" for c in chats] - return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] - - -def make_handle_rename( - client: Any | None, - store: Any | None, -) -> Callable[..., Awaitable[list]]: - async def handle_rename( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list: - if not event.args: - return [ - OutgoingMessage(chat_id=event.chat_id, text="Укажите название: !rename Название") - ] - if _is_unregistered_chat_id(event.chat_id): - return [ - OutgoingMessage( - chat_id=event.chat_id, - text=( - "Этот чат не найден в локальном состоянии бота. " - "Открой зарегистрированную комнату или создай новый чат через !new." - ), - ) - ] - - new_name = " ".join(event.args) - ctx = await chat_mgr.rename(event.chat_id, new_name, user_id=event.user_id) - if client is not None and ctx.surface_ref: - await client.room_put_state( - room_id=ctx.surface_ref, - event_type="m.room.name", - content={"name": new_name}, - state_key="", - ) - - return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")] - - return handle_rename - - -def make_handle_archive( - client: Any | None, - store: Any | None, -) -> Callable[..., Awaitable[list]]: - async def handle_archive( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list: - if _is_unregistered_chat_id(event.chat_id): - return [ - OutgoingMessage( - chat_id=event.chat_id, - text=( - "Этот чат не найден в локальном состоянии бота. " - "Создай новый чат через !new." - ), - ) - ] - ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id) - if ctx is None: - return [OutgoingMessage(chat_id=event.chat_id, text="Этот чат не найден.")] - await chat_mgr.archive(event.chat_id, user_id=event.user_id) - if client is not None and ctx.surface_ref: - await client.room_leave(ctx.surface_ref) - return [OutgoingMessage(chat_id=event.chat_id, text="Чат архивирован.")] - - return handle_archive diff --git a/adapter/matrix/handlers/confirm.py b/adapter/matrix/handlers/confirm.py deleted file mode 100644 index e988dac..0000000 --- a/adapter/matrix/handlers/confirm.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -from adapter.matrix.store import clear_pending_confirm, get_pending_confirm -from core.protocol import IncomingCallback, OutgoingMessage - - -def make_handle_confirm(store=None): - async def handle_confirm( - event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list: - if store is None: - return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")] - - room_id = event.payload.get("room_id") - pending = None - if room_id: - pending = await get_pending_confirm(store, event.user_id, room_id) - if not pending: - pending = await get_pending_confirm(store, event.chat_id) - if not pending: - return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")] - - description = pending.get("description", "действие") - if room_id: - await clear_pending_confirm(store, event.user_id, room_id) - else: - await clear_pending_confirm(store, event.chat_id) - - return [OutgoingMessage(chat_id=event.chat_id, text=f"Подтверждено: {description}")] - - return handle_confirm - - -def make_handle_cancel(store=None): - async def handle_cancel( - event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list: - if store is None: - return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")] - - room_id = event.payload.get("room_id") - pending = None - if room_id: - pending = await get_pending_confirm(store, event.user_id, room_id) - if not pending: - pending = await get_pending_confirm(store, event.chat_id) - if not pending: - return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")] - - if room_id: - await clear_pending_confirm(store, event.user_id, room_id) - else: - await clear_pending_confirm(store, event.chat_id) - return [OutgoingMessage(chat_id=event.chat_id, text="Действие отменено.")] - - return handle_cancel diff --git a/adapter/matrix/handlers/context_commands.py b/adapter/matrix/handlers/context_commands.py deleted file mode 100644 index 121d76b..0000000 --- a/adapter/matrix/handlers/context_commands.py +++ /dev/null @@ -1,230 +0,0 @@ -from __future__ import annotations - -import re -from datetime import UTC, datetime -from typing import TYPE_CHECKING - -import httpx -import structlog - -from adapter.matrix.store import ( - get_room_meta, - next_platform_chat_id, - set_load_pending, - set_platform_chat_id, -) -from core.protocol import IncomingCommand, OutgoingEvent, OutgoingMessage - -if TYPE_CHECKING: - from core.store import StateStore - from sdk.prototype_state import PrototypeStateStore - -logger = structlog.get_logger(__name__) - -SAVE_PROMPT = ( - "Summarize our conversation and save to /workspace/contexts/{name}.md. " - "Reply only with: Saved: {name}" -) -LOAD_PROMPT = ( - "Load context from /workspace/contexts/{name}.md and use it as background " - "for our conversation. Reply: Loaded: {name}" -) -_VALID_NAME = re.compile(r"^[A-Za-z0-9_-]+$") - - -def _sanitize_session_name(raw_name: str) -> str | None: - name = raw_name.strip() - if not name or not _VALID_NAME.fullmatch(name): - return None - return name - - -async def _resolve_room_id(event: IncomingCommand, chat_mgr) -> str: - if chat_mgr is None: - return event.chat_id - ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id) - if ctx is not None and ctx.surface_ref: - return ctx.surface_ref - return event.chat_id - - -async def _resolve_context_scope( - event: IncomingCommand, - store: StateStore, - chat_mgr, -) -> tuple[str, str | None]: - room_id = await _resolve_room_id(event, chat_mgr) - room_meta = await get_room_meta(store, room_id) - platform_chat_id = room_meta.get("platform_chat_id") if room_meta else None - return room_id, platform_chat_id - - -async def _require_platform_context( - event: IncomingCommand, - store: StateStore, - chat_mgr, -) -> tuple[str, str]: - room_id, platform_chat_id = await _resolve_context_scope(event, store, chat_mgr) - if not platform_chat_id: - raise RuntimeError(f"matrix room context is incomplete: {room_id}") - return room_id, platform_chat_id - - -def make_handle_save(agent_api, store: StateStore, prototype_state: PrototypeStateStore): - async def handle_save( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list[OutgoingEvent]: - if event.args: - name = _sanitize_session_name(event.args[0]) - if name is None: - return [ - OutgoingMessage( - chat_id=event.chat_id, - text="Имя сохранения может содержать только буквы, цифры, _ и -.", - ) - ] - else: - name = f"context-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}" - - try: - await platform.send_message( - event.user_id, - event.chat_id, - SAVE_PROMPT.format(name=name), - ) - except Exception as exc: - logger.warning("save_agent_call_failed", error=str(exc)) - return [OutgoingMessage(chat_id=event.chat_id, text=f"Ошибка при сохранении: {exc}")] - - try: - _, platform_chat_id = await _require_platform_context(event, store, chat_mgr) - except RuntimeError as exc: - logger.warning("save_context_incomplete", error=str(exc)) - return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")] - - await prototype_state.add_saved_session( - event.user_id, - name, - source_context_id=platform_chat_id, - ) - return [ - OutgoingMessage( - chat_id=event.chat_id, - text=f"Запрос на сохранение отправлен агенту: {name}", - ) - ] - - return handle_save - - -def make_handle_load(store: StateStore, prototype_state: PrototypeStateStore): - async def handle_load( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list[OutgoingEvent]: - sessions = await prototype_state.list_saved_sessions(event.user_id) - if not sessions: - return [ - OutgoingMessage( - chat_id=event.chat_id, - text="Нет сохранённых сессий. Используй !save [имя].", - ) - ] - - room_id, _ = await _resolve_context_scope(event, store, chat_mgr) - lines = ["Сохранённые сессии:"] - for index, session in enumerate(sessions, start=1): - created = session.get("created_at", "")[:10] - lines.append(f" {index}. {session['name']} ({created})") - lines.append("") - lines.append("Введи номер или 0 / !cancel для отмены.") - - await set_load_pending(store, event.user_id, room_id, {"saves": sessions}) - return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] - - return handle_load - - -def make_handle_reset(store: StateStore, prototype_state: PrototypeStateStore): - async def handle_reset( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list[OutgoingEvent]: - try: - room_id, old_chat_id = await _require_platform_context(event, store, chat_mgr) - except RuntimeError as exc: - logger.warning("clear_context_incomplete", error=str(exc)) - return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")] - - new_chat_id = await next_platform_chat_id(store) - await set_platform_chat_id(store, room_id, new_chat_id) - - disconnect = getattr(platform, "disconnect_chat", None) - if callable(disconnect): - await disconnect(old_chat_id) - - await prototype_state.clear_current_session(old_chat_id) - await prototype_state.clear_current_session(new_chat_id) - - return [ - OutgoingMessage( - chat_id=event.chat_id, - text="Контекст сброшен. Агент не помнит предыдущий разговор.", - ) - ] - - return handle_reset - - -async def _call_reset_endpoint(agent_base_url: str, chat_id: str) -> list[OutgoingEvent]: - try: - async with httpx.AsyncClient() as client: - response = await client.post(f"{agent_base_url}/reset", timeout=5.0) - except (httpx.ConnectError, httpx.TimeoutException) as exc: - logger.warning("reset_endpoint_unreachable", error=str(exc)) - return [ - OutgoingMessage( - chat_id=chat_id, - text="Reset endpoint недоступен. Обратитесь к администратору.", - ) - ] - - if response.status_code == 404: - return [ - OutgoingMessage( - chat_id=chat_id, - text="Reset endpoint недоступен. Обратитесь к администратору.", - ) - ] - return [OutgoingMessage(chat_id=chat_id, text="Контекст сброшен.")] - - -def make_handle_context(store: StateStore, prototype_state: PrototypeStateStore): - async def handle_context( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr - ) -> list[OutgoingEvent]: - try: - _, platform_chat_id = await _require_platform_context(event, store, chat_mgr) - except RuntimeError as exc: - logger.warning("context_scope_incomplete", error=str(exc)) - return [OutgoingMessage(chat_id=event.chat_id, text="Контекст комнаты не готов. Попробуй позже.")] - - current_session = await prototype_state.get_current_session(platform_chat_id) - tokens_used = await prototype_state.get_last_tokens_used(platform_chat_id) - sessions = await prototype_state.list_saved_sessions(event.user_id) - - lines = [ - "Контекст:", - f" Контекст чата: {platform_chat_id}", - f" Сессия: {current_session or 'не загружена'}", - f" Токены (последний ответ): {tokens_used}", - f" Сохранения ({len(sessions)}):", - ] - if sessions: - for session in sessions: - created = session.get("created_at", "")[:10] - lines.append(f" - {session['name']} ({created})") - else: - lines.append(" (нет)") - - return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] - - return handle_context diff --git a/adapter/matrix/handlers/settings.py b/adapter/matrix/handlers/settings.py deleted file mode 100644 index 59bee6b..0000000 --- a/adapter/matrix/handlers/settings.py +++ /dev/null @@ -1,96 +0,0 @@ -from __future__ import annotations - -from core.protocol import IncomingCommand, OutgoingMessage - -HELP_TEXT = "\n".join( - [ - "Команды", - "", - "!new [название] создать новый чат", - "!chats список активных чатов", - "!rename <название> переименовать текущий чат", - "!archive архивировать текущий чат", - "", - "!clear сбросить контекст текущего чата", - "", - "!list показать файлы в очереди", - "!remove удалить файл из очереди", - "!remove all очистить очередь файлов", - "", - "!yes / !no подтвердить или отменить действие", - "!help эта справка", - ] -) - - -MVP_UNAVAILABLE_TEXT = ( - "Эта команда скрыта в MVP и сейчас недоступна. " - "Используй !help для списка поддерживаемых команд." -) - - -async def handle_settings( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr -) -> list: - return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)] - - -async def handle_help(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list: - return [OutgoingMessage(chat_id=event.chat_id, text=HELP_TEXT)] - - -async def handle_settings_skills( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr -) -> list: - return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)] - - -async def handle_settings_connectors( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr -) -> list: - return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)] - - -async def handle_settings_soul( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr -) -> list: - return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)] - - -async def handle_settings_safety( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr -) -> list: - return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)] - - -async def handle_settings_plan( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr -) -> list: - return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)] - - -async def handle_settings_status( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr -) -> list: - return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)] - - -async def handle_settings_whoami( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr -) -> list: - return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)] - - -async def handle_toggle_skill(event, auth_mgr, platform, chat_mgr, settings_mgr) -> list: - return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)] - - -async def handle_unknown_command( - event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr -) -> list: - return [ - OutgoingMessage( - chat_id=event.chat_id, - text="Неизвестная команда. Используй !help для списка поддерживаемых команд.", - ) - ] diff --git a/adapter/matrix/reactions.py b/adapter/matrix/reactions.py deleted file mode 100644 index 432cb32..0000000 --- a/adapter/matrix/reactions.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from sdk.interface import UserSettings - - -def build_skills_text(settings: UserSettings) -> str: - lines: list[str] = ["Скиллы"] - for name, enabled in settings.skills.items(): - state = "on" if enabled else "off" - lines.append(f" {state} {name}") - lines.append("") - lines.append("!skill on/off <название> — переключить навык.") - return "\n".join(lines) - - -def build_confirmation_text(description: str) -> str: - return "\n".join( - [ - "Lambda", - description, - "", - "Ответьте !yes для подтверждения или !no для отмены.", - ] - ) diff --git a/adapter/matrix/reconciliation.py b/adapter/matrix/reconciliation.py deleted file mode 100644 index 835bd5d..0000000 --- a/adapter/matrix/reconciliation.py +++ /dev/null @@ -1,180 +0,0 @@ -from __future__ import annotations - -import re -from dataclasses import dataclass - -from adapter.matrix.store import ( - get_room_meta, - get_user_meta, - next_platform_chat_id, - set_room_meta, - set_user_meta, -) - -_CHAT_ID_PATTERNS = ( - re.compile(r"\bC(?P\d+)\b", re.IGNORECASE), - re.compile(r"^Чат\s+(?P\d+)$", re.IGNORECASE), -) - - -@dataclass(slots=True) -class ReconciliationResult: - recovered_rooms: int = 0 - repaired_rooms: int = 0 - backfilled_platform_chat_ids: int = 0 - - -def _room_name(room: object) -> str | None: - for attr in ("name", "display_name"): - value = getattr(room, attr, None) - if isinstance(value, str) and value.strip(): - return value.strip() - return None - - -def _chat_id_from_room(room: object, existing_meta: dict | None) -> str | None: - chat_id = (existing_meta or {}).get("chat_id") - if isinstance(chat_id, str) and chat_id: - return chat_id - - name = _room_name(room) - if not name: - return None - - for pattern in _CHAT_ID_PATTERNS: - match = pattern.search(name) - if match: - return f"C{int(match.group('index'))}" - return None - - -def _space_id_for_room( - room: object, rooms_by_id: dict[str, object], existing_meta: dict | None -) -> str | None: - existing_space_id = (existing_meta or {}).get("space_id") - if isinstance(existing_space_id, str) and existing_space_id: - return existing_space_id - - parents = getattr(room, "parents", None) - if not parents: - parents = getattr(room, "space_parents", None) - if not parents: - return None - - for parent_id in parents: - parent = rooms_by_id.get(parent_id) - if parent is None: - continue - if getattr(parent, "room_type", None) == "m.space" or getattr(parent, "children", None): - return parent_id - return parent_id - return None - - -def _matrix_user_id_for_room( - room: object, bot_user_id: str | None, existing_meta: dict | None -) -> str | None: - existing_user_id = (existing_meta or {}).get("matrix_user_id") - if isinstance(existing_user_id, str) and existing_user_id: - return existing_user_id - - users = getattr(room, "users", None) or {} - for user_id in users: - if user_id != bot_user_id: - return user_id - return None - - -async def reconcile_startup_state(client: object, runtime: object) -> ReconciliationResult: - rooms_by_id = getattr(client, "rooms", None) or {} - bot_user_id = getattr(client, "user_id", None) - result = ReconciliationResult() - max_chat_index_by_user: dict[str, int] = {} - recovered_space_by_user: dict[str, str] = {} - - for room_id, room in rooms_by_id.items(): - if getattr(room, "room_type", None) == "m.space": - continue - - existing_meta = await get_room_meta(runtime.store, room_id) - if existing_meta and existing_meta.get("redirect_room_id"): - continue - - space_id = _space_id_for_room(room, rooms_by_id, existing_meta) - chat_id = _chat_id_from_room(room, existing_meta) - matrix_user_id = _matrix_user_id_for_room(room, bot_user_id, existing_meta) - if not space_id or not chat_id or not matrix_user_id: - continue - - recovered_space_by_user[matrix_user_id] = space_id - chat_index = int(chat_id[1:]) - max_chat_index_by_user[matrix_user_id] = max( - max_chat_index_by_user.get(matrix_user_id, 0), - chat_index, - ) - - display_name = (existing_meta or {}).get("display_name") or _room_name(room) or chat_id - room_meta = dict(existing_meta or {}) - room_meta.update( - { - "room_type": "chat", - "chat_id": chat_id, - "display_name": display_name, - "matrix_user_id": matrix_user_id, - "space_id": space_id, - } - ) - - if not room_meta.get("platform_chat_id"): - room_meta["platform_chat_id"] = await next_platform_chat_id(runtime.store) - result.backfilled_platform_chat_ids += 1 - - if not room_meta.get("agent_id"): - registry = getattr(runtime, "registry", None) - if registry is not None: - assignment = registry.resolve_agent_for_user(matrix_user_id) - if assignment.agent_id: - room_meta["agent_id"] = assignment.agent_id - room_meta["agent_assignment"] = assignment.source - else: - registry = getattr(runtime, "registry", None) - if registry is not None: - assignment = registry.resolve_agent_for_user(matrix_user_id) - if assignment.source == "configured" and ( - room_meta.get("agent_id") != assignment.agent_id - or room_meta.get("agent_assignment") != "configured" - ): - room_meta["agent_id"] = assignment.agent_id - room_meta["agent_assignment"] = "configured" - elif ( - assignment.source == "default" - and room_meta.get("agent_id") == assignment.agent_id - and not room_meta.get("agent_assignment") - ): - room_meta["agent_assignment"] = "default" - - if existing_meta is None: - result.recovered_rooms += 1 - elif room_meta != existing_meta: - result.repaired_rooms += 1 - - await set_room_meta(runtime.store, room_id, room_meta) - await runtime.auth_mgr.confirm(matrix_user_id) - await runtime.chat_mgr.get_or_create( - user_id=matrix_user_id, - chat_id=chat_id, - platform="matrix", - surface_ref=room_id, - name=display_name, - ) - - for matrix_user_id, recovered_space_id in recovered_space_by_user.items(): - user_meta = dict(await get_user_meta(runtime.store, matrix_user_id) or {}) - user_meta["space_id"] = user_meta.get("space_id") or recovered_space_id - next_chat_index = max_chat_index_by_user[matrix_user_id] + 1 - user_meta["next_chat_index"] = max( - int(user_meta.get("next_chat_index", 1)), next_chat_index - ) - await set_user_meta(runtime.store, matrix_user_id, user_meta) - - return result diff --git a/adapter/matrix/room_router.py b/adapter/matrix/room_router.py deleted file mode 100644 index 81e8f1b..0000000 --- a/adapter/matrix/room_router.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -import structlog - -from adapter.matrix.store import get_room_meta -from core.store import StateStore - -logger = structlog.get_logger(__name__) - - -async def resolve_chat_id(store: StateStore, room_id: str, matrix_user_id: str) -> str: - meta = await get_room_meta(store, room_id) - if meta and meta.get("chat_id"): - return meta["chat_id"] - - logger.warning("unregistered_room", room_id=room_id, user=matrix_user_id) - return f"unregistered:{room_id}" diff --git a/adapter/matrix/routed_platform.py b/adapter/matrix/routed_platform.py deleted file mode 100644 index 3f9adc8..0000000 --- a/adapter/matrix/routed_platform.py +++ /dev/null @@ -1,133 +0,0 @@ -from __future__ import annotations - -import os -from collections.abc import AsyncIterator, Mapping - -import structlog - -from adapter.matrix.store import get_room_meta -from core.chat import ChatManager -from core.store import StateStore -from sdk.interface import ( - Attachment, - MessageChunk, - MessageResponse, - PlatformClient, - PlatformError, - User, - UserSettings, -) - -logger = structlog.get_logger(__name__) - - -def _ws_debug_enabled() -> bool: - value = os.environ.get("SURFACES_DEBUG_WS", "") - return value.strip().lower() in {"1", "true", "yes", "on"} - - -class RoutedPlatformClient(PlatformClient): - def __init__( - self, - *, - chat_mgr: ChatManager, - store: StateStore, - delegates: Mapping[str, PlatformClient], - ) -> None: - if not delegates: - raise ValueError("RoutedPlatformClient requires at least one delegate") - self._chat_mgr = chat_mgr - self._store = store - self._delegates = dict(delegates) - self._default_client = next(iter(self._delegates.values())) - self._prototype_state = getattr(self._default_client, "_prototype_state", None) - - async def get_or_create_user( - self, - external_id: str, - platform: str, - display_name: str | None = None, - ) -> User: - return await self._default_client.get_or_create_user( - external_id=external_id, - platform=platform, - display_name=display_name, - ) - - async def send_message( - self, - user_id: str, - chat_id: str, - text: str, - attachments: list[Attachment] | None = None, - ) -> MessageResponse: - delegate, platform_chat_id = await self._resolve_delegate(user_id, chat_id) - return await delegate.send_message(user_id, platform_chat_id, text, attachments) - - async def stream_message( - self, - user_id: str, - chat_id: str, - text: str, - attachments: list[Attachment] | None = None, - ) -> AsyncIterator[MessageChunk]: - delegate, platform_chat_id = await self._resolve_delegate(user_id, chat_id) - async for chunk in delegate.stream_message(user_id, platform_chat_id, text, attachments): - yield chunk - - async def get_settings(self, user_id: str) -> UserSettings: - return await self._default_client.get_settings(user_id) - - async def update_settings(self, user_id: str, action) -> None: - await self._default_client.update_settings(user_id, action) - - async def close(self) -> None: - for delegate in self._delegates.values(): - close = getattr(delegate, "close", None) - if callable(close): - await close() - - async def _resolve_delegate( - self, user_id: str, local_chat_id: str - ) -> tuple[PlatformClient, str]: - chat = await self._chat_mgr.get(local_chat_id, user_id) - if chat is None: - raise PlatformError( - f"unknown matrix chat id: {local_chat_id}", - code="MATRIX_CHAT_NOT_FOUND", - ) - - room_meta = await get_room_meta(self._store, chat.surface_ref) - if room_meta is None: - raise PlatformError( - f"matrix room is not bound: {chat.surface_ref}", - code="MATRIX_ROOM_NOT_BOUND", - ) - - agent_id = room_meta.get("agent_id") - platform_chat_id = room_meta.get("platform_chat_id") - if not agent_id or not platform_chat_id: - raise PlatformError( - f"matrix room routing is incomplete: {chat.surface_ref}", - code="MATRIX_ROUTE_INCOMPLETE", - ) - - delegate = self._delegates.get(str(agent_id)) - if delegate is None: - raise PlatformError( - f"unknown matrix agent id: {agent_id}", - code="MATRIX_AGENT_NOT_FOUND", - ) - - if _ws_debug_enabled(): - logger.warning( - "matrix_route_resolved", - user_id=user_id, - local_chat_id=local_chat_id, - surface_ref=chat.surface_ref, - agent_id=str(agent_id), - platform_chat_id=str(platform_chat_id), - delegate_type=type(delegate).__name__, - ) - - return delegate, str(platform_chat_id) diff --git a/adapter/matrix/store.py b/adapter/matrix/store.py deleted file mode 100644 index 8ecd557..0000000 --- a/adapter/matrix/store.py +++ /dev/null @@ -1,207 +0,0 @@ -from __future__ import annotations - -import asyncio -from weakref import WeakValueDictionary - -from core.store import StateStore - -ROOM_META_PREFIX = "matrix_room:" -USER_META_PREFIX = "matrix_user:" -ROOM_STATE_PREFIX = "matrix_state:" -SKILLS_MSG_PREFIX = "matrix_skills_msg:" -PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:" -LOAD_PENDING_PREFIX = "matrix_load_pending:" -RESET_PENDING_PREFIX = "matrix_reset_pending:" -STAGED_ATTACHMENTS_PREFIX = "matrix_staged_attachments:" -PLATFORM_CHAT_SEQ_KEY = "matrix_platform_chat_seq" -_STAGED_ATTACHMENTS_LOCKS: WeakValueDictionary[str, asyncio.Lock] = WeakValueDictionary() -_PLATFORM_CHAT_SEQ_LOCK = asyncio.Lock() - - -async def get_room_meta(store: StateStore, room_id: str) -> dict | None: - return await store.get(f"{ROOM_META_PREFIX}{room_id}") - - -async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None: - await store.set(f"{ROOM_META_PREFIX}{room_id}", meta) - - -async def get_platform_chat_id(store: StateStore, room_id: str) -> str | None: - meta = await get_room_meta(store, room_id) - return meta.get("platform_chat_id") if meta else None - - -async def set_platform_chat_id(store: StateStore, room_id: str, platform_chat_id: str) -> None: - meta = dict(await get_room_meta(store, room_id) or {}) - meta["platform_chat_id"] = platform_chat_id - await set_room_meta(store, room_id, meta) - - -async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None: - return await store.get(f"{USER_META_PREFIX}{matrix_user_id}") - - -async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None: - await store.set(f"{USER_META_PREFIX}{matrix_user_id}", meta) - - -async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None: - meta = dict(await get_room_meta(store, room_id) or {}) - meta["agent_id"] = agent_id - await set_room_meta(store, room_id, meta) - - -async def get_room_state(store: StateStore, room_id: str) -> str: - data = await store.get(f"{ROOM_STATE_PREFIX}{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"{ROOM_STATE_PREFIX}{room_id}", {"state": state}) - - -async def get_skills_message_id(store: StateStore, room_id: str) -> str | None: - data = await store.get(f"{SKILLS_MSG_PREFIX}{room_id}") - return data["event_id"] if data else None - - -async def set_skills_message_id(store: StateStore, room_id: str, event_id: str) -> None: - await store.set(f"{SKILLS_MSG_PREFIX}{room_id}", {"event_id": event_id}) - - -async def next_chat_id(store: StateStore, matrix_user_id: str) -> str: - meta = await get_user_meta(store, matrix_user_id) or {} - index = int(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}" - - -async def next_platform_chat_id(store: StateStore) -> str: - async with _PLATFORM_CHAT_SEQ_LOCK: - data = await store.get(PLATFORM_CHAT_SEQ_KEY) - index = int((data or {}).get("next_platform_chat_index", 1)) - await store.set( - PLATFORM_CHAT_SEQ_KEY, - {"next_platform_chat_index": index + 1}, - ) - return str(index) - - -def _pending_confirm_key(user_id: str, room_id: str | None = None) -> str: - if room_id is None: - return f"{PENDING_CONFIRM_PREFIX}{user_id}" - return f"{PENDING_CONFIRM_PREFIX}{user_id}:{room_id}" - - -async def get_pending_confirm( - store: StateStore, user_id: str, room_id: str | None = None -) -> dict | None: - return await store.get(_pending_confirm_key(user_id, room_id)) - - -async def set_pending_confirm( - store: StateStore, user_id: str, room_id: str | dict, meta: dict | None = None -) -> None: - if meta is None: - await store.set(_pending_confirm_key(user_id), room_id) - return - await store.set(_pending_confirm_key(user_id, str(room_id)), meta) - - -async def clear_pending_confirm( - store: StateStore, user_id: str, room_id: str | None = None -) -> None: - await store.delete(_pending_confirm_key(user_id, room_id)) - - -def _load_pending_key(user_id: str, room_id: str) -> str: - return f"{LOAD_PENDING_PREFIX}{user_id}:{room_id}" - - -async def get_load_pending(store: StateStore, user_id: str, room_id: str) -> dict | None: - return await store.get(_load_pending_key(user_id, room_id)) - - -async def set_load_pending(store: StateStore, user_id: str, room_id: str, data: dict) -> None: - await store.set(_load_pending_key(user_id, room_id), data) - - -async def clear_load_pending(store: StateStore, user_id: str, room_id: str) -> None: - await store.delete(_load_pending_key(user_id, room_id)) - - -def _reset_pending_key(user_id: str, room_id: str) -> str: - return f"{RESET_PENDING_PREFIX}{user_id}:{room_id}" - - -async def get_reset_pending(store: StateStore, user_id: str, room_id: str) -> dict | None: - return await store.get(_reset_pending_key(user_id, room_id)) - - -async def set_reset_pending( - store: StateStore, - user_id: str, - room_id: str, - data: dict, -) -> None: - await store.set(_reset_pending_key(user_id, room_id), data) - - -async def clear_reset_pending(store: StateStore, user_id: str, room_id: str) -> None: - await store.delete(_reset_pending_key(user_id, room_id)) - - -def _staged_attachments_key(room_id: str, user_id: str) -> str: - return f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}" - - -def _staged_attachments_lock(room_id: str, user_id: str) -> asyncio.Lock: - key = _staged_attachments_key(room_id, user_id) - lock = _STAGED_ATTACHMENTS_LOCKS.get(key) - if lock is None: - lock = asyncio.Lock() - _STAGED_ATTACHMENTS_LOCKS[key] = lock - return lock - - -async def get_staged_attachments(store: StateStore, room_id: str, user_id: str) -> list[dict]: - data = await store.get(_staged_attachments_key(room_id, user_id)) - if not isinstance(data, dict): - return [] - - attachments = data.get("attachments") - if not isinstance(attachments, list): - return [] - - return [attachment for attachment in attachments if isinstance(attachment, dict)] - - -async def add_staged_attachment( - store: StateStore, room_id: str, user_id: str, attachment: dict -) -> None: - async with _staged_attachments_lock(room_id, user_id): - attachments = await get_staged_attachments(store, room_id, user_id) - attachments.append(attachment) - await store.set(_staged_attachments_key(room_id, user_id), {"attachments": attachments}) - - -async def remove_staged_attachment_at( - store: StateStore, room_id: str, user_id: str, index: int -) -> dict | None: - async with _staged_attachments_lock(room_id, user_id): - attachments = await get_staged_attachments(store, room_id, user_id) - if index < 0 or index >= len(attachments): - return None - - removed = attachments.pop(index) - if attachments: - await store.set(_staged_attachments_key(room_id, user_id), {"attachments": attachments}) - else: - await store.delete(_staged_attachments_key(room_id, user_id)) - return removed - - -async def clear_staged_attachments(store: StateStore, room_id: str, user_id: str) -> None: - async with _staged_attachments_lock(room_id, user_id): - await store.delete(_staged_attachments_key(room_id, user_id)) diff --git a/adapter/telegram/bot.py b/adapter/telegram/bot.py index bfff1ee..fef12e4 100644 --- a/adapter/telegram/bot.py +++ b/adapter/telegram/bot.py @@ -1,21 +1,27 @@ +# adapter/telegram/bot.py from __future__ import annotations import asyncio import os import structlog -from dotenv import load_dotenv - -load_dotenv() from aiogram import Bot, Dispatcher from aiogram.fsm.storage.memory import MemoryStorage from aiogram.types import BotCommand from adapter.telegram import db -from adapter.telegram.handlers import commands, message, settings, start, topic_events +from adapter.telegram.handlers import auth, chat, confirm, forum, settings from core.auth import AuthManager from core.chat import ChatManager from core.handler import EventDispatcher +from core.handlers.callback import handle_confirm as core_handle_confirm +from core.handlers.chat import handle_archive, handle_list_chats, handle_new_chat, handle_rename +from core.handlers.message import handle_message +from core.handlers.settings import ( + handle_settings, + handle_settings_skills, +) +from core.handlers.start import handle_start from core.settings import SettingsManager from core.store import InMemoryStore from sdk.mock import MockPlatformClient @@ -23,7 +29,9 @@ from sdk.mock import MockPlatformClient logger = structlog.get_logger(__name__) -class PlatformMiddleware: +class DispatcherMiddleware: + """Injects EventDispatcher into every handler via data dict.""" + def __init__(self, dispatcher: EventDispatcher) -> None: self._dispatcher = dispatcher @@ -32,47 +40,70 @@ class PlatformMiddleware: return await handler(event, data) -def build_event_dispatcher() -> EventDispatcher: - platform = MockPlatformClient() +def build_event_dispatcher(platform: MockPlatformClient) -> EventDispatcher: store = InMemoryStore() - return EventDispatcher( + chat_mgr = ChatManager(platform, store) + auth_mgr = AuthManager(platform, store) + settings_mgr = SettingsManager(platform, store) + + ed = EventDispatcher( platform=platform, - chat_mgr=ChatManager(platform, store), - auth_mgr=AuthManager(platform, store), - settings_mgr=SettingsManager(platform, store), + chat_mgr=chat_mgr, + auth_mgr=auth_mgr, + settings_mgr=settings_mgr, ) + # Register core handlers + from core.protocol import IncomingCallback, IncomingCommand, IncomingMessage + ed.register(IncomingCommand, "start", handle_start) + ed.register(IncomingCommand, "settings", handle_settings) + ed.register(IncomingCommand, "settings_skills", handle_settings_skills) + ed.register(IncomingCommand, "new", handle_new_chat) + ed.register(IncomingCommand, "chats", handle_list_chats) + ed.register(IncomingCommand, "rename", handle_rename) + ed.register(IncomingCommand, "archive", handle_archive) + ed.register(IncomingMessage, "*", handle_message) + ed.register(IncomingCallback, "confirm", core_handle_confirm) + ed.register(IncomingCallback, "cancel", core_handle_confirm) + + return ed + async def main() -> None: - token = os.environ.get("BOT_TOKEN") or os.environ.get("TELEGRAM_BOT_TOKEN") + token = os.environ.get("BOT_TOKEN") if not token: - raise RuntimeError("BOT_TOKEN (or TELEGRAM_BOT_TOKEN) env variable is not set") + raise RuntimeError("BOT_TOKEN env variable is not set") db.init_db() bot = Bot(token=token) - dp = Dispatcher(storage=MemoryStorage()) - event_dispatcher = build_event_dispatcher() + storage = MemoryStorage() + dp = Dispatcher(storage=storage) - dp.message.middleware(PlatformMiddleware(event_dispatcher)) - dp.callback_query.middleware(PlatformMiddleware(event_dispatcher)) + platform = MockPlatformClient() + event_dispatcher = build_event_dispatcher(platform) - dp.include_router(topic_events.router) - dp.include_router(start.router) - dp.include_router(commands.router) + # Register middleware on all update types + dp.message.middleware(DispatcherMiddleware(event_dispatcher)) + dp.callback_query.middleware(DispatcherMiddleware(event_dispatcher)) + + # Include routers + dp.include_router(auth.router) + dp.include_router(forum.router) + dp.include_router(chat.router) dp.include_router(settings.router) - dp.include_router(message.router) + dp.include_router(confirm.router) await bot.set_my_commands([ BotCommand(command="start", description="Начать / восстановить сессию"), BotCommand(command="new", description="Создать новый чат"), - BotCommand(command="archive", description="Архивировать текущий чат"), - BotCommand(command="rename", description="Переименовать текущий чат"), + BotCommand(command="chats", description="Список чатов"), BotCommand(command="settings", description="Настройки"), + BotCommand(command="forum", description="Подключить Forum-группу"), ]) - logger.info("bot_starting") - await dp.start_polling(bot, allowed_updates=["message", "callback_query"]) + logger.info("Bot starting") + await dp.start_polling(bot) if __name__ == "__main__": diff --git a/adapter/telegram/converter.py b/adapter/telegram/converter.py index 1c00927..640ba67 100644 --- a/adapter/telegram/converter.py +++ b/adapter/telegram/converter.py @@ -1,24 +1,37 @@ +# adapter/telegram/converter.py from __future__ import annotations from aiogram.types import Message +from adapter.telegram import db from core.protocol import Attachment, IncomingMessage, OutgoingEvent, OutgoingMessage, OutgoingUI -def from_message(message: Message) -> IncomingMessage | None: - """Convert aiogram Message to IncomingMessage. Returns None for General topic.""" - thread_id = message.message_thread_id - if thread_id is None: - return None +def from_message(message: Message, chat_id: str) -> IncomingMessage: return IncomingMessage( user_id=str(message.from_user.id), - chat_id=str(thread_id), + chat_id=chat_id, text=message.text or message.caption or "", attachments=_extract_attachments(message), platform="telegram", ) +def is_forum_message(message: Message) -> bool: + return getattr(message, "message_thread_id", None) is not None + + +def resolve_forum_chat_id(message: Message, tg_user_id: int) -> str | None: + thread_id = getattr(message, "message_thread_id", None) + if thread_id is None: + return None + + chat = db.get_chat_by_thread(tg_user_id, thread_id) + if not chat: + return None + return chat["chat_id"] + + def _extract_attachments(message: Message) -> list[Attachment]: attachments: list[Attachment] = [] if message.photo: @@ -44,8 +57,10 @@ def _extract_attachments(message: Message) -> list[Attachment]: return attachments -def format_outgoing(event: OutgoingEvent) -> str: - """Extract text from an outgoing event for sending to Telegram.""" - if isinstance(event, (OutgoingMessage, OutgoingUI)): - return event.text - return str(event) +def format_outgoing(chat_name: str, event: OutgoingEvent, *, prefix: bool = True) -> str: + rendered_prefix = f"[{chat_name}] " if prefix else "" + if isinstance(event, OutgoingMessage): + return rendered_prefix + event.text + if isinstance(event, OutgoingUI): + return rendered_prefix + event.text + return rendered_prefix + str(event) diff --git a/adapter/telegram/db.py b/adapter/telegram/db.py index 7e4602a..697dd8d 100644 --- a/adapter/telegram/db.py +++ b/adapter/telegram/db.py @@ -1,7 +1,9 @@ +# adapter/telegram/db.py from __future__ import annotations import os import sqlite3 +import uuid from contextlib import contextmanager DB_PATH = os.environ.get("DB_PATH", "lambda_bot.db") @@ -21,83 +23,145 @@ def _conn(): def init_db() -> None: with _conn() as con: con.executescript(""" - CREATE TABLE IF NOT EXISTS chats ( - user_id INTEGER NOT NULL, - thread_id INTEGER NOT NULL, - chat_name TEXT NOT NULL DEFAULT 'Чат #1', - archived_at DATETIME, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (user_id, thread_id) + CREATE TABLE IF NOT EXISTS tg_users ( + tg_user_id INTEGER PRIMARY KEY, + platform_user_id TEXT NOT NULL, + display_name TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + forum_group_id INTEGER + ); + + CREATE TABLE IF NOT EXISTS chats ( + chat_id TEXT PRIMARY KEY, + tg_user_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + archived_at TIMESTAMP, + forum_thread_id INTEGER, + FOREIGN KEY(tg_user_id) REFERENCES tg_users(tg_user_id) ); - CREATE INDEX IF NOT EXISTS idx_chats_user_id ON chats(user_id); """) + # Миграция для существующих БД + try: + con.execute("ALTER TABLE tg_users ADD COLUMN forum_group_id INTEGER") + except Exception: + pass + try: + con.execute("ALTER TABLE chats ADD COLUMN forum_thread_id INTEGER") + except Exception: + pass -def create_chat(user_id: int, thread_id: int, chat_name: str) -> None: - with _conn() as con: - con.execute( - "INSERT OR IGNORE INTO chats (user_id, thread_id, chat_name) VALUES (?, ?, ?)", - (user_id, thread_id, chat_name), - ) - - -def get_chat(user_id: int, thread_id: int) -> dict | None: +def get_or_create_tg_user( + tg_user_id: int, + platform_user_id: str, + display_name: str | None, +) -> dict: with _conn() as con: row = con.execute( - "SELECT * FROM chats WHERE user_id = ? AND thread_id = ?", - (user_id, thread_id), + "SELECT * FROM tg_users WHERE tg_user_id = ?", (tg_user_id,) + ).fetchone() + if row: + return dict(row) + con.execute( + "INSERT INTO tg_users (tg_user_id, platform_user_id, display_name) VALUES (?, ?, ?)", + (tg_user_id, platform_user_id, display_name), + ) + return { + "tg_user_id": tg_user_id, + "platform_user_id": platform_user_id, + "display_name": display_name, + } + + +def create_chat(tg_user_id: int, name: str) -> str: + chat_id = str(uuid.uuid4()) + with _conn() as con: + con.execute( + "INSERT INTO chats (chat_id, tg_user_id, name) VALUES (?, ?, ?)", + (chat_id, tg_user_id, name), + ) + return chat_id + + +def get_last_chat(tg_user_id: int) -> dict | None: + with _conn() as con: + row = con.execute( + "SELECT * FROM chats WHERE tg_user_id = ? AND archived_at IS NULL " + "ORDER BY created_at DESC LIMIT 1", + (tg_user_id,), ).fetchone() return dict(row) if row else None -def get_active_chats(user_id: int) -> list[dict]: +def get_user_chats(tg_user_id: int) -> list[dict]: with _conn() as con: rows = con.execute( - "SELECT * FROM chats WHERE user_id = ? AND archived_at IS NULL " + "SELECT * FROM chats WHERE tg_user_id = ? AND archived_at IS NULL " "ORDER BY created_at ASC", - (user_id,), + (tg_user_id,), ).fetchall() return [dict(r) for r in rows] -def count_active_chats(user_id: int) -> int: +def count_chats(tg_user_id: int) -> int: with _conn() as con: row = con.execute( - "SELECT COUNT(*) FROM chats WHERE user_id = ? AND archived_at IS NULL", - (user_id,), + "SELECT COUNT(*) FROM chats WHERE tg_user_id = ? AND archived_at IS NULL", + (tg_user_id,), ).fetchone() return row[0] -def archive_chat(user_id: int, thread_id: int) -> None: +def get_chat_by_id(chat_id: str) -> dict | None: + with _conn() as con: + row = con.execute("SELECT * FROM chats WHERE chat_id = ?", (chat_id,)).fetchone() + return dict(row) if row else None + + +def rename_chat(chat_id: str, new_name: str) -> None: + with _conn() as con: + con.execute("UPDATE chats SET name = ? WHERE chat_id = ?", (new_name, chat_id)) + + +def archive_chat(chat_id: str) -> None: with _conn() as con: con.execute( - "UPDATE chats SET archived_at = CURRENT_TIMESTAMP " - "WHERE user_id = ? AND thread_id = ?", - (user_id, thread_id), + "UPDATE chats SET archived_at = CURRENT_TIMESTAMP WHERE chat_id = ?", + (chat_id,), ) -def rename_chat(user_id: int, thread_id: int, new_name: str) -> None: +def set_forum_group(tg_user_id: int, group_id: int) -> None: with _conn() as con: con.execute( - "UPDATE chats SET chat_name = ? WHERE user_id = ? AND thread_id = ?", - (new_name, user_id, thread_id), + "UPDATE tg_users SET forum_group_id = ? WHERE tg_user_id = ?", + (group_id, tg_user_id), ) -def get_display_number(user_id: int, thread_id: int) -> int: - """Return 1-based display number for a chat (by creation order).""" +def get_forum_group(tg_user_id: int) -> int | None: with _conn() as con: row = con.execute( - """ - SELECT rn FROM ( - SELECT thread_id, - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn - FROM chats - WHERE user_id = ? - ) WHERE thread_id = ? - """, - (user_id, thread_id), + "SELECT forum_group_id FROM tg_users WHERE tg_user_id = ?", + (tg_user_id,), ).fetchone() - return row[0] if row else 1 + return row["forum_group_id"] if row else None + + +def set_forum_thread(chat_id: str, thread_id: int) -> None: + with _conn() as con: + con.execute( + "UPDATE chats SET forum_thread_id = ? WHERE chat_id = ?", + (thread_id, chat_id), + ) + + +def get_chat_by_thread(tg_user_id: int, thread_id: int) -> dict | None: + with _conn() as con: + row = con.execute( + "SELECT * FROM chats WHERE tg_user_id = ? AND forum_thread_id = ? " + "AND archived_at IS NULL", + (tg_user_id, thread_id), + ).fetchone() + return dict(row) if row else None diff --git a/adapter/telegram/handlers/auth.py b/adapter/telegram/handlers/auth.py new file mode 100644 index 0000000..fa55b1e --- /dev/null +++ b/adapter/telegram/handlers/auth.py @@ -0,0 +1,67 @@ +# adapter/telegram/handlers/auth.py +from __future__ import annotations + +from aiogram import Router +from aiogram.filters import CommandStart +from aiogram.fsm.context import FSMContext +from aiogram.types import Message + +from adapter.telegram import db +from adapter.telegram.states import ChatState +from core.handler import EventDispatcher +from core.protocol import IncomingCommand + +router = Router(name="auth") + + +@router.message(CommandStart()) +async def cmd_start( + message: Message, + state: FSMContext, + dispatcher: EventDispatcher, +) -> None: + tg_id = message.from_user.id + display_name = message.from_user.full_name + + # Ensure user exists in platform (mock) + from sdk.mock import MockPlatformClient + # platform is available via dispatcher._platform + platform = dispatcher._platform + user = await platform.get_or_create_user( + external_id=str(tg_id), + platform="telegram", + display_name=display_name, + ) + platform_user_id = user.user_id + + # Upsert in local DB + db.get_or_create_tg_user(tg_id, platform_user_id, display_name) + + last_chat = db.get_last_chat(tg_id) + + if last_chat is None: + # New user — create first chat + chat_name = "Чат #1" + chat_id = db.create_chat(tg_id, chat_name) + await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) + await state.set_state(ChatState.idle) + await message.answer( + f"Привет, {message.from_user.first_name}! 👋\n" + f"Я создал тебе первый чат. Просто пиши.\n\n" + f"Команды: /new — новый чат, /chats — список чатов" + ) + else: + chat_id = last_chat["chat_id"] + chat_name = last_chat["name"] + await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) + await state.set_state(ChatState.idle) + await message.answer(f"С возвращением! Продолжаем [{chat_name}]") + + # Register auth in core + event = IncomingCommand( + user_id=platform_user_id, + platform="telegram", + chat_id=chat_id, + command="start", + ) + await dispatcher.dispatch(event) diff --git a/adapter/telegram/handlers/chat.py b/adapter/telegram/handlers/chat.py new file mode 100644 index 0000000..2128043 --- /dev/null +++ b/adapter/telegram/handlers/chat.py @@ -0,0 +1,282 @@ +# adapter/telegram/handlers/chat.py +from __future__ import annotations + +import asyncio + +import structlog +from aiogram import F, Router +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message + +from adapter.telegram import db +from adapter.telegram.converter import ( + format_outgoing, + from_message, + is_forum_message, + resolve_forum_chat_id, +) +from adapter.telegram.keyboards.chat import chats_list_keyboard +from adapter.telegram.keyboards.confirm import confirm_keyboard +from adapter.telegram.states import ChatState +from core.handler import EventDispatcher +from core.protocol import OutgoingMessage, OutgoingUI + +logger = structlog.get_logger(__name__) + +router = Router(name="chat") + + +def _thread_id(message: Message) -> int | None: + return getattr(message, "message_thread_id", None) + + +def _callback_thread_id(callback: CallbackQuery) -> int | None: + if callback.message is None: + return None + return getattr(callback.message, "message_thread_id", None) + + +async def _send_reply( + message: Message, + text: str, + *, + reply_markup=None, + thread_id: int | None = None, +) -> None: + if thread_id is None: + await message.answer(text, reply_markup=reply_markup) + return + + await message.bot.send_message( + message.chat.id, + text, + reply_markup=reply_markup, + message_thread_id=thread_id, + ) + + +async def _send_outgoing( + message: Message, + chat_name: str, + events: list, + *, + forum_mode: bool, + thread_id: int | None = None, +) -> None: + for event in events: + if isinstance(event, OutgoingUI): + action_id = ( + event.buttons[0].payload.get("action_id", "unknown") + if event.buttons + else "unknown" + ) + kb = confirm_keyboard(action_id) + await _send_reply( + message, + format_outgoing(chat_name, event, prefix=not forum_mode), + reply_markup=kb, + thread_id=thread_id, + ) + elif isinstance(event, OutgoingMessage): + await _send_reply( + message, + format_outgoing(chat_name, event, prefix=not forum_mode), + thread_id=thread_id, + ) + + +@router.message(ChatState.idle, (F.text | F.photo | F.document | F.voice) & ~F.text.startswith("/")) +async def handle_message( + message: Message, + state: FSMContext, + dispatcher: EventDispatcher, +) -> None: + data = await state.get_data() + tg_id = message.from_user.id + tg_user = db.get_or_create_tg_user(tg_id, str(tg_id), message.from_user.full_name) + platform_user_id = tg_user.get("platform_user_id", str(tg_id)) + forum_mode = is_forum_message(message) + thread_id = _thread_id(message) if forum_mode else None + + if forum_mode: + chat_id = resolve_forum_chat_id(message, tg_id) + if not chat_id: + await _send_reply( + message, + "Эта форум-тема ещё не зарегистрирована. Выполните /new в этой теме.", + thread_id=thread_id, + ) + return + + chat = db.get_chat_by_id(chat_id) + chat_name = chat["name"] if chat else data.get("active_chat_name", "Чат") + await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) + else: + chat_id = data.get("active_chat_id") + chat_name = data.get("active_chat_name", "Чат") + + if not chat_id: + await message.answer("Нет активного чата. Введите /start") + return + + await state.set_state(ChatState.waiting_response) + + async def _typing_loop() -> None: + while True: + if thread_id is None: + await message.bot.send_chat_action(message.chat.id, "typing") + else: + await message.bot.send_chat_action( + message.chat.id, + "typing", + message_thread_id=thread_id, + ) + await asyncio.sleep(4) + + task = asyncio.create_task(_typing_loop()) + events: list = [] + try: + incoming = from_message(message, chat_id) + incoming.user_id = platform_user_id + events = await dispatcher.dispatch(incoming) + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + await state.set_state(ChatState.idle) + await _send_outgoing( + message, + chat_name, + events, + forum_mode=forum_mode, + thread_id=thread_id, + ) + + +@router.message(Command("new")) +async def cmd_new_chat(message: Message, state: FSMContext) -> None: + tg_id = message.from_user.id + args = message.text.split(maxsplit=1) + name = args[1].strip() if len(args) > 1 else None + thread_id = _thread_id(message) + + if thread_id is not None: + chat = db.get_chat_by_thread(tg_id, thread_id) + if chat: + chat_id = chat["chat_id"] + chat_name = name or chat["name"] + if name and name != chat["name"]: + db.rename_chat(chat_id, name) + await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) + await state.set_state(ChatState.idle) + await _send_reply( + message, + f"✅ [{chat_name}] уже связан с этой темой.", + thread_id=thread_id, + ) + return + + count = db.count_chats(tg_id) + chat_name = name or f"Чат #{count + 1}" + chat_id = db.create_chat(tg_id, chat_name) + db.set_forum_thread(chat_id, thread_id) + await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) + await state.set_state(ChatState.idle) + await _send_reply( + message, + f"✅ [{chat_name}] зарегистрирован в этой теме. Пиши!", + thread_id=thread_id, + ) + return + + count = db.count_chats(tg_id) + chat_name = name or f"Чат #{count + 1}" + chat_id = db.create_chat(tg_id, chat_name) + created_thread_id = None + forum_group_id = db.get_forum_group(tg_id) + + if forum_group_id is not None: + try: + topic = await message.bot.create_forum_topic(chat_id=forum_group_id, name=chat_name) + created_thread_id = ( + getattr(topic, "message_thread_id", None) + or getattr(topic, "thread_id", None) + ) + if created_thread_id is not None: + db.set_forum_thread(chat_id, created_thread_id) + except Exception as exc: # pragma: no cover - defensive fallback for Telegram API + logger.warning( + "Failed to create forum topic for new chat", + tg_user_id=tg_id, + chat_name=chat_name, + error=str(exc), + ) + + await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) + await state.set_state(ChatState.idle) + if created_thread_id is not None: + await message.answer(f"✅ [{chat_name}] создан. Форум-тема тоже создана.") + else: + await message.answer(f"✅ [{chat_name}] создан. Пиши!") + + +@router.message(Command("chats")) +async def cmd_list_chats(message: Message, state: FSMContext) -> None: + if is_forum_message(message): + await _send_reply( + message, + "В forum-теме переключение между чатами отключено. " + "Эта тема всегда привязана к одному чату. Используй /chats в личке с ботом.", + thread_id=_thread_id(message), + ) + return + + tg_id = message.from_user.id + chats = db.get_user_chats(tg_id) + if not chats: + await message.answer("Нет активных чатов. Введи /new чтобы создать.") + return + + data = await state.get_data() + active_id = data.get("active_chat_id") + kb = chats_list_keyboard(chats, active_id) + await message.answer("Твои чаты:", reply_markup=kb) + + +@router.callback_query(F.data.startswith("switch:")) +async def switch_chat(callback: CallbackQuery, state: FSMContext) -> None: + if _callback_thread_id(callback) is not None: + await callback.answer( + "Переключение чатов доступно только в личке с ботом.", + show_alert=True, + ) + return + + _, chat_id, chat_name = callback.data.split(":", 2) + await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) + await state.set_state(ChatState.idle) + await callback.message.edit_text(f"✅ Переключился на [{chat_name}]") + await callback.answer() + + +@router.callback_query(F.data == "new_chat") +async def cb_new_chat(callback: CallbackQuery, state: FSMContext) -> None: + if _callback_thread_id(callback) is not None: + await callback.answer( + "Создание нового чата из списка доступно только в личке с ботом.", + show_alert=True, + ) + return + + tg_id = callback.from_user.id + count = db.count_chats(tg_id) + chat_name = f"Чат #{count + 1}" + chat_id = db.create_chat(tg_id, chat_name) + await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) + await state.set_state(ChatState.idle) + await callback.message.edit_text(f"✅ [{chat_name}] создан. Пиши!") + await callback.answer() diff --git a/adapter/telegram/handlers/commands.py b/adapter/telegram/handlers/commands.py deleted file mode 100644 index 5a72836..0000000 --- a/adapter/telegram/handlers/commands.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import annotations - -import structlog -from aiogram import Router -from aiogram.exceptions import TelegramBadRequest -from aiogram.filters import Command -from aiogram.types import Message - -from adapter.telegram import db -from adapter.telegram.keyboards.settings import settings_main_keyboard - -logger = structlog.get_logger(__name__) - -router = Router(name="commands") - - -@router.message(Command("new")) -async def cmd_new(message: Message) -> None: - """Create a new topic and register it as a new chat.""" - user_id = message.from_user.id - chat_id = message.chat.id - n = db.count_active_chats(user_id) + 1 - new_name = f"Чат #{n}" - try: - topic = await message.bot.create_forum_topic(chat_id=chat_id, name=new_name) - except TelegramBadRequest as e: - if "topics limit" in str(e).lower(): - await message.answer("Достигнут лимит топиков (1000). Заархивируй неиспользуемые чаты.") - else: - logger.error("cmd_new_failed", error=str(e)) - await message.answer("Не удалось создать чат, попробуй позже.") - return - thread_id = topic.message_thread_id - db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=new_name) - await message.answer(f"Создан {new_name}. Перейди в новый топик.") - logger.info("cmd_new", user_id=user_id, thread_id=thread_id, name=new_name) - - -@router.message(Command("archive")) -async def cmd_archive(message: Message) -> None: - """Archive the current topic.""" - user_id = message.from_user.id - thread_id = message.message_thread_id - chat = db.get_chat(user_id=user_id, thread_id=thread_id) - if chat is None or chat["archived_at"] is not None: - await message.answer("Этот чат не найден или уже архивирован.") - return - db.archive_chat(user_id=user_id, thread_id=thread_id) - - try: - await message.bot.delete_forum_topic( - chat_id=message.chat.id, message_thread_id=thread_id - ) - logger.info("cmd_archive_deleted", user_id=user_id, thread_id=thread_id) - except TelegramBadRequest as e: - logger.warning("cmd_archive_delete_failed", error=str(e)) - await message.answer( - "Чат архивирован — бот больше не будет отвечать здесь.\n\n" - "Удалить топик из списка не получится: он создан ботом, " - "а Telegram не позволяет пользователям удалять чужие топики." - ) - - logger.info("cmd_archive", user_id=user_id, thread_id=thread_id) - - -@router.message(Command("rename")) -async def cmd_rename(message: Message) -> None: - """Rename the current topic. Usage: /rename New Name""" - user_id = message.from_user.id - thread_id = message.message_thread_id - parts = (message.text or "").split(maxsplit=1) - new_name = parts[1].strip() if len(parts) > 1 else "" - if not new_name: - await message.answer("Использование: /rename Новое название") - return - chat = db.get_chat(user_id=user_id, thread_id=thread_id) - if chat is None: - await message.answer("Этот чат не найден.") - return - try: - await message.bot.edit_forum_topic( - chat_id=message.chat.id, - message_thread_id=thread_id, - name=new_name[:128], - ) - except TelegramBadRequest as e: - logger.error("cmd_rename_failed", error=str(e)) - await message.answer("Не удалось переименовать топик.") - return - db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name[:128]) - logger.info("cmd_rename", user_id=user_id, thread_id=thread_id, new_name=new_name) - - -@router.message(Command("settings")) -async def cmd_settings(message: Message) -> None: - """Open settings menu.""" - await message.answer("⚙️ Настройки", reply_markup=settings_main_keyboard()) diff --git a/adapter/telegram/handlers/confirm.py b/adapter/telegram/handlers/confirm.py new file mode 100644 index 0000000..11b5021 --- /dev/null +++ b/adapter/telegram/handlers/confirm.py @@ -0,0 +1,81 @@ +# adapter/telegram/handlers/confirm.py +from __future__ import annotations + +from aiogram import F, Router +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery + +from adapter.telegram import db +from adapter.telegram.converter import format_outgoing, is_forum_message, resolve_forum_chat_id +from core.handler import EventDispatcher +from core.protocol import IncomingCallback, OutgoingMessage, OutgoingUI + +router = Router(name="confirm") + + +async def _send_reply( + callback: CallbackQuery, + text: str, + *, + thread_id: int | None = None, +) -> None: + if callback.message is None: + await callback.answer() + return + + if thread_id is None: + await callback.message.answer(text) + return + + await callback.message.bot.send_message( + callback.message.chat.id, + text, + message_thread_id=thread_id, + ) + + +@router.callback_query(F.data.startswith("confirm:")) +async def handle_confirm( + callback: CallbackQuery, + state: FSMContext, + dispatcher: EventDispatcher, +) -> None: + parts = callback.data.split(":", 2) + _, decision, action_id = parts # "yes" or "no" + + data = await state.get_data() + chat_id = data.get("active_chat_id", "") + chat_name = data.get("active_chat_name", "Чат") + thread_id = getattr(callback.message, "message_thread_id", None) + forum_mode = callback.message is not None and is_forum_message(callback.message) + + tg_id = callback.from_user.id + tg_user = db.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name) + platform_user_id = tg_user.get("platform_user_id", str(tg_id)) + + if forum_mode and callback.message is not None: + resolved_chat_id = resolve_forum_chat_id(callback.message, tg_id) + if resolved_chat_id: + chat_id = resolved_chat_id + chat = db.get_chat_by_id(chat_id) + if chat: + chat_name = chat["name"] + + incoming = IncomingCallback( + user_id=platform_user_id, + platform="telegram", + chat_id=chat_id, + action="confirm" if decision == "yes" else "cancel", + payload={"action_id": action_id}, + ) + events = await dispatcher.dispatch(incoming) + + if callback.message is not None: + await callback.message.edit_reply_markup(reply_markup=None) + + for event in events: + if isinstance(event, (OutgoingMessage, OutgoingUI)): + rendered = format_outgoing(chat_name, event, prefix=not forum_mode) + await _send_reply(callback, rendered, thread_id=thread_id if forum_mode else None) + + await callback.answer() diff --git a/adapter/telegram/handlers/forum.py b/adapter/telegram/handlers/forum.py new file mode 100644 index 0000000..61bff25 --- /dev/null +++ b/adapter/telegram/handlers/forum.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import structlog +from aiogram import F, Router +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import Chat, Message, ReplyKeyboardRemove + +from adapter.telegram import db +from adapter.telegram.keyboards.forum import forum_group_request_keyboard +from adapter.telegram.states import ChatState, ForumSetupState + +logger = structlog.get_logger(__name__) + +router = Router(name="forum") + + +def _thread_id_from_topic(topic: object) -> int | None: + thread_id = getattr(topic, "message_thread_id", None) + if thread_id is not None: + return thread_id + return getattr(topic, "thread_id", None) + + +def _resolve_forwarded_chat(message: Message) -> Chat | None: + forwarded_chat = getattr(message, "forward_from_chat", None) + if forwarded_chat is not None: + return forwarded_chat + + forward_origin = getattr(message, "forward_origin", None) + if forward_origin is None: + return None + + sender_chat = getattr(forward_origin, "sender_chat", None) + if sender_chat is not None: + return sender_chat + + return getattr(forward_origin, "chat", None) + + +def _forward_debug_payload(message: Message) -> dict[str, object]: + forward_origin = getattr(message, "forward_origin", None) + forwarded_chat = _resolve_forwarded_chat(message) + return { + "has_forward_from_chat": getattr(message, "forward_from_chat", None) is not None, + "has_forward_origin": forward_origin is not None, + "forward_origin_type": getattr(forward_origin, "type", None), + "forwarded_chat_id": getattr(forwarded_chat, "id", None), + "forwarded_chat_type": getattr(forwarded_chat, "type", None), + "forwarded_chat_is_forum": getattr(forwarded_chat, "is_forum", None), + } + + +async def _send_message( + message: Message, + text: str, + *, + reply_markup=None, + thread_id: int | None = None, +) -> None: + if thread_id is None: + await message.answer(text, reply_markup=reply_markup) + return + + await message.bot.send_message( + message.chat.id, + text, + reply_markup=reply_markup, + message_thread_id=thread_id, + ) + + +async def _complete_group_link(message: Message, state: FSMContext, forwarded_chat: Chat) -> None: + bot_user = await message.bot.get_me() + member = await message.bot.get_chat_member(forwarded_chat.id, bot_user.id) + can_manage_topics = getattr(member, "can_manage_topics", False) + is_admin = member.status in ("administrator", "creator") + if not is_admin or (member.status == "administrator" and not can_manage_topics): + logger.warning( + "Forum onboarding failed: bot lacks forum admin rights", + tg_user_id=message.from_user.id, + forum_group_id=forwarded_chat.id, + member_status=member.status, + can_manage_topics=can_manage_topics, + ) + await message.answer( + "Я не вижу прав на управление темами. " + "Добавь меня администратором с правом `can_manage_topics` и попробуй снова.", + reply_markup=ReplyKeyboardRemove(), + ) + return + + tg_user_id = message.from_user.id + db.set_forum_group(tg_user_id, forwarded_chat.id) + logger.info( + "Forum group linked", + tg_user_id=tg_user_id, + forum_group_id=forwarded_chat.id, + forum_group_title=getattr(forwarded_chat, "title", None), + ) + + created_topics = 0 + for chat in db.get_user_chats(tg_user_id): + if chat.get("forum_thread_id") is not None: + continue + + topic = await message.bot.create_forum_topic( + chat_id=forwarded_chat.id, + name=chat["name"], + ) + thread_id = _thread_id_from_topic(topic) + if thread_id is None: + logger.warning("Forum topic created without thread id", chat_id=chat["chat_id"]) + continue + + db.set_forum_thread(chat["chat_id"], thread_id) + created_topics += 1 + logger.info( + "Forum topic linked to chat", + tg_user_id=tg_user_id, + chat_id=chat["chat_id"], + forum_group_id=forwarded_chat.id, + forum_thread_id=thread_id, + ) + + await state.set_state(ChatState.idle) + logger.info( + "Forum onboarding completed", + tg_user_id=tg_user_id, + forum_group_id=forwarded_chat.id, + created_topics=created_topics, + ) + await message.answer( + f"✅ Группа подключена. Создал {created_topics} тем(ы) для существующих чатов.", + reply_markup=ReplyKeyboardRemove(), + ) + + +@router.message(Command("forum")) +async def cmd_forum(message: Message, state: FSMContext) -> None: + await state.set_state(ForumSetupState.waiting_for_group) + logger.info("Forum onboarding started", tg_user_id=message.from_user.id) + await message.answer( + "Выбери forum-группу кнопкой ниже. Бот должен уже быть добавлен туда " + "администратором с правом управления темами.\n\n" + "Если кнопка не сработает, можно переслать сообщение из группы как fallback.", + reply_markup=forum_group_request_keyboard(), + ) + + +@router.message(ForumSetupState.waiting_for_group) +async def handle_group_forward(message: Message, state: FSMContext) -> None: + chat_shared = getattr(message, "chat_shared", None) + if chat_shared is not None: + logger.info( + "Forum onboarding chat selected via request_chat", + tg_user_id=message.from_user.id, + forum_group_id=chat_shared.chat_id, + forum_group_title=getattr(chat_shared, "title", None), + request_id=getattr(chat_shared, "request_id", None), + ) + forwarded_chat = Chat( + id=chat_shared.chat_id, + type="supergroup", + title=getattr(chat_shared, "title", None), + is_forum=True, + ) + await _complete_group_link(message, state, forwarded_chat) + return + + debug_payload = _forward_debug_payload(message) + logger.info( + "Forum onboarding message received", + tg_user_id=message.from_user.id, + **debug_payload, + ) + + forwarded_chat = _resolve_forwarded_chat(message) + if forwarded_chat is None: + logger.warning( + "Forum onboarding failed: missing forwarded chat metadata", + tg_user_id=message.from_user.id, + **debug_payload, + ) + await message.answer( + "Не вижу в сообщении данных о группе. " + "Нажми кнопку `Выбрать forum-группу` или перешли сообщение именно из нужной супергруппы, не копируй текст вручную." + ) + return + + if forwarded_chat.type != "supergroup": + logger.warning( + "Forum onboarding failed: forwarded chat is not supergroup", + tg_user_id=message.from_user.id, + **debug_payload, + ) + await message.answer( + "Пересылка пришла не из супергруппы. Нужна именно supergroup с включёнными Topics." + ) + return + + if getattr(forwarded_chat, "is_forum", None) is False: + logger.warning( + "Forum onboarding failed: supergroup is not forum-enabled", + tg_user_id=message.from_user.id, + **debug_payload, + ) + await message.answer( + "Это супергруппа, но в ней выключены Topics. Включи Topics и попробуй снова." + ) + return + await _complete_group_link(message, state, forwarded_chat) diff --git a/adapter/telegram/handlers/message.py b/adapter/telegram/handlers/message.py deleted file mode 100644 index d70df0d..0000000 --- a/adapter/telegram/handlers/message.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import asyncio -import time - -import structlog -from aiogram import F, Router -from aiogram.exceptions import TelegramBadRequest -from aiogram.types import Message - -from adapter.telegram import converter, db -from core.handler import EventDispatcher - -logger = structlog.get_logger(__name__) - -router = Router(name="message") - -STREAM_EDIT_INTERVAL = 1.5 -STREAM_MIN_DELTA = 100 -TELEGRAM_MAX_LEN = 4096 - - -@router.message(F.text & F.message_thread_id) -async def handle_topic_message(message: Message, dispatcher: EventDispatcher) -> None: - """Route a text message in a topic to the platform and stream the response.""" - user_id = message.from_user.id - thread_id = message.message_thread_id - - chat = db.get_chat(user_id=user_id, thread_id=thread_id) - if chat is None or chat["archived_at"] is not None: - return - - incoming = converter.from_message(message) - if incoming is None: - return - - platform_user = await dispatcher._platform.get_or_create_user( - external_id=str(user_id), - platform="telegram", - display_name=message.from_user.full_name, - ) - - placeholder = await message.reply("...") - - accumulated = "" - last_edit_time = 0.0 - last_edit_len = 0 - - try: - async with asyncio.timeout(30): - async for chunk in dispatcher._platform.stream_message( - user_id=platform_user.user_id, - chat_id=str(thread_id), - text=incoming.text, - attachments=None, - ): - accumulated += chunk.delta - now = time.monotonic() - delta = len(accumulated) - last_edit_len - if delta >= STREAM_MIN_DELTA and (now - last_edit_time) >= STREAM_EDIT_INTERVAL: - await _safe_edit(placeholder, accumulated) - last_edit_time = now - last_edit_len = len(accumulated) - - await _safe_edit(placeholder, accumulated or "...") - - except TimeoutError: - logger.warning("platform_timeout", user_id=user_id, thread_id=thread_id) - await _safe_edit(placeholder, "Сервис не отвечает, попробуй позже") - except TelegramBadRequest as e: - if "thread not found" in str(e).lower(): - db.archive_chat(user_id=user_id, thread_id=thread_id) - logger.warning("topic_deleted_during_message", thread_id=thread_id) - else: - logger.error("telegram_error", error=str(e)) - await _safe_edit(placeholder, "Ошибка отправки, попробуй ещё раз") - except Exception: - logger.exception("platform_error", user_id=user_id, thread_id=thread_id) - await _safe_edit(placeholder, "Сервис временно недоступен, попробуй позже") - - -async def _safe_edit(message: Message, text: str) -> None: - try: - await message.edit_text(text[:TELEGRAM_MAX_LEN]) - except TelegramBadRequest as e: - if "not modified" not in str(e).lower(): - raise diff --git a/adapter/telegram/handlers/settings.py b/adapter/telegram/handlers/settings.py index 7a98a1b..546b0af 100644 --- a/adapter/telegram/handlers/settings.py +++ b/adapter/telegram/handlers/settings.py @@ -12,7 +12,7 @@ from adapter.telegram.keyboards.settings import ( settings_main_keyboard, skills_keyboard, ) -from adapter.telegram.states import SettingsState +from adapter.telegram.states import ChatState, SettingsState from core.handler import EventDispatcher from core.protocol import SettingsAction @@ -34,7 +34,13 @@ async def cb_settings_back(callback: CallbackQuery, state: FSMContext) -> None: @router.callback_query(F.data == "settings:skills") async def cb_skills(callback: CallbackQuery, state: FSMContext, dispatcher: EventDispatcher) -> None: - platform_user_id = str(callback.from_user.id) + data = await state.get_data() + active_chat_id = data.get("active_chat_id", "") + tg_id = callback.from_user.id + # Get platform user id + from adapter.telegram import db as tgdb + tg_user = tgdb.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name) + platform_user_id = tg_user.get("platform_user_id", str(tg_id)) settings = await dispatcher._platform.get_settings(platform_user_id) await callback.message.edit_text( @@ -51,7 +57,10 @@ async def cb_toggle_skill( dispatcher: EventDispatcher, ) -> None: skill = callback.data.split(":", 1)[1] - platform_user_id = str(callback.from_user.id) + from adapter.telegram import db as tgdb + tg_id = callback.from_user.id + tg_user = tgdb.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name) + platform_user_id = tg_user.get("platform_user_id", str(tg_id)) settings = await dispatcher._platform.get_settings(platform_user_id) current = settings.skills.get(skill, False) @@ -68,7 +77,10 @@ async def cb_toggle_skill( @router.callback_query(F.data == "settings:safety") async def cb_safety(callback: CallbackQuery, state: FSMContext, dispatcher: EventDispatcher) -> None: - platform_user_id = str(callback.from_user.id) + from adapter.telegram import db as tgdb + tg_id = callback.from_user.id + tg_user = tgdb.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name) + platform_user_id = tg_user.get("platform_user_id", str(tg_id)) settings = await dispatcher._platform.get_settings(platform_user_id) await callback.message.edit_text( @@ -85,7 +97,10 @@ async def cb_toggle_safety( dispatcher: EventDispatcher, ) -> None: trigger = callback.data.split(":", 1)[1] - platform_user_id = str(callback.from_user.id) + from adapter.telegram import db as tgdb + tg_id = callback.from_user.id + tg_user = tgdb.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name) + platform_user_id = tg_user.get("platform_user_id", str(tg_id)) settings = await dispatcher._platform.get_settings(platform_user_id) current = settings.safety.get(trigger, False) @@ -121,7 +136,10 @@ async def handle_soul_input( dispatcher: EventDispatcher, ) -> None: text = message.text or "" - platform_user_id = str(message.from_user.id) + from adapter.telegram import db as tgdb + tg_id = message.from_user.id + tg_user = tgdb.get_or_create_tg_user(tg_id, str(tg_id), message.from_user.full_name) + platform_user_id = tg_user.get("platform_user_id", str(tg_id)) if ":" in text: field, _, value = text.partition(":") @@ -155,7 +173,10 @@ async def cb_connectors(callback: CallbackQuery) -> None: @router.callback_query(F.data == "settings:plan") async def cb_plan(callback: CallbackQuery, dispatcher: EventDispatcher) -> None: - platform_user_id = str(callback.from_user.id) + from adapter.telegram import db as tgdb + tg_id = callback.from_user.id + tg_user = tgdb.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name) + platform_user_id = tg_user.get("platform_user_id", str(tg_id)) settings = await dispatcher._platform.get_settings(platform_user_id) plan = settings.plan diff --git a/adapter/telegram/handlers/start.py b/adapter/telegram/handlers/start.py deleted file mode 100644 index 789f649..0000000 --- a/adapter/telegram/handlers/start.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import structlog -from aiogram import Router -from aiogram.exceptions import TelegramBadRequest -from aiogram.filters import CommandStart -from aiogram.types import Message - -from adapter.telegram import db - -logger = structlog.get_logger(__name__) - -router = Router(name="start") - - -@router.message(CommandStart()) -async def cmd_start(message: Message) -> None: - """ - Bootstrap the user's forum. - - First visit: create Чат #1, hide General topic. - Returning visit: health-check all active topics, archive stale ones. - """ - user_id = message.from_user.id - chat_id = message.chat.id - - try: - await _check_and_prune_stale_topics(message, user_id, chat_id) - except Exception: - logger.exception("prune_stale_topics_error", user_id=user_id) - - active = db.get_active_chats(user_id) - - if not active: - try: - topic = await message.bot.create_forum_topic(chat_id=chat_id, name="Чат #1") - thread_id = topic.message_thread_id - db.create_chat(user_id=user_id, thread_id=thread_id, chat_name="Чат #1") - logger.info("start_created_first_topic", user_id=user_id, thread_id=thread_id) - except TelegramBadRequest as e: - logger.warning("start_create_topic_failed", error=str(e)) - await message.answer( - "Не удалось создать топик. Убедись, что в @BotFather включён " - "Threaded Mode для этого бота." - ) - return - - try: - await message.bot.hide_general_forum_topic(chat_id=chat_id) - except TelegramBadRequest: - pass # Not critical - - await message.answer( - "Привет! Это твоё личное пространство с AI-агентом Lambda. " - "Каждый топик — отдельный контекст. Напиши что-нибудь." - ) - else: - await message.answer( - f"Снова привет! У тебя {len(active)} активных чатов. " - "Напиши /new чтобы создать новый." - ) - - -async def _check_and_prune_stale_topics( - message: Message, user_id: int, chat_id: int -) -> None: - """Send typing action to each active topic; archive any that no longer exist.""" - for chat in db.get_active_chats(user_id): - thread_id = chat["thread_id"] - try: - await message.bot.send_chat_action( - chat_id=chat_id, - action="typing", - message_thread_id=thread_id, - ) - except TelegramBadRequest: - db.archive_chat(user_id=user_id, thread_id=thread_id) - logger.info("pruned_stale_topic", user_id=user_id, thread_id=thread_id) diff --git a/adapter/telegram/handlers/topic_events.py b/adapter/telegram/handlers/topic_events.py deleted file mode 100644 index 3ad8750..0000000 --- a/adapter/telegram/handlers/topic_events.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -import structlog -from aiogram import F, Router -from aiogram.types import Message - -from adapter.telegram import db - -logger = structlog.get_logger(__name__) - -router = Router(name="topic_events") - - -@router.message(F.forum_topic_created) -async def on_topic_created(message: Message) -> None: - """User created a topic via Telegram UI — register it as a new chat. - - Skip topics created by the bot itself — those are already registered - by cmd_new at the time create_forum_topic() is called. - """ - if message.from_user is None or message.from_user.id == message.bot.id: - return - user_id = message.from_user.id - thread_id = message.message_thread_id - name = message.forum_topic_created.name - db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=name) - logger.info("topic_created", user_id=user_id, thread_id=thread_id, name=name) - - -@router.message(F.forum_topic_edited) -async def on_topic_edited(message: Message) -> None: - """User renamed a topic via Telegram UI — sync chat_name in DB.""" - user_id = message.from_user.id - thread_id = message.message_thread_id - new_name = message.forum_topic_edited.name - if db.get_chat(user_id=user_id, thread_id=thread_id) is None: - return - db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name) - logger.info("topic_renamed", user_id=user_id, thread_id=thread_id, new_name=new_name) - - -@router.message(F.forum_topic_closed) -async def on_topic_closed(message: Message) -> None: - """User closed a topic via Telegram UI — auto-archive the chat.""" - user_id = message.from_user.id - thread_id = message.message_thread_id - if db.get_chat(user_id=user_id, thread_id=thread_id) is None: - return - db.archive_chat(user_id=user_id, thread_id=thread_id) - logger.info("topic_closed_archived", user_id=user_id, thread_id=thread_id) diff --git a/adapter/telegram/keyboards/chat.py b/adapter/telegram/keyboards/chat.py new file mode 100644 index 0000000..b580993 --- /dev/null +++ b/adapter/telegram/keyboards/chat.py @@ -0,0 +1,16 @@ +# adapter/telegram/keyboards/chat.py +from __future__ import annotations + +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + + +def chats_list_keyboard(chats: list[dict], active_chat_id: str | None) -> InlineKeyboardMarkup: + buttons = [] + for chat in chats: + mark = "● " if chat["chat_id"] == active_chat_id else "" + buttons.append([InlineKeyboardButton( + text=f"{mark}{chat['name']}", + callback_data=f"switch:{chat['chat_id']}:{chat['name']}", + )]) + buttons.append([InlineKeyboardButton(text="➕ Новый чат", callback_data="new_chat")]) + return InlineKeyboardMarkup(inline_keyboard=buttons) diff --git a/adapter/telegram/keyboards/forum.py b/adapter/telegram/keyboards/forum.py new file mode 100644 index 0000000..6aa1012 --- /dev/null +++ b/adapter/telegram/keyboards/forum.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from aiogram.types import KeyboardButton, KeyboardButtonRequestChat, ReplyKeyboardMarkup + + +def forum_group_request_keyboard() -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + keyboard=[[ + KeyboardButton( + text="Выбрать forum-группу", + request_chat=KeyboardButtonRequestChat( + request_id=1, + chat_is_channel=False, + chat_is_forum=True, + bot_is_member=True, + request_title=True, + ), + ) + ]], + resize_keyboard=True, + one_time_keyboard=True, + ) diff --git a/adapter/telegram/states.py b/adapter/telegram/states.py index 3326127..2b57517 100644 --- a/adapter/telegram/states.py +++ b/adapter/telegram/states.py @@ -1,8 +1,17 @@ -from __future__ import annotations - +# adapter/telegram/states.py from aiogram.fsm.state import State, StatesGroup +class ChatState(StatesGroup): + idle = State() # В активном чате, ждём сообщения + waiting_response = State() # Запрос ушёл на платформу, ждём ответа + + class SettingsState(StatesGroup): - menu = State() - soul_editing = State() + menu = State() # Главное меню настроек + soul_editing = State() # Редактирует имя/инструкции агента + confirm_action = State() # Подтверждение деструктивного действия + + +class ForumSetupState(StatesGroup): + waiting_for_group = State() # Ждём пересылку сообщения из супергруппы diff --git a/bot-examples/README.md b/bot-examples/README.md deleted file mode 100644 index 247e885..0000000 --- a/bot-examples/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# Reference Examples for Bot Development - -Sanitized code examples from the agent-core project for building -Telegram and Matrix bots that integrate with LLM backends. - -## Files - -### Telegram Bot with Forum Topics - -**`telegram_bot_topics.py`** — Complete Telegram bot using python-telegram-bot 22+. - -Key patterns: -- **Forum topics**: Create/rename topics, route messages by `message_thread_id` -- **Message types**: Text, photos, voice/audio, documents — each with its own handler -- **Streaming responses**: Progressive message editing as LLM generates text -- **Outbox pattern**: LLM writes to `outbox.jsonl`, bot sends files after response -- **Topic naming**: LLM generates topic labels, bot auto-renames forum topics -- **Voice transcription**: Download voice → external STT → send text to LLM -- **Proxy support**: SOCKS5 proxy with retry logic for unreliable connections - -Dependencies: `python-telegram-bot>=22.0`, `httpx`, `pyyaml` - -### Matrix Bot with Room Management - -**`matrix_bot_rooms.py`** — Matrix bot using matrix-nio with E2E encryption. - -Key patterns: -- **Room creation**: Create private encrypted rooms, invite users, set avatars -- **Room modes**: Per-room behavior (quiet/context/full) stored in config.json -- **Multi-user**: Users map with per-user profiles loaded from YAML -- **E2E encryption**: Crypto store, key upload, cross-signing, device verification -- **Media handling**: Download + decrypt encrypted media (images, voice, files) -- **Message queuing**: Persistent queue (queue.jsonl) for messages arriving while busy -- **Status threads**: Post tool progress as thread replies under user's message -- **Session management**: Per-room Claude sessions with idle timeout, cancel support -- **Room naming**: Auto-generate room names from conversation content via local LLM -- **Bot commands**: `!new`, `!mode`, `!status`, `!security`, `!help` -- **Security modes**: strict/guarded/open for E2E device verification policy -- **Typing indicators**: Show typing while LLM processes - -Dependencies: `matrix-nio[e2e]>=0.24`, `httpx`, `markdown`, `pyyaml` - -### Shared: LLM Session Manager - -**`llm_session.py`** — Process manager for Claude Code CLI (adaptable to any LLM). - -Key patterns: -- **Session persistence**: Save/restore session IDs for conversation continuity -- **Stream parsing**: Parse `stream-json` output for real-time tool/status tracking -- **Idle timeout**: Watchdog task resets on output, kills on silence -- **Cancel support**: External event to kill LLM process mid-turn -- **Fallback chain**: Primary LLM fails → try secondary provider -- **Sandbox**: bubblewrap (bwrap) wrapper for filesystem isolation -- **Status callbacks**: Emit events for tool_start, tool_end, thinking text -- **Environment isolation**: Strip sensitive env vars before spawning subprocess - -### Shared: Config - -**`config_example.py`** — Simple dataclass config loaded from environment variables. - -## Architecture - -``` -User ──► Bot (Telegram/Matrix) ──► LLM Session Manager ──► Claude CLI (sandboxed) - │ │ - ├── media download ├── session persistence - ├── typing indicators ├── stream parsing - ├── outbox file sending ├── timeout watchdog - └── topic/room management └── fallback provider -``` - -The bot and LLM session are decoupled — the session manager doesn't know -about Telegram or Matrix. It takes a message string, runs the CLI process, -and returns text + status callbacks. The bot handles all platform-specific -concerns (formatting, media, rooms/topics). diff --git a/bot-examples/asr.py b/bot-examples/asr.py deleted file mode 100644 index ebfd8a9..0000000 --- a/bot-examples/asr.py +++ /dev/null @@ -1,233 +0,0 @@ -"""ASR via OpenAI-compatible STT server (GigaAM, Whisper, etc). - -Default: GigaAM (Russian-optimized, handles long-form natively via pyannote). -Fallback: Whisper (multilingual, needs client-side chunking for long audio). - -Truncation detection and chunked retry only applies to Whisper-based backends. -GigaAM handles long-form audio server-side via pyannote segmentation. -""" - -import asyncio -import logging -import os -import re -import tempfile -from pathlib import Path - -import httpx - -logger = logging.getLogger(__name__) - -MAX_RETRIES = 3 -TIMEOUT = 300.0 -# If Whisper covers less than this fraction of the audio, retry with chunks -COVERAGE_THRESHOLD = 0.85 - - -def _is_whisper(stt_url: str) -> bool: - """Heuristic: URL points to a Whisper-based server.""" - return "whisper" in stt_url.lower() - - -async def _get_duration(audio_path: str) -> float | None: - """Get audio duration in seconds via ffprobe.""" - try: - proc = await asyncio.create_subprocess_exec( - "ffprobe", "-v", "quiet", "-show_entries", "format=duration", - "-of", "default=noprint_wrappers=1:nokey=1", audio_path, - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, - ) - stdout, _ = await proc.communicate() - return float(stdout.decode().strip()) - except Exception: - return None - - -async def _find_split_points(audio_path: str, target_chunk: float = 30.0) -> list[float]: - """Find silence gaps for splitting audio into ~target_chunk second pieces.""" - try: - proc = await asyncio.create_subprocess_exec( - "ffmpeg", "-i", audio_path, - "-af", "silencedetect=noise=-35dB:d=0.4", - "-f", "null", "-", - stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, - ) - _, stderr = await proc.communicate() - output = stderr.decode("utf-8", errors="replace") - - silences = [] - for m in re.finditer(r"silence_end:\s*([\d.]+)", output): - silences.append(float(m.group(1))) - - if not silences: - return [] - - duration = await _get_duration(audio_path) or silences[-1] + 10 - splits = [] - target = target_chunk - while target < duration - 10: - best = min(silences, key=lambda s: abs(s - target)) - if not splits or best > splits[-1] + 10: - splits.append(best) - target += target_chunk - return splits - except Exception: - return [] - - -async def _stt_request( - url: str, audio_path: str, language: str | None = None, - response_format: str = "json", -) -> dict: - """Single STT API call. Returns the JSON response dict.""" - last_exc = None - for attempt in range(MAX_RETRIES): - try: - async with httpx.AsyncClient(timeout=TIMEOUT) as client: - with open(audio_path, "rb") as f: - data = {"response_format": response_format} - if _is_whisper(url): - data["model"] = "Systran/faster-whisper-large-v3" - if language: - data["language"] = language - files = {"file": (Path(audio_path).name, f, "application/octet-stream")} - resp = await client.post(url, data=data, files=files) - - if resp.status_code != 200: - raise RuntimeError( - f"STT API returned {resp.status_code}: {resp.text[:200]}" - ) - return resp.json() - - except (httpx.ConnectError, httpx.TimeoutException) as e: - last_exc = e - if attempt < MAX_RETRIES - 1: - logger.warning( - "STT connection error (attempt %d/%d): %s", - attempt + 1, MAX_RETRIES, e, - ) - continue - except RuntimeError: - raise - except Exception as e: - raise RuntimeError(f"STT transcription failed: {e}") from e - - raise RuntimeError(f"STT unavailable after {MAX_RETRIES} attempts: {last_exc}") - - -async def _transcribe_chunked( - url: str, audio_path: str, split_points: list[float], - language: str | None = None, -) -> str: - """Split audio at silence boundaries and transcribe each chunk.""" - tmpdir = tempfile.mkdtemp(prefix="asr_chunk_") - chunks = [] - - try: - boundaries = [0.0] + split_points - for i, start in enumerate(boundaries): - chunk_path = os.path.join(tmpdir, f"chunk{i}.ogg") - args = ["ffmpeg", "-y", "-i", audio_path, "-ss", str(start)] - if i < len(split_points): - args += ["-t", str(split_points[i] - start)] - args += ["-c", "copy", chunk_path] - - proc = await asyncio.create_subprocess_exec( - *args, - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, - ) - await proc.wait() - chunks.append(chunk_path) - - texts = [] - for chunk in chunks: - if not os.path.exists(chunk) or os.path.getsize(chunk) < 100: - continue - result = await _stt_request(url, chunk, language=language) - text = result.get("text", "").strip() - if text: - texts.append(text) - - return " ".join(texts) - finally: - for f in chunks: - try: - os.unlink(f) - except OSError: - pass - try: - os.rmdir(tmpdir) - except OSError: - pass - - -HYBRID_THRESHOLD = 30.0 # seconds — use Whisper for short, GigaAM for long - - -async def transcribe( - audio_path: str, - stt_url: str, - language: str | None = None, - whisper_url: str | None = None, -) -> tuple[str, str]: - """Transcribe audio file via OpenAI-compatible STT server. - - Hybrid mode: if both stt_url and whisper_url are provided, uses Whisper - for short audio (<30s) and the primary STT for longer audio. - - Returns: - (transcribed_text, engine_tag) — engine_tag is "w" or "g" (or first letter of host). - - Raises: - RuntimeError: If transcription fails after retries. - """ - # Hybrid: pick engine based on duration - chosen_url = stt_url - if whisper_url and whisper_url != stt_url: - duration = await _get_duration(audio_path) - if duration is not None and duration < HYBRID_THRESHOLD: - chosen_url = whisper_url - - url = f"{chosen_url.rstrip('/')}/v1/audio/transcriptions" - whisper = _is_whisper(chosen_url) - engine_tag = "w" if whisper else chosen_url.split("//")[-1][0] - - # For Whisper: use verbose_json to detect truncation - # For others: simple json is enough - fmt = "verbose_json" if whisper else "json" - - result = await _stt_request(url, audio_path, language=language, response_format=fmt) - text = result.get("text", "").strip() - if not text: - raise RuntimeError("STT returned empty transcription") - - # Whisper truncation detection — only for Whisper backends - if whisper: - file_duration = await _get_duration(audio_path) - segments = result.get("segments", []) - if file_duration and segments and file_duration > 30: - last_segment_end = segments[-1].get("end", 0) - coverage = last_segment_end / file_duration - - if coverage < COVERAGE_THRESHOLD: - logger.warning( - "Whisper truncated %s: covered %.0f/%.0fs (%.0f%%), retrying with chunks", - Path(audio_path).name, last_segment_end, file_duration, coverage * 100, - ) - split_points = await _find_split_points(audio_path, target_chunk=30.0) - if not split_points: - n_chunks = max(2, int(file_duration / 30)) - split_points = [file_duration * i / n_chunks for i in range(1, n_chunks)] - chunked_text = await _transcribe_chunked( - url, audio_path, split_points, language=language, - ) - if len(chunked_text) > len(text): - text = chunked_text - logger.info( - "Chunked transcription recovered %d chars (was %d)", - len(text), len(result.get("text", "")), - ) - - logger.info("Transcribed %s: %d chars [%s]", Path(audio_path).name, len(text), engine_tag) - return text, engine_tag diff --git a/bot-examples/bwrap-claude b/bot-examples/bwrap-claude deleted file mode 100755 index 3d24ae7..0000000 --- a/bot-examples/bwrap-claude +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# Sandboxed wrapper for Claude Code using bubblewrap. -# Restricts filesystem access: DATA_DIR is writable, system is read-only. -# -# Usage: bwrap-claude [args...] -# bwrap-claude claude -p --verbose ... -# bwrap-claude claude-zai -p --verbose ... -# -# Requires: bubblewrap (apt install bubblewrap) - -set -euo pipefail - -DATA_DIR="${DATA_DIR:?DATA_DIR must be set}" - -exec bwrap \ - --ro-bind / / \ - --tmpfs /tmp \ - --tmpfs /run \ - --tmpfs /root \ - --proc /proc \ - --dev /dev \ - --bind "$DATA_DIR" "$DATA_DIR" \ - --bind "$HOME/.claude" "$HOME/.claude" \ - --bind-try "$HOME/.claude-zai" "$HOME/.claude-zai" \ - --setenv HOME "$HOME" \ - --setenv DATA_DIR "$DATA_DIR" \ - --die-with-parent \ - --new-session \ - "$@" diff --git a/bot-examples/config_example.py b/bot-examples/config_example.py deleted file mode 100644 index 2088fb5..0000000 --- a/bot-examples/config_example.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Load configuration from environment variables.""" - -import os -from dataclasses import dataclass, field -from pathlib import Path - - -@dataclass -class Config: - bot_token: str = "" - owner_id: int = 0 - data_dir: Path = Path(".") - claude_cmd: str = "claude" - proxy: str | None = None - stt_url: str | None = None - allowed_tools: list[str] = field(default_factory=list) - claude_idle_timeout: int = 120 - claude_max_timeout: int = 1800 - workspace_dir: Path | None = None - - @classmethod - def from_env(cls) -> "Config": - bot_token = os.environ.get("BOT_TOKEN", "") - owner_id_str = os.environ.get("OWNER_ID", "0") - owner_id = int(owner_id_str) - - data_dir_str = os.environ.get("DATA_DIR", "") - if not data_dir_str: - raise ValueError("DATA_DIR env var is required") - data_dir = Path(data_dir_str) - - claude_cmd = os.environ.get("CLAUDE_CMD", "claude") - proxy = os.environ.get("PROXY") or None - stt_url = os.environ.get("STT_URL") or os.environ.get("WHISPER_URL") or None - - default_tools = "Read,Write,Edit,Glob,Grep,Bash,WebSearch,WebFetch,mcp__fetcher,mcp__yandex-search" - allowed_tools_str = os.environ.get("ALLOWED_TOOLS", default_tools) - allowed_tools = [t.strip() for t in allowed_tools_str.split(",") if t.strip()] - - idle_timeout_str = os.environ.get("CLAUDE_IDLE_TIMEOUT", - os.environ.get("CLAUDE_TIMEOUT", "120")) - claude_idle_timeout = int(idle_timeout_str) - max_timeout_str = os.environ.get("CLAUDE_MAX_TIMEOUT", "1800") - claude_max_timeout = int(max_timeout_str) - - workspace_dir_str = os.environ.get("WORKSPACE_DIR") - workspace_dir = Path(workspace_dir_str) if workspace_dir_str else None - - return cls( - bot_token=bot_token, - owner_id=owner_id, - data_dir=data_dir, - claude_cmd=claude_cmd, - proxy=proxy, - stt_url=stt_url, - allowed_tools=allowed_tools, - claude_idle_timeout=claude_idle_timeout, - claude_max_timeout=claude_max_timeout, - workspace_dir=workspace_dir, - ) diff --git a/bot-examples/llm_session.py b/bot-examples/llm_session.py deleted file mode 100644 index 3b9b55d..0000000 --- a/bot-examples/llm_session.py +++ /dev/null @@ -1,635 +0,0 @@ -"""Claude CLI session manager. - -Manages Claude Code CLI sessions per topic. Each topic gets a persistent -session ID so conversation context is maintained across messages. - -Uses --output-format stream-json with asyncio subprocess to stream responses. -Falls back to claude-zai if primary claude fails. - -Timeout: idle-based (resets on any output from Claude) + hard ceiling. -Status: streams tool_use/agent events via on_status callback. -Cancel: external cancel_event to stop processing. -""" - -import asyncio -import json -import logging -import os -import shutil -import time -import uuid -from collections.abc import Callable -from pathlib import Path - -from core.config import Config - -logger = logging.getLogger(__name__) - - -def _session_path(data_dir: Path, topic_id: int | str, provider: str = "") -> Path: - """Path to session ID file for a topic.""" - suffix = f"_{provider}" if provider else "" - return data_dir / "topics" / str(topic_id) / f"session{suffix}.txt" - - -def load_session(data_dir: Path, topic_id: int | str, provider: str = "") -> str | None: - """Load existing session ID for a topic, or None.""" - path = _session_path(data_dir, topic_id, provider) - if path.exists(): - return path.read_text().strip() - return None - - -def save_session(data_dir: Path, topic_id: int | str, session_id: str, provider: str = "") -> None: - """Save session ID for a topic.""" - path = _session_path(data_dir, topic_id, provider) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(session_id) - - -async def send_message( - config: Config, - topic_id: int | str, - message: str, - on_chunk: Callable | None = None, - on_question: Callable | None = None, - on_status: Callable | None = None, - cancel_event: asyncio.Event | None = None, - idle_timeout_ref: list | None = None, - user_profile: str = "", - workspace_dir: Path | None = None, -) -> str: - """Send a message to Claude CLI and return the response. - - Args: - config: Application config. - topic_id: Topic ID (determines session and working directory). - message: User message text. - on_chunk: Optional async callback(text_so_far) for streaming updates. - on_question: Optional async callback(question) -> answer for ask-user tool. - on_status: Optional async callback(dict) for tool/agent status events. - cancel_event: Optional asyncio.Event — set to cancel processing. - idle_timeout_ref: Optional mutable [int] — current idle timeout in seconds. - Can be modified externally (e.g. user "more time" command). - user_profile: Optional user profile text (from user.md) to inject into system prompt. - workspace_dir: Optional per-user workspace directory path. - - Returns: - Full response text. - - Raises: - RuntimeError: If both primary and fallback CLI fail. - """ - # Try primary provider first - try: - 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="", user_profile=user_profile, - workspace_dir=workspace_dir) - except RuntimeError as e: - # Don't fallback if user cancelled - if cancel_event and cancel_event.is_set(): - raise RuntimeError("Cancelled") - logger.warning("Primary claude failed (%s), trying fallback (claude-zai)", e) - - # Fallback: claude-zai with separate session (using opus model) - try: - response = 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="zai", cmd_override="claude-zai", model_override="opus", - user_profile=user_profile, workspace_dir=workspace_dir, - ) - # Add note that fallback provider was used - return response + "\n\n_[(via z.ai fallback)]_" - except RuntimeError: - raise RuntimeError("Both claude and claude-zai failed") - - -async def _watch_questions(topic_dir: Path, on_question: Callable) -> None: - """Watch for ask-user.json and forward questions to the bot.""" - question_file = topic_dir / "ask-user.json" - fifo_file = topic_dir / "ask-user.fifo" - while True: - await asyncio.sleep(0.5) - if not question_file.exists(): - continue - try: - data = json.loads(question_file.read_text()) - question = data.get("question", "") - logger.info("Claude asks user: %s", question[:200]) - answer = await on_question(question) - # Write answer to FIFO (unblocks ask-user script) - with open(fifo_file, "w") as f: - f.write(answer) - question_file.unlink(missing_ok=True) - except Exception as e: - logger.error("Error handling ask-user: %s", e) - question_file.unlink(missing_ok=True) - - -def _tool_preview(tool_name: str, raw_input: str) -> str: - """Extract a human-readable preview from tool input JSON.""" - try: - inp = json.loads(raw_input) - except (json.JSONDecodeError, TypeError): - return raw_input[:200] - - if tool_name == "Bash": - return inp.get("command", "")[:500] - if tool_name in ("Read", "Write"): - return inp.get("file_path", "")[:300] - if tool_name == "Edit": - return inp.get("file_path", "")[:300] - if tool_name in ("Glob", "Grep"): - return inp.get("pattern", "")[:200] - if tool_name == "WebSearch": - return inp.get("query", "")[:200] - if tool_name == "WebFetch": - return inp.get("url", "")[:300] - if tool_name == "Agent": - desc = inp.get("description", "") - prompt = inp.get("prompt", "") - return desc[:200] if desc else prompt[:300] - if tool_name == "TodoWrite": - todos = inp.get("todos", []) - if todos: - items = [t.get("content", "")[:80] for t in todos[:3]] - return "; ".join(items) - - # Generic: show first key=value - for k, v in inp.items(): - return f"{k}={str(v)[:200]}" - return "" - - -def _load_conversation_log(data_dir: Path, topic_id: str, limit: int = 5) -> str: - """Load recent conversation log for context. - - Returns formatted summary of last N interactions from log.jsonl, - so Claude has context even after session resets or fallback switches. - """ - log_file = data_dir / "rooms" / str(topic_id) / "log.jsonl" - if not log_file.exists(): - return "" - try: - with open(log_file) as f: - entries = [json.loads(line.strip()) for line in f if line.strip()] - except Exception: - return "" - if not entries: - return "" - - recent = entries[-limit:] - parts = [] - for e in recent: - ts = e.get("ts", "")[:16].replace("T", " ") - user = e.get("user", "")[:300] - bot = e.get("bot", "")[:500] - parts.append(f"[{ts}] User: {user}") - parts.append(f"[{ts}] Bot: {bot}") - return "\n".join(parts) - - -async def _send_with_provider( - config: Config, - topic_id: int | str, - message: str, - on_chunk: Callable | None, - on_question: Callable | None, - on_status: Callable | None = None, - cancel_event: asyncio.Event | None = None, - idle_timeout_ref: list | None = None, - provider: str = "", - cmd_override: str | None = None, - model_override: str | None = None, - user_profile: str = "", - workspace_dir: Path | None = None, - _retry_count: int = 0, -) -> str: - """Send message using a specific provider.""" - existing_session = load_session(config.data_dir, topic_id, provider) - topic_dir = config.data_dir / "topics" / str(topic_id) - topic_dir.mkdir(parents=True, exist_ok=True) - - cmd = cmd_override or config.claude_cmd - - # Build args: --resume for existing sessions, --session-id for new ones - if existing_session: - session_flag = ["--resume", existing_session] - else: - new_id = str(uuid.uuid4()) - session_flag = ["--session-id", new_id] - - # User profile: prefer explicit parameter, fallback to workspace user.md - user_context = "" - if user_profile: - user_context = f"\n\nUSER PROFILE:\n{user_profile}\n" - elif config.workspace_dir: - user_md = config.workspace_dir / "user.md" - if user_md.exists(): - user_context = f"\n\nUSER PROFILE:\n{user_md.read_text().strip()}\n" - - # Load recent conversation log — provides context after session resets, - # fallback switches, or timeouts. Always included so Claude knows what happened. - conv_log = _load_conversation_log(config.data_dir, str(topic_id)) - conv_context = "" - if conv_log: - conv_context = ( - "\n\nRECENT CONVERSATION LOG (from bot's perspective, " - "may overlap with your session memory — use to fill gaps " - "after timeouts or session switches):\n" + conv_log + "\n" - ) - - # Per-user workspace context - workspace_context = "" - if workspace_dir and workspace_dir.is_dir(): - ws_md = workspace_dir / "WORKSPACE.md" - if ws_md.exists(): - workspace_context = ( - f"\n\nUSER WORKSPACE ({workspace_dir}):\n" - f"{ws_md.read_text().strip()}\n" - f"\nYour working directory is the topic dir ({topic_dir}). " - f"Use it for scratch work (scripts, downloads, temp files). " - f"Save important/refined results to the workspace at {workspace_dir}. " - f"The workspace is a git repo — your changes will be committed automatically.\n" - ) - - # Paths Claude should know about - room_dir = config.data_dir / "rooms" / str(topic_id) - log_file = room_dir / "log.jsonl" - history_file = room_dir / "history.jsonl" - - # System prompt with topic context - system_extra = ( - f"Topic/room ID: {topic_id}. Data dir: {topic_dir}. " - f"After responding, update {config.data_dir / 'topic-map.yml'} " - f"with this topic's ID, path, and a short label. " - f"The bot renames the topic from the label. " - f"CONVERSATION HISTORY: Full conversation log is at {log_file} (JSONL, " - f"fields: ts, user, bot — every interaction with timestamps). " - f"Detailed message history with sender info: {history_file}. " - f"If you lose context (after timeout, session switch, or restart), " - f"READ these files to recover the full conversation. " - f"Entries ending with '[timed out]' or '[idle timeout]' mean your previous " - f"response was cut short — check what you were doing and continue. " - f"FORMATTING: User reads on mobile (Telegram/Matrix Element). " - f"NEVER use markdown tables — they render as broken text on mobile. " - f"Prefer bullet lists, bold headers, numbered lists to structure data. " - f"Small tables (2-4 cols, few rows): use monospace code block with aligned columns. " - f"Large/complex tables: generate HTML, convert to PDF via " - f"`html-to-pdf input.html output.pdf`, send via send-to-user. " - f"Do NOT use wkhtmltopdf — its PDFs are broken on iOS. " - f"SCREENSHOTS: `screenshot-page output.png [--width 1280] [--height 900] " - f"[--wait 3] [--full-page] [--stealth]`. Works with URLs and local HTML files (folium maps etc). " - f"IMAGE SEARCH: `search-images \"query\" -o dir/ -n 4 -p prefix [--size large] " - f"[--orient horizontal]`. Uses Yandex Image Search API. Downloads images automatically. " - f"Add --no-download to just list URLs. " - f"WEB SEARCH: `search-web \"query\" [-n 10] [--lang ru]`. Yandex web search — " - f"best for Russian-language queries. Returns titles, URLs, snippets. " - f"Use for research, reviews, travel tips, local info. Lang: ru (default), en, tr. " - f"SENDING FILES: To send files to the user, use: `send-to-user [caption]`. " - f"It is in PATH. The file will be delivered after your response. " - f"ASKING USER: To ask the user a question and wait for their reply, use: " - f"`ask-user \"your question\"`. It blocks until the user responds via the chat. " - f"IMAGE GENERATION: Use `generate-image` (NanoBanana/Gemini 3 Pro). " - f"It supports multi-turn chat for iterative refinement of images. " - f"First generation: `generate-image \"prompt\" output.png --chat history.json [-a 16:9]`. " - f"Refinement (edits the PREVIOUS image): `generate-image --chat history.json --refine \"change X to Y\" output2.png`. " - f"The --chat flag saves conversation context so the model remembers what it generated. " - f"ALWAYS use --chat with a history file in the current dir so you can refine later. " - f"The model can modify its own previous output when you use --refine — " - f"it does NOT generate from scratch, it edits the existing image. " - f"You can also pass reference images (up to 14): `generate-image \"prompt\" out.png --chat h.json --ref photo.jpg --ref photo2.jpg`. " - f"Aspect ratios: 9:16, 16:9, 1:1, 4:3, 3:4. Sizes: 1K, 2K, 4K (default). " - f"THREAD VISIBILITY: Your response is posted in a Matrix thread. " - f"The user sees ONLY the final message at a glance — intermediate tool output " - f"and thread messages are hidden unless expanded. " - f"All text the user needs to read MUST be in your response message, not only in files. " - f"Writing to files for persistence is fine, but the conversation text — " - f"analysis, notes, discussion points — must appear in the response itself. " - f"The user is chatting with you, not reading files. " - f"IMAGES IN CONTEXT: When conversation history contains entries like " - f"'[image: /path/to/file.png]', these are actual image files on disk. " - f"Use the Read tool to view them — they contain photos, screenshots, or book pages " - f"that the user shared. Always review referenced images before responding about them. " - f"TOOL DISCOVERY: Before installing packages or writing scripts, check what tools " - f"are already available. Common tools in PATH: transcribe-audio, send-to-user, " - f"ask-user, search-web, search-images, screenshot-page, generate-image, html-to-pdf, browser. " - f"BROWSER: If BROWSER_CDP_URL is set, you have access to a real Chrome browser via " - f"`browser `. Commands: navigate , screenshot [file], click , " - f"type , read [selector], eval , tabs, new [url], close. " - f"Use this for web interaction, authenticated sites, downloads, form filling. " - f"Run `ls /opt/agent-core/common-tools/` to see all. " - f"Prefer existing tools over writing new code." - f"{user_context}" - f"{workspace_context}" - f"{conv_context}" - ) - - claude_args = [ - cmd, - *session_flag, - "-p", - "--verbose", - "--output-format", "stream-json", - "--append-system-prompt", system_extra, - "--allowedTools", ",".join(config.allowed_tools), - "--max-turns", "50", - ] - if model_override: - claude_args.extend(["--model", model_override]) - claude_args.append(message) - - # Wrap with bwrap if available - bwrap_path = Path(__file__).resolve().parent.parent / "bwrap-claude" - if bwrap_path.exists() and shutil.which("bwrap"): - args = [str(bwrap_path)] + claude_args - else: - args = claude_args - - # Build clean environment for Claude subprocess - _strip_prefixes = ("CLAUDECODE", "CLAUDE_CODE") - _strip_keys = { - "BOT_TOKEN", "MATRIX_ACCESS_TOKEN", "MATRIX_HOMESERVER", - "MATRIX_USER_ID", "MATRIX_OWNER_MXID", "MATRIX_DEVICE_ID", - } - # Auth env vars that must pass through to Claude CLI - _passthrough_keys = {"CLAUDE_CODE_OAUTH_TOKEN"} - env = { - k: v for k, v in os.environ.items() - if k in _passthrough_keys - or (not any(k.startswith(p) for p in _strip_prefixes) and k not in _strip_keys) - } - # Add common-tools to PATH so Claude can use send-to-user, generate-image, etc. - common_tools = str(Path(__file__).resolve().parent.parent / "common-tools") - env["PATH"] = common_tools + ":" + env.get("PATH", "") - - # Load per-user workspace .env (Readest keys, Linkwarden keys, etc.) - if workspace_dir: - ws_env = workspace_dir / ".env" - if ws_env.exists(): - for line in ws_env.read_text().splitlines(): - line = line.strip() - if line and not line.startswith("#") and "=" in line: - key, _, val = line.partition("=") - env[key.strip()] = val.strip().strip("'\"") # handle KEY="value" and KEY='value' - - session_label = existing_session[:8] if existing_session else f"new:{new_id[:8]}" - logger.info("Claude CLI: topic=%s session=%s cmd=%s", topic_id, session_label, cmd) - - proc = await asyncio.create_subprocess_exec( - *args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=str(topic_dir), - env=env, - limit=10 * 1024 * 1024, # 10MB — stream-json lines can be huge (base64 images) - ) - - response_parts: list[str] = [] - full_text = "" - result_text = "" # clean final response from result event - result_session_id = None - timeout_reason = None - - # Tool tracking for status events - block_tools: dict[str, str] = {} # tool_use_id -> tool name - - # Idle timeout state — mutable so watchdog can read, user can extend - idle_timeout = idle_timeout_ref if idle_timeout_ref is not None else [config.claude_idle_timeout] - last_activity = [time.monotonic()] - start_time = time.monotonic() - - # Start question watcher if callback provided - question_task = None - if on_question: - question_task = asyncio.create_task(_watch_questions(topic_dir, on_question)) - - # Watchdog: checks idle timeout, hard timeout, and cancel - async def _watchdog(): - nonlocal timeout_reason - while True: - await asyncio.sleep(2) - now = time.monotonic() - if cancel_event and cancel_event.is_set(): - timeout_reason = "cancelled" - proc.kill() - return - idle = now - last_activity[0] - if idle > idle_timeout[0]: - timeout_reason = "idle" - proc.kill() - return - elapsed = now - start_time - if elapsed > config.claude_max_timeout: - timeout_reason = "max" - proc.kill() - return - - watchdog_task = asyncio.create_task(_watchdog()) - - # Stream log — save all events from Claude CLI for debugging/replay - stream_log_path = topic_dir / "stream.jsonl" - stream_log = open(stream_log_path, "a") - - try: - async for line in proc.stdout: - last_activity[0] = time.monotonic() # reset idle timer on ANY output - - line = line.decode("utf-8", errors="replace").strip() - if not line: - continue - - # Log raw event to stream.jsonl - stream_log.write(line + "\n") - stream_log.flush() - - try: - event = json.loads(line) - except json.JSONDecodeError: - logger.debug("Non-JSON stdout: %s", line[:200]) - continue - - etype = event.get("type") - - # Capture session_id from init or result events - if etype == "system" and event.get("session_id"): - result_session_id = event["session_id"] - elif etype == "result" and event.get("session_id"): - result_session_id = event["session_id"] - - # Handle result events — this has the clean final response - if etype == "result": - if event.get("is_error"): - errors = event.get("errors", []) - logger.error("Claude CLI error: %s", "; ".join(errors)) - if event.get("result"): - result_text = event["result"] - - # --- Status events from stream-json --- - # Claude CLI emits full "assistant" snapshots (with tool_use blocks) - # followed by "user" events (with tool_result). - if etype == "assistant": - content = event.get("message", {}).get("content", []) - has_tools = any(b.get("type") == "tool_use" for b in content) - - for block in content: - if block.get("type") == "tool_use" and on_status: - tool_name = block.get("name", "") - tool_id = block.get("id", "") - inp = block.get("input", {}) - preview = _tool_preview(tool_name, json.dumps(inp, ensure_ascii=False)) - if tool_id: - block_tools[tool_id] = tool_name - if tool_name == "Agent": - desc = inp.get("description", "") - bg = inp.get("run_in_background", False) - await on_status({ - "event": "agent_start", - "description": desc, - "background": bg, - }) - else: - await on_status({ - "event": "tool_start", - "tool": tool_name, - "input_preview": preview, - }) - - # All assistant text goes to thread as narration. - # Only result.result is the final clean response. - if block.get("type") == "text" and block.get("text"): - text = block["text"] - if on_status: - await on_status({ - "event": "thinking", - "text": text, - }) - # Also accumulate for on_chunk (Telegram streaming) - response_parts.append(text) - full_text = "".join(response_parts) - if on_chunk: - await on_chunk(full_text) - - # Tool results mark tool completion - if etype == "user" and on_status: - content = event.get("message", {}).get("content", []) - if isinstance(content, list): - for block in content: - if isinstance(block, dict) and block.get("type") == "tool_result": - tool_id = block.get("tool_use_id", "") - tool_name = block_tools.pop(tool_id, "tool") - await on_status({"event": "tool_end", "tool": tool_name}) - - # Check if watchdog killed the process - if watchdog_task.done(): - break - - await proc.wait() - - except Exception: - if not watchdog_task.done(): - watchdog_task.cancel() - raise - finally: - stream_log.close() - if not watchdog_task.done(): - watchdog_task.cancel() - try: - await watchdog_task - except asyncio.CancelledError: - pass - if question_task: - question_task.cancel() - try: - await question_task - except asyncio.CancelledError: - pass - - elapsed = int(time.monotonic() - start_time) - - # Handle timeout/cancel - if timeout_reason: - await proc.wait() - if timeout_reason == "cancelled": - logger.info("Claude CLI cancelled by user after %ds", elapsed) - suffix = "\n\n[cancelled by user]" - elif timeout_reason == "idle": - logger.warning("Claude CLI idle timeout after %ds (idle limit: %ds)", elapsed, idle_timeout[0]) - suffix = f"\n\n[idle timeout — no output for {idle_timeout[0]}s]" - else: - logger.error("Claude CLI hard timeout after %ds (max: %ds)", elapsed, config.claude_max_timeout) - suffix = f"\n\n[timeout — {elapsed}s elapsed]" - - # Save session even on timeout — don't lose conversation history - if result_session_id: - save_session(config.data_dir, topic_id, result_session_id, provider) - - # On timeout: prefer result_text (clean), fall back to full_text (has thinking) - response = result_text or full_text - error_patterns = ["Failed to authenticate", "API Error:", "authentication_error", "401"] - if response and not any(p in response for p in error_patterns): - return response + suffix - raise RuntimeError(f"Claude CLI {timeout_reason} after {elapsed}s (error response: {full_text[:100]})") - - # Save session ID for future resume - if result_session_id: - save_session(config.data_dir, topic_id, result_session_id, provider) - - # Check for error responses (auth failures, API errors) - these should trigger fallback - error_patterns = ["Failed to authenticate", "API Error:", "authentication_error", "401"] - is_error_response = any(p in full_text for p in error_patterns) - - if proc.returncode != 0 or is_error_response: - stderr = await proc.stderr.read() - stderr_text = stderr.decode("utf-8", errors="replace").strip() - logger.error("Claude CLI failed (rc=%d): %s", proc.returncode, stderr_text[:500]) - if is_error_response: - raise RuntimeError(f"Claude CLI returned error: {full_text[:200]}") - response = result_text or full_text - if response: - return response - # Non-auth failure with no output — raise to trigger fallback - # but preserve session file (conversation history is valuable) - raise RuntimeError(f"Claude CLI exited with code {proc.returncode}") - - response = result_text or full_text - if not response and _retry_count < 1: - logger.warning("Claude CLI returned empty response, retrying (attempt %d)", _retry_count + 1) - return await _send_with_provider( - config, topic_id, message, on_chunk, on_question, - on_status=on_status, cancel_event=cancel_event, - idle_timeout_ref=idle_timeout_ref, - provider=provider, cmd_override=cmd_override, model_override=model_override, - user_profile=user_profile, workspace_dir=workspace_dir, - _retry_count=_retry_count + 1, - ) - - return response or "(no response)" - - -def _extract_text(event: dict) -> str | None: - """Extract text content from a stream-json event.""" - etype = event.get("type") - - if etype == "assistant": - content = event.get("message", {}).get("content", []) - texts = [] - for block in content: - if block.get("type") == "text": - texts.append(block.get("text", "")) - return "".join(texts) if texts else None - - if etype == "content_block_delta": - delta = event.get("delta", {}) - if delta.get("type") == "text_delta": - return delta.get("text", "") - - # Don't extract from "result" — it duplicates what was already - # streamed via "assistant" events. The caller uses it as fallback - # only if full_text is empty after processing all events. - - return None diff --git a/bot-examples/matrix_bot_rooms.py b/bot-examples/matrix_bot_rooms.py deleted file mode 100755 index 8e6eadf..0000000 --- a/bot-examples/matrix_bot_rooms.py +++ /dev/null @@ -1,2667 +0,0 @@ -"""Matrix bot frontend. - -Connects to a Matrix homeserver, listens for messages in rooms, -routes them through Claude CLI sessions. Same session layer as Telegram bot. - -Commands: - !new [topic] — Create a new conversation room with optional topic name. - !claude-auth — Refresh Claude Code OAuth token (manual browser flow). -""" - -import asyncio -import json -import logging -import os -import re -import time -from dataclasses import dataclass, field -from datetime import datetime, timezone -from pathlib import Path - -import httpx -from nio import ( - AsyncClient, - AsyncClientConfig, - MatrixRoom, - MegolmEvent, - RoomEncryptedAudio, - RoomEncryptedFile, - RoomEncryptedImage, - RoomMemberEvent, - RoomMessageAudio, - RoomMessageImage, - RoomMessageText, - RoomMessageFile, - RoomMessageUnknown, - SyncResponse, - UnknownEvent, -) -from nio.events.to_device import ( - KeyVerificationCancel, - KeyVerificationKey, - KeyVerificationMac, - KeyVerificationStart, -) - -from nio.crypto import decrypt_attachment - -from core.asr import transcribe -from core.claude_session import send_message as claude_send -from core.config import Config - -logger = logging.getLogger(__name__) - - -@dataclass -class SessionState: - """Tracks an active Claude session for a room.""" - cancel_event: asyncio.Event - user_event_id: str # original user message (thread root) - status_event_id: str | None = None # status message in thread - status_lines: list[str] = field(default_factory=list) - last_status_edit: float = 0.0 - idle_timeout_ref: list = field(default_factory=lambda: [120]) - start_time: float = field(default_factory=time.monotonic) - - -class MatrixBot: - def __init__(self, config: Config, homeserver: str, user_id: str, access_token: str, - owner_mxid: str = "", users: dict[str, dict] | None = None, - device_id: str = "AGENT_CORE", admin_mxid: str = ""): - self.config = config - self.owner_mxid = owner_mxid - self.admin_mxid = admin_mxid # For admin notifications (fallback, errors) - self._users = users or {} - # If single-owner mode (no users map), treat owner as the only allowed user - if not self._users and owner_mxid: - self._users = {owner_mxid: {}} - # E2E: crypto store for keys, auto-decrypt/encrypt - store_path = str(config.data_dir / "crypto_store") - Path(store_path).mkdir(parents=True, exist_ok=True) - client_config = AsyncClientConfig( - encryption_enabled=True, - store_sync_tokens=True, - ) - self.client = AsyncClient( - homeserver, user_id, - device_id=device_id, - store_path=store_path, - config=client_config, - ) - self.client.restore_login(user_id, device_id, access_token) - self._synced = False - self._default_room_prefix = "Bot: " - self._pending_questions: dict[str, asyncio.Future] = {} - self._active_sessions: dict[str, SessionState] = {} # room_id -> session state - # Persistent message queue removed — using queue.jsonl files instead - self._auth_flows: dict[str, dict] = {} # safe_id -> {tmux_session, started} - self._collect_preambles: dict[str, str] = {} # safe_id -> preamble for next Claude call - self._processed_events: set[str] = set() - self._room_verifications: dict[str, dict] = {} # tx_id → state - self._sync_token_path = config.data_dir / "matrix_sync_token.txt" - self._avatar_mxc: str | None = None # cached after upload - - def _is_allowed_user(self, sender: str) -> bool: - return sender in self._users - - def _get_user_workspace(self, sender: str) -> Path | None: - """Get workspace directory for a user, or None.""" - user_info = self._users.get(sender, {}) - ws = user_info.get("workspace") - if ws: - path = Path(ws) - if path.is_dir(): - return path - return None - - def _get_user_profile(self, sender: str) -> str: - """Load user.md content for a sender, or empty string.""" - user_info = self._users.get(sender, {}) - profile_file = user_info.get("profile") - if profile_file and self.config.workspace_dir: - path = self.config.workspace_dir / profile_file - if path.exists(): - return path.read_text().strip() - # Fallback: single-user mode with user.md - if self.config.workspace_dir: - path = self.config.workspace_dir / "user.md" - if path.exists(): - return path.read_text().strip() - return "" - - def _is_group_room(self, room: MatrixRoom) -> bool: - """Room has more than 2 members (joined + invited, not a 1:1 chat).""" - return (room.member_count + room.invited_count) > 2 - - def _text_mentions_bot(self, text: str) -> bool: - """Check if text contains a bot mention (@user_id, localpart, or display name).""" - text = text.lower() - # Check user_id (@bot:your.homeserver.example) - if self.client.user_id.lower() in text: - return True - # Check localpart (bot) - local_name = self.client.user_id.split(":")[0].lstrip("@").lower() - if local_name in text: - return True - # Check display name from any room - for room in self.client.rooms.values(): - me = room.users.get(self.client.user_id) - if me and me.display_name and me.display_name.lower() in text: - return True - return False - - def _strip_mention_prefix(self, text: str) -> str: - """Strip bot mention prefix from text (e.g. '@[bot-dev] !status' → '!status').""" - import re - local_name = self.client.user_id.split(":")[0].lstrip("@") - names = [re.escape(self.client.user_id), re.escape(local_name)] - for room in self.client.rooms.values(): - me = room.users.get(self.client.user_id) - if me and me.display_name: - names.append(re.escape(me.display_name)) - break - alts = "|".join(names) - # Match: @[name], @name, name: , name, — with optional @[] wrapping and trailing punctuation - pattern = r"^@?\[?(?:" + alts + r")\]?[\s:,]*" - return re.sub(pattern, "", text, flags=re.IGNORECASE) - - def _is_bot_mentioned(self, event: RoomMessageText) -> bool: - """Check if bot is mentioned in a message event.""" - # Check structured mentions first (m.mentions in content) - mentions = event.source.get("content", {}).get("m.mentions", {}) - user_ids = mentions.get("user_ids", []) - if self.client.user_id in user_ids: - return True - return self._text_mentions_bot(event.body) - - def _room_dir(self, room_id: str) -> Path: - safe_id = room_id.replace(":", "_").replace("!", "") - d = self.config.data_dir / "rooms" / safe_id - d.mkdir(parents=True, exist_ok=True) - return d - - def _topic_dir(self, safe_id: str) -> Path: - return self.config.data_dir / "topics" / safe_id - - # --- Room history --- - - def _save_room_message(self, room_id: str, sender: str, msg_type: str, text: str, - file_path: str | None = None) -> None: - """Append a message to room history. Called for ALL messages in ALL rooms.""" - history_file = self._room_dir(room_id) / "history.jsonl" - display = sender.split(":")[0].lstrip("@") - entry: dict = { - "ts": datetime.now(timezone.utc).isoformat(), - "sender": sender, - "name": display, - "type": msg_type, - "text": text, - } - if file_path: - entry["file"] = file_path - with open(history_file, "a") as f: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") - - def _get_room_context(self, room_id: str, limit: int = 50) -> str: - """Read last N messages from history.jsonl and format as chat context.""" - history_file = self._room_dir(room_id) / "history.jsonl" - if not history_file.exists(): - return "" - lines = [] - try: - with open(history_file) as f: - all_lines = f.readlines() - for line in all_lines[-limit:]: - line = line.strip() - if line: - lines.append(json.loads(line)) - except Exception as e: - logger.warning("Failed to read room history: %s", e) - return "" - if not lines: - return "" - parts = [] - for msg in lines: - name = msg.get("name", "?") - text = msg.get("text", "") - msg_type = msg.get("type", "text") - ts = msg.get("ts", "")[:16].replace("T", " ") - if msg_type == "image": - parts.append(f"[{ts}] {name}: [sent an image] {text}") - elif msg_type == "audio": - parts.append(f"[{ts}] {name}: [voice] {text}") - elif msg_type == "file": - parts.append(f"[{ts}] {name}: [sent a file] {text}") - else: - parts.append(f"[{ts}] {name}: {text}") - context = "\n".join(parts) - return ( - "[Recent room history — you can see what participants discussed before mentioning you. " - "Use this context to understand the conversation. Do NOT repeat this history back.]\n\n" - + context - ) - - # --- Room mode (quiet / context / full / collect) --- - - ROOM_MODES = ("quiet", "context", "full", "collect") - - def _get_room_mode(self, room_id: str) -> str: - """Get room mode from config.json. Default: quiet for groups, full for 1:1.""" - config_file = self._room_dir(room_id) / "config.json" - if config_file.exists(): - try: - data = json.loads(config_file.read_text()) - mode = data.get("mode", "") - if mode in self.ROOM_MODES: - return mode - except Exception: - pass - room = self.client.rooms.get(room_id) - if room and self._is_group_room(room): - return "quiet" - return "full" - - def _set_room_mode(self, room_id: str, mode: str) -> None: - """Save room mode to config.json.""" - config_file = self._room_dir(room_id) / "config.json" - data = {} - if config_file.exists(): - try: - data = json.loads(config_file.read_text()) - except Exception: - pass - data["mode"] = mode - config_file.write_text(json.dumps(data, ensure_ascii=False, indent=2)) - - # --- Room security mode (strict / guarded / open) --- - - SECURITY_MODES = ("strict", "guarded", "open") - - def _get_security_mode(self, room_id: str) -> str: - """Get room security mode from config.json. Default: guarded.""" - config_file = self._room_dir(room_id) / "config.json" - if config_file.exists(): - try: - data = json.loads(config_file.read_text()) - mode = data.get("security", "") - if mode in self.SECURITY_MODES: - return mode - except Exception: - pass - return "guarded" - - def _set_security_mode(self, room_id: str, mode: str) -> None: - """Save room security mode to config.json.""" - config_file = self._room_dir(room_id) / "config.json" - data = {} - if config_file.exists(): - try: - data = json.loads(config_file.read_text()) - except Exception: - pass - data["security"] = mode - config_file.write_text(json.dumps(data, ensure_ascii=False, indent=2)) - - def _get_unverified_devices(self, room_id: str) -> dict[str, list[str]]: - """Return {user_id: [device_id, ...]} for unverified devices in a room. - - Only checks allowed users (room members known to the bot). - """ - if not self.client.olm: - return {} - room = self.client.rooms.get(room_id) - if not room: - return {} - unverified: dict[str, list[str]] = {} - for user_id in room.users: - if user_id == self.client.user_id: - continue - for device in self.client.device_store.active_user_devices(user_id): - if not device.verified: - unverified.setdefault(user_id, []).append(device.id) - return unverified - - def _user_fully_verified(self, sender: str) -> bool: - """Check if all of sender's devices are verified.""" - if not self.client.olm: - return True # no E2E, no verification needed - for device in self.client.device_store.active_user_devices(sender): - if not device.verified: - return False - return True - - def _format_unverified_warning(self, unverified: dict[str, list[str]]) -> str: - """Format a warning string listing unverified devices.""" - parts = [] - for user_id, devices in unverified.items(): - dev_str = ", ".join(f"`{d}`" for d in devices) - parts.append(f"{user_id}: {dev_str}") - return "\u26a0 Unverified devices in room: " + "; ".join(parts) - - async def _check_security(self, room_id: str, sender: str) -> tuple[bool, str | None]: - """Check room security policy for a sender. - - Returns: - (allowed, warning_or_error): - - (True, None) — proceed, no warning - - (True, warning) — proceed, append warning to response - - (False, error) — refuse, send error message - """ - security = self._get_security_mode(room_id) - if security == "open": - unverified = self._get_unverified_devices(room_id) - if unverified: - return True, self._format_unverified_warning(unverified) - return True, None - - unverified = self._get_unverified_devices(room_id) - if not unverified: - return True, None - - if security == "strict": - return False, ( - "Room has unverified devices — refusing to respond.\n" - + self._format_unverified_warning(unverified) - + "\n\nVerify devices or use `!security open` from a fully verified session." - ) - - # guarded: block only users with unverified devices - sender_unverified = unverified.get(sender) - if sender_unverified: - dev_str = ", ".join(f"`{d}`" for d in sender_unverified) - return False, ( - f"You have unverified devices ({dev_str}) — not accepting commands.\n" - "Verify your devices or ask a verified user to `!security open`." - ) - return True, None - - def _log_interaction(self, room_id: str, user_msg: str, bot_msg: str) -> None: - log_file = self._room_dir(room_id) / "log.jsonl" - entry = { - "ts": datetime.now(timezone.utc).isoformat(), - "user": user_msg[:1000], - "bot": bot_msg[:2000], - } - with open(log_file, "a") as f: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") - - def _md_to_html(self, text: str) -> str: - """Convert markdown to Matrix HTML, with tables as monospace
 blocks."""
-        import re
-        import markdown
-
-        lines = text.split("\n")
-        result_lines = []
-        table_lines = []
-        in_table = False
-
-        for line in lines:
-            is_table_line = bool(re.match(r"^\s*\|.*\|\s*$", line))
-            is_separator = bool(re.match(r"^\s*\|[-:| ]+\|\s*$", line))
-
-            if is_table_line:
-                if not in_table:
-                    in_table = True
-                    table_lines = []
-                if not is_separator:
-                    table_lines.append(line)
-                else:
-                    table_lines.append(line)
-            else:
-                if in_table:
-                    result_lines.append("```")
-                    result_lines.extend(table_lines)
-                    result_lines.append("```")
-                    table_lines = []
-                    in_table = False
-                result_lines.append(line)
-
-        if in_table:
-            result_lines.append("```")
-            result_lines.extend(table_lines)
-            result_lines.append("```")
-
-        text = "\n".join(result_lines)
-        html = markdown.markdown(text, extensions=["fenced_code"])
-        return html
-
-    # --- Avatar management ---
-
-    def _avatar_path(self) -> Path | None:
-        """Return path to avatar.jpg in workspace, or None."""
-        if self.config.workspace_dir:
-            p = self.config.workspace_dir / "avatar.jpg"
-            if p.exists():
-                return p
-        return None
-
-    async def _set_bot_avatar(self) -> None:
-        """Upload avatar.jpg and set as bot profile picture (only if not already set)."""
-        path = self._avatar_path()
-        if not path:
-            return
-        try:
-            async with httpx.AsyncClient() as http:
-                user_id = self.client.user_id
-                hs = self.client.homeserver
-                # Check if avatar already set
-                resp = await http.get(
-                    f"{hs}/_matrix/client/v3/profile/{user_id}/avatar_url",
-                    headers={"Authorization": f"Bearer {self.client.access_token}"},
-                    timeout=10,
-                )
-                if resp.status_code == 200:
-                    existing = resp.json().get("avatar_url", "")
-                    if existing:
-                        self._avatar_mxc = existing
-                        logger.info("Bot avatar already set: %s", existing)
-                        return
-                # Upload and set
-                data = path.read_bytes()
-                mxc = await self._upload_file(data, "image/jpeg", "avatar.jpg")
-                if not mxc:
-                    return
-                self._avatar_mxc = mxc
-                resp = await http.put(
-                    f"{hs}/_matrix/client/v3/profile/{user_id}/avatar_url",
-                    json={"avatar_url": mxc},
-                    headers={"Authorization": f"Bearer {self.client.access_token}"},
-                    timeout=15,
-                )
-                if resp.status_code == 200:
-                    logger.info("Set bot profile avatar: %s", mxc)
-                else:
-                    logger.warning("Failed to set profile avatar (%d): %s",
-                                   resp.status_code, resp.text[:200])
-        except Exception as e:
-            logger.warning("Failed to set bot avatar: %s", e)
-
-    async def _set_room_avatar(self, room_id: str) -> None:
-        """Set room avatar to bot's avatar if not already set. Uses HTTP API directly."""
-        if not self._avatar_mxc:
-            return
-        try:
-            from urllib.parse import quote
-            hs = self.client.homeserver
-            rid = quote(room_id, safe="")
-            async with httpx.AsyncClient() as http:
-                # Check if avatar already set
-                resp = await http.get(
-                    f"{hs}/_matrix/client/v3/rooms/{rid}/state/m.room.avatar",
-                    headers={"Authorization": f"Bearer {self.client.access_token}"},
-                    timeout=10,
-                )
-                if resp.status_code == 200:
-                    return  # already has avatar
-                # Set avatar
-                resp = await http.put(
-                    f"{hs}/_matrix/client/v3/rooms/{rid}/state/m.room.avatar",
-                    json={"url": self._avatar_mxc},
-                    headers={"Authorization": f"Bearer {self.client.access_token}"},
-                    timeout=10,
-                )
-                if resp.status_code == 200:
-                    logger.info("Set room avatar for %s", room_id)
-                else:
-                    logger.warning("Failed to set room avatar for %s (%d): %s",
-                                   room_id, resp.status_code, resp.text[:200])
-        except Exception as e:
-            logger.warning("Failed to set room avatar for %s: %s", room_id, e)
-
-    # --- Room management ---
-
-    async def _generate_room_label(self, room_id: str, current_label: str = "") -> str | None:
-        """Generate a short room label via local LLM based on conversation history.
-
-        Returns None if generation fails, or the new label string.
-        """
-        # Build context from history
-        history_file = self._room_dir(room_id) / "history.jsonl"
-        chat_lines = []
-        if history_file.exists():
-            try:
-                with open(history_file) as f:
-                    all_lines = f.readlines()
-                for line in all_lines[-15:]:
-                    line = line.strip()
-                    if line:
-                        msg = json.loads(line)
-                        name = msg.get("name", "?")
-                        text = msg.get("text", "")[:150]
-                        chat_lines.append(f"{name}: {text}")
-            except Exception:
-                pass
-        if not chat_lines:
-            return None
-
-        conversation = "\n".join(chat_lines)
-        user_content = conversation
-        if current_label:
-            user_content = f"Current name: {current_label}\n\n{conversation}"
-
-        api_base = os.environ.get("LOCAL_LLM_URL") or os.environ.get("OPENAI_API_BASE", "http://localhost:4000/v1")
-        api_key = os.environ.get("OPENAI_API_KEY", "")
-        model = os.environ.get("LOCAL_LLM_MODEL", "qwen3.5-122b")
-        llm_url = api_base.rstrip("/") + "/chat/completions"
-        headers = {}
-        if api_key:
-            headers["Authorization"] = f"Bearer {api_key}"
-        try:
-            async with httpx.AsyncClient() as http:
-                resp = await http.post(llm_url, json={
-                    "model": model,
-                    "messages": [
-                        {"role": "system", "content": (
-                            "You generate short chat room titles (3-5 words) based on what the user is asking about. "
-                            "Rules: output ONLY the title. No quotes, no prefixes. Same language as the user. "
-                            "Focus on the user's main question or task, ignore bot replies and minor tangents."
-                        )},
-                        {"role": "user", "content": user_content},
-                    ],
-                    "max_tokens": 20,
-                    "temperature": 0.3,
-                    "chat_template_kwargs": {"enable_thinking": False},
-                }, headers=headers, timeout=15)
-                if resp.status_code == 200:
-                    data = resp.json()
-                    label = data["choices"][0]["message"]["content"].strip().strip('"\'')
-                    return label[:80] if label else None
-        except Exception as e:
-            logger.warning("Failed to generate room label: %s", e)
-        return None
-
-    async def _rename_room(self, room_id: str, safe_id: str,
-                           user_text: str = "", response: str = "") -> None:
-        """Rename room if it still has the default 'Bot: ' prefix."""
-        room = self.client.rooms.get(room_id)
-        if not room:
-            return
-        current_name = room.name or ""
-        if not current_name.startswith(self._default_room_prefix):
-            return  # user renamed it manually — don't touch
-        current_label = current_name[len(self._default_room_prefix):].strip()
-        label = await self._generate_room_label(room_id, current_label)
-        if not label:
-            return
-        new_name = f"{self._default_room_prefix}{label}"
-        if new_name == current_name:
-            return
-        try:
-            from nio.responses import RoomPutStateError
-            resp = await self.client.room_put_state(
-                room_id, "m.room.name", {"name": new_name[:255]},
-            )
-            if isinstance(resp, RoomPutStateError):
-                logger.warning("Cannot rename room %s: %s", room_id, resp.status_code)
-                return
-            logger.info("Renamed room %s to: %s", room_id, new_name)
-            await self._set_room_avatar(room_id)
-        except Exception as e:
-            logger.warning("Failed to rename room: %s", e)
-
-    async def _create_conversation_room(self, name: str, for_user: str | None = None) -> str | None:
-        """Create a private encrypted room and invite the user."""
-        initial_state = [
-            {
-                "type": "m.room.encryption",
-                "state_key": "",
-                "content": {"algorithm": "m.megolm.v1.aes-sha2"},
-            },
-        ]
-        if self._avatar_mxc:
-            initial_state.append({
-                "type": "m.room.avatar",
-                "state_key": "",
-                "content": {"url": self._avatar_mxc},
-            })
-        body: dict = {
-            "name": name,
-            "visibility": "private",
-            "preset": "trusted_private_chat",
-            "invite": [for_user] if for_user else [],
-        }
-        # Give the target user admin power (matches Element-created rooms)
-        if for_user:
-            body["power_level_content_override"] = {
-                "users": {
-                    self.client.user_id: 100,
-                    for_user: 100,
-                },
-            }
-        if initial_state:
-            body["initial_state"] = initial_state
-        try:
-            async with httpx.AsyncClient() as http:
-                resp = await http.post(
-                    f"{self.client.homeserver}/_matrix/client/v3/createRoom",
-                    headers={
-                        "Authorization": f"Bearer {self.client.access_token}",
-                        "Content-Type": "application/json",
-                    },
-                    json=body,
-                    timeout=15,
-                )
-                if resp.status_code == 200:
-                    room_id = resp.json()["room_id"]
-                    logger.info("Created room %s: %s", room_id, name)
-                    return room_id
-                logger.error("Failed to create room (%d): %s", resp.status_code, resp.text[:200])
-        except Exception as e:
-            logger.error("Failed to create room: %s", e)
-        return None
-
-    # --- Sending ---
-
-    async def _send_response(self, room_id: str, response: str,
-                             ignore_unverified_devices: bool = True) -> None:
-        """Send response with HTML formatting."""
-        html = self._md_to_html(response)
-        await self.client.room_send(
-            room_id, "m.room.message",
-            {
-                "msgtype": "m.text",
-                "body": response,
-                "format": "org.matrix.custom.html",
-                "formatted_body": html,
-            },
-            ignore_unverified_devices=ignore_unverified_devices,
-        )
-
-    async def _upload_file(self, data: bytes, content_type: str, filename: str) -> str | None:
-        """Upload file to Matrix via HTTP API directly."""
-        homeserver = self.client.homeserver
-        url = f"{homeserver}/_matrix/media/v3/upload?filename={filename}"
-        async with httpx.AsyncClient() as http:
-            resp = await http.post(
-                url, content=data,
-                headers={
-                    "Authorization": f"Bearer {self.client.access_token}",
-                    "Content-Type": content_type,
-                },
-                timeout=60,
-            )
-            if resp.status_code == 200:
-                return resp.json().get("content_uri")
-            logger.error("Matrix upload failed (%d): %s", resp.status_code, resp.text[:200])
-            return None
-
-    async def _download_media(self, event) -> bytes | None:
-        """Download media from Matrix, decrypting if E2E encrypted."""
-        resp = await self.client.download(event.url)
-        if not hasattr(resp, "body"):
-            logger.error("Failed to download media: %s", resp)
-            return None
-        data = resp.body
-        # Encrypted media (RoomEncryptedImage/Audio/File) has key/hashes/iv
-        if hasattr(event, "key") and hasattr(event, "hashes") and hasattr(event, "iv"):
-            try:
-                data = decrypt_attachment(
-                    data, event.key["k"], event.hashes["sha256"], event.iv,
-                )
-            except Exception as e:
-                logger.error("Failed to decrypt attachment: %s", e)
-                return None
-        return data
-
-    async def _send_outbox(self, room_id: str, room_dir: Path) -> None:
-        """Send files queued in outbox.jsonl by Claude via send-to-user tool."""
-        outbox = room_dir / "outbox.jsonl"
-        if not outbox.exists():
-            return
-
-        entries = []
-        try:
-            with open(outbox) as f:
-                for line in f:
-                    line = line.strip()
-                    if line:
-                        entries.append(json.loads(line))
-            outbox.unlink()
-        except Exception as e:
-            logger.error("Failed to read outbox: %s", e)
-            return
-
-        mime_map = {
-            "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
-            "webp": "image/webp", "gif": "image/gif", "bmp": "image/bmp",
-            "mp4": "video/mp4", "mov": "video/quicktime", "webm": "video/webm",
-            "ogg": "audio/ogg", "mp3": "audio/mpeg", "wav": "audio/wav", "m4a": "audio/mp4",
-            "pdf": "application/pdf", "doc": "application/msword",
-            "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
-            "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
-            "html": "text/html", "txt": "text/plain", "csv": "text/csv",
-            "zip": "application/zip", "json": "application/json",
-        }
-
-        for entry in entries:
-            fpath = Path(entry.get("path", ""))
-            ftype = entry.get("type", "document")
-
-            if not fpath.is_file():
-                logger.warning("Outbox file not found: %s", fpath)
-                continue
-
-            try:
-                data = fpath.read_bytes()
-                ext = fpath.suffix.lstrip(".").lower()
-                content_type = mime_map.get(ext, "application/octet-stream")
-
-                content_uri = await self._upload_file(data, content_type, fpath.name)
-                if not content_uri:
-                    continue
-
-                if ftype == "image":
-                    msgtype = "m.image"
-                elif ftype == "video":
-                    msgtype = "m.video"
-                elif ftype == "audio":
-                    msgtype = "m.audio"
-                else:
-                    msgtype = "m.file"
-
-                await self.client.room_send(
-                    room_id, "m.room.message",
-                    {
-                        "msgtype": msgtype,
-                        "body": fpath.name,
-                        "filename": fpath.name,
-                        "url": content_uri,
-                        "info": {"mimetype": content_type, "size": len(data)},
-                    },
-                    ignore_unverified_devices=True,
-                )
-                logger.info("Sent %s to Matrix: %s", ftype, fpath.name)
-            except Exception as e:
-                logger.error("Failed to send %s %s: %s", ftype, fpath.name, e)
-
-    def _sender_display_name(self, room: MatrixRoom, sender: str) -> str:
-        """Get display name for a sender in a room, fallback to localpart."""
-        member = room.users.get(sender)
-        if member and member.display_name:
-            return member.display_name
-        return sender.split(":")[0].lstrip("@")
-
-    async def _fetch_recent_messages(self, room_id: str, limit: int = 5) -> list[dict]:
-        """Fetch recent messages from a room for context mode."""
-        room = self.client.rooms.get(room_id)
-        if not room or not room.prev_batch:
-            return []
-        resp = await self.client.room_messages(room_id, start=room.prev_batch, limit=limit)
-        if not hasattr(resp, "chunk"):
-            return []
-        messages = []
-        for event in reversed(resp.chunk):  # chronological order
-            if event.sender == self.client.user_id:
-                continue
-            body = getattr(event, "body", None)
-            if not body:
-                continue
-            name = self._sender_display_name(room, event.sender)
-            messages.append({"sender": name, "text": body})
-        return messages
-
-    # --- Thread status messaging ---
-
-    async def _send_thread_message(self, room_id: str, thread_root_event_id: str,
-                                    body: str) -> str | None:
-        """Send a notice in a thread under the given event."""
-        content = {
-            "msgtype": "m.notice",
-            "body": body,
-            "m.relates_to": {
-                "rel_type": "m.thread",
-                "event_id": thread_root_event_id,
-                "is_falling_back": True,
-                "m.in_reply_to": {"event_id": thread_root_event_id},
-            },
-        }
-        resp = await self.client.room_send(
-            room_id, "m.room.message", content,
-            ignore_unverified_devices=True,
-        )
-        if hasattr(resp, "event_id"):
-            return resp.event_id
-        return None
-
-    async def _edit_message(self, room_id: str, event_id: str, new_body: str) -> None:
-        """Edit an existing message using m.replace relation."""
-        content = {
-            "msgtype": "m.notice",
-            "body": f"* {new_body}",
-            "m.new_content": {
-                "msgtype": "m.notice",
-                "body": new_body,
-            },
-            "m.relates_to": {
-                "rel_type": "m.replace",
-                "event_id": event_id,
-            },
-        }
-        await self.client.room_send(
-            room_id, "m.room.message", content,
-            ignore_unverified_devices=True,
-        )
-
-    async def _run_claude_session(self, room: MatrixRoom, event, message: str,
-                                   security_msg: str | None = None,
-                                   on_question=None,
-                                   on_done=None,
-                                   **extra_kwargs) -> None:
-        """Run a Claude session as a background task.
-
-        Runs concurrently so the sync loop stays free to process !stop etc.
-        on_done(response) is called after session completes (for logging, renaming).
-        """
-        room_id = room.room_id
-        safe_id = room_id.replace(":", "_").replace("!", "")
-
-        cancel_event = asyncio.Event()
-        idle_timeout_ref = [self.config.claude_idle_timeout]
-        session = SessionState(
-            cancel_event=cancel_event,
-            user_event_id=event.event_id,
-            idle_timeout_ref=idle_timeout_ref,
-            start_time=time.monotonic(),
-        )
-        self._active_sessions[room_id] = session
-
-        status_event_id = await self._send_thread_message(
-            room_id, event.event_id, "Working..."
-        )
-        session.status_event_id = status_event_id
-        on_status = self._make_on_status(room_id, session)
-
-        user_profile = self._get_user_profile(event.sender)
-        workspace_dir = self._get_user_workspace(event.sender)
-
-        # Default on_question: post to room, wait for user reply
-        if on_question is None:
-            async def on_question(question: str) -> str:
-                await self.client.room_send(
-                    room_id, "m.room.message",
-                    {"msgtype": "m.text", "body": f"? {question}"},
-                    ignore_unverified_devices=True,
-                )
-                future = asyncio.get_event_loop().create_future()
-                self._pending_questions[safe_id] = future
-                return await future
-
-        # Run as background task so sync loop stays free to process !stop etc.
-        async def _session_task():
-            response = ""
-            try:
-                response = await self._call_claude(
-                    room_id, safe_id, message,
-                    on_status=on_status, cancel_event=cancel_event,
-                    idle_timeout_ref=idle_timeout_ref,
-                    on_question=on_question,
-                    user_profile=user_profile, sender=event.sender,
-                    workspace_dir=workspace_dir,
-                    **extra_kwargs,
-                )
-                display = response + f"\n\n{security_msg}" if security_msg else response
-                await self._send_response(room_id, display)
-            except RuntimeError as e:
-                if cancel_event.is_set():
-                    await self._send_response(room_id, "Stopped.")
-                    response = "[cancelled]"
-                else:
-                    logger.error("Claude error in room %s: %s", room.display_name, e)
-                    await self._send_response(room_id, f"Error: {e}")
-                    response = f"[error] {e}"
-            finally:
-                elapsed = int(time.monotonic() - session.start_time)
-                mins, secs = divmod(elapsed, 60)
-                time_str = f"{mins}m {secs:02d}s" if mins else f"{secs}s"
-                tools_used = len(session.status_lines)
-                final_status = f"Done ({time_str}, {tools_used} tools)"
-                if session.cancel_event.is_set():
-                    final_status = f"Cancelled ({time_str})"
-                try:
-                    if session.status_event_id:
-                        await self._edit_message(room_id, session.status_event_id, final_status)
-                except Exception:
-                    pass
-
-            await self._send_outbox(room_id, self._topic_dir(safe_id))
-
-            # Auto-commit workspace changes
-            if workspace_dir:
-                asyncio.create_task(self._auto_commit_workspace(workspace_dir, room))
-
-            # Post-session callback (logging, renaming, etc.)
-            if on_done:
-                try:
-                    await on_done(response)
-                except Exception as e:
-                    logger.warning("on_done callback failed: %s", e)
-
-            # Process queued messages — combine all into one prompt.
-            # Drain BEFORE popping session so room stays "busy" and new
-            # messages don't sneak in between drain and new session start.
-            queued, last_eid = self._drain_queue(room_id)
-            if queued and last_eid:
-                # _process_queued_messages calls _run_claude_session which
-                # overwrites _active_sessions[room_id] with a new session.
-                await self._process_queued_messages(room, queued, last_eid)
-            else:
-                self._active_sessions.pop(room_id, None)
-
-        asyncio.create_task(_session_task())
-
-    async def _auto_commit_workspace(self, workspace_dir: Path, room: MatrixRoom) -> None:
-        """Git commit workspace changes after a session, if any."""
-        try:
-            # Check for uncommitted changes
-            proc = await asyncio.create_subprocess_exec(
-                "git", "status", "--porcelain",
-                cwd=str(workspace_dir),
-                stdout=asyncio.subprocess.PIPE,
-                stderr=asyncio.subprocess.PIPE,
-            )
-            stdout, _ = await proc.communicate()
-            if not stdout.strip():
-                return  # nothing changed
-
-            # Stage all and commit
-            await (await asyncio.create_subprocess_exec(
-                "git", "add", "-A",
-                cwd=str(workspace_dir),
-                stdout=asyncio.subprocess.PIPE,
-                stderr=asyncio.subprocess.PIPE,
-            )).communicate()
-
-            room_name = room.display_name or room.room_id
-            msg = f"auto: {room_name}"
-            await (await asyncio.create_subprocess_exec(
-                "git", "commit", "-m", msg, "--no-gpg-sign",
-                cwd=str(workspace_dir),
-                stdout=asyncio.subprocess.PIPE,
-                stderr=asyncio.subprocess.PIPE,
-            )).communicate()
-            logger.info("Auto-committed workspace changes: %s", workspace_dir)
-        except Exception as e:
-            logger.warning("Workspace auto-commit failed: %s", e)
-
-    def _is_room_busy(self, room_id: str) -> bool:
-        return room_id in self._active_sessions
-
-    def _enqueue_message(self, room_id: str, event_id: str, sender: str,
-                         text: str, msg_type: str = "text",
-                         file_path: str | None = None) -> None:
-        """Queue a processed message to queue.jsonl for later delivery."""
-        queue_file = self._room_dir(room_id) / "queue.jsonl"
-        entry = {
-            "ts": datetime.now(timezone.utc).isoformat(),
-            "event_id": event_id,
-            "sender": sender,
-            "type": msg_type,
-            "text": text,
-        }
-        if file_path:
-            entry["file"] = file_path
-        with open(queue_file, "a") as f:
-            f.write(json.dumps(entry, ensure_ascii=False) + "\n")
-        count = sum(1 for _ in open(queue_file))
-        logger.info("Queued message for room %s (%d pending)", room_id, count)
-
-    def _drain_queue(self, room_id: str) -> tuple[list[dict], str | None]:
-        """Read and clear queue.jsonl. Returns (messages, last_event_id)."""
-        queue_file = self._room_dir(room_id) / "queue.jsonl"
-        if not queue_file.exists():
-            return [], None
-        messages = []
-        try:
-            with open(queue_file) as f:
-                for line in f:
-                    line = line.strip()
-                    if line:
-                        messages.append(json.loads(line))
-            queue_file.unlink()
-        except Exception as e:
-            logger.warning("Failed to drain queue for %s: %s", room_id, e)
-        last_event_id = messages[-1]["event_id"] if messages else None
-        return messages, last_event_id
-
-    async def _process_queued_messages(self, room: MatrixRoom,
-                                        messages: list[dict], last_event_id: str) -> None:
-        """Combine queued messages into one prompt and send to Claude."""
-        room_id = room.room_id
-        safe_id = room_id.replace(":", "_").replace("!", "")
-
-        # Build combined prompt
-        parts = []
-        for msg in messages:
-            mtype = msg.get("type", "text")
-            text = msg.get("text", "")
-            fpath = msg.get("file", "")
-            if mtype == "image":
-                parts.append(f"[User sent an image: {fpath}]")
-                if text:
-                    parts.append(text)
-            elif mtype == "audio":
-                parts.append(f"[voice message]: {text}")
-            elif mtype == "file":
-                parts.append(f"[User sent a file: {fpath}]")
-            else:
-                parts.append(text)
-
-        combined = "\n".join(parts)
-        if len(messages) > 1:
-            combined = (f"[{len(messages)} messages arrived while you were busy. "
-                        f"Process them all:]\n\n{combined}")
-
-        # Minimal event-like object — covers all attributes accessed by
-        # _run_claude_session and downstream code paths
-        sender = messages[-1].get("sender", "")
-        event = type("QueuedEvent", (), {
-            "event_id": last_event_id,
-            "sender": sender,
-            "body": combined[:100],
-            "source": {"content": {}},  # empty — won't match thread checks
-        })()
-
-        mode = self._get_room_mode(room_id)
-
-        async def _on_done(response: str):
-            if mode == "full":
-                self._save_room_message(room_id, self.client.user_id, "text", response)
-                await self._rename_room(room_id, safe_id)
-            self._log_interaction(room_id, combined[:200], response)
-
-        # Add full context if in full mode
-        message_for_claude = combined
-        if mode == "full":
-            for msg in messages:
-                self._save_room_message(room_id, msg.get("sender", ""),
-                                        msg.get("type", "text"), msg.get("text", ""))
-            context = self._get_room_context(room_id)
-            if context:
-                message_for_claude = context + "\n\n---\n\n" + combined
-
-        await self._run_claude_session(
-            room, event, message_for_claude, on_done=_on_done,
-        )
-
-    async def _handle_thread_command(self, room_id: str, user_text: str,
-                                      session: SessionState) -> bool:
-        """Handle user commands in a session thread. Returns True if handled."""
-        cmd = user_text.strip().lower().lstrip("!")
-        if cmd in ("stop", "cancel", "abort"):
-            session.cancel_event.set()
-            await self._send_thread_message(room_id, session.user_event_id, "Stopping...")
-            return True
-        if cmd in ("more time", "+5m", "+5"):
-            session.idle_timeout_ref[0] += 300
-            mins = session.idle_timeout_ref[0] // 60
-            await self._send_thread_message(
-                room_id, session.user_event_id, f"Timeout extended to {mins}m")
-            return True
-        if cmd in ("+10m", "+10"):
-            session.idle_timeout_ref[0] += 600
-            mins = session.idle_timeout_ref[0] // 60
-            await self._send_thread_message(
-                room_id, session.user_event_id, f"Timeout extended to {mins}m")
-            return True
-        return False
-
-    def _make_on_status(self, room_id: str, session: SessionState):
-        """Create an on_status callback that posts individual thread messages."""
-        async def on_status(status: dict):
-            event_type = status.get("event")
-            msg = None
-
-            if event_type == "tool_start":
-                tool = status.get("tool", "?")
-                preview = status.get("input_preview", "")
-                session.status_lines.append(tool)  # count for final summary
-                if preview:
-                    msg = f"`{tool}`: {preview}"
-                else:
-                    msg = f"`{tool}`"
-            elif event_type == "tool_end":
-                pass  # tool_start already posted, no need for end message
-            elif event_type == "agent_start":
-                desc = status.get("description", "subagent")
-                bg = " (bg)" if status.get("background") else ""
-                session.status_lines.append("Agent")
-                msg = f"`Agent{bg}`: {desc}"
-            elif event_type == "thinking":
-                text = status.get("text", "").strip()
-                if text:
-                    msg = text
-
-            if msg and session.user_event_id:
-                try:
-                    await self._send_thread_message(room_id, session.user_event_id, msg)
-                except Exception as e:
-                    logger.debug("Failed to send thread status: %s", e)
-
-        return on_status
-
-    # --- Claude call wrapper ---
-
-    async def _notify_fallback_used(self, room_id: str, sender: str) -> None:
-        """Send notification to admin when fallback provider was used."""
-        if not self.admin_mxid or sender == self.admin_mxid:
-            return  # Don't notify if no admin or admin triggered it
-
-        # Find DM room with admin — prefer room named exactly after the bot
-        # Priority: exact bot name > "Bot: something" > any 1:1 room
-        dm_room_id = None
-        named_dm_id = None
-        any_dm_id = None
-        bot_name = self.client.user_id.split(":")[0].lstrip("@")
-        for room in self.client.rooms.values():
-            if len(room.users) == 2 and self.admin_mxid in room.users:
-                name = (room.name or "").strip()
-                if name.lower() == bot_name.lower():
-                    dm_room_id = room.room_id
-                    break
-                if bot_name.lower() in name.lower() and not named_dm_id:
-                    named_dm_id = room.room_id
-                if not any_dm_id:
-                    any_dm_id = room.room_id
-        if not dm_room_id:
-            dm_room_id = named_dm_id or any_dm_id
-
-        if not dm_room_id:
-            # Create DM room with admin
-            resp = await self.client.room_create(
-                visibility="private",
-                preset="trusted_private_chat",
-                invite=[self.admin_mxid],
-            )
-            if hasattr(resp, "room_id"):
-                dm_room_id = resp.room_id
-                logger.info("Created DM room with admin: %s", dm_room_id)
-
-        if dm_room_id:
-            room_link = f"https://matrix.to/#/{room_id}"
-            await self.client.room_send(
-                dm_room_id, "m.room.message",
-                {
-                    "msgtype": "m.notice",
-                    "body": f"⚠️ Fallback (z.ai) used for room {room_link} (sender: {sender})",
-                },
-                ignore_unverified_devices=True,
-            )
-
-    async def _call_claude(self, room_id: str, safe_id: str, message: str,
-                           sender: str = "", on_status=None, cancel_event=None,
-                           idle_timeout_ref=None, **kwargs) -> str:
-        """Call Claude CLI with typing indicator and status updates."""
-        await self.client.room_typing(room_id, typing_state=True, timeout=30000)
-        try:
-            response = await claude_send(
-                self.config, safe_id, message,
-                on_status=on_status, cancel_event=cancel_event,
-                idle_timeout_ref=idle_timeout_ref,
-                **kwargs,
-            )
-            # Check if fallback was used and notify owner
-            if "(via z.ai fallback)" in response and sender:
-                asyncio.create_task(self._notify_fallback_used(room_id, sender))
-            return response
-        finally:
-            await self.client.room_typing(room_id, typing_state=False)
-
-    # --- Bot commands ---
-
-    async def _handle_status(self, room: MatrixRoom) -> None:
-        """Handle !status: show room/session info."""
-        safe_id = room.room_id.replace(":", "_").replace("!", "")
-        topic_dir = self._topic_dir(safe_id)
-        is_busy = room.room_id in self._active_sessions
-        lines = [f"**Status: {'working' if is_busy else 'idle'}**", f"Room: `{safe_id}`"]
-
-        # Session info
-        session_file = topic_dir / "session.txt"
-        if session_file.exists():
-            sid = session_file.read_text().strip()
-            lines.append(f"Session: `{sid[:12]}...`")
-        else:
-            lines.append("Session: new")
-
-        # Topic dir size
-        if topic_dir.exists():
-            total = sum(f.stat().st_size for f in topic_dir.rglob("*") if f.is_file())
-            files = sum(1 for f in topic_dir.rglob("*") if f.is_file())
-            if total < 1024:
-                size_str = f"{total} B"
-            elif total < 1024 * 1024:
-                size_str = f"{total // 1024} KB"
-            else:
-                size_str = f"{total // (1024 * 1024)} MB"
-            lines.append(f"Dir: {files} files, {size_str}")
-
-        # Interaction count from log
-        log_file = self._room_dir(room.room_id) / "log.jsonl"
-        if log_file.exists():
-            count = sum(1 for _ in open(log_file))
-            lines.append(f"Interactions: {count}")
-
-        # Auth info
-        if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
-            lines.append("Auth: `CLAUDE_CODE_OAUTH_TOKEN` (long-lived)")
-        else:
-            lines.append("Auth: OAuth credentials (short-lived)")
-
-        await self._send_response(room.room_id, "\n".join(lines))
-
-    async def _handle_help(self, room: MatrixRoom) -> None:
-        """Show available commands."""
-        room_id = room.room_id
-        mode = self._get_room_mode(room_id)
-        await self._send_response(room_id,
-            f"**Commands:**\n"
-            f"`!new [topic]` — new conversation room\n"
-            f"`!mode [mode]` — set room mode (current: `{mode}`)\n"
-            f"  `quiet` — transcribe voice only\n"
-            f"  `context` — include recent history\n"
-            f"  `full` — persistent session with full history\n"
-            f"  `collect` — accumulate notes/images/voice, no replies\n"
-            f"`!stop` — stop active Claude session\n"
-            f"`!status` — bot status and active sessions\n"
-            f"`!security [mode]` — room security level\n"
-            f"`!claude-auth` — refresh OAuth token (admin, 1:1 only)\n"
-            f"`!help` — this message")
-
-    async def _handle_mode_command(self, room: MatrixRoom, args: str) -> None:
-        """Handle !mode [quiet|context|full]: set or show room mode."""
-        room_id = room.room_id
-        mode = args.strip().lower()
-        if not mode:
-            current = self._get_room_mode(room_id)
-            await self._send_response(room_id,
-                f"**Mode:** `{current}`\n"
-                f"Available: `quiet` (transcribe only), `context` (recent history), "
-                f"`full` (persistent session), `collect` (accumulate context, no replies)")
-            return
-        if mode not in self.ROOM_MODES:
-            await self._send_response(room_id,
-                f"Unknown mode `{mode}`. Use: quiet, context, full, collect")
-            return
-        prev_mode = self._get_room_mode(room_id)
-        self._set_room_mode(room_id, mode)
-
-        # When leaving collect mode, summarize what was accumulated
-        if prev_mode == "collect" and mode != "collect":
-            summary = self._collect_summary(room_id)
-            if summary:
-                await self._send_response(room_id,
-                    f"Mode set to `{mode}`\n\n{summary}")
-                # Store preamble for next Claude call
-                safe_id = room_id.replace(":", "_").replace("!", "")
-                self._collect_preambles[safe_id] = summary
-            else:
-                await self._send_response(room_id, f"Mode set to `{mode}`")
-        else:
-            await self._send_response(room_id, f"Mode set to `{mode}`")
-
-    def _collect_summary(self, room_id: str) -> str:
-        """Summarize what was accumulated in collect mode."""
-        history_file = self._room_dir(room_id) / "history.jsonl"
-        if not history_file.exists():
-            return ""
-        images, voice, texts, files = 0, 0, 0, 0
-        try:
-            with open(history_file) as f:
-                for line in f:
-                    line = line.strip()
-                    if not line:
-                        continue
-                    msg = json.loads(line)
-                    mtype = msg.get("type", "text")
-                    sender = msg.get("sender", "")
-                    if sender == self.client.user_id:
-                        continue  # skip bot messages
-                    if mtype == "image":
-                        images += 1
-                    elif mtype == "audio":
-                        voice += 1
-                    elif mtype == "file":
-                        files += 1
-                    else:
-                        texts += 1
-        except Exception:
-            return ""
-        parts = []
-        if images:
-            parts.append(f"{images} image(s)")
-        if voice:
-            parts.append(f"{voice} voice note(s)")
-        if texts:
-            parts.append(f"{texts} text message(s)")
-        if files:
-            parts.append(f"{files} file(s)")
-        if not parts:
-            return ""
-        return f"Accumulated: {', '.join(parts)}"
-
-    async def _handle_security_command(self, room: MatrixRoom, sender: str, args: str) -> None:
-        """Handle !security [strict|guarded|open]: set or show room security mode."""
-        room_id = room.room_id
-        mode = args.strip().lower()
-        if not mode:
-            current = self._get_security_mode(room_id)
-            unverified = self._get_unverified_devices(room_id)
-            lines = [
-                f"**Security:** `{current}`",
-                "Available: `strict` (block all if unverified), "
-                "`guarded` (block unverified users), `open` (allow all + warning)",
-            ]
-            if unverified:
-                lines.append(self._format_unverified_warning(unverified))
-            else:
-                lines.append("All devices in room are verified.")
-            await self._send_response(room_id, "\n".join(lines))
-            return
-        if mode not in self.SECURITY_MODES:
-            await self._send_response(room_id,
-                f"Unknown security mode `{mode}`. Use: strict, guarded, open")
-            return
-        # Loosening security requires fully verified sender
-        current = self._get_security_mode(room_id)
-        mode_rank = {"strict": 2, "guarded": 1, "open": 0}
-        if mode_rank[mode] < mode_rank[current]:
-            if not self._user_fully_verified(sender):
-                await self._send_response(room_id,
-                    "Only users with all devices verified can loosen security.")
-                return
-        self._set_security_mode(room_id, mode)
-        await self._send_response(room_id, f"Security set to `{mode}`")
-
-    async def _handle_claude_auth_command(self, room: MatrixRoom, sender: str, args: str) -> None:
-        """Handle !claude-auth command: refresh Claude Code OAuth token.
-
-        Restricted to admin (MATRIX_ADMIN_MXID) in 1:1 rooms only.
-
-        Flow:
-        1. !claude-auth -> runs `claude setup-token` in tmux, extracts URL
-        2. User opens URL, authenticates, copies token
-        3. User pastes token here -> bot feeds it to tmux via send-keys
-        4. `claude setup-token` finishes and writes credentials itself
-        """
-        room_id = room.room_id
-
-        # Admin-only, 1:1 rooms only (token must not leak to group chat history)
-        if not self.admin_mxid or sender != self.admin_mxid:
-            await self._send_response(room_id, "This command is admin-only.")
-            return
-        if self._is_group_room(room):
-            await self._send_response(room_id, "This command only works in 1:1 rooms (token security).")
-            return
-
-        safe_id = room_id.replace(":", "_").replace("!", "")
-
-        # Phase 2: user pasted the token — feed it to tmux
-        if safe_id in self._auth_flows:
-            token = args.strip()
-            flow = self._auth_flows.get(safe_id, {})
-            tmux_session = flow.get("tmux_session")
-
-            if not tmux_session:
-                self._auth_flows.pop(safe_id, None)
-                await self._send_response(room_id, "Auth flow lost its tmux session. Run `!claude-auth` again.")
-                return
-
-            try:
-                # Feed token to claude setup-token via tmux
-                proc = await asyncio.create_subprocess_exec(
-                    "tmux", "send-keys", "-t", tmux_session, token, "Enter",
-                    stdout=asyncio.subprocess.DEVNULL,
-                    stderr=asyncio.subprocess.PIPE
-                )
-                _, stderr = await proc.communicate()
-                if proc.returncode != 0:
-                    self._auth_flows.pop(safe_id, None)
-                    await self._send_response(room_id,
-                        f"Failed to send token to tmux: {stderr.decode().strip()}\nRun `!claude-auth` again.")
-                    return
-
-                # Wait for setup-token to process and exit
-                await self._send_response(room_id, "Token sent to `claude setup-token`, waiting for it to finish...")
-
-                success = False
-                for _ in range(15):
-                    await asyncio.sleep(1)
-                    # Check if tmux session still exists
-                    check = await asyncio.create_subprocess_exec(
-                        "tmux", "has-session", "-t", tmux_session,
-                        stdout=asyncio.subprocess.DEVNULL,
-                        stderr=asyncio.subprocess.DEVNULL
-                    )
-                    await check.wait()
-                    if check.returncode != 0:
-                        # Session exited — setup-token finished
-                        success = True
-                        break
-
-                    # Also check pane output for success/error messages
-                    cap = await asyncio.create_subprocess_exec(
-                        "tmux", "capture-pane", "-t", tmux_session, "-p",
-                        stdout=asyncio.subprocess.PIPE,
-                        stderr=asyncio.subprocess.DEVNULL
-                    )
-                    stdout, _ = await cap.communicate()
-                    output = stdout.decode('utf-8', errors='replace').lower()
-                    if 'success' in output or 'saved' in output or 'authenticated' in output:
-                        success = True
-                        break
-                    if 'error' in output or 'invalid' in output or 'failed' in output:
-                        clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', stdout.decode('utf-8', errors='replace'))
-                        self._auth_flows.pop(safe_id, None)
-                        await self._kill_tmux(tmux_session)
-                        await self._send_response(room_id,
-                            f"`claude setup-token` reported an error:\n```\n{clean.strip()[-500:]}\n```")
-                        return
-
-                self._auth_flows.pop(safe_id, None)
-
-                # Capture pane output BEFORE killing tmux — it contains the long-lived token
-                final_output = ""
-                if success:
-                    cap = await asyncio.create_subprocess_exec(
-                        "tmux", "capture-pane", "-t", tmux_session, "-p", "-S", "-100",
-                        stdout=asyncio.subprocess.PIPE,
-                        stderr=asyncio.subprocess.DEVNULL
-                    )
-                    stdout, _ = await cap.communicate()
-                    final_output = stdout.decode('utf-8', errors='replace')
-
-                await self._kill_tmux(tmux_session)
-
-                if success:
-                    # Extract long-lived token from setup-token output
-                    clean_output = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', final_output)
-                    clean_output = re.sub(r'\x1b[^a-zA-Z]*[a-zA-Z]', '', clean_output)
-                    oauth_token = self._extract_oauth_token(clean_output)
-
-                    if oauth_token:
-                        # Try to save to deploy .env
-                        saved = self._save_oauth_token_to_env(oauth_token)
-                        if saved:
-                            msg = "Long-lived token saved to deploy `.env`. Restart bot to apply."
-                        else:
-                            msg = (f"Token extracted. Set in deploy `.env` and restart:\n"
-                                   f"`CLAUDE_CODE_OAUTH_TOKEN={oauth_token}`")
-                    else:
-                        msg = "Auth completed but could not extract long-lived token from output."
-
-                    # Also verify with claude auth status
-                    status_proc = await asyncio.create_subprocess_exec(
-                        "claude", "auth", "status",
-                        stdout=asyncio.subprocess.PIPE,
-                        stderr=asyncio.subprocess.PIPE
-                    )
-                    status_out, _ = await status_proc.communicate()
-                    status_text = status_out.decode('utf-8', errors='replace').strip()
-
-                    await self._send_response(room_id,
-                        f"{msg}\n\n```\n{status_text[:500]}\n```")
-                    logger.info("Claude auth flow completed for room %s (token saved: %s)",
-                                room_id, bool(oauth_token))
-                else:
-                    await self._send_response(room_id,
-                        "`claude setup-token` didn't finish within 15s. "
-                        "Check manually with `claude auth status`.")
-
-            except Exception as e:
-                self._auth_flows.pop(safe_id, None)
-                await self._kill_tmux(tmux_session)
-                logger.error("Error feeding token to tmux: %s", e)
-                await self._send_response(room_id, f"Error: {e}")
-            return
-
-        # Phase 1: start claude setup-token in tmux, extract URL
-        await self._send_response(room_id, "Starting Claude Code OAuth flow...")
-
-        tmux_session = f"claude-auth-{safe_id[:20]}"
-
-        try:
-            # Kill any leftover session
-            await self._kill_tmux(tmux_session)
-            await asyncio.sleep(0.3)
-
-            # Start claude setup-token in tmux
-            proc = await asyncio.create_subprocess_exec(
-                "tmux", "new-session", "-d", "-s", tmux_session,
-                "-x", "200", "-y", "50",
-                "claude", "setup-token"
-            )
-            await proc.wait()
-
-            # Poll for the OAuth URL to appear
-            output = ""
-            for _ in range(15):
-                await asyncio.sleep(1)
-
-                cap = await asyncio.create_subprocess_exec(
-                    "tmux", "capture-pane", "-t", tmux_session, "-p",
-                    stdout=asyncio.subprocess.PIPE,
-                    stderr=asyncio.subprocess.DEVNULL
-                )
-                stdout, _ = await cap.communicate()
-                output = stdout.decode('utf-8', errors='replace')
-
-                if 'oauth/authorize' in output.lower() or 'console.anthropic.com' in output.lower():
-                    break
-
-            # Strip ANSI escapes
-            clean_output = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', output)
-            clean_output = re.sub(r'\x1b[^a-zA-Z]*[a-zA-Z]', '', clean_output)
-
-            # tmux wraps long URLs across lines — join continuation lines
-            # Remove newlines that break mid-URL (lines not starting with whitespace
-            # after a line ending with a URL-safe char)
-            lines = clean_output.split('\n')
-            joined = lines[0] if lines else ''
-            for line in lines[1:]:
-                stripped = line.strip()
-                # If prev line ends with URL-safe char and this line looks like URL continuation
-                if stripped and not stripped.startswith(('$', '#', '>', ' ')) and re.match(r'^[a-zA-Z0-9%&=_.~:/?#\[\]@!$\'()*+,;-]', stripped):
-                    # Check if we're likely in a URL context
-                    if joined.rstrip().endswith(tuple('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789%&=_.-~:/?#[]@!$\'()*+,;')):
-                        joined += stripped
-                        continue
-                joined += '\n' + line
-            clean_output = joined
-
-            # Extract URL
-            url_match = re.search(r'(https://[^\s]*(?:oauth/authorize|console\.anthropic\.com)[^\s]*)', clean_output)
-
-            if not url_match:
-                await self._kill_tmux(tmux_session)
-                await self._send_response(room_id,
-                    "Could not extract auth URL from `claude setup-token`.\n"
-                    f"```\n{clean_output.strip()[:500]}\n```")
-                logger.warning("claude setup-token output: %s", clean_output)
-                return
-
-            auth_url = url_match.group(1)
-
-            # Register auth flow
-            self._auth_flows[safe_id] = {
-                "tmux_session": tmux_session,
-                "started": time.time()
-            }
-
-            await self._send_response(room_id,
-                "**Claude Code Authentication**\n\n"
-                f"1. Open: {auth_url}\n\n"
-                "2. Authenticate and copy the token from the page\n\n"
-                "3. Paste it here\n\n"
-                "Flow expires in 5 minutes."
-            )
-
-            # Timeout cleanup
-            async def _auth_cleanup():
-                await asyncio.sleep(300)
-                if safe_id in self._auth_flows:
-                    flow = self._auth_flows.pop(safe_id, {})
-                    await self._kill_tmux(flow.get("tmux_session"))
-                    await self._send_response(room_id, "Auth flow expired. Run `!claude-auth` to restart.")
-
-            asyncio.create_task(_auth_cleanup())
-
-        except Exception as e:
-            await self._kill_tmux(tmux_session)
-            logger.error("Error starting claude setup-token: %s", e)
-            await self._send_response(room_id, f"Error: {e}")
-
-    async def _kill_tmux(self, session: str | None) -> None:
-        """Kill a tmux session if it exists."""
-        if not session:
-            return
-        proc = await asyncio.create_subprocess_exec(
-            "tmux", "kill-session", "-t", session,
-            stdout=asyncio.subprocess.DEVNULL,
-            stderr=asyncio.subprocess.DEVNULL
-        )
-        await proc.wait()
-
-    @staticmethod
-    def _extract_oauth_token(text: str) -> str | None:
-        """Extract CLAUDE_CODE_OAUTH_TOKEN from setup-token output."""
-        # Look for the token after "export CLAUDE_CODE_OAUTH_TOKEN=" or similar
-        m = re.search(r'CLAUDE_CODE_OAUTH_TOKEN[=\s]+([a-zA-Z0-9_\-]+)', text)
-        if m:
-            return m.group(1)
-        # Fallback: look for sk-ant-oat pattern (setup-token format)
-        m = re.search(r'(sk-ant-oat[a-zA-Z0-9_\-]+)', text)
-        if m:
-            return m.group(1)
-        return None
-
-    def _save_oauth_token_to_env(self, token: str) -> bool:
-        """Save CLAUDE_CODE_OAUTH_TOKEN to workspace .env file."""
-        if not self.config.workspace_dir:
-            return False
-        env_path = Path(self.config.workspace_dir) / ".env"
-        try:
-            content = env_path.read_text() if env_path.exists() else ""
-            if "CLAUDE_CODE_OAUTH_TOKEN=" in content:
-                content = re.sub(
-                    r'CLAUDE_CODE_OAUTH_TOKEN=.*',
-                    f'CLAUDE_CODE_OAUTH_TOKEN={token}',
-                    content
-                )
-            else:
-                content = content.rstrip('\n') + f'\nCLAUDE_CODE_OAUTH_TOKEN={token}\n'
-            env_path.write_text(content)
-            os.chmod(env_path, 0o600)
-            logger.info("Saved CLAUDE_CODE_OAUTH_TOKEN to %s", env_path)
-            return True
-        except Exception as e:
-            logger.error("Failed to save token to %s: %s", env_path, e)
-            return False
-
-    async def _handle_new_command(self, room: MatrixRoom, event_sender: str, topic: str) -> None:
-        """Handle !new command: create a new conversation room and invite user."""
-        room_id = room.room_id
-        name = topic.strip() if topic.strip() else f"{self._default_room_prefix}Новый чат"
-
-        new_room_id = await self._create_conversation_room(name, for_user=event_sender)
-        if not new_room_id:
-            await self._send_response(room_id, "Failed to create room.")
-            return
-
-        room_link = f"https://matrix.to/#/{new_room_id}"
-        display_name = name.removeprefix(self._default_room_prefix)
-        await self.client.room_send(
-            room_id, "m.room.message",
-            {
-                "msgtype": "m.text",
-                "body": f"{display_name}: {room_link}",
-                "format": "org.matrix.custom.html",
-                "formatted_body": f"{display_name}",
-            },
-            ignore_unverified_devices=True,
-        )
-        logger.info("Created /new room %s: %s", new_room_id, name)
-
-    # --- Message handlers ---
-
-    async def _handle_text(self, room: MatrixRoom, event: RoomMessageText) -> None:
-        is_group = self._is_group_room(room)
-
-        # 1:1 rooms: only owner can use the bot
-        # Group rooms: anyone can mention the bot
-        if not is_group and not self._is_allowed_user(event.sender):
-            return
-
-        user_text = event.body
-        room_id = room.room_id
-        safe_id = room_id.replace(":", "_").replace("!", "")
-
-        # Check if this is a session command — thread reply or !command while busy
-        session = self._active_sessions.get(room_id)
-        if session:
-            relates_to = event.source.get("content", {}).get("m.relates_to", {})
-            is_thread = relates_to.get("rel_type") == "m.thread"
-            is_bang_cmd = user_text.strip().lower().lstrip("!") in (
-                "stop", "cancel", "abort", "+5m", "+5", "+10m", "+10",
-            )
-            if is_thread or is_bang_cmd:
-                if await self._handle_thread_command(room_id, user_text, session):
-                    return
-
-        # Strip mention prefix (e.g. "Bot: !status" → "!status")
-        command_text = self._strip_mention_prefix(user_text)
-
-        # If Claude is waiting for an answer in this room, deliver it
-        if safe_id in self._pending_questions:
-            future = self._pending_questions.pop(safe_id)
-            if not future.done():
-                future.set_result(user_text)
-                return
-
-        # Check if we're in an auth flow for this room
-        if safe_id in self._auth_flows:
-            # Only intercept if it looks like a token (long, no spaces, no command prefix)
-            candidate = user_text.strip()
-            if len(candidate) > 20 and ' ' not in candidate and not candidate.startswith('!'):
-                # Redact the token message from chat history
-                try:
-                    await self.client.room_redact(room_id, event.event_id, reason="auth token")
-                except Exception:
-                    pass  # best-effort, E2E rooms may not support redaction
-                await self._handle_claude_auth_command(room, event.sender, user_text)
-                return
-            # If it looks like a command or normal message, check for !claude-auth cancel
-            if candidate.lower() in ('!cancel', '!claude-auth cancel', 'cancel'):
-                flow = self._auth_flows.pop(safe_id, {})
-                await self._kill_tmux(flow.get("tmux_session"))
-                await self._send_response(room_id, "Auth flow cancelled.")
-                return
-            # Fall through to normal message handling
-
-        # Bot commands — only allowed users
-        if self._is_allowed_user(event.sender):
-            if command_text.strip() in ("!help", "!commands", "!?"):
-                await self._handle_help(room)
-                return
-            if command_text.startswith("!new"):
-                topic = command_text[4:].strip()
-                await self._handle_new_command(room, event.sender, topic)
-                return
-            if command_text.strip() == "!status":
-                await self._handle_status(room)
-                return
-            if command_text.startswith("!mode"):
-                await self._handle_mode_command(room, command_text[5:])
-                return
-            if command_text.startswith("!security"):
-                await self._handle_security_command(room, event.sender, command_text[9:])
-                return
-            if command_text.strip() in ("!claude-auth", "!claudeauth"):
-                await self._handle_claude_auth_command(room, event.sender, "")
-                return
-
-        mode = self._get_room_mode(room_id)
-
-        # Group rooms: only respond when mentioned (quiet/context modes)
-        if is_group and mode not in ("full", "collect"):
-            logger.info("Group room %s (members=%d), checking mention", room_id, room.member_count)
-            if not self._is_bot_mentioned(event):
-                logger.info("Not mentioned in group room, skipping")
-                return
-
-        # Collect mode: save to history, acknowledge, no Claude
-        if mode == "collect":
-            self._save_room_message(room_id, event.sender, "text", user_text)
-            return
-
-        # Check if already processing in this room — queue if busy
-        if self._is_room_busy(room_id):
-            self._enqueue_message(room_id, event.event_id, event.sender, user_text)
-            return
-
-        # Security check — after mention check, before Claude interaction
-        allowed, security_msg = await self._check_security(room_id, event.sender)
-        if not allowed:
-            await self._send_response(room_id, security_msg)
-            return
-
-        # In full mode, save every message to room history
-        if mode == "full":
-            self._save_room_message(room_id, event.sender, "text", user_text)
-
-        # Build message for Claude
-        message_for_claude = user_text
-        if mode == "context":
-            recent = await self._fetch_recent_messages(room_id, limit=10)
-            if recent:
-                context_lines = [f"{m['sender']}: {m['text']}" for m in recent]
-                context_block = "\n".join(context_lines)
-                message_for_claude = (
-                    "[Recent room messages for context]\n"
-                    f"{context_block}\n\n---\n\n{user_text}"
-                )
-        elif mode == "full":
-            context = self._get_room_context(room_id)
-            if context:
-                message_for_claude = context + "\n\n---\n\n" + user_text
-
-        # Inject collect mode preamble if switching from collect
-        preamble = self._collect_preambles.pop(safe_id, "")
-        if preamble:
-            message_for_claude = (
-                "[CONTEXT UPDATE: User just switched from COLLECT mode. "
-                "New material was accumulated in this room's history — images, voice notes, "
-                "and/or text that you haven't seen yet. Review the conversation history above carefully, "
-                "especially entries with [image:] paths (use Read tool to view them) "
-                "and voice transcriptions. Process all accumulated material before responding.]\n\n"
-                + message_for_claude
-            )
-
-        async def _on_done(response: str):
-            self._pending_questions.pop(safe_id, None)
-            if mode == "full":
-                self._save_room_message(room_id, self.client.user_id, "text", response)
-                await self._rename_room(room_id, safe_id, user_text=user_text, response=response)
-            self._log_interaction(room_id, user_text, response)
-
-        await self._run_claude_session(
-            room, event, message_for_claude,
-            security_msg=security_msg, on_done=_on_done,
-        )
-
-    async def _handle_image(self, room: MatrixRoom, event) -> None:
-        if not self._is_allowed_user(event.sender):
-            return
-        mode = self._get_room_mode(room.room_id)
-        if self._is_group_room(room) and mode not in ("full", "collect"):
-            return
-
-        room_id = room.room_id
-        safe_id = room_id.replace(":", "_").replace("!", "")
-
-        # Download and save image regardless of mode
-        images_dir = self._room_dir(room_id) / "images"
-        images_dir.mkdir(exist_ok=True)
-
-        data = await self._download_media(event)
-        if data is None:
-            return
-
-        ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
-        filename = f"{ts}_{event.body or 'image'}"
-        if not any(filename.endswith(ext) for ext in (".jpg", ".jpeg", ".png", ".webp", ".gif")):
-            filename += ".jpg"
-        filepath = images_dir / filename
-        with open(filepath, "wb") as f:
-            f.write(data)
-
-        caption = event.body if event.body and event.body != "image" else ""
-
-        # Collect mode: save to history, no Claude
-        if mode == "collect":
-            history_text = f"[image: {filepath}]"
-            if caption:
-                history_text += f" {caption}"
-            self._save_room_message(room_id, event.sender, "image", history_text, file_path=str(filepath))
-            return
-
-        # Security check
-        allowed, security_msg = await self._check_security(room_id, event.sender)
-        if not allowed:
-            await self._send_response(room_id, security_msg)
-            return
-
-        message = f"User sent an image: {filepath}"
-        if caption:
-            message += f"\nCaption: {caption}"
-
-        if self._is_room_busy(room_id):
-            history_text = f"[image: {filepath}]"
-            if caption:
-                history_text += f" {caption}"
-            self._enqueue_message(room_id, event.event_id, event.sender,
-                                  history_text, msg_type="image", file_path=str(filepath))
-            return
-
-        async def _on_done(response: str):
-            await self._rename_room(room_id, safe_id, user_text=message, response=response)
-            self._log_interaction(room_id, f"[image] {event.body}", response)
-
-        await self._run_claude_session(
-            room, event, message, security_msg=security_msg, on_done=_on_done,
-        )
-
-    async def _handle_audio(self, room: MatrixRoom, event) -> None:
-        is_group = self._is_group_room(room)
-        if not is_group and not self._is_allowed_user(event.sender):
-            return
-
-        room_id = room.room_id
-        safe_id = room_id.replace(":", "_").replace("!", "")
-        mode = self._get_room_mode(room_id)
-        voice_dir = self._room_dir(room_id) / "voice"
-        voice_dir.mkdir(exist_ok=True)
-
-        data = await self._download_media(event)
-        if data is None:
-            return
-
-        ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
-        filename = f"{ts}_{event.body or 'voice.ogg'}"
-        filepath = voice_dir / filename
-        with open(filepath, "wb") as f:
-            f.write(data)
-
-        # Transcribe
-        transcribed_text = None
-        engine_tag = ""
-        if self.config.stt_url:
-            try:
-                transcribed_text, engine_tag = await transcribe(
-                    str(filepath), self.config.stt_url,
-                    whisper_url=os.environ.get("STT_SHORT_URL"),
-                )
-                logger.info("Transcribed voice in room %s: %d chars [%s]",
-                            room.display_name, len(transcribed_text), engine_tag)
-            except RuntimeError as e:
-                logger.error("ASR failed for room %s: %s", room.display_name, e)
-
-        # Post transcription with sender attribution + engine tag
-        if transcribed_text:
-            sender_name = self._sender_display_name(room, event.sender)
-            notice = f"🎙 {sender_name}: {transcribed_text}"
-            if engine_tag and os.environ.get("STT_SHORT_URL"):
-                notice += f" // {engine_tag}"
-            await self.client.room_send(
-                room_id, "m.room.message",
-                {"msgtype": "m.notice", "body": notice},
-                ignore_unverified_devices=True,
-            )
-
-        # Save to history in full/collect modes
-        if mode in ("full", "collect"):
-            history_text = transcribed_text or f"[audio: {filepath}]"
-            self._save_room_message(room_id, event.sender, "audio", history_text, file_path=str(filepath))
-
-        # Collect mode: transcribe and save, no Claude
-        if mode == "collect":
-            return
-
-        # Decide whether to respond via Claude
-        should_respond = not is_group  # always respond in 1:1
-        if is_group and transcribed_text and self._text_mentions_bot(transcribed_text):
-            should_respond = True
-        if not should_respond:
-            return
-
-        if self._is_room_busy(room_id):
-            queue_text = transcribed_text or f"[audio: {filepath}]"
-            self._enqueue_message(room_id, event.event_id, event.sender,
-                                  queue_text, msg_type="audio", file_path=str(filepath))
-            return
-
-        # Security check — before Claude interaction
-        allowed, security_msg = await self._check_security(room_id, event.sender)
-        if not allowed:
-            await self._send_response(room_id, security_msg)
-            return
-
-        # Build message for Claude
-        if transcribed_text:
-            message = f"[voice message transcription]: {transcribed_text}"
-        else:
-            message = f"User sent a voice message: {filepath}"
-
-        if mode == "context":
-            recent = await self._fetch_recent_messages(room_id, limit=10)
-            if recent:
-                context_lines = [f"{m['sender']}: {m['text']}" for m in recent]
-                context_block = "\n".join(context_lines)
-                message = f"[Recent room messages for context]\n{context_block}\n\n---\n\n{message}"
-
-        async def _on_done(response: str):
-            if mode == "full":
-                self._save_room_message(room_id, self.client.user_id, "text", response)
-                await self._rename_room(room_id, safe_id, user_text=message, response=response)
-            self._log_interaction(room_id, message, response)
-
-        await self._run_claude_session(
-            room, event, message, security_msg=security_msg, on_done=_on_done,
-        )
-
-    async def _handle_file(self, room: MatrixRoom, event) -> None:
-        if not self._is_allowed_user(event.sender):
-            return
-        mode = self._get_room_mode(room.room_id)
-        if self._is_group_room(room) and mode not in ("full", "collect"):
-            return
-
-        room_id = room.room_id
-        safe_id = room_id.replace(":", "_").replace("!", "")
-
-        # Download and save file regardless of mode
-        docs_dir = self._room_dir(room_id) / "documents"
-        docs_dir.mkdir(exist_ok=True)
-
-        data = await self._download_media(event)
-        if data is None:
-            return
-
-        ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
-        orig_name = event.body or "document"
-        filename = f"{ts}_{orig_name}"
-        filepath = docs_dir / filename
-        with open(filepath, "wb") as f:
-            f.write(data)
-
-        # Collect mode: save to history, no Claude
-        if mode == "collect":
-            self._save_room_message(room_id, event.sender, "file",
-                                    f"[file: {orig_name}]", file_path=str(filepath))
-            return
-
-        # Security check
-        allowed, security_msg = await self._check_security(room_id, event.sender)
-        if not allowed:
-            await self._send_response(room_id, security_msg)
-            return
-
-        message = f"User sent a document: {filepath} (name: {orig_name}, size: {len(data)} bytes)"
-
-        if self._is_room_busy(room_id):
-            self._enqueue_message(room_id, event.event_id, event.sender,
-                                  f"[file: {orig_name}]", msg_type="file", file_path=str(filepath))
-            return
-
-        async def _on_done(response: str):
-            await self._rename_room(room_id, safe_id, user_text=message, response=response)
-            self._log_interaction(room_id, f"[document: {orig_name}]", response)
-
-        await self._run_claude_session(
-            room, event, message, security_msg=security_msg, on_done=_on_done,
-        )
-
-    # --- E2E cross-signing & trust ---
-
-    async def _setup_cross_signing(self) -> None:
-        """Generate cross-signing keys (or load existing) and self-sign device."""
-        if not self.client.olm:
-            return
-        import base64
-        import olm as _olm
-
-        seeds_path = self.config.data_dir / "crypto_store" / "cross_signing_seeds.json"
-
-        # Load or generate seeds
-        if seeds_path.exists():
-            seeds = json.loads(seeds_path.read_text())
-            master_seed = base64.b64decode(seeds["master_seed"])
-            self_signing_seed = base64.b64decode(seeds["self_signing_seed"])
-            user_signing_seed = base64.b64decode(seeds["user_signing_seed"])
-        else:
-            master_seed = _olm.PkSigning.generate_seed()
-            self_signing_seed = _olm.PkSigning.generate_seed()
-            user_signing_seed = _olm.PkSigning.generate_seed()
-            seeds_path.parent.mkdir(parents=True, exist_ok=True)
-            seeds_path.write_text(json.dumps({
-                "master_seed": base64.b64encode(master_seed).decode(),
-                "self_signing_seed": base64.b64encode(self_signing_seed).decode(),
-                "user_signing_seed": base64.b64encode(user_signing_seed).decode(),
-            }))
-
-        master = _olm.PkSigning(master_seed)
-        self_signing = _olm.PkSigning(self_signing_seed)
-        _olm.PkSigning(user_signing_seed)  # validate
-
-        def _canonical(obj):
-            return json.dumps(obj, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
-
-        def _sign(obj, key_id, signing_key):
-            to_sign = {k: v for k, v in obj.items() if k not in ("signatures", "unsigned")}
-            sig = signing_key.sign(_canonical(to_sign))
-            obj.setdefault("signatures", {}).setdefault(self.client.user_id, {})[key_id] = sig
-
-        user_id = self.client.user_id
-        hs = self.client.homeserver
-
-        async with httpx.AsyncClient() as http:
-            headers = {"Authorization": f"Bearer {self.client.access_token}",
-                       "Content-Type": "application/json"}
-
-            # Check if already uploaded
-            resp = await http.post(f"{hs}/_matrix/client/v3/keys/query",
-                                   headers=headers, json={"device_keys": {user_id: []}}, timeout=10)
-            existing = resp.json().get("master_keys", {}).get(user_id)
-            if existing:
-                logger.info("Cross-signing keys already uploaded")
-            else:
-                # Build and upload cross-signing keys
-                master_key = {"user_id": user_id, "usage": ["master"],
-                              "keys": {f"ed25519:{master.public_key}": master.public_key}}
-                self_signing_key = {"user_id": user_id, "usage": ["self_signing"],
-                                    "keys": {f"ed25519:{self_signing.public_key}": self_signing.public_key}}
-                user_signing_key_obj = {"user_id": user_id, "usage": ["user_signing"],
-                                        "keys": {f"ed25519:{_olm.PkSigning(user_signing_seed).public_key}":
-                                                 _olm.PkSigning(user_signing_seed).public_key}}
-                _sign(self_signing_key, f"ed25519:{master.public_key}", master)
-                _sign(user_signing_key_obj, f"ed25519:{master.public_key}", master)
-                resp = await http.post(f"{hs}/_matrix/client/v3/keys/device_signing/upload",
-                                       headers=headers, timeout=10,
-                                       json={"master_key": master_key,
-                                             "self_signing_key": self_signing_key,
-                                             "user_signing_key": user_signing_key_obj})
-                if resp.status_code == 401:
-                    session = resp.json().get("session", "")
-                    resp = await http.post(f"{hs}/_matrix/client/v3/keys/device_signing/upload",
-                                           headers=headers, timeout=10,
-                                           json={"master_key": master_key,
-                                                  "self_signing_key": self_signing_key,
-                                                  "user_signing_key": user_signing_key_obj,
-                                                  "auth": {"type": "m.login.dummy", "session": session}})
-                if resp.status_code == 200:
-                    logger.info("Uploaded cross-signing keys")
-                else:
-                    logger.error("Failed to upload cross-signing keys (%d): %s",
-                                 resp.status_code, resp.text[:200])
-                    return
-
-            # Self-sign our device with self-signing key
-            resp = await http.post(f"{hs}/_matrix/client/v3/keys/query",
-                                   headers=headers, json={"device_keys": {user_id: []}}, timeout=10)
-            device_keys = resp.json()["device_keys"][user_id].get(self.client.device_id)
-            if not device_keys:
-                logger.error("Own device keys not found on server")
-                return
-
-            # Check if already signed by self-signing key
-            existing_sigs = device_keys.get("signatures", {}).get(user_id, {})
-            ss_key_id = f"ed25519:{self_signing.public_key}"
-            if ss_key_id in existing_sigs:
-                logger.info("Device already self-signed")
-                return
-
-            to_sign = {k: v for k, v in device_keys.items() if k not in ("signatures", "unsigned")}
-            sig = self_signing.sign(_canonical(to_sign))
-            sig_body = {user_id: {self.client.device_id: {
-                **to_sign,
-                "signatures": {user_id: {ss_key_id: sig}},
-            }}}
-            resp = await http.post(f"{hs}/_matrix/client/v3/keys/signatures/upload",
-                                   headers=headers, json=sig_body, timeout=10)
-            if resp.status_code == 200:
-                logger.info("Self-signed device %s", self.client.device_id)
-            else:
-                logger.error("Failed to self-sign device (%d): %s",
-                             resp.status_code, resp.text[:200])
-
-    async def _sync_cross_signing_trust(self) -> None:
-        """Query server for cross-signing keys and trust devices signed by self-signing keys.
-
-        This bridges the gap between server-side cross-signing verification
-        (what Element shows as green/red) and nio's local device trust store.
-        A device is considered verified if it's signed by its owner's self-signing key.
-        """
-        if not self.client.olm:
-            return
-        hs = self.client.homeserver
-        headers = {"Authorization": f"Bearer {self.client.access_token}",
-                   "Content-Type": "application/json"}
-
-        # Collect all user IDs we care about
-        user_ids = set(self._users.keys())
-        if not user_ids:
-            return
-
-        try:
-            async with httpx.AsyncClient() as http:
-                resp = await http.post(
-                    f"{hs}/_matrix/client/v3/keys/query",
-                    headers=headers,
-                    json={"device_keys": {uid: [] for uid in user_ids}},
-                    timeout=10,
-                )
-                if resp.status_code != 200:
-                    logger.warning("Cross-signing trust sync failed (%d)", resp.status_code)
-                    return
-                data = resp.json()
-        except Exception as e:
-            logger.warning("Cross-signing trust sync error: %s", e)
-            return
-
-        # For each user, find their self-signing key
-        for user_id in user_ids:
-            ss_key_obj = data.get("self_signing_keys", {}).get(user_id)
-            if not ss_key_obj:
-                continue
-            # Extract the self-signing public key
-            ss_keys = ss_key_obj.get("keys", {})
-            ss_pubkey = None
-            for key_id, key_val in ss_keys.items():
-                if key_id.startswith("ed25519:"):
-                    ss_pubkey = key_id  # e.g. "ed25519:ABCDEF..."
-                    break
-            if not ss_pubkey:
-                continue
-
-            # Check each device: is it signed by the self-signing key?
-            user_devices = data.get("device_keys", {}).get(user_id, {})
-            for device_id, dev_keys in user_devices.items():
-                sigs = dev_keys.get("signatures", {}).get(user_id, {})
-                is_cross_signed = ss_pubkey in sigs
-
-                # Find this device in nio's local store
-                nio_device = None
-                for d in self.client.device_store.active_user_devices(user_id):
-                    if d.id == device_id:
-                        nio_device = d
-                        break
-
-                if nio_device is None:
-                    continue
-
-                if is_cross_signed and not nio_device.verified:
-                    self.client.verify_device(nio_device)
-                    logger.info("Trusted cross-signed device %s of %s", device_id, user_id)
-                elif not is_cross_signed and nio_device.verified:
-                    # Device lost cross-signing — untrust it
-                    # (nio has no unverify, but we can note it)
-                    logger.warning("Device %s of %s no longer cross-signed", device_id, user_id)
-
-        logger.info("Cross-signing trust sync complete")
-
-    # --- Auto-join and room locking ---
-
-    async def _auto_join_invites(self) -> None:
-        for room_id in list(self.client.invited_rooms):
-            await self.client.join(room_id)
-            logger.info("Accepted invite to room %s", room_id)
-
-    def _load_sync_token(self) -> str | None:
-        if self._sync_token_path.exists():
-            token = self._sync_token_path.read_text().strip()
-            return token if token else None
-        return None
-
-    def _save_sync_token(self, token: str) -> None:
-        self._sync_token_path.parent.mkdir(parents=True, exist_ok=True)
-        self._sync_token_path.write_text(token)
-
-    async def run(self) -> None:
-        """Start the Matrix bot."""
-        # Plain events
-        self.client.add_event_callback(self._on_message, RoomMessageText)
-        self.client.add_event_callback(self._on_image, RoomMessageImage)
-        self.client.add_event_callback(self._on_audio, RoomMessageAudio)
-        self.client.add_event_callback(self._on_file, RoomMessageFile)
-        self.client.add_event_callback(self._on_member, RoomMemberEvent)
-        # Encrypted events (nio auto-decrypts to RoomMessage* types above,
-        # but encrypted media comes as RoomEncrypted* types)
-        self.client.add_event_callback(self._on_image, RoomEncryptedImage)
-        self.client.add_event_callback(self._on_audio, RoomEncryptedAudio)
-        self.client.add_event_callback(self._on_file, RoomEncryptedFile)
-        # Undecryptable events (missing keys)
-        self.client.add_event_callback(self._on_megolm, MegolmEvent)
-        # In-room verification events (Element X, FluffyChat)
-        self.client.add_event_callback(self._on_room_verify_event, RoomMessageUnknown)
-        self.client.add_event_callback(self._on_room_verify_event, UnknownEvent)
-        self.client.add_response_callback(self._on_sync, SyncResponse)
-        # SAS key verification (to-device events)
-        self.client.add_to_device_callback(self._on_verify_start, KeyVerificationStart)
-        self.client.add_to_device_callback(self._on_verify_key, KeyVerificationKey)
-        self.client.add_to_device_callback(self._on_verify_mac, KeyVerificationMac)
-        self.client.add_to_device_callback(self._on_verify_cancel, KeyVerificationCancel)
-
-        logger.info("Matrix bot starting as %s", self.client.user_id)
-
-        saved_token = self._load_sync_token()
-        if saved_token:
-            logger.info("Resuming from saved sync token")
-
-        resp = await self.client.sync(timeout=10000, since=saved_token, full_state=True)
-        if hasattr(resp, "next_batch") and resp.next_batch:
-            self._save_sync_token(resp.next_batch)
-        await self._auto_join_invites()
-        # E2E setup: upload our keys, then fetch and trust other users' devices
-        if self.client.olm:
-            if self.client.should_upload_keys:
-                await self.client.keys_upload()
-                logger.info("Uploaded device keys to server")
-            try:
-                await self.client.keys_query()
-            except Exception:
-                pass  # no keys to query yet (fresh user, no rooms)
-        # Note: we intentionally do NOT auto-trust all user devices here.
-        # The security model (strict/guarded/open) handles unverified devices
-        # per room. Devices are verified via in-room verification or cross-signing.
-        await self._sync_cross_signing_trust()
-        await self._setup_cross_signing()
-        await self._set_bot_avatar()
-        self._synced = True
-        logger.info("Initial sync complete, E2E=%s, listening for new messages",
-                     "enabled" if self.client.olm else "disabled")
-
-        await self.client.sync_forever(timeout=30000)
-
-    def _should_process(self, event, room: MatrixRoom | None = None) -> bool:
-        """Check if event should be processed (not own, not old, not duplicate, after sync)."""
-        eid = event.event_id
-        room_id = room.room_id if room else "?"
-        logger.info("_should_process: eid=%s sender=%s room=%s ts=%s body=%s",
-                     eid, event.sender, room_id, event.server_timestamp,
-                     getattr(event, 'body', '')[:50])
-        if not self._synced:
-            return False
-        if event.sender == self.client.user_id:
-            return False
-        if eid in self._processed_events:
-            logger.warning("Duplicate event %s, skipping", eid)
-            return False
-        self._processed_events.add(eid)
-        # Keep set bounded
-        if len(self._processed_events) > 1000:
-            self._processed_events = set(list(self._processed_events)[-500:])
-        return True
-
-    async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
-        if not self._should_process(event, room):
-            return
-        await self._handle_text(room, event)
-
-    async def _on_image(self, room: MatrixRoom, event) -> None:
-        if not self._should_process(event, room):
-            return
-        await self._handle_image(room, event)
-
-    async def _on_audio(self, room: MatrixRoom, event) -> None:
-        if not self._should_process(event, room):
-            return
-        await self._handle_audio(room, event)
-
-    async def _on_file(self, room: MatrixRoom, event) -> None:
-        if not self._should_process(event, room):
-            return
-        await self._handle_file(room, event)
-
-    async def _on_megolm(self, room: MatrixRoom, event: MegolmEvent) -> None:
-        """Handle messages we couldn't decrypt."""
-        if not self._synced:
-            return
-        logger.warning("Could not decrypt event %s in %s from %s (session %s)",
-                       event.event_id, room.room_id, event.sender,
-                       event.session_id)
-
-    # --- SAS key verification (auto-accept for allowed users) ---
-
-    async def _on_verify_start(self, event: KeyVerificationStart) -> None:
-        """Incoming verification request — auto-accept from allowed users."""
-        if not self._is_allowed_user(event.sender):
-            logger.warning("Verification from non-allowed user %s, ignoring", event.sender)
-            return
-        logger.info("Verification request from %s (tx=%s), auto-accepting",
-                     event.sender, event.transaction_id)
-        resp = await self.client.accept_key_verification(event.transaction_id)
-        if hasattr(resp, "message"):
-            logger.error("Failed to accept verification: %s", resp.message)
-
-    async def _on_verify_key(self, event: KeyVerificationKey) -> None:
-        """Key exchange done — emojis available. Auto-confirm (bot trusts allowed users)."""
-        sas = self.client.key_verifications.get(event.transaction_id)
-        if not sas:
-            return
-        emojis = sas.get_emoji()
-        emoji_str = " ".join(f"{e[0]} ({e[1]})" for e in emojis)
-        logger.info("Verification emojis for %s: %s", sas.other_olm_device.user_id, emoji_str)
-        resp = await self.client.confirm_short_auth_string(event.transaction_id)
-        if hasattr(resp, "message"):
-            logger.error("Failed to confirm SAS: %s", resp.message)
-
-    async def _on_verify_mac(self, event: KeyVerificationMac) -> None:
-        """MAC received — verification complete."""
-        sas = self.client.key_verifications.get(event.transaction_id)
-        if not sas:
-            return
-        if sas.verified:
-            logger.info("Device %s of %s verified via SAS",
-                         sas.other_olm_device.id, sas.other_olm_device.user_id)
-        else:
-            logger.warning("SAS verification failed for %s", event.transaction_id)
-
-    async def _on_verify_cancel(self, event: KeyVerificationCancel) -> None:
-        """Verification canceled."""
-        logger.info("Verification %s canceled by %s: %s",
-                     event.transaction_id, event.sender, event.reason)
-
-    # --- In-room verification (used by Element X, FluffyChat) ---
-
-    async def _on_room_verify_event(self, room: MatrixRoom, event) -> None:
-        """Handle in-room verification events (m.key.verification.*)."""
-        if not self._synced:
-            return
-        source = getattr(event, "source", {})
-        content = source.get("content", {})
-        event_type = source.get("type", "")
-        sender = source.get("sender", "")
-        event_id = source.get("event_id", "")
-        logger.debug("Room event: type=%s sender=%s eid=%s keys=%s",
-                      event_type, sender, event_id, list(content.keys()))
-
-        # m.room.message with msgtype m.key.verification.request
-        if event_type == "m.room.message":
-            msgtype = content.get("msgtype", "")
-            if msgtype != "m.key.verification.request":
-                return
-            event_type = "m.key.verification.request"
-
-        if not event_type.startswith("m.key.verification."):
-            return
-
-        if sender == self.client.user_id:
-            return
-
-        if not self._is_allowed_user(sender):
-            return
-
-        # Get transaction_id from m.relates_to or from the request event_id
-        relates_to = content.get("m.relates_to", {})
-        tx_id = relates_to.get("event_id", "")
-
-        room_id = room.room_id
-        logger.info("In-room verification: %s from %s (tx=%s)", event_type, sender, tx_id or event_id)
-
-        if event_type == "m.key.verification.request":
-            tx_id = event_id  # the request event_id IS the transaction_id
-            # Store SAS state
-            import olm as _olm
-            sas_obj = _olm.Sas()
-            self._room_verifications[tx_id] = {
-                "sas": sas_obj,
-                "room_id": room_id,
-                "sender": sender,
-                "from_device": content.get("from_device", ""),
-            }
-            # Send m.key.verification.ready
-            await self.client.room_send(room_id, "m.key.verification.ready", {
-                "from_device": self.client.device_id,
-                "methods": ["m.sas.v1"],
-                "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
-            }, ignore_unverified_devices=True)
-            logger.info("Sent verification ready for tx=%s", tx_id)
-            # Send start immediately (bot always initiates SAS after ready)
-            try:
-                resp = await self.client.room_send(room_id, "m.key.verification.start", {
-                    "from_device": self.client.device_id,
-                    "method": "m.sas.v1",
-                    "key_agreement_protocols": ["curve25519-hkdf-sha256"],
-                    "hashes": ["sha256"],
-                    "message_authentication_codes": ["hkdf-hmac-sha256.v2"],
-                    "short_authentication_string": ["decimal", "emoji"],
-                    "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
-                }, ignore_unverified_devices=True)
-                logger.info("Sent verification start for tx=%s", tx_id)
-            except Exception as e:
-                logger.error("Failed to send verification start: %s", e)
-
-        elif event_type == "m.key.verification.accept":
-            state = self._room_verifications.get(tx_id)
-            if not state:
-                return
-            state["their_commitment"] = content.get("commitment", "")
-            state["mac_method"] = content.get("message_authentication_code", "hkdf-hmac-sha256.v2")
-            # Send our public key
-            await self.client.room_send(room_id, "m.key.verification.key", {
-                "key": state["sas"].pubkey,
-                "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
-            }, ignore_unverified_devices=True)
-            logger.info("Sent verification key for tx=%s", tx_id)
-
-        elif event_type == "m.key.verification.start":
-            state = self._room_verifications.get(tx_id)
-            if not state:
-                return
-            # Send our key
-            await self.client.room_send(room_id, "m.key.verification.key", {
-                "key": state["sas"].pubkey,
-                "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
-            }, ignore_unverified_devices=True)
-            logger.info("Sent verification key for tx=%s", tx_id)
-
-        elif event_type == "m.key.verification.key":
-            state = self._room_verifications.get(tx_id)
-            if not state:
-                return
-            their_key = content.get("key", "")
-            state["sas"].set_their_pubkey(their_key)
-            # Generate SAS bytes for emoji
-            sas_info = (
-                "MATRIX_KEY_VERIFICATION_SAS"
-                f"{self.client.user_id}{self.client.device_id}"
-                f"{state['sas'].pubkey}"
-                f"{state['sender']}{state['from_device']}"
-                f"{their_key}{tx_id}"
-            )
-            sas_bytes = state["sas"].generate_bytes(sas_info, 6)
-            state["sas_bytes"] = sas_bytes
-            emojis = self._sas_to_emojis(sas_bytes)
-            logger.info("Verification emojis for %s: %s", state["sender"],
-                        " ".join(f"{e[0]}({e[1]})" for e in emojis))
-            # Auto-confirm: calculate and send MAC for device key + master key
-            mac_info_base = (
-                "MATRIX_KEY_VERIFICATION_MAC"
-                f"{self.client.user_id}{self.client.device_id}"
-                f"{state['sender']}{state['from_device']}{tx_id}"
-            )
-            own_device_key_id = f"ed25519:{self.client.device_id}"
-            own_ed25519 = self.client.olm.account.identity_keys["ed25519"]
-            mac_dict = {}
-            key_ids = []
-            # MAC device key
-            mac_dict[own_device_key_id] = state["sas"].calculate_mac_fixed_base64(
-                own_ed25519, mac_info_base + own_device_key_id)
-            key_ids.append(own_device_key_id)
-            # MAC master key (so other side can cross-sign our identity)
-            seeds_path = self.config.data_dir / "crypto_store" / "cross_signing_seeds.json"
-            if seeds_path.exists():
-                import base64
-                import olm as _olm
-                seeds = json.loads(seeds_path.read_text())
-                master_pubkey = _olm.PkSigning(base64.b64decode(seeds["master_seed"])).public_key
-                master_key_id = f"ed25519:{master_pubkey}"
-                mac_dict[master_key_id] = state["sas"].calculate_mac_fixed_base64(
-                    master_pubkey, mac_info_base + master_key_id)
-                key_ids.append(master_key_id)
-            # KEY_IDS mac covers sorted comma-separated key ids
-            key_ids.sort()
-            keys_str = ",".join(key_ids)
-            keys_mac = state["sas"].calculate_mac_fixed_base64(
-                keys_str, mac_info_base + "KEY_IDS")
-            await self.client.room_send(room_id, "m.key.verification.mac", {
-                "keys": keys_mac,
-                "mac": mac_dict,
-                "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
-            }, ignore_unverified_devices=True)
-            logger.info("Sent verification MAC for tx=%s", tx_id)
-
-        elif event_type == "m.key.verification.mac":
-            state = self._room_verifications.get(tx_id)
-            if not state:
-                return
-            # Send done
-            await self.client.room_send(room_id, "m.key.verification.done", {
-                "m.relates_to": {"rel_type": "m.reference", "event_id": tx_id},
-            }, ignore_unverified_devices=True)
-            # Cross-sign the user's master key with our user-signing key
-            await self._cross_sign_user(state["sender"])
-            logger.info("Verification complete for tx=%s with %s", tx_id, state["sender"])
-            self._room_verifications.pop(tx_id, None)
-
-        elif event_type == "m.key.verification.cancel":
-            logger.info("In-room verification %s canceled: %s", tx_id, content.get("reason", ""))
-            self._room_verifications.pop(tx_id, None)
-
-        elif event_type == "m.key.verification.done":
-            logger.info("In-room verification %s done by %s", tx_id, sender)
-            self._room_verifications.pop(tx_id, None)
-
-    async def _cross_sign_user(self, user_id: str) -> None:
-        """Sign user's master key with our user-signing key after successful verification."""
-        import base64
-        import olm as _olm
-
-        seeds_path = self.config.data_dir / "crypto_store" / "cross_signing_seeds.json"
-        if not seeds_path.exists():
-            logger.warning("No cross-signing seeds, cannot cross-sign user")
-            return
-
-        seeds = json.loads(seeds_path.read_text())
-        user_signing = _olm.PkSigning(base64.b64decode(seeds["user_signing_seed"]))
-
-        hs = self.client.homeserver
-        headers = {"Authorization": f"Bearer {self.client.access_token}",
-                   "Content-Type": "application/json"}
-
-        async with httpx.AsyncClient() as http:
-            # Get user's master key
-            resp = await http.post(f"{hs}/_matrix/client/v3/keys/query",
-                                   headers=headers,
-                                   json={"device_keys": {user_id: []}}, timeout=10)
-            data = resp.json()
-            master_key_obj = data.get("master_keys", {}).get(user_id)
-            if not master_key_obj:
-                logger.warning("No master key found for %s", user_id)
-                return
-
-            # Sign the master key with our user-signing key
-            to_sign = {k: v for k, v in master_key_obj.items()
-                       if k not in ("signatures", "unsigned")}
-            canonical = json.dumps(to_sign, separators=(",", ":"),
-                                   sort_keys=True, ensure_ascii=False)
-            sig = user_signing.sign(canonical)
-            us_key_id = f"ed25519:{user_signing.public_key}"
-
-            sig_body = {user_id: {
-                list(master_key_obj["keys"].keys())[0].split(":")[1]: {
-                    **to_sign,
-                    "signatures": {self.client.user_id: {us_key_id: sig}},
-                }
-            }}
-            resp = await http.post(f"{hs}/_matrix/client/v3/keys/signatures/upload",
-                                   headers=headers, json=sig_body, timeout=10)
-            if resp.status_code == 200:
-                logger.info("Cross-signed master key of %s", user_id)
-            else:
-                logger.error("Failed to cross-sign %s (%d): %s",
-                             user_id, resp.status_code, resp.text[:200])
-
-    @staticmethod
-    def _sas_to_emojis(sas_bytes: bytes) -> list[tuple[str, str]]:
-        """Convert 6 SAS bytes to 7 emojis (per Matrix spec)."""
-        emoji_list = [
-            ("🐶","Dog"),("🐱","Cat"),("🦁","Lion"),("🐴","Horse"),("🦄","Unicorn"),
-            ("🐷","Pig"),("🐘","Elephant"),("🐰","Rabbit"),("🐼","Panda"),("🐔","Rooster"),
-            ("🐧","Penguin"),("🐢","Turtle"),("🐟","Fish"),("🐙","Octopus"),("🦋","Butterfly"),
-            ("🌷","Flower"),("🌳","Tree"),("🌵","Cactus"),("🍄","Mushroom"),("🌏","Globe"),
-            ("🌙","Moon"),("☁️","Cloud"),("🔥","Fire"),("🍌","Banana"),("🍎","Apple"),
-            ("🍓","Strawberry"),("🌽","Corn"),("🍕","Pizza"),("🎂","Cake"),("❤️","Heart"),
-            ("😀","Smiley"),("🤖","Robot"),("🎩","Hat"),("👓","Glasses"),("🔧","Wrench"),
-            ("🎅","Santa"),("👍","Thumbs Up"),("☂️","Umbrella"),("⌛","Hourglass"),("⏰","Clock"),
-            ("🎁","Gift"),("💡","Light Bulb"),("📕","Book"),("✏️","Pencil"),("📎","Paperclip"),
-            ("✂️","Scissors"),("🔒","Lock"),("🔑","Key"),("🔨","Hammer"),("☎️","Telephone"),
-            ("🏁","Flag"),("🚂","Train"),("🚲","Bicycle"),("✈️","Airplane"),("🚀","Rocket"),
-            ("🏆","Trophy"),("⚽","Ball"),("🎸","Guitar"),("🎺","Trumpet"),("🔔","Bell"),
-            ("⚓","Anchor"),("🎧","Headphones"),("📁","Folder"),("📌","Pin"),
-        ]
-        # 6 bytes → 42 bits → 7 × 6-bit indices
-        val = int.from_bytes(sas_bytes, "big")
-        result = []
-        for i in range(6, -1, -1):
-            idx = (val >> (i * 6)) & 0x3F
-            result.append(emoji_list[idx])
-        return result
-
-    async def _on_member(self, room: MatrixRoom, event: RoomMemberEvent) -> None:
-        """Handle member events (joins, leaves)."""
-        if not self._synced:
-            return
-        if event.sender == self.client.user_id:
-            return
-        # Query keys for new members so we know their devices
-        if event.membership == "join" and self.client.olm:
-            try:
-                await self.client.keys_query()
-            except Exception:
-                pass
-
-    async def _on_sync(self, response: SyncResponse) -> None:
-        if response.next_batch:
-            self._save_sync_token(response.next_batch)
-        if self._synced:
-            await self._auto_join_invites()
-            # Query keys and re-sync cross-signing trust when device lists change
-            if self.client.olm and response.device_list.changed:
-                try:
-                    await self.client.keys_query()
-                    await self._sync_cross_signing_trust()
-                except Exception:
-                    pass
-
-    async def close(self) -> None:
-        await self.client.close()
diff --git a/bot-examples/matrix_main.py b/bot-examples/matrix_main.py
deleted file mode 100644
index 03e2e7f..0000000
--- a/bot-examples/matrix_main.py
+++ /dev/null
@@ -1,123 +0,0 @@
-"""Entry point for Matrix bot frontend."""
-
-import asyncio
-import logging
-import os
-import sys
-from pathlib import Path
-
-import httpx
-import yaml
-
-from core.config import Config
-from core.matrix_bot import MatrixBot
-
-
-def _load_dotenv(workspace: Path) -> None:
-    env_file = workspace / ".env"
-    if not env_file.exists():
-        return
-    for line in env_file.read_text().splitlines():
-        line = line.strip()
-        if not line or line.startswith("#") or "=" not in line:
-            continue
-        key, _, value = line.partition("=")
-        key = key.strip()
-        value = value.strip().strip('"').strip("'")
-        if key not in os.environ:
-            os.environ[key] = value
-
-
-def _load_users(workspace: Path) -> dict[str, dict]:
-    """Load users.yml from workspace. Returns {mxid: {profile: ...}}."""
-    users_file = workspace / "users.yml"
-    if not users_file.exists():
-        return {}
-    with open(users_file) as f:
-        data = yaml.safe_load(f) or {}
-    return data
-
-
-async def main() -> None:
-    logging.basicConfig(
-        level=logging.INFO,
-        format="%(asctime)s %(name)s %(levelname)s %(message)s",
-        datefmt="%Y-%m-%d %H:%M:%S",
-    )
-
-    workspace_dir = os.environ.get("WORKSPACE_DIR")
-    if workspace_dir:
-        _load_dotenv(Path(workspace_dir))
-
-    # MATRIX_DATA_DIR overrides DATA_DIR for Matrix bot
-    matrix_data_dir = os.environ.get("MATRIX_DATA_DIR")
-    if matrix_data_dir:
-        os.environ["DATA_DIR"] = matrix_data_dir
-
-    # Matrix-specific env vars
-    homeserver = os.environ.get("MATRIX_HOMESERVER")
-    user_id = os.environ.get("MATRIX_USER_ID")
-    access_token = os.environ.get("MATRIX_ACCESS_TOKEN")
-    owner_mxid = os.environ.get("MATRIX_OWNER_MXID", "")
-    admin_mxid = os.environ.get("MATRIX_ADMIN_MXID", "")  # For admin notifications
-
-    if not all([homeserver, user_id, access_token]):
-        logging.error(
-            "Missing Matrix config. Need: MATRIX_HOMESERVER, MATRIX_USER_ID, "
-            "MATRIX_ACCESS_TOKEN"
-        )
-        sys.exit(1)
-
-    # Resolve device_id from server (must match access token)
-    async with httpx.AsyncClient() as http:
-        resp = await http.get(
-            f"{homeserver}/_matrix/client/v3/account/whoami",
-            headers={"Authorization": f"Bearer {access_token}"},
-            timeout=10,
-        )
-        if resp.status_code != 200:
-            logging.error("whoami failed (%d): %s", resp.status_code, resp.text)
-            sys.exit(1)
-        device_id = resp.json().get("device_id")
-        logging.info("Resolved device_id: %s", device_id)
-
-    # Load users map (multi-user mode)
-    users = {}
-    if workspace_dir:
-        users = _load_users(Path(workspace_dir))
-    if not users and not owner_mxid:
-        logging.error("Need either users.yml in workspace or MATRIX_OWNER_MXID env var")
-        sys.exit(1)
-
-    try:
-        config = Config.from_env()
-    except ValueError as e:
-        logging.error("Config error: %s", e)
-        sys.exit(1)
-
-    if config.workspace_dir:
-        logging.info("Workspace: %s", config.workspace_dir)
-        # Symlink workspace CLAUDE.md into data dir
-        claude_md_link = config.data_dir / "CLAUDE.md"
-        claude_md_src = config.workspace_dir / "CLAUDE.md"
-        if claude_md_src.exists() and not claude_md_link.exists():
-            claude_md_link.symlink_to(claude_md_src)
-            logging.info("Symlinked CLAUDE.md into data dir")
-
-    if users:
-        logging.info("Multi-user mode: %d users", len(users))
-    logging.info("Data dir: %s", config.data_dir)
-
-    bot = MatrixBot(config, homeserver, user_id, access_token,
-                    owner_mxid=owner_mxid, users=users, device_id=device_id,
-                    admin_mxid=admin_mxid)
-    try:
-        await bot.run()
-    except KeyboardInterrupt:
-        pass
-    finally:
-        await bot.close()
-
-
-if __name__ == "__main__":
-    asyncio.run(main())
diff --git a/bot-examples/telegram_bot_topics.py b/bot-examples/telegram_bot_topics.py
deleted file mode 100644
index 491c579..0000000
--- a/bot-examples/telegram_bot_topics.py
+++ /dev/null
@@ -1,511 +0,0 @@
-"""Telegram bot engine.
-
-Handles messages (text, photo, voice), topic management, and Claude CLI integration.
-Uses RetryHTTPXRequest for proxy resilience, progressive message editing for streaming.
-"""
-
-import asyncio
-import json
-import logging
-import time
-from datetime import datetime, timezone
-from pathlib import Path
-
-import yaml
-
-from telegram import BotCommand, Update
-from telegram.constants import ChatAction, ParseMode
-from telegram.error import BadRequest, NetworkError
-from telegram.ext import (
-    Application,
-    CommandHandler,
-    ContextTypes,
-    MessageHandler,
-    filters,
-)
-from telegram.request import HTTPXRequest
-
-from core.asr import transcribe
-from core.claude_session import send_message as claude_send
-from core.config import Config
-
-logger = logging.getLogger(__name__)
-
-# Streaming edit parameters
-EDIT_INTERVAL = 1.5  # seconds between message edits
-EDIT_MIN_DELTA = 150  # minimum new chars before editing
-
-
-class RetryHTTPXRequest(HTTPXRequest):
-    """HTTPXRequest with retry on ConnectError (SOCKS5 proxy hiccups)."""
-
-    MAX_RETRIES = 3
-    RETRY_DELAY = 2
-
-    async def do_request(self, *args, **kwargs):
-        last_exc = None
-        for attempt in range(self.MAX_RETRIES):
-            try:
-                return await super().do_request(*args, **kwargs)
-            except NetworkError as e:
-                if "ConnectError" in str(e):
-                    last_exc = e
-                    if attempt < self.MAX_RETRIES - 1:
-                        logger.warning(
-                            "Telegram ConnectError (attempt %d/%d), retrying in %ds...",
-                            attempt + 1, self.MAX_RETRIES, self.RETRY_DELAY,
-                        )
-                        await asyncio.sleep(self.RETRY_DELAY)
-                else:
-                    raise
-        raise last_exc
-
-
-def build_app(config: Config) -> Application:
-    """Build and configure the Telegram Application."""
-    builder = Application.builder().token(config.bot_token)
-
-    # Configure HTTP client with proxy and timeouts
-    request_kwargs = {
-        "connect_timeout": 30.0,
-        "read_timeout": 60.0,
-        "write_timeout": 60.0,
-        "pool_timeout": 10.0,
-    }
-    if config.proxy:
-        request_kwargs["proxy"] = config.proxy
-
-    request = RetryHTTPXRequest(**request_kwargs)
-    builder = builder.request(request)
-    builder = builder.concurrent_updates(True)
-
-    app = builder.build()
-
-    # Store config in bot_data for handler access
-    app.bot_data["config"] = config
-
-    # Register handlers (order matters — more specific first)
-    app.add_handler(CommandHandler("start", handle_start))
-    app.add_handler(CommandHandler("newtopic", handle_new_topic))
-    app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
-    app.add_handler(MessageHandler(filters.VOICE | filters.AUDIO, handle_voice))
-    app.add_handler(MessageHandler(filters.Document.ALL, handle_document))
-    app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
-
-    # Post-init: set bot commands
-    app.post_init = _post_init
-
-    return app
-
-
-async def _post_init(application: Application) -> None:
-    """Set bot commands menu after initialization."""
-    commands = [
-        BotCommand("newtopic", "Create a new topic"),
-        BotCommand("start", "Start / help"),
-    ]
-    await application.bot.set_my_commands(commands)
-    logger.info("Bot initialized: @%s", application.bot.username)
-
-
-def _get_config(context: ContextTypes.DEFAULT_TYPE) -> Config:
-    return context.bot_data["config"]
-
-
-def _is_owner(update: Update, config: Config) -> bool:
-    return update.effective_user and update.effective_user.id == config.owner_id
-
-
-def _topic_id(update: Update) -> str:
-    """Get topic ID from message, or 'general' for the default topic."""
-    thread_id = update.effective_message.message_thread_id
-    return str(thread_id) if thread_id else "general"
-
-
-def _topic_dir(config: Config, topic_id: str) -> Path:
-    """Get data directory for a topic."""
-    d = config.data_dir / "topics" / topic_id
-    d.mkdir(parents=True, exist_ok=True)
-    return d
-
-
-def _log_interaction(config: Config, topic_id: str, user_msg: str, bot_msg: str) -> None:
-    """Append interaction to topic log."""
-    log_file = _topic_dir(config, topic_id) / "log.jsonl"
-    entry = {
-        "ts": datetime.now(timezone.utc).isoformat(),
-        "user": user_msg[:1000],
-        "bot": bot_msg[:2000],
-    }
-    with open(log_file, "a") as f:
-        f.write(json.dumps(entry, ensure_ascii=False) + "\n")
-
-
-def _md_to_html(text: str) -> str:
-    """Convert common Markdown to Telegram HTML."""
-    import re
-    # Escape HTML entities first (but preserve our conversions)
-    text = text.replace("&", "&").replace("<", "<").replace(">", ">")
-
-    # Code blocks: ```lang\n...\n```
-    text = re.sub(
-        r"```\w*\n(.*?)```",
-        lambda m: f"
{m.group(1)}
", - text, flags=re.DOTALL, - ) - # Inline code: `...` - text = re.sub(r"`([^`]+)`", r"\1", text) - # Bold: **...** - text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) - # Italic: *...* - text = re.sub(r"\*(.+?)\*", r"\1", text) - # Headers: ## ... → bold line - text = re.sub(r"^#{1,6}\s+(.+)$", r"\1", text, flags=re.MULTILINE) - # Bullet lists: - item → bullet - text = re.sub(r"^- ", "• ", text, flags=re.MULTILINE) - - return text - - -async def _edit_text_md(message, text: str) -> None: - """Edit message with HTML formatting, falling back to plain text.""" - try: - html = _md_to_html(text) - await message.edit_text(html, parse_mode=ParseMode.HTML) - except BadRequest: - try: - await message.edit_text(text) - except BadRequest: - pass - - -# Cache of topic labels we've already applied: {topic_id: label} -_applied_labels: dict[str, str] = {} - -# Pending questions from Claude: {topic_id: asyncio.Future} -_pending_questions: dict[str, asyncio.Future] = {} - - -async def _sync_topic_name(update: Update, config: Config, topic_id: str) -> None: - """Rename Telegram topic if topic-map.yml has a new/changed label.""" - if topic_id == "general": - return - topic_map_path = config.data_dir / "topic-map.yml" - if not topic_map_path.exists(): - return - try: - with open(topic_map_path) as f: - topic_map = yaml.safe_load(f) or {} - entry = topic_map.get(topic_id) or topic_map.get(int(topic_id)) - if not entry or not isinstance(entry, dict): - return - label = entry.get("label") - if not label or _applied_labels.get(topic_id) == label: - return - await update.get_bot().edit_forum_topic( - chat_id=update.effective_chat.id, - message_thread_id=int(topic_id), - name=label[:128], - ) - _applied_labels[topic_id] = label - logger.info("Renamed topic %s to: %s", topic_id, label) - except BadRequest as e: - if "not modified" not in str(e).lower(): - logger.warning("Failed to rename topic %s: %s", topic_id, e) - _applied_labels[topic_id] = label # don't retry - except Exception as e: - logger.warning("Error reading topic-map.yml: %s", e) - - -async def handle_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle /start command.""" - config = _get_config(context) - if not _is_owner(update, config): - return - await update.effective_message.reply_text( - "Ready. Send me a message or use /newtopic to create a topic." - ) - - -async def handle_new_topic(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle /newtopic — create a forum topic.""" - config = _get_config(context) - if not _is_owner(update, config): - return - - name = " ".join(context.args) if context.args else None - if not name: - await update.effective_message.reply_text("Usage: /newtopic Topic Name") - return - - try: - topic = await context.bot.create_forum_topic( - chat_id=update.effective_chat.id, - name=name, - ) - tid = str(topic.message_thread_id) - _topic_dir(config, tid) - await context.bot.send_message( - chat_id=update.effective_chat.id, - message_thread_id=topic.message_thread_id, - text=f"Topic created. Send me anything here.", - ) - logger.info("Created topic: %s (id=%s)", name, tid) - except BadRequest as e: - logger.error("Failed to create topic: %s", e) - await update.effective_message.reply_text(f"Failed to create topic: {e}") - - -async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle text messages — send to Claude CLI.""" - config = _get_config(context) - if not _is_owner(update, config): - return - - tid = _topic_id(update) - user_text = update.effective_message.text - - # If Claude is waiting for an answer in this topic, deliver it - if tid in _pending_questions: - future = _pending_questions.pop(tid) - if not future.done(): - future.set_result(user_text) - return - - # Send typing indicator and placeholder - await context.bot.send_chat_action( - chat_id=update.effective_chat.id, - action=ChatAction.TYPING, - message_thread_id=update.effective_message.message_thread_id, - ) - placeholder = await update.effective_message.reply_text("thinking...") - - # Streaming state - last_edit_time = 0.0 - last_edit_len = 0 - - async def on_chunk(text_so_far: str): - nonlocal last_edit_time, last_edit_len - now = time.monotonic() - delta = len(text_so_far) - last_edit_len - - if delta >= EDIT_MIN_DELTA and (now - last_edit_time) >= EDIT_INTERVAL: - try: - display = _truncate_for_telegram(text_so_far) - await placeholder.edit_text(display) - last_edit_time = now - last_edit_len = len(text_so_far) - except BadRequest: - pass # message not modified or too long - - async def on_question(question: str) -> str: - """Claude asks user a question — send it and wait for reply.""" - await update.effective_message.reply_text(f"❓ {question}") - loop = asyncio.get_event_loop() - future = loop.create_future() - _pending_questions[tid] = future - return await future - - topic_dir = _topic_dir(config, tid) - - try: - response = await claude_send( - config, tid, user_text, on_chunk=on_chunk, on_question=on_question, - ) - display = _truncate_for_telegram(response) - await _edit_text_md(placeholder, display) - except RuntimeError as e: - logger.error("Claude error for topic %s: %s", tid, e) - await placeholder.edit_text(f"Error: {e}") - response = f"[error] {e}" - finally: - _pending_questions.pop(tid, None) - - await _send_outbox(update, topic_dir) - _log_interaction(config, tid, user_text, response) - await _sync_topic_name(update, config, tid) - - -async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle photo messages — save image, send path to Claude.""" - config = _get_config(context) - if not _is_owner(update, config): - return - - tid = _topic_id(update) - images_dir = _topic_dir(config, tid) / "images" - images_dir.mkdir(exist_ok=True) - - # Download the largest photo - photo = update.effective_message.photo[-1] - file = await context.bot.get_file(photo.file_id) - ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") - filename = f"{ts}_{photo.file_unique_id}.jpg" - filepath = images_dir / filename - await file.download_to_drive(str(filepath)) - - caption = update.effective_message.caption or "" - message = f"User sent an image: {filepath}" - if caption: - message += f"\nCaption: {caption}" - - # Send typing and placeholder - placeholder = await update.effective_message.reply_text("looking at image...") - - try: - response = await claude_send(config, tid, message) - display = _truncate_for_telegram(response) - await _edit_text_md(placeholder, display) - except RuntimeError as e: - logger.error("Claude error for photo in topic %s: %s", tid, e) - await placeholder.edit_text(f"Error: {e}") - response = f"[error] {e}" - - _log_interaction(config, tid, f"[photo] {caption}", response) - await _sync_topic_name(update, config, tid) - - -async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle document messages — save file, send path to Claude.""" - config = _get_config(context) - if not _is_owner(update, config): - return - - tid = _topic_id(update) - docs_dir = _topic_dir(config, tid) / "documents" - docs_dir.mkdir(exist_ok=True) - - doc = update.effective_message.document - file = await context.bot.get_file(doc.file_id) - # Use original filename if available, otherwise generate one - orig_name = doc.file_name or f"{doc.file_unique_id}" - ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") - filename = f"{ts}_{orig_name}" - filepath = docs_dir / filename - await file.download_to_drive(str(filepath)) - - caption = update.effective_message.caption or "" - message = f"User sent a document: {filepath} (name: {orig_name}, size: {doc.file_size} bytes)" - if caption: - message += f"\nCaption: {caption}" - - topic_dir = _topic_dir(config, tid) - placeholder = await update.effective_message.reply_text("reading document...") - - try: - response = await claude_send(config, tid, message) - display = _truncate_for_telegram(response) - await _edit_text_md(placeholder, display) - except RuntimeError as e: - logger.error("Claude error for document in topic %s: %s", tid, e) - await placeholder.edit_text(f"Error: {e}") - response = f"[error] {e}" - - await _send_outbox(update, topic_dir) - _log_interaction(config, tid, f"[document: {orig_name}] {caption}", response) - await _sync_topic_name(update, config, tid) - - -async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle voice/audio messages — save file, send path to Claude.""" - config = _get_config(context) - if not _is_owner(update, config): - return - - tid = _topic_id(update) - voice_dir = _topic_dir(config, tid) / "voice" - voice_dir.mkdir(exist_ok=True) - - # Download voice file - voice = update.effective_message.voice or update.effective_message.audio - file = await context.bot.get_file(voice.file_id) - ext = "ogg" if update.effective_message.voice else "mp3" - ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") - filename = f"{ts}_{voice.file_unique_id}.{ext}" - filepath = voice_dir / filename - await file.download_to_drive(str(filepath)) - - topic_dir = _topic_dir(config, tid) - - # Transcribe via Whisper if available, otherwise send file path - if config.whisper_url: - placeholder = await update.effective_message.reply_text("transcribing voice...") - try: - text = await transcribe(str(filepath), config.whisper_url) - message = f"[voice message transcription]: {text}" - logger.info("Transcribed voice in topic %s: %d chars", tid, len(text)) - # Show transcription to user, then send to Claude - try: - await placeholder.edit_text(f"🎤 {text}") - except BadRequest: - pass - placeholder = await update.effective_message.reply_text("thinking...") - except RuntimeError as e: - logger.error("ASR failed for topic %s: %s", tid, e) - message = f"User sent a voice message: {filepath} (duration: {voice.duration}s)\n(transcription failed: {e})" - else: - message = f"User sent a voice message: {filepath} (duration: {voice.duration}s)" - placeholder = await update.effective_message.reply_text("processing voice...") - - try: - response = await claude_send(config, tid, message) - display = _truncate_for_telegram(response) - await _edit_text_md(placeholder, display) - except RuntimeError as e: - logger.error("Claude error for voice in topic %s: %s", tid, e) - await placeholder.edit_text(f"Error: {e}") - response = f"[error] {e}" - - await _send_outbox(update, topic_dir) - _log_interaction(config, tid, message, response) - await _sync_topic_name(update, config, tid) - - -async def _send_outbox(update: Update, topic_dir: Path) -> None: - """Send files queued in outbox.jsonl by Claude via send-to-user tool.""" - outbox = topic_dir / "outbox.jsonl" - if not outbox.exists(): - return - - entries = [] - try: - with open(outbox) as f: - for line in f: - line = line.strip() - if line: - entries.append(json.loads(line)) - # Clear outbox - outbox.unlink() - except Exception as e: - logger.error("Failed to read outbox: %s", e) - return - - for entry in entries: - fpath = Path(entry.get("path", "")) - ftype = entry.get("type", "document") - caption = entry.get("caption", "") or fpath.name - - if not fpath.is_file(): - logger.warning("Outbox file not found: %s", fpath) - continue - - try: - with open(fpath, "rb") as f: - if ftype == "image": - await update.effective_message.reply_photo(photo=f, caption=caption) - elif ftype == "video": - await update.effective_message.reply_video(video=f, caption=caption) - elif ftype == "audio": - await update.effective_message.reply_voice(voice=f, caption=caption) - else: - await update.effective_message.reply_document(document=f, caption=caption) - logger.info("Sent %s: %s", ftype, fpath.name) - except Exception as e: - logger.error("Failed to send %s %s: %s", ftype, fpath.name, e) - - -def _truncate_for_telegram(text: str, max_len: int = 4096) -> str: - """Truncate text to Telegram message limit.""" - if len(text) <= max_len: - return text - return text[: max_len - 20] + "\n\n[truncated]" diff --git a/bot-examples/telegram_main.py b/bot-examples/telegram_main.py deleted file mode 100644 index cf5d13e..0000000 --- a/bot-examples/telegram_main.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Entry point for agent-core bot. - -Loads config from environment, optionally reads .env from workspace, -builds and runs the Telegram bot. -""" - -import logging -import sys -from pathlib import Path - -from core.bot import build_app -from core.config import Config - - -def _load_dotenv(workspace_dir: Path | None) -> None: - """Load .env file from workspace directory if it exists.""" - if not workspace_dir: - return - env_file = workspace_dir / ".env" - if not env_file.exists(): - return - - import os - for line in env_file.read_text().splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - if "=" not in line: - continue - key, _, value = line.partition("=") - key = key.strip() - value = value.strip().strip('"').strip("'") - # Don't override existing env vars - if key not in os.environ: - os.environ[key] = value - - -def main() -> None: - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(name)s %(levelname)s %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - - import os - workspace_dir = os.environ.get("WORKSPACE_DIR") - if workspace_dir: - _load_dotenv(Path(workspace_dir)) - - try: - config = Config.from_env() - except ValueError as e: - logging.error("Config error: %s", e) - sys.exit(1) - - if config.workspace_dir: - logging.info("Workspace: %s", config.workspace_dir) - # Symlink workspace CLAUDE.md into data dir so Claude CLI finds it - # when running in topic subdirectories - claude_md_link = config.data_dir / "CLAUDE.md" - claude_md_src = config.workspace_dir / "CLAUDE.md" - if claude_md_src.exists() and not claude_md_link.exists(): - claude_md_link.symlink_to(claude_md_src) - logging.info("Symlinked CLAUDE.md into data dir") - logging.info("Data dir: %s", config.data_dir) - - app = build_app(config) - app.run_polling( - allowed_updates=["message", "edited_message"], - stop_signals=None, - ) - - -if __name__ == "__main__": - main() diff --git a/config/matrix-agents.example.yaml b/config/matrix-agents.example.yaml deleted file mode 100644 index 84221eb..0000000 --- a/config/matrix-agents.example.yaml +++ /dev/null @@ -1,44 +0,0 @@ -# Agent registry for the Matrix bot. -# Production target: one surface bot routes to 25-30 externally managed agents. -# Keep adding entries with the same base_url/workspace_path pattern. -# -# user_agents: maps a Matrix user ID to an agent ID. -# If a user is not listed, the bot uses the first agent from the list below. -# Omit this section entirely for a single-agent setup. -# -# agents: list of available agents. -# id — must match the agent ID known to the platform -# label — human-readable name (shown in logs) -# base_url — HTTP/WS URL of this agent's endpoint -# (overrides the global AGENT_BASE_URL env var for this agent) -# workspace_path — absolute path to this agent's workspace directory inside the bot container -# (the bot saves incoming files directly here and reads outgoing files from here) -# Example: /agents/0 means the bot mounts the shared volume at /agents/ -# and this agent's files live under /agents/0/ - -user_agents: - "@user0:matrix.example.org": agent-0 - "@user1:matrix.example.org": agent-1 - "@user2:matrix.example.org": agent-2 - -agents: - - id: agent-0 - label: "Agent 0" - base_url: "http://lambda.coredump.ru:7000/agent_0/" - workspace_path: "/agents/0" - - - id: agent-1 - label: "Agent 1" - base_url: "http://lambda.coredump.ru:7000/agent_1/" - workspace_path: "/agents/1" - - - id: agent-2 - label: "Agent 2" - base_url: "http://lambda.coredump.ru:7000/agent_2/" - workspace_path: "/agents/2" - - # Continue the same pattern through agent-29 for a 25-30 agent deployment: - # - id: agent-29 - # label: "Agent 29" - # base_url: "http://lambda.coredump.ru:7000/agent_29/" - # workspace_path: "/agents/29" diff --git a/config/matrix-agents.smoke.yaml b/config/matrix-agents.smoke.yaml deleted file mode 100644 index 9b357fe..0000000 --- a/config/matrix-agents.smoke.yaml +++ /dev/null @@ -1,10 +0,0 @@ -agents: - - id: agent-0 - label: "Smoke Agent 0" - base_url: "http://agent-proxy:7000/agent_0/" - workspace_path: "/agents/0" - - - id: agent-1 - label: "Smoke Agent 1" - base_url: "http://agent-proxy:7000/agent_1/" - workspace_path: "/agents/1" diff --git a/config/matrix-agents.yaml b/config/matrix-agents.yaml deleted file mode 100644 index 3ab9366..0000000 --- a/config/matrix-agents.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# Single-agent configuration for MVP deployment. -# For multi-agent setup with per-user routing, see config/matrix-agents.example.yaml. - -agents: - - id: agent-1 - label: Surface - base_url: "http://lambda.coredump.ru:7000/agent_1/" - workspace_path: "/agents/1" diff --git a/core/handlers/chat.py b/core/handlers/chat.py index a7140b5..8e32468 100644 --- a/core/handlers/chat.py +++ b/core/handlers/chat.py @@ -4,19 +4,9 @@ from __future__ import annotations from core.protocol import IncomingCommand, OutgoingMessage -def _command(platform: str, name: str) -> str: - prefix = "!" if platform == "matrix" else "/" - return f"{prefix}{name}" - - async def handle_new_chat(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list: if not await auth_mgr.is_authenticated(event.user_id): - return [ - OutgoingMessage( - chat_id=event.chat_id, - text=f"Введите {_command(event.platform, 'start')} чтобы начать.", - ) - ] + return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")] name = " ".join(event.args) if event.args else None ctx = await chat_mgr.get_or_create( user_id=event.user_id, @@ -30,12 +20,7 @@ async def handle_new_chat(event: IncomingCommand, auth_mgr, platform, chat_mgr, async def handle_rename(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list: if not event.args: - return [ - OutgoingMessage( - chat_id=event.chat_id, - text=f"Укажите название: {_command(event.platform, 'rename')} Название", - ) - ] + return [OutgoingMessage(chat_id=event.chat_id, text="Укажите название: /rename Название")] ctx = await chat_mgr.rename(event.chat_id, " ".join(event.args)) return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")] diff --git a/core/handlers/message.py b/core/handlers/message.py index 876754c..e1475ef 100644 --- a/core/handlers/message.py +++ b/core/handlers/message.py @@ -1,49 +1,12 @@ # core/handlers/message.py from __future__ import annotations -from core.protocol import Attachment, IncomingMessage, OutgoingMessage, OutgoingTyping - - -def _infer_attachment_type(mime_type: str | None) -> str: - if not mime_type: - return "document" - if mime_type.startswith("image/"): - return "image" - if mime_type.startswith("audio/"): - return "audio" - if mime_type.startswith("video/"): - return "video" - return "document" - - -def _to_core_attachments(raw: list) -> list[Attachment]: - result = [] - for a in raw: - if isinstance(a, Attachment): - result.append(a) - else: - result.append(Attachment( - type=getattr(a, "type", None) or _infer_attachment_type(getattr(a, "mime_type", None)), - url=getattr(a, "url", None), - filename=getattr(a, "filename", None), - mime_type=getattr(a, "mime_type", None), - workspace_path=getattr(a, "workspace_path", None), - )) - return result - - -def _start_command(platform: str) -> str: - return "!start" if platform == "matrix" else "/start" +from core.protocol import IncomingMessage, OutgoingMessage, OutgoingTyping async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, settings_mgr) -> list: if not await auth_mgr.is_authenticated(event.user_id): - return [ - OutgoingMessage( - chat_id=event.chat_id, - text=f"Введите {_start_command(event.platform)} чтобы начать.", - ) - ] + return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")] # Voice slot fallback: audio attachment without registered voice_handler if event.attachments and event.attachments[0].type == "audio": @@ -57,15 +20,10 @@ async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, s user_id=event.user_id, chat_id=event.chat_id, text=event.text, - attachments=event.attachments, + attachments=[], ) return [ OutgoingTyping(chat_id=event.chat_id, is_typing=False), - OutgoingMessage( - chat_id=event.chat_id, - text=response.response, - parse_mode="markdown", - attachments=_to_core_attachments(getattr(response, "attachments", [])), - ), + OutgoingMessage(chat_id=event.chat_id, text=response.response, parse_mode="markdown"), ] diff --git a/core/protocol.py b/core/protocol.py index 7d6e25f..02a9f4a 100644 --- a/core/protocol.py +++ b/core/protocol.py @@ -12,7 +12,6 @@ class Attachment: content: bytes | None = None filename: str | None = None mime_type: str | None = None - workspace_path: str | None = None @dataclass diff --git a/docker-compose.fullstack.yml b/docker-compose.fullstack.yml deleted file mode 100644 index 88ff37b..0000000 --- a/docker-compose.fullstack.yml +++ /dev/null @@ -1,61 +0,0 @@ -services: - matrix-bot: - extends: - file: docker-compose.prod.yml - service: matrix-bot - build: - context: . - dockerfile: Dockerfile - target: development - args: - LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master} - additional_contexts: - agent_api: ./external/platform-agent_api - tags: - - ${SURFACES_BOT_DEV_IMAGE:-surfaces-bot:dev} - environment: - AGENT_BASE_URL: http://platform-agent:8000 - depends_on: - platform-agent: - condition: service_healthy - - platform-agent: - build: - context: ./external/platform-agent - target: development - additional_contexts: - agent_api: ./external/platform-agent_api - environment: - PYTHONUNBUFFERED: "1" - AGENT_ID: ${AGENT_ID:-matrix-dev} - PROVIDER_MODEL: ${PROVIDER_MODEL:-openai/gpt-4o-mini} - PROVIDER_URL: ${PROVIDER_URL:-} - PROVIDER_API_KEY: ${PROVIDER_API_KEY:-} - COMPOSIO_API_KEY: ${COMPOSIO_API_KEY:-} - volumes: - - ./external/platform-agent/src:/app/src - - ./external/platform-agent_api:/agent_api - - agents:/workspace - command: > - sh -lc " - mkdir -p /workspace && - chown -R agent:agent /workspace && - exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log - " - ports: - - "8000:8000" - healthcheck: - test: - - CMD-SHELL - - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()" - interval: 60s - timeout: 5s - retries: 5 - start_period: 15s - restart: unless-stopped - -volumes: - agents: - name: ${SURFACES_SHARED_VOLUME:-surfaces-agents} - bot-state: - name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index 2c7e942..0000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,26 +0,0 @@ -services: - matrix-bot: - image: "${SURFACES_BOT_IMAGE:?Set SURFACES_BOT_IMAGE to the pushed image, e.g. mput1/surfaces-bot:latest}" - environment: - MATRIX_HOMESERVER: ${MATRIX_HOMESERVER:-} - MATRIX_USER_ID: ${MATRIX_USER_ID:-} - MATRIX_PASSWORD: ${MATRIX_PASSWORD:-} - MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-} - MATRIX_PLATFORM_BACKEND: ${MATRIX_PLATFORM_BACKEND:-real} - MATRIX_AGENT_REGISTRY_PATH: ${MATRIX_AGENT_REGISTRY_PATH:-/app/config/matrix-agents.yaml} - AGENT_BASE_URL: ${AGENT_BASE_URL:-} - SURFACES_WORKSPACE_DIR: ${SURFACES_WORKSPACE_DIR:-/agents} - MATRIX_DB_PATH: /app/state/lambda_matrix.db - MATRIX_STORE_PATH: /app/state/matrix_store - PYTHONUNBUFFERED: "1" - volumes: - - agents:/agents - - bot-state:/app/state - - ./config:/app/config:ro - restart: unless-stopped - -volumes: - agents: - name: ${SURFACES_SHARED_VOLUME:-surfaces-agents} - bot-state: - name: ${SURFACES_BOT_STATE_VOLUME:-surfaces-bot-state} diff --git a/docker-compose.smoke.timeout.yml b/docker-compose.smoke.timeout.yml deleted file mode 100644 index c8f4ba3..0000000 --- a/docker-compose.smoke.timeout.yml +++ /dev/null @@ -1,18 +0,0 @@ -services: - agent-proxy: - volumes: - - ./docker/nginx/smoke-agents-timeout.conf:/etc/nginx/nginx.conf:ro - depends_on: - agent-no-status: - condition: service_started - - agent-no-status: - build: - context: . - dockerfile: Dockerfile - target: production - args: - LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master} - environment: - PYTHONUNBUFFERED: "1" - command: ["python", "-m", "tools.no_status_agent", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker-compose.smoke.yml b/docker-compose.smoke.yml deleted file mode 100644 index ed4e8b8..0000000 --- a/docker-compose.smoke.yml +++ /dev/null @@ -1,109 +0,0 @@ -services: - surface-smoke: - build: - context: . - dockerfile: Dockerfile - target: production - args: - LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master} - environment: - PYTHONUNBUFFERED: "1" - SMOKE_TIMEOUT: ${SMOKE_TIMEOUT:-5} - volumes: - - agents:/agents - - ./config:/app/config:ro - depends_on: - agent-proxy: - condition: service_healthy - command: > - sh -lc " - python -m tools.check_matrix_agents --config /app/config/matrix-agents.smoke.yaml --timeout ${SMOKE_TIMEOUT:-5} - " - - agent-proxy: - image: nginx:1.27-alpine - volumes: - - ./docker/nginx/smoke-agents.conf:/etc/nginx/nginx.conf:ro - healthcheck: - test: - - CMD-SHELL - - nc -z 127.0.0.1 7000 - interval: 2s - timeout: 2s - retries: 15 - start_period: 2s - depends_on: - agent-0: - condition: service_healthy - agent-1: - condition: service_healthy - ports: - - "${SMOKE_PROXY_PORT:-7000}:7000" - - agent-0: - build: - context: ./external/platform-agent - target: development - additional_contexts: - agent_api: ./external/platform-agent_api - environment: - PYTHONUNBUFFERED: "1" - AGENT_ID: ${AGENT_0_ID:-agent-0} - PROVIDER_MODEL: ${PROVIDER_MODEL:-debug-model} - PROVIDER_URL: ${PROVIDER_URL:-http://provider.invalid/v1} - PROVIDER_API_KEY: ${PROVIDER_API_KEY:-debug-key} - volumes: - - ./external/platform-agent/src:/app/src - - ./external/platform-agent_api:/agent_api - - agents:/shared-agents - healthcheck: - test: - - CMD-SHELL - - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()" - interval: 5s - timeout: 3s - retries: 12 - start_period: 5s - command: > - sh -lc " - mkdir -p /shared-agents/0 && - rm -rf /workspace && - ln -s /shared-agents/0 /workspace && - exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log - " - - agent-1: - build: - context: ./external/platform-agent - target: development - additional_contexts: - agent_api: ./external/platform-agent_api - environment: - PYTHONUNBUFFERED: "1" - AGENT_ID: ${AGENT_1_ID:-agent-1} - PROVIDER_MODEL: ${PROVIDER_MODEL:-debug-model} - PROVIDER_URL: ${PROVIDER_URL:-http://provider.invalid/v1} - PROVIDER_API_KEY: ${PROVIDER_API_KEY:-debug-key} - volumes: - - ./external/platform-agent/src:/app/src - - ./external/platform-agent_api:/agent_api - - agents:/shared-agents - healthcheck: - test: - - CMD-SHELL - - python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/openapi.json', timeout=2).read()" - interval: 5s - timeout: 3s - retries: 12 - start_period: 5s - command: > - sh -lc " - mkdir -p /shared-agents/1 && - rm -rf /workspace && - ln -s /shared-agents/1 /workspace && - exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 --no-access-log - " - -volumes: - agents: - name: ${SURFACES_SMOKE_VOLUME:-surfaces-smoke-agents} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index c7323d0..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,39 +0,0 @@ -services: - platform-agent: - build: - context: ./external/platform-agent - target: development - additional_contexts: - agent_api: ./external/platform-agent_api - env_file: .env - environment: - PYTHONUNBUFFERED: "1" - volumes: - - ./external/platform-agent/src:/app/src - - ./external/platform-agent_api:/agent_api - - workspace:/workspace - command: > - sh -lc " - mkdir -p /workspace && - chown -R agent:agent /workspace && - exec /app/.venv/bin/uvicorn src.main:app --host 0.0.0.0 --port 8000 - " - ports: - - "8000:8000" - restart: unless-stopped - - matrix-bot: - build: . - env_file: .env - environment: - AGENT_BASE_URL: http://platform-agent:8000 - SURFACES_WORKSPACE_DIR: /workspace - depends_on: - - platform-agent - volumes: - - workspace:/workspace - - ./config:/app/config:ro - restart: unless-stopped - -volumes: - workspace: diff --git a/docker/nginx/smoke-agents-timeout.conf b/docker/nginx/smoke-agents-timeout.conf deleted file mode 100644 index 03c7e79..0000000 --- a/docker/nginx/smoke-agents-timeout.conf +++ /dev/null @@ -1,28 +0,0 @@ -events {} - -http { - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - server { - listen 7000; - - location /agent_0/ { - proxy_pass http://agent-0:8000/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - } - - location /agent_1/ { - proxy_pass http://agent-no-status:8000/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - } - } -} diff --git a/docker/nginx/smoke-agents.conf b/docker/nginx/smoke-agents.conf deleted file mode 100644 index e3bcaab..0000000 --- a/docker/nginx/smoke-agents.conf +++ /dev/null @@ -1,28 +0,0 @@ -events {} - -http { - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - server { - listen 7000; - - location /agent_0/ { - proxy_pass http://agent-0:8000/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - } - - location /agent_1/ { - proxy_pass http://agent-1:8000/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - } - } -} diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..10fd899 --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,143 @@ +# API Contract — Lambda Platform + +> **Статус:** ЧЕРНОВИК — проектируем сами, уточняем с Азаматом когда SDK будет готов +> **Последнее обновление:** 2026-03-29 + +--- + +## Архитектурный контекст + +Каждому пользователю выделяется **один LXC-контейнер** с workspace 10 ГБ. +Workspace содержит директории чатов: `C1/`, `C2/`, `C3/` — файлы + `history.db` в каждом. + +**Master** управляет lifecycle контейнера (запуск, заморозка, пробуждение). +Бот **не управляет lifecycle** — он передаёт `user_id` + `chat_id` + сообщение. +Master сам решает: нужно ли поднять контейнер, смонтировать нужный чат, запустить агента. + +--- + +## Base URL + +``` +https://api.lambda-platform.io/v1 +``` + +## Аутентификация + +``` +Authorization: Bearer {SERVICE_TOKEN} +``` + +Сервисный токен выдаётся команде поверхностей. Не путать с токеном пользователя. + +--- + +## Users + +### GET /users/{external_id}?platform={platform} + +Получает или создаёт пользователя. + +**Query params:** +- `platform` — `telegram` | `matrix` + +**Response 200:** +```json +{ + "user_id": "usr_abc123", + "external_id": "12345678", + "platform": "telegram", + "display_name": "Иван Иванов", + "created_at": "2025-01-15T10:30:00Z", + "is_new": false +} +``` + +--- + +## Messages + +Бот не управляет сессиями явно. Отправка сообщения — единственная операция. +Master решает: нужен ли новый контейнер, или разбудить существующий. + +### POST /users/{user_id}/chats/{chat_id}/messages + +Отправляет сообщение пользователя агенту. Master поднимает/размораживает контейнер, +монтирует нужный чат (`C1/`, `C2/`...), запускает агента. + +**Request:** +```json +{ + "text": "Привет, что ты умеешь?", + "attachments": [] +} +``` + +**Response 200:** +```json +{ + "message_id": "msg_qwe012", + "response": "Я AI-агент Lambda...", + "tokens_used": 142, + "finished": true +} +``` + +--- + +## Settings + +### GET /users/{user_id}/settings + +Настройки пользователя: скиллы, коннекторы, SOUL, безопасность, план. + +**Response 200:** +```json +{ + "skills": {"web-search": true, "browser": false}, + "connectors": {"gmail": {"connected": true, "email": "user@gmail.com"}}, + "soul": {"name": "Лямбда", "style": "friendly"}, + "safety": {"email-send": true, "file-delete": true}, + "plan": {"name": "Beta", "tokens_used": 800, "tokens_limit": 1000} +} +``` + +### POST /users/{user_id}/settings + +Применяет действие над настройками. + +**Request:** +```json +{ + "action": "toggle_skill", + "payload": {"skill": "browser", "enabled": true} +} +``` + +**Response 200:** +```json +{"ok": true} +``` + +--- + +## Error format + +```json +{ + "error": "ERROR_CODE", + "message": "Human readable description", + "details": {} +} +``` + +Коды ошибок: `USER_NOT_FOUND`, `RATE_LIMITED`, `PLATFORM_ERROR`, `CONTAINER_UNAVAILABLE` + +--- + +## Открытые вопросы к команде платфрмы (SDK) + +- [ ] Точный формат эндпоинта отправки сообщения — URL, поля +- [ ] Как передавать вложения (файлы, изображения)? Через S3 pre-signed URL или напрямую? +- [ ] Стриминговый ответ (SSE / WebSocket) или только sync? +- [ ] Формат `SettingsAction` — совпадает с нашим или другой? diff --git a/docs/deploy-architecture.md b/docs/deploy-architecture.md deleted file mode 100644 index e838611..0000000 --- a/docs/deploy-architecture.md +++ /dev/null @@ -1,197 +0,0 @@ -# Deployment Architecture — Matrix Bot + Agents - -> Сформировано 2026-04-27 по итогам обсуждения с платформой. - ---- - -## Compose Artifacts - -- **Production deploy:** `docker-compose.prod.yml` - Bot-only handoff через published image (`SURFACES_BOT_IMAGE`). Поднимает только `matrix-bot`, монтирует shared volume в `/agents`. - Платформа предоставляет 25-30 agent containers/services отдельно; бот подключается к ним через `base_url` из `matrix-agents.yaml`. -- **Internal full-stack E2E:** `docker-compose.fullstack.yml` - Внутренний harness для тестирования. Локально собирает `matrix-bot` через Dockerfile target `development`, поднимает один `platform-agent`, health-gated startup. - -Production operators: `docker-compose.prod.yml`. Internal E2E: `docker-compose.fullstack.yml`. - ---- - -## Топология - -``` -lambda.coredump.ru -├── :7000 (reverse proxy, path-based routing) -│ ├── /agent_0/ → agent_0 container -│ ├── /agent_1/ → agent_1 container -│ └── /agent_N/ → agent_N container -│ -└── Matrix bot instance (один инстанс на всех) - └── volume /agents/ (shared с агентами) - ├── /agents/0/ ← workspace agent_0 - ├── /agents/1/ ← workspace agent_1 - └── /agents/N/ -``` - -- **Один инстанс Matrix-бота** обслуживает всех пользователей. -- **Один агент-контейнер на пользователя.** Production scale target: 25-30 внешних агентов. Изоляция по `agent_id`, не через один общий agent instance. -- **Shared volume** `/agents/` смонтирован в Matrix-бот. В internal full-stack harness тот же volume mounted as `/workspace` inside `platform-agent`, чтобы bot-side absolute paths и agent workspace относились к одному и тому же хранилищу. - ---- - -## Конфиг (два словаря) - -```yaml -# config/matrix-agents.yaml - -user_agents: - "@user0:matrix.lambda.coredump.ru": agent-0 - "@user1:matrix.lambda.coredump.ru": agent-1 - "@user2:matrix.lambda.coredump.ru": agent-2 - -agents: - - id: agent-0 - label: "Agent 0" - base_url: "http://lambda.coredump.ru:7000/agent_0/" - workspace_path: "/agents/0" - - - id: agent-1 - label: "Agent 1" - base_url: "http://lambda.coredump.ru:7000/agent_1/" - workspace_path: "/agents/1" - - - id: agent-2 - label: "Agent 2" - base_url: "http://lambda.coredump.ru:7000/agent_2/" - workspace_path: "/agents/2" -``` - -- `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент из списка. -- `agents[].base_url` — HTTP URL агент-эндпоинта. Бот подключается через AgentApi. -- `agents[].workspace_path` — абсолютный путь к воркспейсу агента **внутри контейнера бота** (т.е. на shared volume). - Бот сохраняет входящие файлы прямо в `{workspace_path}/`, читает исходящие из `{workspace_path}/`. -- Для 25-30 агентов продолжайте тот же паттерн до нужного номера: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`. - -## Surface Image Build Contract - -Production image содержит только Matrix surface. Он не содержит `platform-agent` и не требует локального `external/` build context. - -```bash -docker login -export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest - -docker build --target production \ - --build-arg LAMBDA_AGENT_API_REF=master \ - -t "$SURFACES_BOT_IMAGE" . -docker push "$SURFACES_BOT_IMAGE" -``` - -Published image: - -```text -mput1/surfaces-bot:latest -sha256:2f135f3535f7765d4377b440cdabe41195ad2efbc3e175def159ae4689ef90bd -``` - -`SURFACES_BOT_IMAGE` должен указывать на registry namespace, куда текущий Docker account может пушить. Ошибка `insufficient_scope` означает, что пользователь не залогинен в этот namespace, repository не создан, или у аккаунта нет push-доступа. - -Production Dockerfile ставит `platform/agent_api` из Git по тому же принципу, что и `platform-agent` production image: - -```bash -git+https://git.lambda.coredump.ru/platform/agent_api.git -``` - -Локальный `docker-compose.fullstack.yml` остаётся dev/E2E harness: он использует target `development` и `additional_contexts.agent_api=./external/platform-agent_api`, чтобы можно было тестировать surface вместе с локальным checkout SDK. - ---- - -## Agent API (используем master ветку `platform/agent_api`) - -```python -from lambda_agent_api.agent_api import AgentApi - -connected_agents: dict[tuple[str, int], AgentApi] = {} - -def on_agent_disconnect(agent: AgentApi): - connected_agents.pop((agent.id, agent.chat_id), None) - -async def on_message(matrix_user_id: str, matrix_room_id: str, text: str): - agent_id = get_agent_id_by_user(matrix_user_id) # из user_agents конфига - platform_chat_id = get_room_platform_chat_id(matrix_room_id) - - agent = connected_agents.get((agent_id, platform_chat_id)) - if not agent: - agent = AgentApi( - agent_id, - get_agent_base_url(agent_id), # ws://lambda.coredump.ru:7000/agent_0/ - on_disconnect=on_agent_disconnect, - chat_id=platform_chat_id, # отдельный thread на Matrix room - ) - await agent.connect() - connected_agents[(agent_id, platform_chat_id)] = agent - - async for event in agent.send_message(text): - ... -``` - -**Параметры конструктора (master):** -```python -AgentApi( - agent_id: str, - base_url: str, # ws://host:port/agent_N/ - chat_id: int = 0, # surfaces must supply per-room platform_chat_id - on_disconnect: callable, -) -``` - -**Lifecycle:** агент автоматически отключается после нескольких минут бездействия. -`on_disconnect` удаляет из пула → следующее сообщение создаёт новое соединение. - ---- - -## Передача файлов - -### Пользователь → Агент (входящий файл) - -1. Matrix-бот получает файл от пользователя -2. Сохраняет в workspace агента: `/agents/{N}/{filename}` -3. Если файл уже существует, выбирает следующее имя: `filename (1).ext`, `filename (2).ext` -4. Вызывает `agent.send_message(text, attachments=["filename"])` - — путь относительно `/workspace` агента - -### Агент → Пользователь (исходящий файл) - -1. Агент эмитит `MsgEventSendFile(path="report.pdf")` -2. Matrix-бот читает файл: `/agents/{N}/report.pdf` -3. Отправляет как Matrix file message пользователю - -**Ключевое:** production handoff через `docker-compose.prod.yml` и internal E2E через `docker-compose.fullstack.yml` используют один и тот же `/agents` contract на стороне поверхности. Прямой HTTP-доступ к файлам не нужен. - ---- - -## Текущее состояние platform-agent (main) - -- Composio интегрирован в main (`#9-интеграция-composIO`) -- Агент требует в `.env`: `AGENT_ID`, `COMPOSIO_API_KEY` -- Backend: `IsolatedShellBackend` (main) / `CompositeBackend` (ветка `#19`, не merged) -- Memory: `MemorySaver` — история слетает при рестарте контейнера (known limitation) - ---- - -## platform-master (будущее, пока не используем) - -Ветка `feat/storage` реализует реальный Master-сервис: -- `POST /api/v1/create {chat_id}` → поднимает/переиспользует sandbox-контейнер -- TTL-based lifecycle (300с default, конфигурируемо) -- `ChatStorage` — API для upload/download файлов через Master -- Auth + p2p lease — вне текущего scope MVP - -**Для деплоя MVP используем статический конфиг без Master.** -При готовности Master: `get_agent_url()` будет вызывать `POST /api/v1/create`, URL возвращается в ответе. - ---- - -## Открытые вопросы - -- `platform-agent_api #9-clientside-tool-call` убирает `attachments` и `MsgEventSendFile` — пока используем master. Уточнить у платформы сроки мержа перед деплоем. -- История при рестарте агента теряется — `platform-agent` использует `MemorySaver` (in-memory). Ограничение платформы. -- `AGENT_ID` и `COMPOSIO_API_KEY` для каждого агент-контейнера — значения предоставляет платформа. diff --git a/docs/known-limitations.md b/docs/known-limitations.md deleted file mode 100644 index e98f0ba..0000000 --- a/docs/known-limitations.md +++ /dev/null @@ -1,51 +0,0 @@ -# Known Limitations - -## Telegram — Threaded Mode (Bot API 9.3+) - -Threaded Mode — относительно новая фича Bot API. Ряд ограничений связан с незрелостью клиентов Telegram, а не с нашим кодом. - -### Telegram Mac клиент - -- Новые топики, созданные ботом через `/new`, не появляются в сайдбаре сразу. - Топики существуют на сервере и доступны на мобильном клиенте — это баг Mac клиента. - -### Bot API — управление топиками - -- `closeForumTopic` и аналогичные методы работают только для supergroup-форумов. - В Threaded Mode личного чата эти вызовы возвращают `"the chat is not a supergroup forum"`. -- `deleteForumTopic` работает на мобильных клиентах, поведение на Mac непоследовательно. -- Топики, созданные ботом через API (`/new`), пользователь не может удалить через Mac UI - (только через мобильный клиент). Бот пытается удалить топик сам при `/archive`. - -### После удаления топика - -- Когда все топики удалены, Telegram показывает кнопку Start как при первом запуске. - Это стандартное поведение Telegram, не баг бота. - -### История чатов - -- При пересоздании базы данных (`lambda_bot.db`) старые топики в Telegram остаются. - История сообщений в Telegram не удаляется при сбросе БД бота. - ---- - -*Все перечисленные ограничения — на стороне платформы Telegram. Решение: принято, движемся дальше.* - -## Matrix - -### Незашифрованные комнаты только - -- Текущая Matrix-реализация в этом репозитории тестируется только в незашифрованных комнатах. - Encrypted DM и encrypted rooms пока не поддержаны. - -### Зависимость от локального состояния - -- Бот хранит локальный маппинг `chat_id ↔ room_id`. - Если удалить `lambda_matrix.db` или `matrix_store/`, старые комнаты в Matrix останутся, - но `!rename` и `!archive` для них больше не смогут отработать как для зарегистрированных чатов. - -### Поведение после рестарта - -- При старте бот делает bootstrap sync и продолжает `sync_forever()` с `since`. - Это снижает риск повторной обработки старой timeline, но означает, что рестарт не предназначен - для ретро-обработки уже существующих исторических сообщений. diff --git a/docs/matrix-direct-agent-prototype-ru.md b/docs/matrix-direct-agent-prototype-ru.md deleted file mode 100644 index 2367dc5..0000000 --- a/docs/matrix-direct-agent-prototype-ru.md +++ /dev/null @@ -1,301 +0,0 @@ -# Matrix Direct-Agent Prototype - -> **ВНИМАНИЕ: Это исторический документ.** -> Описанный здесь прототип был интегрирован в `main` и стал основой для Matrix MVP, но архитектура претерпела значительные изменения. Для актуальной информации по деплою смотрите `docs/deploy-architecture.md`, а для создания новой поверхности — `docs/max-surface-guide.md`. - -Русскоязычная заметка по прототипу Matrix surface, который ходит не в `MockPlatformClient`, а напрямую в живой agent backend по WebSocket. - -## Что сделали - -В этой ветке собран рабочий Matrix-only прототип с минимальным вмешательством в существующую архитектуру. - -Ключевая идея: -- Matrix-адаптер и `core/` остаются на старом контракте `PlatformClient` -- вместо `sdk/mock.py` можно включить `sdk/real.py` -- `sdk/real.py` внутри разделяет две ответственности: - - `sdk/agent_session.py` — прямое общение с agent по WebSocket - - `sdk/prototype_state.py` — локальный user/settings state для прототипа - -Это позволило не переписывать Matrix-логику под нестабильный `platform/master` и при этом подключить живого агента вместо мока. - -## Что поменялось в `surfaces-bot` - -Добавлено: -- `sdk/agent_session.py` -- `sdk/prototype_state.py` -- `sdk/real.py` -- тесты для transport/state/real backend - -Изменено: -- `adapter/matrix/bot.py` -- `adapter/matrix/handlers/auth.py` -- `README.md` -- интеграционные и Matrix dispatcher тесты - -Функционально это дало: -- переключение Matrix backend через env: - - `MATRIX_PLATFORM_BACKEND=mock` - - `MATRIX_PLATFORM_BACKEND=real` -- прямую отправку текста в live agent через `AGENT_BASE_URL` -- локальное хранение settings и user mapping -- изоляцию backend memory по `thread_id` -- исправление повторных invite: бот теперь сначала `join()`, а уже потом решает, нужно ли пере-провиженить Space/chat tree - -## Что поменяли в `platform-agent` - -Для прототипа потребовался минимальный локальный патч в клонированном `external/platform-agent`. - -Изменения: -- `src/api/external.py` -- `src/agent/service.py` - -Смысл патча: -- agent больше не использует один общий hardcoded `thread_id="default"` -- `thread_id` читается из query parameter WebSocket-соединения -- дальше этот `thread_id` передаётся в config memory/checkpointer - -Локальный commit в clone: -- `1dca2c1` — `feat: support websocket thread ids` - -Важно: -- этот commit живёт в `external/platform-agent` -- он не входит в git-историю `surfaces-bot` -- если прототип должен запускаться у других людей без ручных патчей, этот commit надо отдельно запушить или повторить в platform repo - -## Текущая архитектура прототипа - -Поток сообщения сейчас такой: - -1. Matrix room event попадает в `adapter/matrix` -2. адаптер переводит его в `IncomingMessage` / `IncomingCommand` -3. `EventDispatcher` вызывает handler из `core/` -4. handler вызывает `PlatformClient` -5. при real backend это `RealPlatformClient` -6. `RealPlatformClient` строит `thread_key` -7. `AgentSessionClient` открывает WebSocket на `agent_ws/?thread_id=...` -8. ответ агента возвращается обратно в Matrix - -Что остаётся локальным в v1: -- `!settings` -- `!skills` -- `!soul` -- `!safety` -- user registration mapping - -Что реально идёт в живого агента: -- обычные текстовые сообщения -- память по чатам через `thread_id` - -## Ограничения прототипа - -Сейчас это не полный platform integration, а рабочий direct-agent prototype. - -Ограничения: -- только текстовый чат -- без attachments в agent -- без async task callbacks/webhooks -- без реального control-plane из `platform/master` -- encrypted Matrix rooms пока не поддержаны -- repeat invite не создаёт новую Space-структуру, если user уже был провиженен локально -- backend/provider ошибки пока не везде деградируют в user-facing reply; часть ошибок всё ещё может уронить процесс surface - -## Как запускать - -Нужно поднять два процесса: -- patched `platform-agent` -- Matrix bot из `surfaces-bot` - -### 1. Подготовить `platform-agent` - -Локальный clone: -- [external/platform-agent](/Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent) - -И связанный SDK clone: -- [external/platform-agent_api](/Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent_api) - -Первичная подготовка: - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent -uv sync -uv pip install --python .venv/bin/python -e ../platform-agent_api -``` - -Если у вас был активирован чужой venv, сначала сделайте: - -```bash -deactivate -``` - -Иначе `uv pip install` может поставить пакет не в тот interpreter. - -### 2. Запустить agent backend - -Пример с OpenRouter: - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent - -export PROVIDER_URL=https://openrouter.ai/api/v1 -export PROVIDER_API_KEY='YOUR_OPENROUTER_KEY' -export PROVIDER_MODEL='qwen/qwen3.5-122b-a10b' - -uv run uvicorn src.main:app --host 0.0.0.0 --port 8000 -``` - -После этого WebSocket endpoint должен быть доступен по: - -```text -ws://127.0.0.1:8000/agent_ws/ -``` - -### 3. Запустить Matrix bot - -В отдельном терминале: - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot - -export MATRIX_PLATFORM_BACKEND=real -export AGENT_BASE_URL=http://127.0.0.1:8000 -export MATRIX_HOMESERVER=https://matrix.lambda.coredump.ru -export MATRIX_USER_ID=@lambda_surface_test_bot:matrix.lambda.coredump.ru -export MATRIX_PASSWORD='YOUR_PASSWORD' - -PYTHONPATH=. uv run python -m adapter.matrix.bot -``` - -Если всё ок, в логах будет что-то вроде: - -```text -Matrix bot starting ... -``` - -## Точные команды - -Ниже команды в том виде, в котором реально поднимался рабочий прототип. - -### Platform / agent backend - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent -deactivate 2>/dev/null || true -uv sync -uv pip install --python .venv/bin/python -e ../platform-agent_api - -export PROVIDER_URL=https://openrouter.ai/api/v1 -export PROVIDER_API_KEY='YOUR_OPENROUTER_KEY' -export PROVIDER_MODEL='qwen/qwen3.5-122b-a10b' - -uv run uvicorn src.main:app --host 0.0.0.0 --port 8000 -``` - -### Matrix bot - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot - -export MATRIX_PLATFORM_BACKEND=real -export AGENT_BASE_URL=http://127.0.0.1:8000 -export MATRIX_HOMESERVER=https://matrix.lambda.coredump.ru -export MATRIX_USER_ID=@lambda_surface_test_bot:matrix.lambda.coredump.ru -export MATRIX_PASSWORD='YOUR_PASSWORD' - -PYTHONPATH=. uv run python -m adapter.matrix.bot -``` - -### Перезапуск Matrix state с нуля - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot -rm -f lambda_matrix.db -rm -rf matrix_store -PYTHONPATH=. uv run python -m adapter.matrix.bot -``` - -## Smoke test - -Рекомендуемый сценарий ручной проверки: - -1. Пригласить бота в fresh unencrypted room -2. Дождаться join -3. Если это первый invite для данного локального state: - - бот создаст private Space - - бот создаст room `Чат 1` -4. Открыть `Чат 1` -5. Отправить `!start` -6. Отправить обычное текстовое сообщение -7. Проверить, что ответ пришёл от live backend, а не от `[MOCK]` -8. Проверить `!new` -9. Проверить, что память разделяется между чатами - -Если бот уже был однажды провиженен и локальный state не очищался: -- повторный invite не создаст новую Space-структуру -- бот просто зайдёт в room и будет отвечать там - -Это нормальное поведение текущей реализации. - -## Сброс локального Matrix state - -Если нужно повторно проверить именно first-invite provisioning: - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot -rm -f lambda_matrix.db -rm -rf matrix_store -PYTHONPATH=. uv run python -m adapter.matrix.bot -``` - -После этого можно снова приглашать бота как "с нуля". - -## Частые проблемы - -### 1. `ModuleNotFoundError: lambda_agent_api` - -Значит `platform-agent_api` не установлен в `.venv` агента. - -Исправление: - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent -uv pip install --python .venv/bin/python -e ../platform-agent_api -``` - -### 2. `CERTIFICATE_VERIFY_FAILED` при запуске Matrix bot - -Это не ошибка surface logic. Это TLS trust problem до Matrix homeserver. - -Нужно: -- либо установить системные/Python certificates -- либо передать корпоративный CA через `SSL_CERT_FILE` - -### 3. Бот заходит в room, но не создаёт новую Space - -Скорее всего user уже есть в локальном state. - -Варианты: -- это ожидаемо для repeat invite -- либо очистить `lambda_matrix.db` и `matrix_store` - -### 4. Бот падает после message send - -Значит backend/provider вернул ошибку, которая ещё не была деградирована в user-facing ответ. - -Пример уже встречавшегося кейса: -- неверный model id -- key не имеет доступа к model - -Сначала проверяйте: -- `PROVIDER_URL` -- `PROVIDER_MODEL` -- `PROVIDER_API_KEY` - -## Полезные ссылки внутри repo - -- [README.md](/Users/a/MAI/sem2/lambda/surfaces-bot/README.md) -- [adapter/matrix/bot.py](/Users/a/MAI/sem2/lambda/surfaces-bot/adapter/matrix/bot.py) -- [sdk/agent_session.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_session.py) -- [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py) -- [sdk/prototype_state.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/prototype_state.py) -- [2026-04-08-matrix-direct-agent-prototype-design.md](/Users/a/MAI/sem2/lambda/surfaces-bot/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md) -- [2026-04-08-matrix-direct-agent-prototype.md](/Users/a/MAI/sem2/lambda/surfaces-bot/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md) diff --git a/docs/matrix-prototype.md b/docs/matrix-prototype.md index d79ff83..5e57c88 100644 --- a/docs/matrix-prototype.md +++ b/docs/matrix-prototype.md @@ -2,103 +2,247 @@ ## Концепция -Один бот, каждый чат — отдельная комната, все комнаты собраны в personal Space. +Один бот, каждый чат — отдельная комната, все комнаты собраны в Space. -При первом invite бот создаёт для пользователя личное пространство (Space) и первую рабочую комнату. -История хранится нативно в Matrix. UX прагматичный: явные `!`-команды, локальный state-store, нативные Matrix rooms. +При первом входе бот создаёт для пользователя личное пространство (Space) — +это как папка в Element. Внутри Space бот создаёт комнату для каждого нового +чата с агентом. Пользователь видит аккуратную структуру: одно пространство, +внутри — список чатов. История хранится нативно в Matrix — это часть протокола, +ничего дополнительно делать не нужно. -Matrix — внутренняя поверхность: команда лаборатории, тестировщики, разработчики скиллов. +Matrix выбран как внутренняя поверхность: команды лаборатории, тестировщики, +разработчики скиллов. Поэтому UX здесь — про удобство работы, а не онбординг. --- -## Онбординг +## Аутентификация -1. Пользователь приглашает бота в личные сообщения (DM) на Matrix-сервере -2. Бот принимает invite, создаёт Space `Lambda — {display_name}` и первую комнату `Чат 1` -3. Приглашает пользователя в `Чат 1` и пишет приветствие -4. Дальнейшее общение ведётся в рабочих комнатах, не в DM +### Флоу +1. Пользователь приглашает бота в личные сообщения или пишет в общей комнате +2. Бот проверяет `@user:matrix.org` — есть ли аккаунт на платформе +3. Если нет — бот отправляет одноразовый код или ссылку +4. Пользователь подтверждает, платформа возвращает токен +5. Бот сохраняет привязку `matrix_user_id → platform_user_id` +### В моке +- Любой пользователь проходит аутентификацию автоматически +- Бот отвечает: «Добро пожаловать, {display_name}. Создаю ваше пространство...» +- Демонстрирует флоу без реальной платформы + +--- + +## Чаты через Space + комнаты (вариант Б) + +### Структура ``` Space: «Lambda — {display_name}» - ├── 💬 Чат 1 ← создаётся автоматически при invite + ├── 📌 Настройки ← специальная комната для команд управления + ├── 💬 Чат 1 ← первый чат, создаётся автоматически ├── 💬 Чат 2 - └── 💬 Исследование рынка ← пользователь называет сам через !new + └── 💬 Исследование рынка ← пользователь сам называет ``` -**Требование:** незашифрованные комнаты. E2EE не поддержан (инфраструктурное ограничение). - ---- - -## Работающие команды +### Создание Space +При первом входе бот: +1. Создаёт Space `Lambda — {display_name}` +2. Создаёт комнату `Настройки` (закреплена вверху) +3. Создаёт первую комнату-чат `Чат 1` +4. Приглашает пользователя во все комнаты +5. Пишет в `Чат 1` приветствие ### Управление чатами +Команды работают в любой комнате Space: | Команда | Действие | |---|---| | `!new` | Создать новый чат (новую комнату в Space) | | `!new Название` | Создать чат с именем | -| `!chats` | Список активных чатов | -| `!rename <название>` | Переименовать текущую комнату | -| `!archive` | Архивировать чат | -| `!help` | Справка | +| `!rename Название` | Переименовать текущую комнату | +| `!archive` | Вывести комнату из Space (не удалять) | +| `!chats` | Показать список чатов | -### Контекст +### Создание нового чата +1. Пользователь пишет `!new` или `!new Анализ конкурентов` +2. Бот создаёт новую комнату в Space +3. Приглашает пользователя +4. Пишет приветствие; при первом сообщении платформа автоматически поднимает контейнер +5. Пользователь переходит в новую комнату — начинает диалог -| Команда | Действие | -|---|---| -| `!clear` | Сбросить контекст текущего чата (создаёт новый thread у агента) | -| `!reset` | Псевдоним для `!clear` | - -### Подтверждения - -| Команда | Действие | -|---|---| -| `!yes` | Подтвердить действие агента | -| `!no` | Отменить действие агента | - -### Вложения (файловая очередь) - -Matrix-клиенты отправляют файлы и текст отдельными событиями. Файл без текстовой инструкции ставится в очередь, а не уходит агенту сразу. - -| Команда | Действие | -|---|---| -| `!list` | Показать файлы в очереди | -| `!remove ` | Удалить файл из очереди по номеру | -| `!remove all` | Очистить всю очередь | - -Как отправить файлы агенту: -1. Отправь один или несколько файлов в рабочую комнату -2. Напиши текстовое сообщение с инструкцией, например: `что на изображении?` -3. Бот отправит агенту текст вместе со всеми файлами из очереди +### В моке +- Space и комнаты создаются реально через matrix-nio +- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...) +- История хранится в Matrix нативно --- -## Диалог +## Основной диалог -- Любое текстовое сообщение уходит агенту, бот показывает typing-индикатор -- Ответ стримится по WebSocket и выводится в ту же комнату -- Каждая комната имеет свой `platform_chat_id` — контексты изолированы между комнатами +### Флоу сообщения +1. Пользователь пишет текст в комнату-чат +2. Бот показывает typing (m.typing event) +3. Запрос уходит в платформу (MockPlatformClient) +4. Бот отвечает в той же комнате + +### Вложения +- Файлы, изображения отправляются как Matrix media events +- Бот принимает `m.file`, `m.image`, `m.audio` +- Передаёт в платформу как `attachments` через `IncomingMessage` +- В моке: подтверждение получения + заглушка-ответ + +### Реакции как действия +Matrix поддерживает реакции на сообщения (`m.reaction`). +Используем это для подтверждения действий агента: + +``` +Агент: Хочу отправить письмо на vasya@mail.ru + Тема: «Отчёт за неделю» + + 👍 — подтвердить ❌ — отменить +``` + +Пользователь ставит реакцию — бот обрабатывает. Нативно и удобно. + +### Треды для длинных задач +Если агент выполняет долгую задачу (deep research, генерация документа), +бот создаёт тред от своего первого ответа и пишет промежуточные статусы туда. +Основной чат не засоряется. + +``` +Бот: Начинаю исследование по теме «AI агенты 2025» [→ в треде] + └── Ищу источники... (1/4) + └── Анализирую статьи... (2/4) + └── Формирую отчёт... (3/4) + └── Готово. Отчёт: [...] +``` --- -## Передача файлов +## Комната «Настройки» -### Пользователь → Агент -Бот сохраняет файл в shared volume: `{workspace_path}/{filename}` -и передаёт агенту относительный путь как `workspace_path`. +Специальная комната для управления агентом. Закреплена вверху Space. +Команды работают только здесь — не мешают диалогу в чатах. -### Агент → Пользователь -Агент эмитит путь к файлу в своём workspace. Бот читает файл из `/agents/...` -и отправляет пользователю как Matrix file message. +### Коннекторы +``` +!connectors — показать список +!connect gmail — подключить Gmail (OAuth ссылка) +!connect github — подключить GitHub +!connect calendar — подключить Google Calendar +!connect notion — подключить Notion +!disconnect gmail — отключить +``` + +Статус: +``` +Коннекторы: + ✅ Gmail — подключён (user@gmail.com) + ❌ GitHub — не подключён → !connect github + ❌ Google Calendar — не подключён + ❌ Notion — не подключён +``` + +В моке: OAuth ссылка-заглушка → «Подключено ✓» + +### Скиллы +``` +!skills — показать список +!skill on browser — включить Browser Use +!skill off browser — выключить +``` + +Статус: +``` +Скиллы: + ✅ web-search — поиск в интернете + ✅ fetch-url — чтение веб-страниц + ✅ email — чтение почты (требует Gmail) + ❌ browser — управление браузером + ❌ image-gen — генерация изображений + ❌ video-gen — генерация видео + ✅ files — работа с файлами + ❌ calendar — календарь (требует Google Calendar) +``` + +В моке: состояние хранится локально. + +### Личность агента +``` +!soul — показать текущий SOUL.md +!soul name Лямбда — задать имя агента +!soul style brief — стиль: brief | friendly | formal +!soul priority «разбирать почту утром» — приоритетная задача +!soul reset — сбросить к дефолту +``` + +В моке: SOUL.md генерируется и хранится локально, агент обращается по имени. + +### Безопасность +``` +!safety — показать настройки +!safety on email-send — требовать подтверждение перед отправкой письма +!safety off calendar-create — не спрашивать для создания событий +``` + +Статус: +``` +Подтверждение требуется для: + ✅ отправка письма + ✅ удаление файлов + ✅ публикация в соцсетях + ❌ создание события в календаре + ❌ поиск в интернете +``` + +### Подписка +``` +!plan — показать текущий план +``` + +``` +Подписка: Beta (бесплатно) +Токены этот месяц: 800 / 1000 +━━━━━━━━░░ 80% +``` + +Заглушка, реализует другая команда. + +### Статус и диагностика +``` +!status — состояние платформы и чатов +!whoami — текущий аккаунт платформы +``` + +``` +Статус: + Платформа: ✅ доступна + Аккаунт: user@lambda.lab + Активных чатов: 3 +``` --- -## Известные ограничения +## FSM состояния -| Проблема | Причина | -|---|---| -| `!save` / `!load` / `!context` | Нестабильны: `!save` зависит от агента (пишет файл в workspace), сессии хранятся in-memory и теряются при рестарте | -| Первый чанк ответа иногда пропадает после tool/file flow | Баг в upstream `platform-agent`; подробности: `docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md` | -| Персистентность истории между рестартами агента | `platform-agent` использует `MemorySaver` (in-memory) | -| E2EE комнаты | `python-olm` не собирается на macOS/ARM | -| `!settings` и настройки скиллов/SOUL/безопасности | Заглушки MVP, требуют готового SDK платформы | +``` +[Invite] → AuthPending → AuthConfirmed + ↓ + SpaceSetup → Idle (в комнате Настройки) + ↓ + [новая комната] → ChatCreated → Idle (в чате) + ↓ + ReceivingMessage → WaitingResponse → Idle + ↓ + WaitingReaction (confirm) → [✅/❌] → Idle + ↓ + LongTask → [тред со статусами] → Done → Idle +``` + +--- + +## Стек + +- Python 3.11+ +- matrix-nio (async) — Matrix клиент +- MockPlatformClient → `platform/interface.py` +- structlog для логирования +- SQLite для хранения `matrix_user_id → platform_user_id`, состояния скиллов, маппинга `chat_id → room_id` diff --git a/docs/new-surface-guide.md b/docs/new-surface-guide.md deleted file mode 100644 index 7ebdc2a..0000000 --- a/docs/new-surface-guide.md +++ /dev/null @@ -1,313 +0,0 @@ -# Руководство по созданию новой поверхности - -Этот документ описывает, как написать новую новую поверхность (например, Discord, Slack или Custom Web) по образцу текущей Matrix-поверхности в ветке `main`. - -Он основан на актуальной реализации Matrix surface в репозитории и отражает текущую продакшн-логику, а не устаревший легаси. - ---- - -## 1. Общая архитектура - -### 1.1. Что такое поверхность - -Поверхность — это тонкий адаптер между конкретной платформой (Платформа) и общим ядром бота. - -В репозитории есть разделение: - -- `core/` — общее ядро и бизнес-логика -- `adapter//` — реализация конкретной поверхности -- `sdk/real.py` — работа с реальной платформой / агентом -- `config/` — статическая конфигурация агентов -- `docs/surface-protocol.md` — общий контракт поверхностей - -### 1.2. Как это работает - -Поверхность должна: - -- принимать нативные события от Платформа -- преобразовывать их в единый внутренний контракт (`IncomingMessage`, `IncomingCommand`, `IncomingCallback`) -- передавать их в `core` -- получать ответы из `core` (`OutgoingMessage`, `OutgoingUI`, `OutgoingTyping`, `OutgoingNotification`) -- преобразовывать ответы обратно в нативные нативные сообщения - -Поверхность не должна: - -- управлять жизненным циклом агентских контейнеров -- хранить долгую историю бесед вне `core`/платформы -- аутентифицировать пользователей сама (если это не часть Платформа API) - ---- - -## 2. Структура новой поверхности - -### 2.1. Основные каталоги - -Рекомендуемая структура для новой платформы: - -``` -adapter// - bot.py - converter.py - agent_registry.py - files.py - handlers/ - store.py -``` - -### 2.2. Принцип reuse - -По примеру Matrix surface, New surface должен переиспользовать общий `core` и общий `sdk`. - -Не дублируйте бизнес-логику, а реализуйте только адаптер: - -- `adapter//converter.py` — конвертация событий платформы ⇄ внутренние структуры -- `adapter//bot.py` — основной runtime, старт Платформа client, loop, отправка/прием -- `adapter//agent_registry.py` — загрузка `config/-agents.yaml` -- `adapter//files.py` — хранение входящих/исходящих вложений - ---- - -## 3. Контракт входящих/исходящих событий - -### 3.1. Внутренний формат - -Смотрите `core/protocol.py`. Основные типы: - -- `IncomingMessage` — обычное текстовое сообщение + вложения -- `IncomingCommand` — управляющая команда -- `IncomingCallback` — подтверждение / интерактивные действия -- `OutgoingMessage` — ответ пользователю -- `OutgoingUI` — интерфейсные элементы (кнопки и т.п.) -- `OutgoingTyping` — индикатор печати -- `OutgoingNotification` — системное уведомление - -### 3.2. Пример конверсии Matrix - -В Matrix-реализации `adapter/matrix/converter.py`: - -- текст `!yes` / `!no` превращается в `IncomingCallback` с `action: confirm/cancel` -- `!list`/`!remove` говорят не агенту, а surface-процессу -- вложения `m.file`, `m.image`, `m.audio`, `m.video` нормализуются в `Attachment` - -Для Платформа реализуйте аналогичную логику для native команд вашего клиента. - ---- - -## 4. Реестр агентов и маршрутизация - -### 4.1. Что хранит реестр - -В текущей Matrix реализации есть `config/matrix-agents.yaml` и `adapter/matrix/agent_registry.py`. - -Структура: - -```yaml -user_agents: - "@user0:matrix.example.org": agent-0 - "@user1:matrix.example.org": agent-1 - -agents: - - id: agent-0 - label: "Agent 0" - base_url: "http://lambda.coredump.ru:7000/agent_0/" - workspace_path: "/agents/0" -``` - -### 4.2. Логика выбора агента - -- `user_agents` маппит конкретного пользователя на `agent_id` -- если user_id не найден, используется первый агент из списка -- `agents[].base_url` определяет URL агента -- `agents[].workspace_path` определяет путь внутри surface-контейнера для этого агента - -Это важно: именно на этом контракте строится разделение агентов по рабочим каталогам. - -### 4.3. Рекомендуемая Версия для новой платформы - -Создайте `config/-agents.yaml` с тем же смыслом. - -- `user_agents` — маппинг external user_id → agent_id -- `agents` — список агентов -- `workspace_path` для каждого агента должен быть абсолютным путем внутри surface-контейнера, например `/agents/0` - ---- - -## 5. Файловый контракт - -### 5.1. Shared volume - -Текущее Matrix-решение использует shared volume: - -- surface монтирует общий том как `/agents` -- каждый агент видит свою поддиректорию как `/workspace` - -Топология: - -``` -Bot (/agents) Agent (/workspace = /agents/N/) - /agents/0/report.pdf ←──→ /workspace/report.pdf -``` - -### 5.2. Правила записи файлов - -В `adapter/matrix/files.py` реализовано: - -- входящий файл сохраняется прямо в `{workspace_root}/{filename}` -- возвращается путь `workspace_path` относительный внутри рабочего каталога агента -- при коллизии имен создаётся `file (1).ext`, `file (2).ext` -- `Attachment.workspace_path` передаётся агенту - -Для исходящих файлов: - -- surface читает файл из `workspace_root / workspace_path` -- загружает его в платформу - -### 5.3. Пример поведения - -- Пользователь отправляет файл → surface скачивает файл и кладёт его в agent workspace -- Агент получает `attachments=["report.pdf"]` и работает с относительным `workspace_path` -- Агент пишет результат в `/workspace/result.txt` -- surface читает `/agents/{N}/result.txt` и отправляет файл пользователю - ---- - -## 6. Чат-менеджмент и контекст - -### 6.1. `platform_chat_id` - -Matrix-реализация использует `platform_chat_id` как стабильный идентификатор чата на стороне агента. - -- `room_meta.platform_chat_id` определяется и сохраняется в `adapter/matrix/store.py` -- `reconcile_startup_state()` восстанавливает отсутствующие `platform_chat_id` при рестарте -- `RoutedPlatformClient` перенаправляет запросы агенту по `agent_id` + `platform_chat_id` - -Для New surface тот же принцип: - -- каждая внешняя беседа должна привязываться к одному внутреннему `chat_id` -- этот `chat_id` используется для вызовов агента -- если в Платформа есть несколько комнат/топиков, каждая должна иметь свой `surface_ref` - -### 6.2. Команды управления чатами - -Matrix поддерживает следующие команды, которые нужно сохранить в Платформа: - -- `!new [название]` — создать новый чат -- `!chats` — список активных чатов -- `!rename <название>` — переименовать текущий чат -- `!archive` — архивировать чат -- `!clear` / `!reset` — сбросить контекст текущего чата -- `!yes` / `!no` — подтвердить или отменить действие агента -- `!list` — показать очередь вложений -- `!remove ` / `!remove all` — удалить вложение из очереди -- `!help` — справка - -Эти команды реализованы в Matrix через `adapter/matrix/handlers/`. - -### 6.3. Очередь вложений - -Matrix surface поддерживает staged attachments: - -- файл может быть отправлен без текста -- surface сохраняет файл в `staged_attachments` для конкретного room_id + user_id -- следующий текст отправляется агенту вместе со всеми файлами из очереди - -В Платформа можно реализовать ту же модель: - -- `!list` показывает текущую очередь -- `!remove` удаляет файл из очереди -- команда-индикатор или следующее текстовое сообщение отправляет queued attachments агенту - ---- - -## 7. Runtime и окружение - -### 7.1. Переменные среды - -Для Matrix surface текущий runtime ожидает: - -- `MATRIX_HOMESERVER` — URL Matrix-сервера -- `MATRIX_USER_ID` — `@bot:example.org` -- `MATRIX_PASSWORD` или `MATRIX_ACCESS_TOKEN` -- `MATRIX_PLATFORM_BACKEND` — должно быть `real` для продакшна -- `MATRIX_AGENT_REGISTRY_PATH` — путь к `config/matrix-agents.yaml` -- `AGENT_BASE_URL` — fallback URL агента -- `SURFACES_WORKSPACE_DIR` — путь к shared volume внутри контейнера (по умолчанию `/workspace` в коде, но в docs рекомендуют `/agents`) - -Для New surface используйте аналогичные переменные: - -- `PLATFORM_PLATFORM_BACKEND=real` -- `PLATFORM_AGENT_REGISTRY_PATH=/app/config/-agents.yaml` -- `SURFACES_WORKSPACE_DIR=/agents` -- `AGENT_BASE_URL` — если хотите общий fallback - -### 7.2. Environment contract - -В коде `adapter/matrix/bot.py`: - -- `_agent_base_url_from_env()` читает `AGENT_BASE_URL` или `AGENT_WS_URL` -- `_load_agent_registry_from_env()` читает `MATRIX_AGENT_REGISTRY_PATH` -- `_build_platform_from_env()` выбирает `RealPlatformClient` при `MATRIX_PLATFORM_BACKEND=real` - -В New surface реализуйте ту же логику, заменив префиксы на `PLATFORM_`. - ---- - -## 8. Локальное тестирование - -Для тестирования новой поверхности вместе с одним локальным агентом используйте паттерн `docker-compose.fullstack.yml`. -В этом режиме: -- Запускается 1 контейнер вашей поверхности -- Запускается 1 контейнер `platform-agent` -- Поднимается локальный shared volume (`surfaces-agents`) -- Поверхность настроена маршрутизировать запросы на `http://platform-agent:8000` (через `AGENT_BASE_URL`) -- Пользователь общается с ботом, а бот напрямую общается с локальным агентом, разделяя с ним общую папку для файлов. - -Это самый быстрый способ проверить интеграцию новой платформы без внешнего бэкенда. - ---- - -## 9. Реализация шаг за шагом - -1. Скопировать `adapter/matrix/` как шаблон для `adapter//`. -2. Сделать `adapter//converter.py`: - - превратить native нативные сообщения в `IncomingMessage` - - превратить команды в `IncomingCommand` - - превратить yes/no-подтверждения в `IncomingCallback` -3. Сделать `adapter//agent_registry.py` на основе `adapter/matrix/agent_registry.py`. -4. Сделать `adapter//files.py` на основе `adapter/matrix/files.py`. -5. Сделать `adapter//bot.py`: - - инстанцировать runtime - - читать env vars `PLATFORM_*` - - загружать реестр агентов - - обрабатывать входящие события - - отправлять `Outgoing*` обратно в Платформа -6. Реализовать команды управления чатами и очередь вложений. -7. Прописать `config/-agents.yaml`. -8. Прописать `docker-compose.platform.yml` или аналог, чтобы surface монтировал `/agents`. -9. Написать тесты по аналогии с `tests/adapter/matrix/`. -10. Проверить, что все env vars читаются из окружения и не зависят от устаревших Matrix-переменных. - ---- - -## 10. Важные замечания - -- Текущий Matrix surface на ветке `main` — активная реализация, а не устаревший легаси. -- Документация и код согласованы: `agent_registry`, `files`, `routed_platform`, `reconciliation` работают вместе. -- Обязательно явно задавайте `SURFACES_WORKSPACE_DIR=/agents` в production, если `workspace_path` в реестре указывает на `/agents/*`. -- Для New surface сохраните ту же архитектуру: surface = thin adapter, агенты = внешние сервисы. -- Не пытайтесь в surface реализовывать логику запуска/стопа агент-контейнеров. - ---- - -## 11. Полезные ссылки внутри репозитория - -- `README.md` -- `docs/deploy-architecture.md` -- `docs/surface-protocol.md` -- `adapter/matrix/bot.py` -- `adapter/matrix/converter.py` -- `adapter/matrix/agent_registry.py` -- `adapter/matrix/files.py` -- `adapter/matrix/routed_platform.py` -- `adapter/matrix/reconciliation.py` -- `tests/adapter/matrix/` diff --git a/docs/reports/2026-04-01-final-report.md b/docs/reports/2026-04-01-final-report.md deleted file mode 100644 index 8298931..0000000 --- a/docs/reports/2026-04-01-final-report.md +++ /dev/null @@ -1,280 +0,0 @@ -# Отчёт о проделанной работе — Surfaces Team - -**Проект:** Lambda Lab 3.0 — Surfaces -**Дата:** 2026-04-01 -**Период:** 2026-03-28 — 2026-04-01 - ---- - -## 1. Цель этапа - -Собрать работоспособный прототип двух поверхностей для взаимодействия пользователя с AI-агентом Lambda: - -- **Telegram-бот** — основная пользовательская поверхность -- **Matrix-бот** — альтернативная децентрализованная поверхность - -Ключевое требование: не ждать готовности платформенного SDK, а двигаться вперёд через собственный контракт и мок-реализацию. Это позволило вести параллельную разработку UX, архитектуры и интеграции без блокировки на внешние зависимости. - ---- - -## 2. Архитектура - -### 2.1. Общее ядро (`core/`) - -Выделен независимый от транспорта слой, используемый обеими поверхностями: - -| Компонент | Файл | Назначение | -|-----------|------|-----------| -| Протокол событий | `core/protocol.py` | `IncomingMessage`, `OutgoingMessage`, `OutgoingUI` и др. | -| Диспетчер | `core/handler.py` | `EventDispatcher`: маршрутизация событий → обработчики | -| Обработчики | `core/handlers/` | `start`, `message`, `chat`, `settings`, `callback` | -| Хранилище состояний | `core/store.py` | `InMemoryStore`, `SQLiteStore` | -| Менеджмент чатов | `core/chat.py` | `ChatManager` | -| Аутентификация | `core/auth.py` | `AuthManager` | -| Настройки | `core/settings.py` | `SettingsManager` | - -Telegram и Matrix — тонкие адаптеры: принимают транспортные события, конвертируют в формат ядра, передают в `core`, рендерят ответ обратно. - -### 2.2. Платформенный контракт (`sdk/`) - -Вместо ожидания SDK Lambda зафиксирован собственный контракт: - -- `sdk/interface.py` — Protocol: `PlatformClient`, `WebhookReceiver` -- `sdk/mock.py` — `MockPlatformClient` (заглушка с симулируемой латентностью) - -При подключении реального SDK заменяется только `sdk/mock.py` — core и адаптеры не трогаются. - -> **Примечание:** в процессе работы директория `platform/` была переименована в `sdk/` для устранения конфликта имён со стандартной библиотекой Python (`platform.python_implementation`). Все импорты обновлены. - -### 2.3. Структура репозитория - -``` -surfaces-bot/ - core/ — общее ядро - sdk/ — платформенный контракт и мок - adapter/ - telegram/ — Telegram-адаптер (worktree: feat/telegram-adapter) - matrix/ — Matrix-адаптер (в main) - docs/ - superpowers/ - specs/ — утверждённые спецификации - plans/ — планы реализации - research/ — исследования API и архитектурных вариантов - reports/ — отчёты - tests/ — pytest (70 тестов) -``` - ---- - -## 3. Telegram: итоги - -### 3.1. Что реализовано - -**Базовый DM-режим (полностью работает):** - -| Функция | Команда/механизм | -|---------|-----------------| -| Онбординг | `/start` — создание первого чата, восстановление сессии | -| Создание чатов | `/new [название]` | -| Список чатов | `/chats` — инлайн-кнопки с переключением | -| Диалог | Любое сообщение → мок-ответ `[MOCK] Ответ на: «...»` | -| Typing indicator | `send_chat_action("typing")` + обновление каждые 4 сек | -| Настройки | `/settings` → меню: скиллы, личность агента, безопасность, подписка | -| Подтверждения | `confirm:yes/` / `confirm:no/` через `InlineKeyboard` | -| Список команд | Зарегистрирован через `set_my_commands()` | -| Вложения | Конвертируются в `Attachment` (фото, документ, голос) | - -**Forum Topics режим (реализован поверх DM):** - -| Функция | Описание | -|---------|----------| -| Подключение группы | `/forum` → FSM онбординг → пересылка сообщения из супергруппы | -| Проверка прав | Бот должен быть администратором с `can_manage_topics` | -| Синхронизация | При подключении группы создаются темы для всех DM-чатов | -| Регистрация темы | `/new` в forum-теме регистрирует её как чат | -| Создание с синхронизацией | `/new` в DM + подключённая группа → создаёт и DM-чат, и forum-тему | -| Маршрутизация | Пришло из DM → ответ в DM с тегом `[Чат #N]`; из темы → ответ в тему без тега | - -**Ключевые принятые решения:** -- Основной режим — виртуальные чаты в DM (нулевое friction) -- Forum Topics — opt-in advanced mode, не обязательный -- Бот не создаёт группы сам (Telegram Bot API не позволяет) -- Один контекст (`chat_id` = UUID) для обеих поверхностей - -### 3.2. Техническая реализация - -``` -adapter/telegram/ - bot.py — Dispatcher, DispatcherMiddleware, регистрация роутеров - states.py — ChatState, SettingsState, ForumSetupState - db.py — SQLite: tg_users + chats (включая forum_group_id, forum_thread_id) - converter.py — from_message(), is_forum_message(), resolve_forum_chat_id() - handlers/ - auth.py — /start - chat.py — сообщения, /new, /chats, forum-маршрутизация - settings.py — /settings, скиллы, личность, безопасность, подписка - confirm.py — подтверждение действий агента - forum.py — /forum, онбординг, регистрация группы - keyboards/ - chat.py — список чатов - settings.py — меню настроек, скиллы, безопасность - confirm.py — кнопки ✅/❌ -``` - -**Исправленные баги:** -- Команды (`/new`, `/settings` и др.) обрабатывались как обычные сообщения — исправлено фильтром `~F.text.startswith("/")` -- Конфликт `platform/` с stdlib Python — устранён переименованием в `sdk/` - -### 3.3. Документация - -- Спецификация DM-режима: `docs/superpowers/specs/2026-03-31-telegram-adapter-design.md` -- Спецификация Forum Topics: `docs/superpowers/specs/2026-03-31-forum-topics-design.md` -- План реализации Forum Topics: `docs/superpowers/plans/2026-03-31-forum-topics.md` -- Исследования: `docs/research/telegram-chat-alternatives.md`, `docs/research/telegram-forum-topics.md` - -### 3.4. Открытые задачи - -- Edge-cases forum synchronization (частично закрыты агентами после лимита) -- Ручной QA форум-сценариев -- Слияние `feat/telegram-adapter` → `main` - ---- - -## 4. Matrix: итоги - -### 4.1. Что реализовано - -- Matrix bot entrypoint (`adapter/matrix/bot.py`) -- Converter layer (Matrix events → `IncomingEvent`) -- Room metadata store -- Маршрутизация входящих событий -- Обработка реакций -- Обработка приглашений (invite → DM onboarding) -- Platform-aware command hints (`/start` для Telegram, `!start` для Matrix) -- Модель room-per-chat: команда `!new` создаёт **реальную Matrix room** - -### 4.2. Архитектурный сдвиг: Space-first → DM-first - -Изначально рассматривалась модель Space-first (персональный Space + settings-room + отдельные комнаты внутри Space). По ходу реализации выбран более прагматичный первый этап: - -- **DM-first onboarding**: пользователь приглашает бота → бот приветствует → первый контекст привязывается к C1 -- **Room-per-chat**: `!new` создаёт реальную Matrix room, бот приглашает пользователя - -Это соответствует принципу: каждый чат — отдельная сущность транспорта, не только внутренняя запись. - -### 4.3. Критические баги, исправленные в ходе работы - -| Баг | Причина | Исправление | -|-----|---------|-------------| -| Бот не принимал invite | Подписка только на `RoomMemberEvent` | Добавлена поддержка `InviteMemberEvent` | -| Бот отвечал сам себе (цикл) | Нет фильтра собственных сообщений | События от `self.client.user_id` игнорируются | -| Дублирование приветствия | Неидемпотентный invite flow | Room onboarding сделан идемпотентным | -| Агрессивные timeout/retry | Настройки sync по умолчанию | Настроен `AsyncClientConfig` | -| Telegram-ориентированные команды | Тексты в ядре не учитывали платформу | Platform-aware hints в core | - -### 4.4. Тесты Matrix - -Собран и проходит набор тестов: -- converter tests -- dispatcher tests -- reactions tests -- store tests -- интеграционные тесты core-сценариев - -Покрытые сценарии: разбор команд `!new`, `!skills`, `!yes`, `!no`; invite onboarding; защита от self-loop; создание реальной Matrix room; mapping `room_id → chat_id`. - -### 4.5. Ограничение: Matrix E2EE - -Шифрование (E2EE) в текущей реализации не поддержано. Причина — внешняя: - -- `matrix-nio` требует `python-olm` для E2EE -- сборка `python-olm` не воспроизводится на текущей macOS/ARM среде - -Текущий рабочий сценарий: **только незашифрованные комнаты**. E2EE — отдельная инфраструктурная задача. - -### 4.6. Документация - -- Спецификация: `docs/superpowers/specs/2026-03-31-matrix-adapter-design.md` -- План реализации: `docs/superpowers/plans/2026-03-31-matrix-adapter.md` - ---- - -## 5. Тесты - -``` -tests/ - core/ — 46 тестов (EventDispatcher, ChatManager, AuthManager, SettingsManager, stores) - platform/ — 5 тестов (MockPlatformClient) - adapter/ — 3 теста (forum DB functions) [в процессе] - -Итого: 70 passed, 3 errors (ошибки — проблема пути импорта в CI, не логики) -``` - ---- - -## 6. Отклонения от исходного плана - -| Аспект | Исходный план | Фактическое решение | Причина | -|--------|--------------|-------------------|---------| -| Telegram Forum | Бот создаёт группу сам | Пользователь создаёт, бот подключается | Telegram Bot API не позволяет создавать группы | -| Matrix UX | Space-first | DM-first + room-per-chat | Быстрее работает, проще в отладке | -| Платформенный слой | `platform/` | `sdk/` | Конфликт имён с stdlib Python | -| Matrix E2EE | В области применения | Вынесено как отдельная задача | Инфраструктурный блокер (python-olm) | - -Все изменения — корректная инженерная адаптация, не регресс. - ---- - -## 7. Текущий статус по направлениям - -| Направление | Статус | Примечание | -|-------------|--------|-----------| -| `core/` | ✅ Готово | Полное покрытие тестами | -| `sdk/` (mock) | ✅ Готово | Замена на реальный SDK — замена одного файла | -| Telegram DM-режим | ✅ Готово | Можно тестировать руками | -| Telegram Forum Topics | ✅ Реализовано | Требует ручного QA | -| Matrix adapter | ✅ Готово | В `main` | -| Matrix E2EE | ⏸ Заблокировано | Инфраструктурный блокер | -| Слияние Telegram ветки | 🔄 В процессе | `feat/telegram-adapter` → `main` | - ---- - -## 8. Риски - -| Риск | Уровень | Митигация | -|------|---------|-----------| -| Matrix E2EE | Средний | Работаем с незашифрованными комнатами, E2EE — отдельный тикет | -| Forum sync edge-cases | Низкий | Базовый сценарий работает, edge-cases в backlog | -| Реальный SDK vs мок | Низкий | Контракт зафиксирован, замена изолирована в `sdk/mock.py` | - ---- - -## 9. Следующие шаги - -**Ближайшие:** -1. Ручной QA Telegram Forum Topics -2. Слияние `feat/telegram-adapter` → `main` -3. Ручной QA Matrix-бота (issue `#14`) - -**Среднесрочные:** -1. Расширить покрытие тестами (adapter-level) -2. Довести Matrix settings workflow -3. Актуализировать `docs/api-contract.md` - -**Стратегические:** -1. Подготовить замену `MockPlatformClient` → реальный SDK Lambda -2. Довести обе поверхности до demo-ready состояния -3. Отдельно решить Matrix E2EE (инфраструктура) - ---- - -## 10. Вывод - -За текущий этап команда собрала работающий каркас двух поверхностей вокруг единого ядра и собственного платформенного контракта. - -**Практический итог:** -- Telegram в стадии реального UX-прототипа — можно демонстрировать -- Matrix имеет рабочий transport-слой и модель комнат -- Архитектура устойчива и готова к замене мока на реальный SDK - -Проект движется по инженерной логике: исследование ограничений → адаптация архитектуры → фиксация решений → реализация. Не по формальному чеклисту. diff --git a/docs/reports/2026-04-01-surfaces-progress-report.md b/docs/reports/2026-04-01-surfaces-progress-report.md deleted file mode 100644 index 2c2e408..0000000 --- a/docs/reports/2026-04-01-surfaces-progress-report.md +++ /dev/null @@ -1,601 +0,0 @@ -# Отчёт о проделанной работе - -**Проект:** Lambda Lab 3.0 — Surfaces -**Команда:** Surfaces Team -**Дата:** 2026-04-01 -**Период отчёта:** текущий этап разработки прототипов Telegram и Matrix - ---- - -## 1. Цель этапа - -Целью текущего этапа было собрать работоспособный прототип двух поверхностей для взаимодействия пользователя с AI-агентом Lambda: - -- Telegram-бота -- Matrix-бота - -При этом важным требованием было не ждать готовности платформенного SDK, а сразу строить систему вокруг собственного контракта и мок-реализации платформы. Это позволило параллельно двигаться по UX, архитектуре и интеграционным сценариям, не блокируясь внешними зависимостями. - ---- - -## 2. Что было сделано на уровне архитектуры - -### 2.1. Сформировано общее ядро - -В репозитории выделено общее `core/`, которое не зависит от конкретного транспорта и используется обеими поверхностями. - -Реализованы: - -- единый протокол событий и ответов -- диспетчеризация входящих событий через `EventDispatcher` -- менеджмент чатов -- менеджмент аутентификации -- менеджмент настроек -- общее state-хранилище (`InMemoryStore`, `SQLiteStore`) - -Это позволило построить Telegram и Matrix как тонкие адаптеры, которые: - -- принимают события транспорта -- конвертируют их в единый формат ядра -- передают в `core` -- рендерят результат обратно в транспорт - -### 2.2. Зафиксирован платформенный контракт - -Вместо ожидания готового SDK был введён собственный контракт через: - -- [`sdk/interface.py`](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/interface.py) -- [`sdk/mock.py`](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/mock.py) - -За счёт этого: - -- UX и интеграционный слой можно развивать уже сейчас -- реальные платформенные вызовы можно позже подключить заменой одной реализации -- транспортные адаптеры и `core` не придётся переписывать - -### 2.3. Уточнена текущая архитектурная стратегия - -По ходу работы часть исходных планов была пересмотрена и адаптирована под реальные ограничения платформ и API. - -Ключевые изменения: - -- `platform/` был переименован в `sdk/` для устранения конфликта имён и более точного смысла слоя -- Telegram ушёл от идеи автоматического создания групп ботом: Bot API этого не позволяет -- Matrix ушёл от Space-first реализации к DM-first / room-first модели как к более реалистичному первому рабочему этапу - ---- - -## 3. Telegram: текущее состояние - -### 3.1. Организация разработки - -Telegram-часть выделена в отдельный worktree: - -- ветка: `feat/telegram-adapter` - -Это позволило вести Telegram независимо от Matrix и не смешивать контексты разработки. - -### 3.2. Что реализовано - -В Telegram-адаптере уже собран рабочий базовый UX: - -- стартовый onboarding через `/start` -- основной диалог в DM -- создание новых чатов -- список чатов и переключение между ними -- меню настроек -- подтверждение действий через inline-кнопки -- базовая работа с вложениями - -Отдельно реализован **Forum Topics mode** как расширение поверх DM-сценария: - -- команда `/forum` -- подключение уже существующей forum-group через пересланное сообщение -- проверка, что бот является администратором с правом управления темами -- синхронизация существующих локальных чатов с forum topics -- routing сообщений из topic обратно в нужный chat context -- routing confirm callbacks внутри topic - -### 3.3. Принятые продуктовые решения - -Во время разработки были приняты важные решения по UX Telegram: - -- основным пользовательским сценарием остаётся DM-first -- Forum Topics не являются обязательным режимом, а выступают как advanced mode -- контекст чатов должен синхронизироваться между DM и topic-представлением -- пользователь не должен сталкиваться с невозможной автоматизацией создания групп со стороны бота - -### 3.4. Что ещё не закрыто - -Для Telegram остаются открытые задачи, в первую очередь в области polish и согласованности UX: - -- не все сценарии forum synchronization доведены до конца -- есть оставшиеся вопросы по командам в topic-контексте -- нужен дополнительный проход по UX-деталям и ручному QA - -Актуальный follow-up зафиксирован в issue: - -- `#15` Telegram forum topics: remaining UX and synchronization gaps - ---- - -## 4. Matrix: текущее состояние - -### 4.1. Что реализовано - -В `main` уже добавлен Matrix-адаптер, включающий: - -- Matrix bot entrypoint -- converter layer -- room metadata store -- routing входящих событий -- обработку реакций -- обработку приглашения в DM -- базовый onboarding -- platform-aware command hints -- набор adapter-level тестов - -### 4.2. Главный архитектурный сдвиг - -Изначально Matrix рассматривался через модель: - -- персональный Space -- settings-room -- отдельные room-чаты внутри Space - -Однако по ходу реализации был выбран более прагматичный маршрут первого этапа: - -- **DM-first onboarding** -- затем **room-per-chat** - -Текущее поведение: - -- пользователь приглашает бота в комнату -- бот приветствует пользователя -- первый контекст привязывается к `C1` -- команда `!new` создаёт **реальную новую Matrix room** -- бот приглашает пользователя в эту новую комнату - -Это уже соответствует целевому принципу: - -> новый чат пользователя должен быть отдельной сущностью транспорта, а не только внутренней записью в `core` - -### 4.3. Критические баги, которые были обнаружены и исправлены - -Во время ручной проверки Matrix были найдены и устранены несколько важных проблем: - -1. **бот не принимал invite корректно** - - причина: подписка только на `RoomMemberEvent` - - исправление: добавлена поддержка `InviteMemberEvent` - -2. **бот отвечал сам себе и уходил в цикл** - - симптом: спам приветствиями и сообщениями типа `Введите !start` - - причина: отсутствие фильтра собственных сообщений - - исправление: события от `self.client.user_id` теперь игнорируются - -3. **дублировалось стартовое приветствие** - - причина: invite-flow был неидемпотентным - - исправление: room onboarding сделан идемпотентным - -4. **слишком агрессивные timeout/retry при sync** - - исправление: настроен более мягкий transport config через `AsyncClientConfig` - -5. **команды и подсказки были Telegram-ориентированными** - - исправление: тексты в ядре стали platform-aware (`/start` для Telegram, `!start` для Matrix) - -### 4.4. Что подтверждено тестами - -Для Matrix собран и пройден набор тестов: - -- converter tests -- dispatcher tests -- reactions tests -- store tests -- интеграционные тесты core-сценариев - -Примеры покрытых сценариев: - -- разбор команд `!new`, `!skills`, `!yes`, `!no` -- invite onboarding -- защита от self-loop -- создание реальной Matrix room на `!new` -- mapping `room_id -> chat_id` - -### 4.5. Ограничение текущей реализации - -Главное незакрытое ограничение Matrix на текущий момент: - -## encrypted DM пока не поддержан - -Причина не в логике бота, а во внешнем crypto-stack: - -- для E2EE в `matrix-nio` нужен `python-olm` -- на текущей macOS/ARM среде сборка `python-olm` не воспроизводится корректно -- поэтому в рабочем сценарии Matrix пока используется **только незашифрованный room flow** - -Это означает: - -- незашифрованные комнаты и room-per-chat можно развивать и тестировать уже сейчас -- encrypted DM нужно рассматривать как отдельную инфраструктурную подзадачу - -### 4.6. Что ещё остаётся по Matrix - -Открытые направления: - -- ручной QA текущего Matrix-бота -- доработка UX и edge-cases room-per-chat -- дальнейшее развитие settings-команд -- возможное возвращение к Space lifecycle как следующему этапу -- отдельный infrastructure task по E2EE / `python-olm` - -Для ручного тестирования создан issue: - -- `#14` Manual QA: test Matrix bot and record issues / gaps - ---- - -## 5. Что было сделано с точки зрения git и процесса - -### 5.1. Основные изменения были оформлены коммитами - -На текущем этапе были сделаны и запушены в репозиторий следующие ключевые коммиты: - -- `82eb711` — базовый Matrix adapter + platform-aware command hints -- `14c091b` — реальное создание новых Matrix rooms на `!new` -- `6a843e8` — transport timeout tuning для Matrix sync -- `27f3da8` — обновление README под фактическую архитектуру проекта - -### 5.2. Проведён аудит backlog - -По открытым issue был выполнен аудит: - -- закрыты уже выполненные задачи -- устаревшие issue переписаны под текущую архитектуру -- не выполненные и актуальные задачи оставлены открытыми - -В частности: - -- закрыт issue `#13` по Matrix research -- актуализированы старые Telegram и Matrix issue под текущие реальные пути, ограничения и UX-модель - ---- - -## 6. Что изменилось по сравнению с изначальным планом - -Это важный блок для руководителя: проект движется не просто по “чеклисту задач”, а по реальным ограничениям платформ. - -### 6.1. Telegram - -Изначально планировался сценарий, где бот создаёт Forum-группу сам. - -Фактический результат исследования и реализации показал: - -- Telegram Bot API этого не позволяет -- группа создаётся пользователем вручную -- бот подключается к уже существующей группе - -Это не регресс, а корректная адаптация архитектуры под реальные ограничения API. - -### 6.2. Matrix - -Изначально планировался Space-first UX. - -Фактически первым рабочим этапом стала модель: - -- DM-first onboarding -- затем room-per-chat - -Причина: - -- так можно получить работающий transport flow раньше -- это проще в отладке -- это не блокирует дальнейший переход к Space lifecycle - -### 6.3. Платформенный слой - -Изначально существовали старые пути и слои, которые затем были пересобраны в более понятную форму. - -Итоговое направление: - -- `sdk/interface.py` -- `sdk/mock.py` -- `core/` как единый уровень бизнес-логики -- transport adapters отдельно - -Это повысило устойчивость архитектуры и упростило дальнейшую замену mock на реальный SDK. - ---- - -## 7. Основные результаты этапа - -К концу текущего этапа проект достиг следующих результатов: - -### Telegram - -- есть рабочий Telegram adapter -- реализован основной DM flow -- реализован Forum Topics mode -- собрана отдельная ветка/worktree под Telegram -- основные пользовательские сценарии уже можно проверять руками - -### Matrix - -- есть рабочий Matrix adapter -- invite/onboarding flow уже функционирует -- реализована модель room-per-chat -- устранены основные критические баги цикла и self-processing -- собран базовый test suite - -### Общий уровень проекта - -- ядро и контракты унифицированы -- backlog приведён в соответствие с реальной архитектурой -- README актуализирован под текущее состояние -- ручной QA Matrix вынесен в отдельную управляемую задачу - ---- - -## 8. Текущие риски и ограничения - -### Технические риски - -1. **Matrix E2EE** - - blocked внешним crypto-stack - - не решается только правками Python-кода в проекте - -2. **Telegram forum synchronization** - - функциональность уже есть, но остаются edge-cases и UX-недоработки - -3. **Расхождение старых документов и новых решений** - - backlog уже частично синхронизирован - - но часть старых design assumptions всё ещё может встречаться в документации - -### Процессные риски - -1. требуется более строгий feature-branch workflow для следующих этапов Matrix -2. для Telegram и Matrix желательно продолжать раздельную работу по веткам/worktree -3. ручной QA остаётся критичным, особенно для Matrix transport behavior - ---- - -## 9. Следующие шаги - -### Ближайшие - -1. Провести ручной QA Matrix-бота по issue `#14` -2. Зафиксировать воспроизводимые проблемы Matrix -3. Продолжить Telegram в worktree `feat/telegram-adapter` -4. Довести Telegram forum synchronization gaps по issue `#15` - -### Среднесрочные - -1. Расширить покрытие тестами -2. Довести Matrix settings workflow -3. Уточнить и обновить `docs/api-contract.md` -4. Отдельно решить вопрос Matrix E2EE support - -### Стратегические - -1. Подготовить замену `MockPlatformClient` на реальный SDK -2. Довести обе поверхности до более стабильного demo-ready состояния -3. Выровнять UX Telegram и Matrix вокруг общих принципов surface protocol - ---- - -## 10. Краткий вывод для руководителя - -На текущем этапе команда не просто написала часть кода, а уже собрала работающий каркас двух поверхностей вокруг общего ядра и собственного платформенного контракта. - -Главный практический результат: - -- Telegram уже находится в стадии реального UX-прототипа -- Matrix уже имеет рабочий transport-слой и модель отдельных комнат для чатов -- архитектура проекта стала значительно устойчивее и ближе к реальной интеграции с платформой - -При этом команда корректно адаптировала исходные планы под реальные ограничения Telegram Bot API и Matrix ecosystem, не пытаясь “продавить” заведомо неверные решения. - -То есть проект движется не по формальному чеклисту, а по зрелой инженерной логике: - -- исследование -- фиксация архитектурных решений -- рабочая реализация -- ручной QA -- корректировка backlog под фактическое состояние системы - -Это хороший признак для дальнейшего перехода от прототипа к более устойчивой демонстрационной версии. - - -## 8. Дополнение: итоги отдельной Telegram-сессии по Forum Topics - -В рамках отдельной рабочей сессии в Telegram worktree `feat/telegram-adapter` был проведён focused pass по качеству и устойчивости **Forum Topics mode**. Целью этой работы было не просто добавить функциональность, а довести forum-сценарии до состояния, в котором их можно стабильно демонстрировать, вручную тестировать и развивать дальше без постоянных расхождений между UX, кодом и документацией. - -### 8.1. Что было выявлено в начале сессии - -При аудите Telegram-ветки подтвердилось, что базовая реализация уже существует: - -- Telegram adapter реализован -- Forum Topics mode уже добавлен -- `/forum` onboarding присутствует -- forum thread routing реализован -- confirm callbacks внутри forum thread уже работают - -Однако вместе с этим были обнаружены существенные проблемы двух типов. - -**Первый тип — расхождение документации и фактической реализации.** -Часть документов всё ещё описывала старую DM-only или forum-only модель, тогда как код фактически уже работал как hybrid `DM + Forum Topics`. - -**Второй тип — реальные поведенческие баги forum mode.** -Наиболее заметные проблемы: - -- нестабильный onboarding подключения forum group -- слабая диагностика ошибок подключения -- возможность сломать соответствие `topic -> chat` через команды управления чатами внутри topic -- неполная согласованность UX внутри forum topics - -### 8.2. Исправление документации - -Были актуализированы Telegram-документы, чтобы они соответствовали реальному состоянию ветки: - -- `docs/telegram-prototype.md` -- `docs/superpowers/specs/2026-03-31-telegram-adapter-design.md` - -Что было отражено в документации: - -- Telegram работает как hybrid-модель `DM + Forum Topics` -- DM остаётся базовой поверхностью -- Forum Topics — расширенный режим поверх того же chat context -- `/forum` подключает уже существующую forum-group пользователя -- один и тот же `chat_id` может быть доступен как из DM, так и из forum topic -- forum thread routing и confirm callbacks уже входят в реализованную модель адаптера - -Практический результат: документация перестала вводить в заблуждение разработчиков и reviewers и теперь описывает не гипотетическую, а фактическую архитектуру Telegram-ветки. - -### 8.3. Разбор и исправление проблемного onboarding `/forum` - -Изначально `/forum` опирался на пересланное сообщение из супергруппы и ожидал, что Telegram отдаст боту `forward_from_chat`. - -В реальном запуске было установлено, что этот сценарий ненадёжен: - -- Telegram/aiogram может присылать не `forward_from_chat`, а `forward_origin` -- в ряде случаев бот видит только `forward_origin_type=user` -- из такого payload невозможно надёжно восстановить `group_id` - -То есть даже при визуально «правильной» пересылке сообщение не обязательно содержит необходимые данные о группе. - -Для диагностики в onboarding были добавлены stage-level логи. Теперь логируются: - -- запуск `/forum` -- получение onboarding message -- тип forward metadata -- наличие или отсутствие данных о группе -- тип найденного chat -- проверка forum-enabled supergroup -- права бота (`administrator` / `can_manage_topics`) -- успешная привязка forum group -- создание и привязка topics -- завершение onboarding - -Это позволило быстро локализовать проблему и убедиться, что узкое место было именно в механике получения `group_id`. - -### 8.4. Перевод onboarding на Telegram-native `request_chat` - -Вместо ненадёжного forwarding-only flow основной путь подключения forum group был переведён на **Telegram-native выбор чата** через `request_chat`. - -Было сделано следующее: - -- добавлена новая клавиатура выбора forum-group -- `/forum` теперь предлагает пользователю выбрать подходящую group кнопкой -- бот получает `chat_shared.chat_id` напрямую -- после выбора выполняется проверка реальных прав бота в группе -- старый forwarding path оставлен как fallback - -Это решение даёт несколько преимуществ: - -- не зависит от нестабильных forwarded metadata -- даёт детерминированный `chat_id` -- лучше соответствует реальному Telegram API -- делает onboarding заметно понятнее для пользователя - -### 8.5. Исправление ошибки `USER_RIGHTS_MISSING` - -После внедрения `request_chat` на реальном запуске проявилась новая ошибка: - -- `TelegramBadRequest: USER_RIGHTS_MISSING` - -Ошибка возникала ещё на этапе отправки кнопки выбора forum-group. - -Причина: в `KeyboardButtonRequestChat` был указан слишком жёсткий набор `bot_administrator_rights`, из-за чего Telegram отклонял сам запрос на показ кнопки. - -Исправление: - -- из `request_chat` были убраны жёсткие `bot_administrator_rights` -- фактическая проверка нужных прав оставлена на следующем шаге через `get_chat_member` - -В результате onboarding сохранил строгую проверку прав, но перестал ломаться на этапе отправки UI. - -### 8.6. Исправление опасного поведения внутри forum topics - -После успешного onboarding был отдельно проверен UX внутри уже созданных topics. Здесь обнаружился критичный баг: пользователь мог использовать `/chats` в topic-контексте и переключать активный чат через inline callbacks. - -Это приводило к рассинхронизации: - -- Telegram topic визуально оставался темой одного чата -- FSM и routing переключались на другой чат -- пользователь начинал фактически разговаривать «в чате 4 внутри темы чата 2» - -Чтобы устранить этот класс ошибок, были введены ограничения для topic-контекста. - -Теперь внутри forum topic: - -- `/chats` не открывает механизм переключения и сообщает, что эта функция доступна только в DM -- callback `switch::` запрещён -- callback `new_chat` из списка чатов запрещён - -Это устранило основной сценарий, которым пользователь мог руками сломать привязку `topic -> chat`. - -### 8.7. Что покрыто тестами - -В рамках этой же сессии были расширены Telegram-specific тесты. Покрыты сценарии: - -- forum routing helpers -- `/forum` переводит FSM в setup state -- подключение группы через `forward_from_chat` -- подключение группы через `forward_origin` -- подключение группы через `chat_shared` -- негативные сценарии без метаданных группы -- негативный сценарий для supergroup без Topics -- routing сообщений в forum thread -- создание forum topic при `/new` в DM -- регистрация чата в текущем topic -- confirm callback внутри forum thread -- запрет `/chats` внутри topic -- запрет `switch` callback внутри topic -- запрет `new_chat` callback внутри topic - -Проверка выполнялась командами: - -- `pytest tests/adapter/telegram/test_forum.py -q` -- `pytest tests/core/test_dispatcher.py tests/core/test_integration.py -q` - -Результат: ключевые улучшения forum mode закреплены тестами, а не остались только на уровне ручной отладки. - -### 8.8. Что ещё осталось как follow-up - -Во время сессии были зафиксированы проблемы, которые разумно вынести в отдельную follow-up задачу, а не смешивать с текущими исправлениями. - -Оставшиеся gap'ы: - -- глобальные команды Telegram всё ещё видны и в topic-контексте, хотя часть из них логически там отключена -- `/new ` внутри уже связанного topic может переименовать локальный чат, но не переименовывает сам Telegram topic -- callback `new_chat` из DM-списка пока не синхронизирован с forum topic creation так же, как `/new` в DM - -Эти пункты были вынесены в отдельный issue: - -- `#15` — `Telegram forum topics: remaining UX and synchronization gaps` - -### 8.9. Git-результат Telegram-сессии - -По итогам сессии изменения были оформлены отдельным коммитом и опубликованы в удалённую ветку. - -**Commit:** - -- `a1b7a14` — `Improve Telegram forum onboarding and topic safety` - -**Push:** - -- `origin/feat/telegram-adapter` - -### 8.10. Практический результат этой Telegram-сессии - -На выходе Telegram Forum Topics mode стал существенно устойчивее и пригоднее для демонстрации и дальнейшей разработки. - -Главные практические улучшения: - -- forum onboarding стал надёжнее за счёт `request_chat` -- диагностика ошибок onboarding стала прозрачной -- пользователю стало сложнее случайно сломать topic-context -- документация приведена в соответствие с кодом -- изменения закреплены тестами -- остаточные проблемы не потеряны и вынесены в issue tracker - -Итог: Telegram forum mode из состояния «уже работает, но легко ломается и плохо диагностируется» был переведён в состояние «работает заметно устойчивее, ограничивает опасные сценарии и имеет понятный backlog дальнейших улучшений». diff --git a/docs/reports/2026-04-21-platform-streaming-bug-report-ru.md b/docs/reports/2026-04-21-platform-streaming-bug-report-ru.md deleted file mode 100644 index f183ede..0000000 --- a/docs/reports/2026-04-21-platform-streaming-bug-report-ru.md +++ /dev/null @@ -1,245 +0,0 @@ -# Баг-репорт: регрессия стриминга платформы после file/tool flow - -## Кратко - -После обновления до текущих upstream-версий платформы стриминг ответов стал нестабильным в сценариях с вложениями и tool/file flow. - -Наблюдаемые симптомы: - -- первый текстовый chunk ответа может приходить уже обрезанным -- соседние ответы могут "протекать" друг в друга -- после некоторых запросов бот перестаёт присылать финальный ответ -- платформа присылает дублирующий `END` - -До обновления платформы этот класс ошибок у нас не воспроизводился. - -## Версии платформы - -В рантайме используются upstream-репозитории без локальных правок: - -- `platform-agent`: `5e7c2df954cc3cd2f5bf8ae688e10a20038dde61` -- `platform-agent_api`: `aa480bbec5bbf8e006284dd03aed1c2754e9bbee` - -## Контекст интеграции - -- поверхность: Matrix -- транспорт к платформе: websocket через `platform-agent_api` -- `chat_id` на платформу отправляется как стабильный числовой surrogate id -- shared workspace: `/workspace` - -Важно: vendored platform repos чистые, совпадают с upstream. Проблема воспроизводится без локальных patch'ей в платформу. - -## Пользовательские симптомы - -Примеры из живого диалога: - -- ожидалось: `Моя ошибка: ...` -- фактически пришло: `оя ошибка: ...` - -- ожидалось начало ответа вида `По фото IMG_3183.png ...` -- фактически пришло: `IMG_3183.png**) — это ...` - -Также наблюдалось: - -- после вопросов по изображениям бот иногда вообще перестаёт отвечать -- в том же чате, до attachment/tool flow, ответы приходят корректно - -## Шаги воспроизведения - -1. Поднять `platform-agent` и Matrix surface на версиях выше. -2. Отправить несколько обычных текстовых сообщений. -3. Убедиться, что начальные ответы стримятся корректно. -4. Отправить изображения/файлы и задать вопросы вида: - - `что изображено на фото` - - уточняющие follow-up вопросы по тем же вложениям -5. Затем отправить ещё одно обычное текстовое сообщение. -6. Наблюдать один или несколько симптомов: - - первый chunk начинается с середины слова - - ответ начинается с середины фразы - - хвост прошлого ответа загрязняет следующий - - видимого финального ответа нет вообще - -## Что удалось доказать - -По debug-логам Matrix surface видно, что обрезание присутствует уже в первом chunk, полученном от платформы. - -Корректные первые chunk'и до attachment/tool flow: - -- `Hey! How` -- `Я` -- `Первый файл не найден — возможно, ...` - -Некорректные первые chunk'и после attachment/tool flow: - -- `IMG_3183.png**) — это ю...` -- `оя ошибка: в первом запросе...` - -Это логируется сразу после десериализации websocket event на клиентской стороне, до Matrix rendering и до финальной сборки текста ответа. Из этого следует, что повреждение происходит на стороне платформенного стриминга, а не в Matrix sender. - -## Дополнительное наблюдение по протоколу - -Платформа сейчас отправляет дублирующий `END`. - -Релевантные места в upstream: - -- `external/platform-agent/src/agent/service.py` - - уже `yield MsgEventEnd(...)` -- `external/platform-agent/src/api/external.py` - - после завершения цикла дополнительно отправляет ещё один `MsgEventEnd(...)` - -В живых логах это видно как: - -- первый `END` -- второй `END` -- клиентская suppression логика, которая гасит дубликат - -Это само по себе делает границы ответа неоднозначными и повышает риск "протекания" поздних событий в следующий запрос. - -## Предполагаемая первопричина - -Похоже, что на стороне платформы одновременно есть две проблемы. - -### 1. Двойной сигнал завершения стрима - -Для одного ответа генерируется два `END`. - -Вероятные последствия: - -- нечёткая граница ответа -- поздние события могут относиться не к тому запросу -- соседние ответы могут смешиваться - -### 2. Некорректное извлечение текстового chunk'а - -В текущем upstream `platform-agent/src/agent/service.py` текст форвардится из `chunk.content` внутри `on_chat_model_stream`. - -Однако в документации LangChain для `astream_events()` примеры используют `event["data"]["chunk"].text`, а в Deep Agents stream дополнительно есть `ns`/`source`, которые важны для отделения main-agent output от tool/subagent/model-internal stream. - -Потенциальные последствия: - -- первый видимый chunk может быть неполным -- во внешний клиент может попадать не только финальный пользовательский текст -- attachment/tool flow сильнее деградирует поведение стрима - -## Почему проблема считается платформенной - -С нашей стороны были проверены и исключены базовые причины: - -- вложения корректно сохраняются в `/workspace` -- контейнер `platform-agent` видит эти файлы -- Matrix surface получает уже обрезанный первый chunk от платформы -- обрезание происходит до сборки финального ответа -- эксперимент с reconnect на каждый запрос не исправил проблему -- платформенные vendored repos сейчас совпадают с upstream - -## Ожидаемое поведение - -Для каждого пользовательского запроса: - -- текстовые chunk'и должны начинаться с реального начала ответа модели -- должен приходить ровно один terminal `END` -- границы ответов должны быть однозначными -- file/tool flow не должен ломать следующий ответ - -## Фактическое поведение - -После attachment/tool flow: - -- первый text chunk может быть уже обрезан -- `END` приходит дважды -- следующий ответ может начаться с середины слова или фразы -- отдельные запросы могут не завершаться видимым ответом - -## Дополнительный failure mode: большие изображения - -В отдельном воспроизведении текстовые запросы до файлового сценария работали корректно, а сбой возникал только после попытки анализа изображений. - -По логам видно уже не только stream corruption, но и конкретный image-path failure: - -- `platform-agent` рвёт websocket с `1009 (message too big)` -- провайдер возвращает `400` с причиной: - - `Exceeded limit on max bytes per data-uri item : 10485760` - -Характерный фрагмент: - -```text -websockets.exceptions.ConnectionClosedError: received 1009 (message too big); then sent 1009 (message too big) -... -Agent error (INTERNAL_ERROR): Error code: 400 - { - 'error': { - 'message': 'Provider returned error', - 'metadata': { - 'raw': '{"error":{"message":"Exceeded limit on max bytes per data-uri item : 10485760"...}}' - } - } -} -``` - -Из этого следует: - -- текстовый path сам по себе работоспособен -- image-analysis path в платформе сейчас передаёт изображение как data URI -- для достаточно больших изображений это утыкается в provider limit `10,485,760` байт на один data-uri item -- параллельно websocket path между surface и platform-agent неустойчив к этому failure scenario и закрывается с `1009` - -То есть в платформе есть как минимум ещё одна отдельная проблема помимо некорректного стриминга: - -- отсутствует безопасная обработка больших изображений до отправки в provider -- отсутствует аккуратная деградация без разрыва websocket-сессии - -## Что стоит исправить в платформе - -1. Отправлять ровно один `MsgEventEnd` на один ответ. -2. Перепроверить extraction текста из `on_chat_model_stream`: - - вероятно, должен использоваться `chunk.text`, а не `chunk.content`. -3. Учитывать `ns`/`source` и форвардить наружу только main assistant output. -4. Перед отправкой изображений в provider проверять размер payload и не пытаться слать oversized data-uri. -5. Для больших изображений: - - либо делать resize/compression, - - либо возвращать контролируемую user-facing ошибку без разрыва websocket. -6. В идеале добавить более жёсткую request boundary в websocket protocol, чтобы late events не могли прилипать к следующему запросу. - -## Наши временные mitigation'ы на стороне surface - -Они не исправляют корень, только снижают ущерб: - -- suppression duplicate `END` -- короткий post-`END` drain window -- idle timeout для зависшего стрима -- transport failures нормализуются в `PlatformError`, чтобы Matrix bot не падал процессом - -Эти меры понадобились только потому, что в текущем сценарии platform stream contract нестабилен. - -## Приложение: характерный фрагмент логов - -```text -[matrix-bot] text chunk queue=True text='Первый файл не найден — возможно,' -[matrix-bot] ... -[matrix-bot] end event queue=True tokens=0 -[matrix-bot] end event queue=True tokens=0 -[matrix-bot] dropped duplicate END tokens=0 -[matrix-bot] text chunk queue=True text='IMG_3183.png**) — это ю' -[matrix-bot] ... -[matrix-bot] end event queue=True tokens=0 -[matrix-bot] end event queue=True tokens=0 -[matrix-bot] dropped duplicate END tokens=0 -[matrix-bot] text chunk queue=True text='оя ошибка: в первом запросе я случайно постав' -``` - -Этот фрагмент показывает две вещи: - -- duplicate `END` действительно приходит от платформы -- следующий первый chunk уже приходит в клиента обрезанным - -## Приложение: характерный фрагмент логов для больших изображений - -```text -platform-agent-1 | websockets.exceptions.ConnectionClosedError: received 1009 (message too big); then sent 1009 (message too big) -... -matrix-bot-1 | Agent error (INTERNAL_ERROR): Error code: 400 - {'error': {'message': 'Provider returned error', 'code': 400, 'metadata': {'raw': '{"error":{"message":"Exceeded limit on max bytes per data-uri item : 10485760","type":"invalid_request_error"...}}}} -``` - -Этот фрагмент показывает ещё две вещи: - -- image path в платформе реально упирается в лимит провайдера на размер data URI -- при этом ошибка не изолируется корректно и сопровождается разрывом websocket-соединения diff --git a/docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md b/docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md deleted file mode 100644 index d03adc6..0000000 --- a/docs/reports/2026-04-22-platform-streaming-final-bug-report-ru.md +++ /dev/null @@ -1,294 +0,0 @@ -# Финальный баг-репорт: потеря начала ответа и сбои streaming/image path в `platform-agent` - -## Статус - -Это финальный отчёт после полного аудита интеграции `surfaces -> platform-agent_api -> platform-agent`. - -Итог: - -- текущая реализация `surfaces` рабочая, но проблемная из-за upstream-дефектов платформы -- баг с пропадающим началом ответа на текущем состоянии **не локализуется в `surfaces`** -- в воспроизведённом сценарии повреждённый первый текстовый chunk рождается уже внутри `platform-agent` -- помимо этого подтверждены ещё два независимых platform-side дефекта: - - duplicate `END` - - некорректная обработка больших изображений (`data-uri > 10 MB`, `WS 1009`) - -## Версии и состояние кода - -Рантайм воспроизводился на vendored upstream-репозиториях без локальных platform patch’ей: - -- `platform-agent`: `5e7c2df954cc3cd2f5bf8ae688e10a20038dde61` -- `platform-agent_api`: `aa480bbec5bbf8e006284dd03aed1c2754e9bbee` - -Со стороны `surfaces` transport layer был предварительно очищен: - -- убрана локальная stream-semantics из `sdk/agent_api_wrapper.py` -- `sdk/real.py` переведён на pinned upstream `platform-agent_api.AgentApi` -- больше нет локального post-END drain, custom listener и wrapper-owned reclassification events - -Это важно: баг воспроизводился **после** удаления наших транспортных костылей. - -## Контекст интеграции - -- поверхность: Matrix -- транспорт к платформе: WebSocket через upstream `platform-agent_api.AgentApi` -- `chat_id` на платформу: стабильный числовой surrogate id, выдаваемый со стороны `surfaces` -- файловый контракт: shared `/workspace`, вложения передаются как относительные пути в `attachments` - -## Пользовательские симптомы - -Наблюдались несколько классов сбоев: - -1. Начало ответа может пропасть -- ожидалось: `Моя ошибка: ...` -- фактически: `оя ошибка: ...` - -- ожидалось: `На двух изображениях: ...` -- фактически: ` двух изображениях: ...` - -2. После tool/file flow ответы могут вести себя нестабильно -- следующий ответ стартует с середины фразы -- в некоторых сценариях после image/tool path платформа отвечает ошибкой или замолкает - -3. На больших изображениях image path падает совсем -- provider error `Exceeded limit on max bytes per data-uri item : 10485760` -- websocket закрывается с `1009 (message too big)` - -## Что было проверено на стороне `surfaces` - -Ниже перечислено, что именно было перепроверено в нашем коде и почему это не выглядит корнем проблемы. - -### 1. Мы больше не режем и не переклассифицируем stream локально - -В текущем `surfaces`: - -- `sdk/agent_api_wrapper.py` — thin construction/factory shim над upstream `AgentApi` -- `sdk/real.py` — просто итерирует upstream events и склеивает `MsgEventTextChunk.text` -- `adapter/matrix/bot.py` — отправляет `OutgoingMessage.text` в Matrix без `strip/lstrip` - -Наблюдение: - -- в текущем коде не осталось места, где строка могла бы превратиться из `Моя ошибка` в `оя ошибка` через локальный slicing - -### 2. Сборка ответа у нас линейная и тупая - -`sdk/real.py` делает только следующее: - -- если пришёл `MsgEventTextChunk` — добавляет `event.text` в `response_parts` -- если пришёл `MsgEventSendFile` — превращает его в `Attachment` -- не пытается “восстанавливать” поток после `END` - -Следствие: - -- если начало уже отсутствует в `event.text`, мы его не можем ни потерять, ни вернуть - -### 3. Matrix sender не модифицирует текст - -`adapter/matrix/bot.py` передаёт текст дальше как есть. - -Следствие: - -- Matrix renderer не является объяснением пропажи первого куска - -## Что было проверено в `platform-agent_api` - -Upstream client всё ещё имеет спорную queue-архитектуру: - -- одна активная `_current_queue` -- `MsgEventEnd` съедается внутри `send_message()` -- в `finally` очередь отвязывается и дренится orphan messages - -Это архитектурно хрупко и может быть источником других boundary bugs. - -Но в конкретном воспроизведении этот слой не был точкой порчи текста. - -Почему: - -- в raw logs клиент получил **ровно тот же** первый text chunk, который сервер уже отправил -- queue/dequeue не изменили его содержимое - -## Что удалось доказать по raw logs - -Для финальной проверки была временно добавлена точечная диагностика в: - -- `external/platform-agent/src/agent/service.py` -- `external/platform-agent/src/api/external.py` -- `external/platform-agent_api/lambda_agent_api/agent_api.py` - -Эта диагностика **не входила** в рабочую реализацию и использовалась только для локализации бага. - -### Ключевое наблюдение - -На проблемном запросе после tool/file flow сервер сам yield’ил уже обрезанный первый chunk: - -```text -platform-agent-1 | [raw-stream][server-yield] chat=1 event=TEXT text=' двух изображениях:\n\n**Первое изображение' -platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение' path=None -matrix-bot-1 | [raw-stream][client-listen] agent=matrix-bot chat=1 queue_active=True AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение' -matrix-bot-1 | [raw-stream][client-dequeue] agent=matrix-bot chat=1 request=2 AGENT_EVENT_TEXT_CHUNK text=' двух изображениях:\n\n**Первое изображение' -``` - -Это означает: - -- порча произошла **до** websocket-клиента -- `surfaces` transport layer не является источником именно этого дефекта -- `platform-agent_api` не исказил этот конкретный chunk по дороге - -Дополнительно тот же паттерн виден и вне image-сценария: - -```text -platform-agent-1 | [raw-stream][server-yield] chat=1 event=TEXT text='сё работает напрямую' -... -matrix-bot-1 | [raw-stream][client-dequeue] ... text='сё работает напрямую' -``` - -То есть сервер уже выдаёт `сё`, а не `Всё`. - -## Наиболее вероятный root cause - -Главный подозреваемый — `external/platform-agent/src/agent/service.py`. - -Сейчас он делает следующее: - -- читает `self._agent.astream_events(...)` -- обрабатывает только `kind == "on_chat_model_stream"` -- берёт `chunk = event["data"]["chunk"]` -- если `chunk.content`, форвардит `MsgEventTextChunk(text=chunk.content)` - -Проблема в том, что это очень грубое преобразование raw event stream в пользовательский текст. - -### Почему именно это место выглядит корнем - -1. Первый битый chunk уже рождается на server-side -- это подтверждено логами выше - -2. Код берёт только `chunk.content` -- если начало ответа приходит в другой форме, поле или raw event, оно просто теряется - -3. Код не учитывает `ns` / `source` -- после tool/vision flow у Deep Agents / LangChain может быть более сложная структура потока -- текущий adapter flatten’ит её слишком агрессивно - -4. Код никак не валидирует, что наружу уходит именно main assistant output - -Итоговая гипотеза: - -> После tool/file/vision flow `platform-agent` неправильно адаптирует `astream_events()` в `MsgEventTextChunk`. Начало итогового пользовательского ответа может находиться не в том raw event, который сейчас читается через `chunk.content`, либо теряться из-за упрощённой фильтрации потока. - -## Подтверждённый отдельный баг: duplicate `END` - -Это отдельный platform-side дефект. - -Сейчас: - -- `external/platform-agent/src/agent/service.py` уже делает `yield MsgEventEnd(...)` -- `external/platform-agent/src/api/external.py` после завершения цикла дополнительно отправляет ещё один `MsgEventEnd(...)` - -По логам это выглядит так: - -```text -platform-agent-1 | [raw-stream][server-yield] chat=1 event=END -platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_END text=None path=None -platform-agent-1 | [raw-stream][ws-send] chat=1 event=AGENT_EVENT_END duplicate_end=true -matrix-bot-1 | ... AGENT_EVENT_END tokens_used=0 -matrix-bot-1 | ... AGENT_EVENT_END tokens_used=0 -``` - -Независимая оценка: - -- duplicate `END` — реальный баг платформы -- он делает границу ответа менее надёжной -- но в текущем воспроизведении он **не объясняет** сам факт потери первого text chunk - -То есть это важный, но вторичный дефект. - -## Подтверждённый отдельный баг: большие изображения ломают image path - -В отдельном воспроизведении платформа падала на анализе изображений с provider error: - -```text -Exceeded limit on max bytes per data-uri item : 10485760 -``` - -И параллельно websocket рвался с: - -```text -received 1009 (message too big); then sent 1009 (message too big) -``` - -Это означает: - -- image path отправляет в provider oversized `data:` URI -- безопасной предвалидации / деградации нет -- failure scenario сопровождается разрывом websocket-соединения - -Независимая оценка: - -- это отдельный platform-side bug -- он не объясняет потерю первого чанка в текстовом сценарии напрямую -- но подтверждает, что path `tool/file/image -> platform stream` в целом сейчас нестабилен - -## Что мы считаем исключённым - -С достаточной уверенностью можно исключить: - -1. Локальный slicing текста в `surfaces` -2. Локальную “умную” реконструкцию потока, потому что она была удалена -3. Matrix sender как источник потери первого чанка -4. `platform-agent_api` queue/dequeue как primary root cause именно в этом воспроизведении - -## Финальная независимая оценка - -Текущая оценка вероятностей: - -- `75%` — ошибка в `platform-agent`, в адаптере `astream_events() -> MsgEventTextChunk` -- `15%` — provider/model stream приносит начало ответа в другой raw event/field, а `platform-agent` его некорректно интерпретирует -- `10%` — вторичные platform-side boundary defects (`duplicate END`, queue semantics и т.д.) -- `~0-5%` — ошибка в `surfaces` - -Итоговый вывод: - -> На текущем состоянии кода баг с пропадающим началом ответа следует считать platform-side дефектом. Воспроизведение после cleanup transport layer показывает, что первый повреждённый text chunk формируется уже внутри `platform-agent` до отправки в websocket. - -## Что нужно исправить в платформе - -### Обязательно - -1. Убрать duplicate `END` -- один ответ должен завершаться ровно одним `MsgEventEnd` - -2. Перепроверить адаптацию `astream_events()` в `service.py` -- логировать и проанализировать raw `event["event"]` -- проверить `event.get("name")` -- смотреть `event.get("ns")` -- сравнить `chunk.content` с тем, что реально лежит в `chunk.text` / raw chunk repr - -3. Форвардить наружу только финальный main assistant output -- не flatten’ить весь поток без учёта `ns/source` - -### Желательно - -4. Сделать image path устойчивым к oversized payload -- preflight check размера -- resize/compress или controlled error без разрыва WS - -5. Улучшить client/server protocol boundary -- более строгая корреляция запроса и ответа -- более однозначная semantics конца ответа - -## Что мы сделали со своей стороны - -Со стороны `surfaces` уже выполнено следующее: - -- transport layer очищен до thin adapter над upstream `AgentApi` -- локальные stream-workaround’ы удалены -- рабочая интеграция сохранена -- known issue задокументирован - -То есть текущая интеграция не “идеальна”, но её поведение теперь достаточно чистое, чтобы platform bug было можно локализовать без смешения ответственности. - -## Приложение: короткий диагноз - -Если нужна самая короткая формулировка для issue tracker: - -> После cleanup transport layer в `surfaces` и воспроизведения на clean upstream platform repos видно, что `platform-agent` иногда сам порождает первый `MsgEventTextChunk` уже обрезанным, особенно после tool/file flow. Дополнительно подтверждены duplicate `END` и отдельный image-path failure на больших `data:` URI. diff --git a/docs/research/aiogram-architecture-review.md b/docs/research/aiogram-architecture-review.md deleted file mode 100644 index c0a7946..0000000 --- a/docs/research/aiogram-architecture-review.md +++ /dev/null @@ -1,172 +0,0 @@ -# Ресёрч: aiogram 3.x Architecture Review - -> **Дата:** 2026-03-30 -> **Вердикт:** APPROVED с двумя уточнениями - ---- - -## 1. Структура проекта - -**Официальный пример multi_file_bot:** -``` -multi_file_bot/ - bot.py - handlers/ - common.py - ... -``` - -**Best practice для средних проектов (наш случай):** -``` -adapter/telegram/ - bot.py ← Dispatcher + include_routers + polling/webhook - converter.py ← граница aiogram ↔ core/ - states.py ← все StatesGroup - handlers/ ← по одному Router на модуль - keyboards/ ← InlineKeyboardBuilder фабрики - middleware.py ← DI + logging + rate limit -``` - -**Оценка:** наша структура соответствует стандарту. ✓ - ---- - -## 2. Middleware vs Converter - -В aiogram 3.x эти два паттерна решают **разные задачи** и должны использоваться вместе. - -| | Middleware | Converter | -|---|---|---| -| Назначение | Infrastructure | Бизнес-логика | -| Что делает | Логирование, DI, rate limit, сессия БД | aiogram Event → IncomingEvent | -| Когда вызывается | До и после хендлера | Внутри хендлера | - -**Правильная комбинация:** -```python -# middleware.py — только infrastructure -class DependencyMiddleware(BaseMiddleware): - def __init__(self, platform, store): - self.platform = platform - self.store = store - - async def __call__(self, handler, event, data): - data["platform"] = self.platform - data["store"] = self.store - return await handler(event, data) - -# handler — converter вызывается внутри -async def handle_message(message: Message, platform, store): - event = to_incoming_message(message) # converter - results = await dispatcher.dispatch(event, platform, store) - await send_results(message, results) # converter обратно -``` - -**Оценка:** наш converter.py — правильный паттерн. Добавить `middleware.py` для DI. ✓+ - ---- - -## 3. Dependency Injection - -Стандарт aiogram 3.x — **через middleware + data dict**: - -```python -# Регистрация в bot.py -dp.message.middleware(DependencyMiddleware(platform=platform_client, store=store)) - -# Получение в handler (через type hint на имя ключа) -async def handle_message(message: Message, platform: PlatformClient, store: StateStore): - ... -``` - -Альтернатива — через `dp["key"] = value` (Dispatcher workflow data): -```python -dp["platform"] = platform_client # в bot.py - -async def handler(message: Message, platform: PlatformClient): # aiogram сам находит по типу - ... -``` - -**Оценка:** нужно явно добавить один из этих механизмов, иначе хендлеры не получат platform/store. ⚠️ - ---- - -## 4. InlineKeyboardBuilder - -`InlineKeyboardBuilder` — рекомендуемый подход в aiogram 3.x. `InlineKeyboardMarkup` с вложенными списками считается устаревшим стилем. - -```python -# keyboards/chat.py -from aiogram.utils.keyboard import InlineKeyboardBuilder - -def chats_keyboard(chats: list[ChatContext]) -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - for chat in chats: - builder.button(text=f"💬 {chat.name}", callback_data=f"chat:{chat.chat_id}") - builder.button(text="➕ Новый чат", callback_data="new_chat") - builder.adjust(1) # одна кнопка в строку - return builder.as_markup() -``` - -**Оценка:** использовать `InlineKeyboardBuilder` везде. ✓ - ---- - -## 5. F-фильтры (MagicFilter) - -aiogram 3.x MagicFilter (`F`) — стандарт вместо ручных проверок в хендлерах: - -```python -from aiogram import F - -# Вместо if message.text == "/start" внутри хендлера -router.message.register(start_handler, Command("start")) - -# Фильтр по типу вложения -router.message.register(voice_handler, F.voice) -router.message.register(photo_handler, F.photo) - -# Фильтр по состоянию -router.message.register(handle_name_input, OnboardingState.waiting_for_name) - -# Callback фильтр -router.callback_query.register(confirm_handler, F.data.startswith("confirm:")) -``` - -**Оценка:** использовать F-фильтры при регистрации роутеров — чище, чем if/else в хендлерах. ✓ - ---- - -## 6. Сцены (Scenes) — новинка aiogram 3.x - -aiogram 3.4+ ввёл `Scene` как улучшенный FSM для сложных диалогов: - -```python -from aiogram.fsm.scene import Scene, on - -class OnboardingScene(Scene, state="onboarding"): - @on.message.enter() - async def on_enter(self, message: Message): - await message.answer("Как зовут твоего агента?") - - @on.message() - async def on_name(self, message: Message, state: FSMContext): - await state.update_data(agent_name=message.text) - await self.wizard.goto(OnboardingScene2) -``` - -**Оценка:** Scenes — опциональное улучшение для онбординга. Классический FSM через StatesGroup тоже корректен и проще для понимания. Использовать StatesGroup для прототипа, Scenes — в будущем. ✓ - ---- - -## Итог - -| Решение | Статус | -|---|---| -| Router-based архитектура, один Router на модуль | ✅ Стандарт | -| converter.py как граница aiogram ↔ core/ | ✅ Правильный паттерн | -| InlineKeyboardBuilder в keyboards/ | ✅ Рекомендуется | -| SQLiteStorage для FSM | ✅ Стандарт для MVP | -| **Нужно добавить: DependencyMiddleware** | ⚠️ DI без него не работает | -| **Нужно добавить: F-фильтры при регистрации** | ⚠️ Иначе проверки в хендлерах | - -**Архитектура одобрена.** Два уточнения (middleware.py и F-фильтры) небольшие и органично вписываются в текущую структуру. diff --git a/docs/superpowers/plans/2026-03-31-forum-topics.md b/docs/superpowers/plans/2026-03-31-forum-topics.md deleted file mode 100644 index 87a92b2..0000000 --- a/docs/superpowers/plans/2026-03-31-forum-topics.md +++ /dev/null @@ -1,704 +0,0 @@ -# Forum Topics Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Добавить опциональный Forum Topics режим — пользователь подключает Telegram-супергруппу, его DM-чаты синхронизируются с нативными темами форума. - -**Architecture:** Каждый `chat` в БД получает опциональный `forum_thread_id`. Адаптер маршрутизирует: пришло из DM → отвечает в DM с тегом, пришло из Forum-темы → отвечает в ту же тему без тега. Core не меняется — `chat_id` (UUID) одинаковый для обеих поверхностей. - -**Tech Stack:** aiogram 3.x, SQLite (sqlite3), Python 3.11+ - -**Working directory:** `/Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram` - ---- - -## File Map - -| Файл | Действие | Что меняется | -|------|----------|--------------| -| `adapter/telegram/db.py` | Modify | Миграция схемы + 4 новых функции | -| `adapter/telegram/states.py` | Modify | Добавить `ForumSetupState` | -| `adapter/telegram/converter.py` | Modify | Добавить `is_forum_message`, `resolve_chat_id` | -| `adapter/telegram/handlers/forum.py` | Create | `/forum` команда + онбординг | -| `adapter/telegram/handlers/chat.py` | Modify | `cmd_new_chat` + `handle_message` с Forum-маршрутизацией | -| `adapter/telegram/bot.py` | Modify | Зарегистрировать `forum.router` | -| `tests/adapter/test_forum_db.py` | Create | Тесты новых функций БД | - ---- - -## Task 1: DB migration + новые функции - -**Files:** -- Modify: `adapter/telegram/db.py` -- Create: `tests/adapter/__init__.py` -- Create: `tests/adapter/test_forum_db.py` - -- [ ] **Step 1: Создать тест-файл и написать падающие тесты** - -```python -# tests/adapter/__init__.py -# (пустой файл) -``` - -```python -# tests/adapter/test_forum_db.py -from __future__ import annotations - -import os -import tempfile -import pytest - -os.environ["DB_PATH"] = ":memory:" - -from adapter.telegram.db import ( - init_db, - get_or_create_tg_user, - create_chat, - set_forum_group, - get_forum_group, - set_forum_thread, - get_chat_by_thread, -) - - -@pytest.fixture(autouse=True) -def fresh_db(tmp_path, monkeypatch): - db_file = str(tmp_path / "test.db") - monkeypatch.setenv("DB_PATH", db_file) - # reload module so DB_PATH is picked up - import importlib - import adapter.telegram.db as db_mod - importlib.reload(db_mod) - db_mod.init_db() - return db_mod - - -def test_set_and_get_forum_group(fresh_db): - db = fresh_db - db.get_or_create_tg_user(111, "usr-111", "Alice") - assert db.get_forum_group(111) is None - db.set_forum_group(111, 999888) - assert db.get_forum_group(111) == 999888 - - -def test_set_forum_thread_and_get_by_thread(fresh_db): - db = fresh_db - db.get_or_create_tg_user(222, "usr-222", "Bob") - chat_id = db.create_chat(222, "Чат #1") - assert db.get_chat_by_thread(222, 42) is None - db.set_forum_thread(chat_id, 42) - chat = db.get_chat_by_thread(222, 42) - assert chat is not None - assert chat["chat_id"] == chat_id - assert chat["forum_thread_id"] == 42 - - -def test_get_chat_by_thread_wrong_user(fresh_db): - db = fresh_db - db.get_or_create_tg_user(333, "usr-333", "Carol") - chat_id = db.create_chat(333, "Чат #1") - db.set_forum_thread(chat_id, 77) - assert db.get_chat_by_thread(999, 77) is None -``` - -- [ ] **Step 2: Запустить тесты — убедиться что падают** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot -PYTHONPATH=.worktrees/telegram pytest tests/adapter/test_forum_db.py -v -``` - -Ожидаем: `ImportError` — функции ещё не существуют. - -- [ ] **Step 3: Добавить миграцию и новые функции в `db.py`** - -В `init_db()` добавить после `CREATE TABLE IF NOT EXISTS chats`: - -```python -def init_db() -> None: - with _conn() as con: - con.executescript(""" - CREATE TABLE IF NOT EXISTS tg_users ( - tg_user_id INTEGER PRIMARY KEY, - platform_user_id TEXT NOT NULL, - display_name TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - forum_group_id INTEGER - ); - - CREATE TABLE IF NOT EXISTS chats ( - chat_id TEXT PRIMARY KEY, - tg_user_id INTEGER NOT NULL, - name TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - archived_at TIMESTAMP, - forum_thread_id INTEGER, - FOREIGN KEY(tg_user_id) REFERENCES tg_users(tg_user_id) - ); - """) - # Миграция для существующих БД - try: - con.execute("ALTER TABLE tg_users ADD COLUMN forum_group_id INTEGER") - except Exception: - pass - try: - con.execute("ALTER TABLE chats ADD COLUMN forum_thread_id INTEGER") - except Exception: - pass -``` - -Добавить в конец файла: - -```python -def set_forum_group(tg_user_id: int, group_id: int) -> None: - with _conn() as con: - con.execute( - "UPDATE tg_users SET forum_group_id = ? WHERE tg_user_id = ?", - (group_id, tg_user_id), - ) - - -def get_forum_group(tg_user_id: int) -> int | None: - with _conn() as con: - row = con.execute( - "SELECT forum_group_id FROM tg_users WHERE tg_user_id = ?", - (tg_user_id,), - ).fetchone() - return row["forum_group_id"] if row else None - - -def set_forum_thread(chat_id: str, thread_id: int) -> None: - with _conn() as con: - con.execute( - "UPDATE chats SET forum_thread_id = ? WHERE chat_id = ?", - (thread_id, chat_id), - ) - - -def get_chat_by_thread(tg_user_id: int, thread_id: int) -> dict | None: - with _conn() as con: - row = con.execute( - "SELECT * FROM chats WHERE tg_user_id = ? AND forum_thread_id = ? " - "AND archived_at IS NULL", - (tg_user_id, thread_id), - ).fetchone() - return dict(row) if row else None -``` - -- [ ] **Step 4: Запустить тесты — убедиться что проходят** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot -PYTHONPATH=.worktrees/telegram pytest tests/adapter/test_forum_db.py -v -``` - -Ожидаем: `3 passed`. - -- [ ] **Step 5: Убедиться что все тесты проекта не сломались** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot -PYTHONPATH=.worktrees/telegram pytest tests/ -v -``` - -Ожидаем: все тесты `passed`. - -- [ ] **Step 6: Commit** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram -git add adapter/telegram/db.py ../../tests/adapter/ -git commit -m "feat: db migration + forum_group_id/forum_thread_id functions" -``` - ---- - -## Task 2: ForumSetupState в states.py - -**Files:** -- Modify: `adapter/telegram/states.py` - -- [ ] **Step 1: Добавить ForumSetupState** - -```python -# adapter/telegram/states.py -from aiogram.fsm.state import State, StatesGroup - - -class ChatState(StatesGroup): - idle = State() - waiting_response = State() - - -class SettingsState(StatesGroup): - menu = State() - soul_editing = State() - confirm_action = State() - - -class ForumSetupState(StatesGroup): - waiting_for_group = State() # ждём пересылку из группы -``` - -- [ ] **Step 2: Проверить синтаксис** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot -uv run python -m py_compile .worktrees/telegram/adapter/telegram/states.py && echo OK -``` - -Ожидаем: `OK`. - -- [ ] **Step 3: Commit** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram -git add adapter/telegram/states.py -git commit -m "feat: add ForumSetupState" -``` - ---- - -## Task 3: converter.py — is_forum_message и resolve_chat_id - -**Files:** -- Modify: `adapter/telegram/converter.py` - -- [ ] **Step 1: Добавить функции в converter.py** - -Добавить в конец файла (после `format_outgoing`): - -```python -def is_forum_message(message: Message) -> bool: - """Сообщение пришло из Forum-темы (не из General и не из DM).""" - return ( - message.message_thread_id is not None - and message.chat.type in ("supergroup", "group") - ) - - -def resolve_forum_chat_id(message: Message) -> str | None: - """ - Для Forum-сообщения ищет chat_id (UUID) по forum_thread_id в БД. - Возвращает None если тема не зарегистрирована. - """ - from adapter.telegram import db - tg_user_id = message.from_user.id - thread_id = message.message_thread_id - chat = db.get_chat_by_thread(tg_user_id, thread_id) - return chat["chat_id"] if chat else None -``` - -- [ ] **Step 2: Проверить синтаксис** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot -uv run python -m py_compile .worktrees/telegram/adapter/telegram/converter.py && echo OK -``` - -Ожидаем: `OK`. - -- [ ] **Step 3: Commit** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram -git add adapter/telegram/converter.py -git commit -m "feat: add is_forum_message and resolve_forum_chat_id to converter" -``` - ---- - -## Task 4: handlers/forum.py — /forum и онбординг - -**Files:** -- Create: `adapter/telegram/handlers/forum.py` - -- [ ] **Step 1: Создать handlers/forum.py** - -```python -# adapter/telegram/handlers/forum.py -from __future__ import annotations - -from aiogram import Bot, F, Router -from aiogram.filters import Command -from aiogram.fsm.context import FSMContext -from aiogram.types import Message - -from adapter.telegram import db -from adapter.telegram.states import ChatState, ForumSetupState - -router = Router(name="forum") - - -async def _check_forum_admin(bot: Bot, group_id: int) -> bool: - """Проверяет что бот — администратор с правом управления темами.""" - try: - me = await bot.get_me() - member = await bot.get_chat_member(group_id, me.id) - return ( - member.status in ("administrator", "creator") - and getattr(member, "can_manage_topics", False) - ) - except Exception: - return False - - -@router.message(Command("forum")) -async def cmd_forum(message: Message, state: FSMContext) -> None: - await state.set_state(ForumSetupState.waiting_for_group) - await message.answer( - "📋 Подключение Forum-группы\n\n" - "1. Создай супергруппу в Telegram\n" - "2. Включи Topics: настройки группы → Topics\n" - "3. Добавь меня как администратора с правом управления темами\n" - "4. Перешли мне любое сообщение из этой группы\n\n" - "Или /cancel чтобы отменить." - ) - - -@router.message(ForumSetupState.waiting_for_group, Command("cancel")) -async def cmd_cancel_forum(message: Message, state: FSMContext) -> None: - await state.set_state(ChatState.idle) - await message.answer("❌ Настройка форума отменена.") - - -@router.message(ForumSetupState.waiting_for_group, F.forward_from_chat) -async def handle_group_forward( - message: Message, - state: FSMContext, -) -> None: - group = message.forward_from_chat - - if group.type != "supergroup": - await message.answer( - "⚠️ Это не супергруппа. Нужна именно супергруппа с включёнными Topics." - ) - return - - group_id = group.id - - if not await _check_forum_admin(message.bot, group_id): - await message.answer( - "⚠️ Не могу управлять темами в этой группе.\n\n" - "Убедись что:\n" - "• Я добавлен как администратор\n" - "• У меня есть право «Управление темами»" - ) - return - - tg_id = message.from_user.id - db.set_forum_group(tg_id, group_id) - - # Создать Forum-темы для всех существующих активных DM-чатов - chats = db.get_user_chats(tg_id) - created = 0 - for chat in chats: - if chat.get("forum_thread_id"): - continue # уже есть тема - try: - topic = await message.bot.create_forum_topic( - chat_id=group_id, - name=chat["name"], - ) - db.set_forum_thread(chat["chat_id"], topic.message_thread_id) - created += 1 - except Exception: - pass # не страшно — тему можно создать позже через /new - - await state.set_state(ChatState.idle) - await message.answer( - f"✅ Группа «{group.title}» подключена!\n" - f"Создано тем в форуме: {created} из {len(chats)}.\n\n" - "Теперь можешь писать как в DM, так и в темах форума." - ) - - -@router.message(ForumSetupState.waiting_for_group) -async def handle_forward_wrong(message: Message) -> None: - await message.answer( - "Жду пересланное сообщение из группы. " - "Перешли любое сообщение из своей супергруппы." - ) -``` - -- [ ] **Step 2: Проверить синтаксис** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot -uv run python -m py_compile .worktrees/telegram/adapter/telegram/handlers/forum.py && echo OK -``` - -Ожидаем: `OK`. - -- [ ] **Step 3: Commit** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram -git add adapter/telegram/handlers/forum.py -git commit -m "feat: add handlers/forum.py — /forum onboarding flow" -``` - ---- - -## Task 5: handlers/chat.py — Forum-маршрутизация - -**Files:** -- Modify: `adapter/telegram/handlers/chat.py` - -- [ ] **Step 1: Обновить импорты в chat.py** - -Заменить блок импортов целиком: - -```python -# adapter/telegram/handlers/chat.py -from __future__ import annotations - -import asyncio - -from aiogram import F, Router -from aiogram.filters import Command -from aiogram.fsm.context import FSMContext -from aiogram.types import CallbackQuery, Message - -from adapter.telegram import db -from adapter.telegram.converter import ( - format_outgoing, - from_message, - is_forum_message, - resolve_forum_chat_id, -) -from adapter.telegram.keyboards.chat import chats_list_keyboard -from adapter.telegram.keyboards.confirm import confirm_keyboard -from adapter.telegram.states import ChatState -from core.handler import EventDispatcher -from core.protocol import OutgoingMessage, OutgoingUI - -router = Router(name="chat") -``` - -- [ ] **Step 2: Обновить `_send_outgoing` — добавить Forum-вариант** - -Заменить функцию `_send_outgoing`: - -```python -async def _send_outgoing( - message: Message, - chat_name: str, - events: list, - forum_group_id: int | None = None, - forum_thread_id: int | None = None, -) -> None: - for event in events: - if forum_group_id and forum_thread_id: - # Ответ в Forum-тему (без тега) - text = event.text if isinstance(event, (OutgoingMessage, OutgoingUI)) else str(event) - if isinstance(event, OutgoingUI) and event.buttons: - action_id = event.buttons[0].payload.get("action_id", "unknown") - kb = confirm_keyboard(action_id) - await message.bot.send_message( - forum_group_id, text, - message_thread_id=forum_thread_id, - reply_markup=kb, - ) - else: - await message.bot.send_message( - forum_group_id, text, - message_thread_id=forum_thread_id, - ) - else: - # Ответ в DM с тегом - if isinstance(event, OutgoingUI) and event.buttons: - action_id = event.buttons[0].payload.get("action_id", "unknown") - kb = confirm_keyboard(action_id) - await message.answer(format_outgoing(chat_name, event), reply_markup=kb) - elif isinstance(event, (OutgoingMessage, OutgoingUI)): - await message.answer(format_outgoing(chat_name, event)) -``` - -- [ ] **Step 3: Обновить `handle_message` — Forum-маршрутизация** - -Заменить функцию `handle_message`: - -```python -@router.message(ChatState.idle, (F.text | F.photo | F.document | F.voice) & ~F.text.startswith("/")) -async def handle_message( - message: Message, - state: FSMContext, - dispatcher: EventDispatcher, -) -> None: - tg_id = message.from_user.id - - # Определяем chat_id и канал ответа - if is_forum_message(message): - chat_id = resolve_forum_chat_id(message) - if not chat_id: - await message.reply( - "Эта тема не зарегистрирована как чат. " - "Введи /new в этой теме чтобы создать чат." - ) - return - chat = db.get_chat_by_thread(tg_id, message.message_thread_id) - chat_name = chat["name"] - forum_group_id = message.chat.id - forum_thread_id = message.message_thread_id - else: - data = await state.get_data() - chat_id = data.get("active_chat_id") - chat_name = data.get("active_chat_name", "Чат") - forum_group_id = None - forum_thread_id = None - - if not chat_id: - await message.answer("Нет активного чата. Введите /start") - return - - await state.set_state(ChatState.waiting_response) - - async def _typing_loop(): - while True: - await message.bot.send_chat_action(message.chat.id, "typing") - await asyncio.sleep(4) - - task = asyncio.create_task(_typing_loop()) - try: - tg_user = db.get_or_create_tg_user(tg_id, str(tg_id), message.from_user.full_name) - platform_user_id = tg_user.get("platform_user_id", str(tg_id)) - - incoming = from_message(message, chat_id) - incoming.user_id = platform_user_id - events = await dispatcher.dispatch(incoming) - finally: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - await state.set_state(ChatState.idle) - await _send_outgoing(message, chat_name, events, forum_group_id, forum_thread_id) -``` - -- [ ] **Step 4: Обновить `cmd_new_chat` — ветвление DM vs Forum** - -Заменить функцию `cmd_new_chat`: - -```python -@router.message(Command("new")) -async def cmd_new_chat(message: Message, state: FSMContext) -> None: - tg_id = message.from_user.id - args = message.text.split(maxsplit=1) - name = args[1].strip() if len(args) > 1 else None - - if is_forum_message(message): - # /new в Forum-теме — регистрируем эту тему как чат - thread_id = message.message_thread_id - existing = db.get_chat_by_thread(tg_id, thread_id) - if existing: - await message.reply(f"Эта тема уже зарегистрирована как [{existing['name']}].") - return - - count = db.count_chats(tg_id) - chat_name = name or f"Чат #{count + 1}" - chat_id = db.create_chat(tg_id, chat_name) - db.set_forum_thread(chat_id, thread_id) - - await message.reply(f"✅ [{chat_name}] зарегистрирован. Пиши здесь!") - else: - # /new в DM - count = db.count_chats(tg_id) - chat_name = name or f"Чат #{count + 1}" - chat_id = db.create_chat(tg_id, chat_name) - - # Если есть форум-группа — создать тему и там - group_id = db.get_forum_group(tg_id) - if group_id: - try: - topic = await message.bot.create_forum_topic( - chat_id=group_id, - name=chat_name, - ) - db.set_forum_thread(chat_id, topic.message_thread_id) - except Exception: - pass # не блокирует создание DM-чата - - await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) - await state.set_state(ChatState.idle) - await message.answer(f"✅ [{chat_name}] создан. Пиши!") -``` - -- [ ] **Step 5: Проверить синтаксис** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot -uv run python -m py_compile .worktrees/telegram/adapter/telegram/handlers/chat.py && echo OK -``` - -Ожидаем: `OK`. - -- [ ] **Step 6: Запустить все тесты** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot -PYTHONPATH=.worktrees/telegram pytest tests/ -v -``` - -Ожидаем: все тесты `passed`. - -- [ ] **Step 7: Commit** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram -git add adapter/telegram/handlers/chat.py -git commit -m "feat: forum routing in handle_message and cmd_new_chat" -``` - ---- - -## Task 6: bot.py — регистрация forum.router - -**Files:** -- Modify: `adapter/telegram/bot.py` - -- [ ] **Step 1: Добавить импорт и регистрацию router** - -В блоке импортов добавить: - -```python -from adapter.telegram.handlers import auth, chat, confirm, forum, settings -``` - -В `main()` после `dp.include_router(auth.router)`: - -```python - dp.include_router(auth.router) - dp.include_router(forum.router) # ← добавить - dp.include_router(chat.router) - dp.include_router(settings.router) - dp.include_router(confirm.router) -``` - -- [ ] **Step 2: Проверить синтаксис** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot -uv run python -m py_compile .worktrees/telegram/adapter/telegram/bot.py && echo OK -``` - -Ожидаем: `OK`. - -- [ ] **Step 3: Финальный прогон всех тестов** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot -PYTHONPATH=.worktrees/telegram pytest tests/ -v -``` - -Ожидаем: все тесты `passed`. - -- [ ] **Step 4: Commit** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot/.worktrees/telegram -git add adapter/telegram/bot.py -git commit -m "feat: register forum router in bot.py" -``` diff --git a/docs/superpowers/plans/2026-03-31-matrix-adapter.md b/docs/superpowers/plans/2026-03-31-matrix-adapter.md deleted file mode 100644 index 7f3ea28..0000000 --- a/docs/superpowers/plans/2026-03-31-matrix-adapter.md +++ /dev/null @@ -1,1681 +0,0 @@ -# Matrix Adapter Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Implement `adapter/matrix/` — Matrix bot using matrix-nio that connects to the Lambda platform via `EventDispatcher` and `MockPlatformClient`. - -**Architecture:** Room-type routing — each incoming event is classified by room type (chat/settings) then dispatched. DM room = C1 (first chat). Space and Settings room created lazily on first `!new`. Core business logic lives in `EventDispatcher`; the adapter converts nio events ↔ protocol events. - -**Tech Stack:** matrix-nio 0.21+, Python 3.11+, `SQLiteStore` (key-value), `MockPlatformClient`, pytest-asyncio - ---- - -## File map - -| File | Responsibility | -|------|---------------| -| `adapter/matrix/store.py` | Key-prefix helpers for room/user metadata in `StateStore` | -| `adapter/matrix/converter.py` | nio event → `IncomingEvent`, `extract_attachments` | -| `adapter/matrix/reactions.py` | `add_reaction`, `edit_message`, `build_skills_text` | -| `adapter/matrix/handlers/auth.py` | Invite → join + register room + welcome message | -| `adapter/matrix/handlers/chat.py` | Text messages, `!new`, `!chats` | -| `adapter/matrix/handlers/confirm.py` | 👍/❌ reactions + `!yes`/`!no` | -| `adapter/matrix/handlers/settings.py` | `!skills` (m.replace), `!soul`, `!safety`, `!plan`, `!status`, `!whoami`, `!connectors` | -| `adapter/matrix/bot.py` | `AsyncClient`, sync loop, event routing | - -Store key conventions (all via `StateStore` KV): -- `matrix_room:{room_id}` → `{room_type, chat_id, display_name, matrix_user_id}` -- `matrix_user:{matrix_user_id}` → `{platform_user_id, display_name, space_id, settings_room_id, next_chat_index}` -- `matrix_state:{room_id}` → `{state}` — one of `idle | waiting_response | confirm_pending | settings_active` -- `matrix_skills_msg:{room_id}` → `{event_id}` — event_id of the last `!skills` message (for m.replace) - ---- - -### Task 1: Store helpers - -**Files:** -- Create: `adapter/matrix/__init__.py` -- Create: `adapter/matrix/store.py` -- Create: `tests/adapter/__init__.py` -- Create: `tests/adapter/matrix/__init__.py` -- Create: `tests/adapter/matrix/test_store.py` - -- [ ] **Step 1: Write failing test** - -```python -# tests/adapter/matrix/test_store.py -import pytest -from core.store import InMemoryStore -from adapter.matrix.store import ( - get_room_meta, set_room_meta, - get_user_meta, set_user_meta, - get_room_state, set_room_state, - next_chat_id, -) - - -@pytest.fixture -def store(): - return InMemoryStore() - - -async def test_room_meta_roundtrip(store): - meta = {"room_type": "chat", "chat_id": "C1", "display_name": "Чат 1", "matrix_user_id": "@alice:m.org"} - await set_room_meta(store, "!r:m.org", meta) - assert await get_room_meta(store, "!r:m.org") == meta - - -async def test_room_meta_missing(store): - assert await get_room_meta(store, "!nonexistent:m.org") is None - - -async def test_user_meta_roundtrip(store): - meta = {"platform_user_id": "usr-1", "display_name": "Alice", - "space_id": None, "settings_room_id": None, "next_chat_index": 1} - await set_user_meta(store, "@alice:m.org", meta) - assert await get_user_meta(store, "@alice:m.org") == meta - - -async def test_room_state_roundtrip(store): - await set_room_state(store, "!r:m.org", "idle") - assert await get_room_state(store, "!r:m.org") == "idle" - await set_room_state(store, "!r:m.org", "waiting_response") - assert await get_room_state(store, "!r:m.org") == "waiting_response" - - -async def test_room_state_default_idle(store): - assert await get_room_state(store, "!unknown:m.org") == "idle" - - -async def test_next_chat_id_increments(store): - uid = "@alice:m.org" - await set_user_meta(store, uid, {"next_chat_index": 1}) - assert await next_chat_id(store, uid) == "C1" - assert await next_chat_id(store, uid) == "C2" - assert await next_chat_id(store, uid) == "C3" -``` - -- [ ] **Step 2: Run — expect ImportError** - -```bash -cd /path/to/surfaces-bot && pytest tests/adapter/matrix/test_store.py -v -``` - -- [ ] **Step 3: Create `__init__.py` files** - -```bash -touch adapter/__init__.py adapter/matrix/__init__.py tests/adapter/__init__.py tests/adapter/matrix/__init__.py -``` - -- [ ] **Step 4: Implement store.py** - -```python -# adapter/matrix/store.py -from __future__ import annotations -from core.store import StateStore - - -async def get_room_meta(store: StateStore, room_id: str) -> dict | None: - return await store.get(f"matrix_room:{room_id}") - - -async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None: - await store.set(f"matrix_room:{room_id}", meta) - - -async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None: - return await store.get(f"matrix_user:{matrix_user_id}") - - -async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None: - await store.set(f"matrix_user:{matrix_user_id}", meta) - - -async def get_room_state(store: StateStore, room_id: str) -> str: - data = await store.get(f"matrix_state:{room_id}") - return data["state"] if data else "idle" - - -async def set_room_state(store: StateStore, room_id: str, state: str) -> None: - await store.set(f"matrix_state:{room_id}", {"state": state}) - - -async def next_chat_id(store: StateStore, matrix_user_id: str) -> str: - """Allocate next chat_id (C1, C2, ...) and increment counter in user meta.""" - meta = await get_user_meta(store, matrix_user_id) or {} - index = meta.get("next_chat_index", 1) - meta["next_chat_index"] = index + 1 - await set_user_meta(store, matrix_user_id, meta) - return f"C{index}" -``` - -- [ ] **Step 5: Run — expect all PASS** - -```bash -pytest tests/adapter/matrix/test_store.py -v -``` -Expected: 6 tests PASS. - -- [ ] **Step 6: Commit** - -```bash -git add adapter/__init__.py adapter/matrix/__init__.py adapter/matrix/store.py \ - tests/adapter/__init__.py tests/adapter/matrix/__init__.py tests/adapter/matrix/test_store.py -git commit -m "feat(matrix): room/user store helpers" -``` - ---- - -### Task 2: Converter - -**Files:** -- Create: `adapter/matrix/converter.py` -- Create: `tests/adapter/matrix/test_converter.py` - -- [ ] **Step 1: Write failing tests** - -```python -# tests/adapter/matrix/test_converter.py -from types import SimpleNamespace -from core.protocol import Attachment, IncomingCallback, IncomingCommand, IncomingMessage -from adapter.matrix.converter import from_room_event - - -def text_event(body, sender="@a:m.org", event_id="$e1"): - return SimpleNamespace(sender=sender, body=body, event_id=event_id, - msgtype="m.text", replyto_event_id=None) - - -def file_event(url="mxc://x/y", filename="doc.pdf", mime="application/pdf"): - return SimpleNamespace(sender="@a:m.org", body=filename, event_id="$e2", - msgtype="m.file", replyto_event_id=None, - url=url, mimetype=mime) - - -def image_event(url="mxc://x/img", mime="image/jpeg"): - return SimpleNamespace(sender="@a:m.org", body="img.jpg", event_id="$e3", - msgtype="m.image", replyto_event_id=None, - url=url, mimetype=mime) - - -def audio_event(url="mxc://x/audio", mime="audio/ogg"): - return SimpleNamespace(sender="@a:m.org", body="voice.ogg", event_id="$e4", - msgtype="m.audio", replyto_event_id=None, - url=url, mimetype=mime) - - -def reaction_event(key, reacted_to="$orig"): - return SimpleNamespace(sender="@a:m.org", key=key, reacted_to_id=reacted_to, event_id="$r1") - - -async def test_plain_text_to_incoming_message(): - result = from_room_event(text_event("Hello"), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingMessage) - assert result.text == "Hello" - assert result.platform == "matrix" - assert result.chat_id == "C1" - assert result.attachments == [] - - -async def test_bang_command_to_incoming_command(): - result = from_room_event(text_event("!new Analysis"), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingCommand) - assert result.command == "new" - assert result.args == ["Analysis"] - - -async def test_bang_command_no_args(): - result = from_room_event(text_event("!skills"), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingCommand) - assert result.command == "skills" - assert result.args == [] - - -async def test_yes_to_callback(): - result = from_room_event(text_event("!yes"), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingCallback) - assert result.action == "confirm" - - -async def test_no_to_callback(): - result = from_room_event(text_event("!no"), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingCallback) - assert result.action == "cancel" - - -async def test_file_attachment(): - result = from_room_event(file_event(), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingMessage) - assert len(result.attachments) == 1 - a = result.attachments[0] - assert a.type == "document" - assert a.url == "mxc://x/y" - assert a.filename == "doc.pdf" - assert a.mime_type == "application/pdf" - - -async def test_image_attachment(): - result = from_room_event(image_event(), room_id="!r:m.org", chat_id="C1") - assert result.attachments[0].type == "image" - assert result.attachments[0].mime_type == "image/jpeg" - - -async def test_audio_attachment(): - result = from_room_event(audio_event(), room_id="!r:m.org", chat_id="C1") - assert result.attachments[0].type == "audio" - - -async def test_confirm_reaction(): - result = from_room_event(reaction_event("👍"), room_id="!r:m.org", chat_id="C1", is_reaction=True) - assert isinstance(result, IncomingCallback) - assert result.action == "confirm" - - -async def test_cancel_reaction(): - result = from_room_event(reaction_event("❌"), room_id="!r:m.org", chat_id="C1", is_reaction=True) - assert isinstance(result, IncomingCallback) - assert result.action == "cancel" - - -async def test_skill_reaction_index(): - result = from_room_event(reaction_event("4️⃣"), room_id="!r:m.org", chat_id="C1", is_reaction=True) - assert isinstance(result, IncomingCallback) - assert result.action == "toggle_skill" - assert result.payload["skill_index"] == 3 # 0-based - - -async def test_unknown_reaction_returns_none(): - result = from_room_event(reaction_event("🎉"), room_id="!r:m.org", chat_id="C1", is_reaction=True) - assert result is None -``` - -- [ ] **Step 2: Run — expect ImportError** - -```bash -pytest tests/adapter/matrix/test_converter.py -v -``` - -- [ ] **Step 3: Implement converter.py** - -```python -# adapter/matrix/converter.py -from __future__ import annotations -from core.protocol import Attachment, IncomingCallback, IncomingCommand, IncomingEvent, IncomingMessage - -SKILL_REACTIONS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣"] -CONFIRM_REACTIONS = {"👍": "confirm", "❌": "cancel"} -_CALLBACK_COMMANDS = {"yes": "confirm", "no": "cancel"} - - -def from_room_event( - event, - room_id: str, - chat_id: str, - is_reaction: bool = False, -) -> IncomingEvent | None: - """Convert a nio event object to an IncomingEvent. Returns None if unrecognised.""" - if is_reaction: - return _from_reaction(event, chat_id) - - body: str = event.body - - if body.startswith("!"): - parts = body[1:].split(maxsplit=1) - cmd = parts[0].lower() - args = parts[1].split() if len(parts) > 1 else [] - - if cmd in _CALLBACK_COMMANDS: - return IncomingCallback( - user_id=event.sender, platform="matrix", chat_id=chat_id, - action=_CALLBACK_COMMANDS[cmd], payload={}, - ) - return IncomingCommand( - user_id=event.sender, platform="matrix", chat_id=chat_id, - command=cmd, args=args, - ) - - return IncomingMessage( - user_id=event.sender, platform="matrix", chat_id=chat_id, - text=body if event.msgtype == "m.text" else "", - attachments=extract_attachments(event), - reply_to=getattr(event, "replyto_event_id", None), - ) - - -def extract_attachments(event) -> list[Attachment]: - msgtype = getattr(event, "msgtype", "m.text") - url = getattr(event, "url", None) - mime = getattr(event, "mimetype", None) - - if msgtype == "m.image": - return [Attachment(type="image", url=url, mime_type=mime)] - if msgtype == "m.file": - return [Attachment(type="document", url=url, filename=event.body, mime_type=mime)] - if msgtype == "m.audio": - return [Attachment(type="audio", url=url, mime_type=mime)] - return [] - - -def _from_reaction(event, chat_id: str) -> IncomingCallback | None: - key = event.key - if key in CONFIRM_REACTIONS: - return IncomingCallback( - user_id=event.sender, platform="matrix", chat_id=chat_id, - action=CONFIRM_REACTIONS[key], - payload={"reacted_to_id": event.reacted_to_id}, - ) - if key in SKILL_REACTIONS: - return IncomingCallback( - user_id=event.sender, platform="matrix", chat_id=chat_id, - action="toggle_skill", - payload={"skill_index": SKILL_REACTIONS.index(key), "reacted_to_id": event.reacted_to_id}, - ) - return None -``` - -- [ ] **Step 4: Run — expect all PASS** - -```bash -pytest tests/adapter/matrix/test_converter.py -v -``` - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/converter.py tests/adapter/matrix/test_converter.py -git commit -m "feat(matrix): event converter" -``` - ---- - -### Task 3: Reactions helpers - -**Files:** -- Create: `adapter/matrix/reactions.py` -- Create: `tests/adapter/matrix/test_reactions.py` - -- [ ] **Step 1: Write failing tests** - -```python -# tests/adapter/matrix/test_reactions.py -from unittest.mock import AsyncMock -from adapter.matrix.reactions import add_reaction, edit_message, build_skills_text -from sdk.interface import UserSettings - - -async def test_add_reaction(): - client = AsyncMock() - await add_reaction(client, "!r:m.org", "$evt", "👍") - client.room_send.assert_called_once_with( - "!r:m.org", "m.reaction", - {"m.relates_to": {"rel_type": "m.annotation", "event_id": "$evt", "key": "👍"}}, - ) - - -async def test_edit_message(): - client = AsyncMock() - await edit_message(client, "!r:m.org", "$orig", "new text") - client.room_send.assert_called_once_with( - "!r:m.org", "m.room.message", - { - "msgtype": "m.text", - "body": "* new text", - "m.new_content": {"msgtype": "m.text", "body": "new text"}, - "m.relates_to": {"rel_type": "m.replace", "event_id": "$orig"}, - }, - ) - - -def test_build_skills_text_shows_status(): - settings = UserSettings(skills={"web-search": True, "browser": False}) - text = build_skills_text(settings) - assert "✅ 1 web-search" in text - assert "❌ 2 browser" in text - - -def test_build_skills_text_has_reaction_hint(): - settings = UserSettings(skills={"web-search": True, "browser": False}) - text = build_skills_text(settings) - assert "1️⃣" in text - assert "Реакция" in text -``` - -- [ ] **Step 2: Run — expect ImportError** - -```bash -pytest tests/adapter/matrix/test_reactions.py -v -``` - -- [ ] **Step 3: Implement reactions.py** - -```python -# adapter/matrix/reactions.py -from __future__ import annotations -from adapter.matrix.converter import SKILL_REACTIONS -from sdk.interface import UserSettings - -_SKILL_DESCRIPTIONS: dict[str, str] = { - "web-search": "поиск в интернете", - "fetch-url": "чтение веб-страниц", - "email": "чтение почты", - "browser": "управление браузером", - "image-gen": "генерация изображений", - "video-gen": "генерация видео", - "files": "работа с файлами", - "calendar": "календарь", -} - - -async def add_reaction(client, room_id: str, event_id: str, key: str) -> None: - await client.room_send( - room_id, "m.reaction", - {"m.relates_to": {"rel_type": "m.annotation", "event_id": event_id, "key": key}}, - ) - - -async def edit_message(client, room_id: str, original_event_id: str, new_body: str) -> None: - await client.room_send( - room_id, "m.room.message", - { - "msgtype": "m.text", - "body": f"* {new_body}", - "m.new_content": {"msgtype": "m.text", "body": new_body}, - "m.relates_to": {"rel_type": "m.replace", "event_id": original_event_id}, - }, - ) - - -def build_skills_text(settings: UserSettings) -> str: - skill_names = list(settings.skills.keys()) - lines = [] - for i, name in enumerate(skill_names): - enabled = settings.skills[name] - emoji = "✅" if enabled else "❌" - desc = _SKILL_DESCRIPTIONS.get(name, name) - lines.append(f"{emoji} {i + 1} {name} — {desc}") - - hint = " ".join(SKILL_REACTIONS[i] for i in range(min(len(skill_names), len(SKILL_REACTIONS)))) - lines += ["", f"Реакция {hint} = переключить скилл"] - return "\n".join(lines) -``` - -- [ ] **Step 4: Run — expect all PASS** - -```bash -pytest tests/adapter/matrix/test_reactions.py -v -``` - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/reactions.py tests/adapter/matrix/test_reactions.py -git commit -m "feat(matrix): reactions and edit helpers" -``` - ---- - -### Task 4: Auth handler — invite → onboarding - -**Files:** -- Create: `adapter/matrix/handlers/__init__.py` -- Create: `adapter/matrix/handlers/auth.py` -- Create: `tests/adapter/matrix/test_auth.py` - -- [ ] **Step 1: Write failing tests** - -```python -# tests/adapter/matrix/test_auth.py -import pytest -from unittest.mock import AsyncMock -from core.store import InMemoryStore -from core.auth import AuthManager -from sdk.mock import MockPlatformClient -from adapter.matrix.handlers.auth import handle_invite -from adapter.matrix.store import get_room_meta, get_room_state, get_user_meta - - -@pytest.fixture -def store(): - return InMemoryStore() - - -@pytest.fixture -def platform(): - return MockPlatformClient() - - -@pytest.fixture -def client(): - c = AsyncMock() - c.join = AsyncMock() - c.room_send = AsyncMock() - return c - - -async def test_invite_joins_room(client, store, platform): - await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice") - client.join.assert_called_once_with("!dm:m.org") - - -async def test_invite_sends_welcome_with_name(client, store, platform): - await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice") - body = client.room_send.call_args[0][2]["body"] - assert "Alice" in body - assert "!new" in body - - -async def test_invite_registers_room_as_c1(client, store, platform): - await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform) - meta = await get_room_meta(store, "!dm:m.org") - assert meta["room_type"] == "chat" - assert meta["chat_id"] == "C1" - assert meta["matrix_user_id"] == "@alice:m.org" - - -async def test_invite_creates_platform_user(client, store, platform): - await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform, display_name="Alice") - user_meta = await get_user_meta(store, "@alice:m.org") - assert user_meta is not None - assert "platform_user_id" in user_meta - - -async def test_invite_authenticates_user(client, store, platform): - await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform) - auth_mgr = AuthManager(platform, store) - assert await auth_mgr.is_authenticated("@alice:m.org") - - -async def test_invite_room_state_idle(client, store, platform): - await handle_invite(client, "!dm:m.org", "@alice:m.org", store, platform) - assert await get_room_state(store, "!dm:m.org") == "idle" - - -async def test_second_invite_gets_c2(client, store, platform): - await handle_invite(client, "!dm1:m.org", "@alice:m.org", store, platform) - await handle_invite(client, "!dm2:m.org", "@alice:m.org", store, platform) - meta = await get_room_meta(store, "!dm2:m.org") - assert meta["chat_id"] == "C2" -``` - -- [ ] **Step 2: Run — expect ImportError** - -```bash -pytest tests/adapter/matrix/test_auth.py -v -``` - -- [ ] **Step 3: Create `__init__.py` and implement auth.py** - -```python -# adapter/matrix/handlers/__init__.py -# (empty) -``` - -```python -# adapter/matrix/handlers/auth.py -from __future__ import annotations -import structlog -from adapter.matrix.store import ( - get_user_meta, next_chat_id, - set_room_meta, set_room_state, set_user_meta, -) -from core.auth import AuthManager -from sdk.interface import PlatformClient - -logger = structlog.get_logger(__name__) - - -async def handle_invite( - client, - room_id: str, - matrix_user_id: str, - store, - platform: PlatformClient, - display_name: str | None = None, -) -> None: - """Accept invite, register DM room as first chat, authenticate user, send welcome.""" - await client.join(room_id) - logger.info("Joined room", room_id=room_id, user=matrix_user_id) - - user = await platform.get_or_create_user(matrix_user_id, "matrix", display_name) - - user_meta = await get_user_meta(store, matrix_user_id) - if user_meta is None: - user_meta = { - "platform_user_id": user.user_id, - "display_name": display_name, - "space_id": None, - "settings_room_id": None, - "next_chat_index": 1, - } - await set_user_meta(store, matrix_user_id, user_meta) - - auth_mgr = AuthManager(platform, store) - await auth_mgr.confirm(matrix_user_id) - - chat_id = await next_chat_id(store, matrix_user_id) - chat_num = chat_id[1:] - await set_room_meta(store, room_id, { - "room_type": "chat", - "chat_id": chat_id, - "display_name": f"Чат {chat_num}", - "matrix_user_id": matrix_user_id, - }) - await set_room_state(store, room_id, "idle") - - name = display_name or matrix_user_id.split(":")[0].lstrip("@") - welcome = ( - f"Привет, {name}! Пиши — я здесь.\n\n" - "Команды: !new · !chats · !rename · !archive · !skills" - ) - await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": welcome}) -``` - -- [ ] **Step 4: Run — expect all PASS** - -```bash -pytest tests/adapter/matrix/test_auth.py -v -``` - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/handlers/__init__.py adapter/matrix/handlers/auth.py tests/adapter/matrix/test_auth.py -git commit -m "feat(matrix): invite handler + onboarding" -``` - ---- - -### Task 5: Chat handler — messages + !new + !chats - -**Files:** -- Create: `adapter/matrix/handlers/chat.py` -- Create: `tests/adapter/matrix/test_chat_handler.py` - -- [ ] **Step 1: Write failing tests** - -```python -# tests/adapter/matrix/test_chat_handler.py -import pytest -from types import SimpleNamespace -from unittest.mock import AsyncMock -from core.store import InMemoryStore -from core.auth import AuthManager -from core.chat import ChatManager -from core.settings import SettingsManager -from core.handler import EventDispatcher -from core.handlers import register_all -from sdk.mock import MockPlatformClient -from adapter.matrix.store import get_room_meta, set_room_meta, set_room_state, set_user_meta -from adapter.matrix.handlers.chat import handle_message, handle_new_chat, handle_list_chats - - -@pytest.fixture -def store(): - return InMemoryStore() - - -@pytest.fixture -def platform(): - return MockPlatformClient() - - -@pytest.fixture -def dispatcher(platform, store): - d = EventDispatcher( - platform=platform, - chat_mgr=ChatManager(platform, store), - auth_mgr=AuthManager(platform, store), - settings_mgr=SettingsManager(platform, store), - ) - register_all(d) - return d - - -@pytest.fixture -def client(): - c = AsyncMock() - c.room_send = AsyncMock() - c.room_typing = AsyncMock() - c.room_create = AsyncMock(return_value=AsyncMock(room_id="!new:m.org")) - c.room_invite = AsyncMock() - c.room_put_state = AsyncMock() - return c - - -async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"): - user = await platform.get_or_create_user(uid, "matrix", "Alice") - await set_user_meta(store, uid, { - "platform_user_id": user.user_id, - "display_name": "Alice", - "space_id": None, - "settings_room_id": None, - "next_chat_index": 2, - }) - await set_room_meta(store, room_id, { - "room_type": "chat", "chat_id": "C1", - "display_name": "Чат 1", "matrix_user_id": uid, - }) - await set_room_state(store, room_id, "idle") - auth = AuthManager(platform, store) - await auth.confirm(uid) - - -def _text_event(body, sender="@alice:m.org"): - return SimpleNamespace(sender=sender, body=body, event_id="$e1", - msgtype="m.text", replyto_event_id=None) - - -async def test_message_gets_response(client, store, platform, dispatcher): - await _setup(store, platform) - await handle_message(client, "!dm:m.org", _text_event("Hello"), store, platform, dispatcher) - texts = [str(c) for c in client.room_send.call_args_list] - assert any("[MOCK]" in t for t in texts) - - -async def test_message_sends_typing(client, store, platform, dispatcher): - await _setup(store, platform) - await handle_message(client, "!dm:m.org", _text_event("Hello"), store, platform, dispatcher) - client.room_typing.assert_called() - - -async def test_new_creates_matrix_room(client, store, platform, dispatcher): - await _setup(store, platform) - await handle_new_chat(client, "!dm:m.org", _text_event("!new Analysis"), store, platform, dispatcher) - client.room_create.assert_called() - client.room_invite.assert_called() - - -async def test_new_registers_room_meta(client, store, platform, dispatcher): - await _setup(store, platform) - await handle_new_chat(client, "!dm:m.org", _text_event("!new Analysis"), store, platform, dispatcher) - meta = await get_room_meta(store, "!new:m.org") - assert meta is not None - assert meta["room_type"] == "chat" - assert meta["display_name"] == "Analysis" - - -async def test_list_chats_includes_room_name(client, store, platform, dispatcher): - await _setup(store, platform) - await handle_list_chats(client, "!dm:m.org", "@alice:m.org", store) - body = client.room_send.call_args[0][2]["body"] - assert "Чат 1" in body -``` - -- [ ] **Step 2: Run — expect ImportError** - -```bash -pytest tests/adapter/matrix/test_chat_handler.py -v -``` - -- [ ] **Step 3: Implement handlers/chat.py** - -```python -# adapter/matrix/handlers/chat.py -from __future__ import annotations -import asyncio -import structlog -from adapter.matrix.converter import from_room_event -from adapter.matrix.store import ( - get_room_meta, get_user_meta, - next_chat_id, set_room_meta, set_room_state, set_user_meta, -) -from core.protocol import OutgoingMessage, OutgoingTyping -from sdk.interface import PlatformClient - -logger = structlog.get_logger(__name__) -_TYPING_INTERVAL = 25 # nio typing expires ~30s - - -async def handle_message(client, room_id: str, event, store, platform: PlatformClient, dispatcher) -> None: - room_meta = await get_room_meta(store, room_id) - if room_meta is None: - return - - incoming = from_room_event(event, room_id=room_id, chat_id=room_meta["chat_id"]) - if incoming is None: - return - - await set_room_state(store, room_id, "waiting_response") - await client.room_typing(room_id, True, timeout=_TYPING_INTERVAL * 1000) - - typing_task = asyncio.create_task(_keep_typing(client, room_id, _TYPING_INTERVAL)) - try: - outgoing_events = await dispatcher.dispatch(incoming) - finally: - typing_task.cancel() - await client.room_typing(room_id, False, timeout=0) - - await set_room_state(store, room_id, "idle") - for out in outgoing_events: - await _send(client, room_id, out) - - -async def handle_new_chat(client, room_id: str, event, store, platform: PlatformClient, dispatcher) -> None: - room_meta = await get_room_meta(store, room_id) - if room_meta is None: - return - - matrix_user_id = room_meta["matrix_user_id"] - parts = event.body[1:].split(maxsplit=1) # "!new Analysis" → ["new", "Analysis"] - display_name_arg = parts[1] if len(parts) > 1 else None - - chat_id = await next_chat_id(store, matrix_user_id) - chat_num = chat_id[1:] - display_name = display_name_arg or f"Чат {chat_num}" - - response = await client.room_create(name=display_name) - new_room_id = response.room_id - await client.room_invite(new_room_id, matrix_user_id) - - user_meta = await get_user_meta(store, matrix_user_id) or {} - space_id = user_meta.get("space_id") - if space_id is None: - space_id = await _create_space(client, store, matrix_user_id, user_meta) - - await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=new_room_id) - await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=room_id) - - await set_room_meta(store, new_room_id, { - "room_type": "chat", "chat_id": chat_id, - "display_name": display_name, "matrix_user_id": matrix_user_id, - }) - await set_room_state(store, new_room_id, "idle") - - await client.room_send( - room_id, "m.room.message", - {"msgtype": "m.text", "body": f"✅ [{display_name}] создан. Перейди в комнату."}, - ) - - -async def handle_list_chats(client, room_id: str, matrix_user_id: str, store) -> None: - all_keys = await store.keys("matrix_room:") - chats = [] - for key in all_keys: - meta = await store.get(key) - if (meta and meta.get("matrix_user_id") == matrix_user_id - and meta.get("room_type") == "chat"): - chats.append(meta) - - if not chats: - body = "Нет активных чатов. Напиши !new чтобы создать." - else: - lines = ["Твои чаты:"] - for chat in chats: - lines.append(f" {chat['display_name']} ({chat['chat_id']})") - body = "\n".join(lines) - - await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) - - -async def _create_space(client, store, matrix_user_id: str, user_meta: dict) -> str: - name = user_meta.get("display_name") or matrix_user_id.split(":")[0].lstrip("@") - space_resp = await client.room_create( - name=f"Lambda — {name}", - initial_state=[{"type": "m.room.create", "content": {"type": "m.space"}}], - ) - space_id = space_resp.room_id - await client.room_invite(space_id, matrix_user_id) - - settings_resp = await client.room_create(name="⚙️ Настройки") - settings_room_id = settings_resp.room_id - await client.room_invite(settings_room_id, matrix_user_id) - await client.room_put_state(space_id, "m.space.child", {"via": []}, state_key=settings_room_id) - - await set_room_meta(store, settings_room_id, { - "room_type": "settings", "chat_id": None, - "display_name": "Настройки", "matrix_user_id": matrix_user_id, - }) - await set_room_state(store, settings_room_id, "settings_active") - - user_meta["space_id"] = space_id - user_meta["settings_room_id"] = settings_room_id - await set_user_meta(store, matrix_user_id, user_meta) - return space_id - - -async def _keep_typing(client, room_id: str, interval: int) -> None: - try: - while True: - await asyncio.sleep(interval) - await client.room_typing(room_id, True, timeout=interval * 1000) - except asyncio.CancelledError: - pass - - -async def _send(client, room_id: str, event) -> None: - if isinstance(event, OutgoingMessage): - await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text}) - elif isinstance(event, OutgoingTyping): - await client.room_typing(room_id, event.is_typing, timeout=0) -``` - -- [ ] **Step 4: Run — expect all PASS** - -```bash -pytest tests/adapter/matrix/test_chat_handler.py -v -``` - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/handlers/chat.py tests/adapter/matrix/test_chat_handler.py -git commit -m "feat(matrix): chat handler — messages, !new, !chats" -``` - ---- - -### Task 6: Confirm handler — 👍/❌ + !yes/!no - -**Files:** -- Create: `adapter/matrix/handlers/confirm.py` -- Create: `tests/adapter/matrix/test_confirm.py` - -- [ ] **Step 1: Write failing tests** - -```python -# tests/adapter/matrix/test_confirm.py -import pytest -from types import SimpleNamespace -from unittest.mock import AsyncMock -from core.store import InMemoryStore -from core.auth import AuthManager -from core.chat import ChatManager -from core.settings import SettingsManager -from core.handler import EventDispatcher -from core.handlers import register_all -from sdk.mock import MockPlatformClient -from adapter.matrix.store import get_room_state, set_room_meta, set_room_state -from adapter.matrix.handlers.confirm import handle_confirm_callback - - -@pytest.fixture -def store(): - return InMemoryStore() - - -@pytest.fixture -def platform(): - return MockPlatformClient() - - -@pytest.fixture -def dispatcher(platform, store): - d = EventDispatcher( - platform=platform, - chat_mgr=ChatManager(platform, store), - auth_mgr=AuthManager(platform, store), - settings_mgr=SettingsManager(platform, store), - ) - register_all(d) - return d - - -@pytest.fixture -def client(): - return AsyncMock() - - -async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"): - await platform.get_or_create_user(uid, "matrix", "Alice") - await set_room_meta(store, room_id, { - "room_type": "chat", "chat_id": "C1", - "display_name": "Чат 1", "matrix_user_id": uid, - }) - await set_room_state(store, room_id, "confirm_pending") - await AuthManager(platform, store).confirm(uid) - - -async def test_yes_command_transitions_to_idle(client, store, platform, dispatcher): - await _setup(store, platform) - event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1", - msgtype="m.text", replyto_event_id=None) - await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False) - assert await get_room_state(store, "!dm:m.org") == "idle" - - -async def test_no_command_transitions_to_idle(client, store, platform, dispatcher): - await _setup(store, platform) - event = SimpleNamespace(sender="@alice:m.org", body="!no", event_id="$e1", - msgtype="m.text", replyto_event_id=None) - await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False) - assert await get_room_state(store, "!dm:m.org") == "idle" - - -async def test_thumbs_up_reaction_transitions_to_idle(client, store, platform, dispatcher): - await _setup(store, platform) - event = SimpleNamespace(sender="@alice:m.org", key="👍", - reacted_to_id="$orig", event_id="$r1") - await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=True) - assert await get_room_state(store, "!dm:m.org") == "idle" - - -async def test_confirm_sends_response(client, store, platform, dispatcher): - await _setup(store, platform) - event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1", - msgtype="m.text", replyto_event_id=None) - await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False) - client.room_send.assert_called() - - -async def test_noop_when_state_not_confirm_pending(client, store, platform, dispatcher): - await _setup(store, platform) - await set_room_state(store, "!dm:m.org", "idle") # wrong state - event = SimpleNamespace(sender="@alice:m.org", body="!yes", event_id="$e1", - msgtype="m.text", replyto_event_id=None) - await handle_confirm_callback(client, "!dm:m.org", event, store, platform, dispatcher, is_reaction=False) - client.room_send.assert_not_called() -``` - -- [ ] **Step 2: Run — expect ImportError** - -```bash -pytest tests/adapter/matrix/test_confirm.py -v -``` - -- [ ] **Step 3: Implement handlers/confirm.py** - -```python -# adapter/matrix/handlers/confirm.py -from __future__ import annotations -import structlog -from adapter.matrix.converter import from_room_event -from adapter.matrix.store import get_room_meta, get_room_state, set_room_state -from core.protocol import OutgoingMessage -from sdk.interface import PlatformClient - -logger = structlog.get_logger(__name__) - - -async def handle_confirm_callback( - client, - room_id: str, - event, - store, - platform: PlatformClient, - dispatcher, - is_reaction: bool = False, -) -> None: - if await get_room_state(store, room_id) != "confirm_pending": - return - - room_meta = await get_room_meta(store, room_id) - if room_meta is None: - return - - incoming = from_room_event(event, room_id=room_id, - chat_id=room_meta["chat_id"], is_reaction=is_reaction) - if incoming is None or getattr(incoming, "action", None) not in ("confirm", "cancel"): - return - - await set_room_state(store, room_id, "idle") - outgoing_events = await dispatcher.dispatch(incoming) - - for out in outgoing_events: - if isinstance(out, OutgoingMessage): - await client.room_send(room_id, "m.room.message", - {"msgtype": "m.text", "body": out.text}) -``` - -- [ ] **Step 4: Run — expect all PASS** - -```bash -pytest tests/adapter/matrix/test_confirm.py -v -``` - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/handlers/confirm.py tests/adapter/matrix/test_confirm.py -git commit -m "feat(matrix): confirm handler — reactions and !yes/!no" -``` - ---- - -### Task 7: Settings handler — !skills (m.replace) + other commands - -**Files:** -- Create: `adapter/matrix/handlers/settings.py` -- Create: `tests/adapter/matrix/test_settings_handler.py` - -- [ ] **Step 1: Write failing tests** - -```python -# tests/adapter/matrix/test_settings_handler.py -import pytest -from unittest.mock import AsyncMock -from core.store import InMemoryStore -from core.auth import AuthManager -from core.chat import ChatManager -from core.settings import SettingsManager -from core.handler import EventDispatcher -from core.handlers import register_all -from sdk.mock import MockPlatformClient -from adapter.matrix.store import set_room_meta, set_room_state, set_user_meta -from adapter.matrix.handlers.settings import handle_skills, handle_skill_toggle, handle_text_setting - - -@pytest.fixture -def store(): - return InMemoryStore() - - -@pytest.fixture -def platform(): - return MockPlatformClient() - - -@pytest.fixture -def dispatcher(platform, store): - d = EventDispatcher( - platform=platform, - chat_mgr=ChatManager(platform, store), - auth_mgr=AuthManager(platform, store), - settings_mgr=SettingsManager(platform, store), - ) - register_all(d) - return d - - -@pytest.fixture -def client(): - c = AsyncMock() - c.room_send = AsyncMock(return_value=AsyncMock(event_id="$skills_msg")) - return c - - -async def _setup(store, platform, uid="@alice:m.org", room_id="!s:m.org"): - user = await platform.get_or_create_user(uid, "matrix", "Alice") - await set_user_meta(store, uid, { - "platform_user_id": user.user_id, "display_name": "Alice", - "space_id": None, "settings_room_id": room_id, "next_chat_index": 2, - }) - await set_room_meta(store, room_id, { - "room_type": "settings", "chat_id": None, - "display_name": "Настройки", "matrix_user_id": uid, - }) - await set_room_state(store, room_id, "settings_active") - await AuthManager(platform, store).confirm(uid) - - -async def test_skills_sends_list(client, store, platform, dispatcher): - await _setup(store, platform) - await handle_skills(client, "!s:m.org", "@alice:m.org", store, platform, dispatcher) - body = client.room_send.call_args[0][2]["body"] - assert "web-search" in body - assert "Реакция" in body - - -async def test_skills_stores_event_id(client, store, platform, dispatcher): - await _setup(store, platform) - await handle_skills(client, "!s:m.org", "@alice:m.org", store, platform, dispatcher) - stored = await store.get("matrix_skills_msg:!s:m.org") - assert stored is not None - assert stored["event_id"] == "$skills_msg" - - -async def test_skill_toggle_edits_message(client, store, platform, dispatcher): - await _setup(store, platform) - await store.set("matrix_skills_msg:!s:m.org", {"event_id": "$skills_msg"}) - from types import SimpleNamespace - reaction = SimpleNamespace(sender="@alice:m.org", key="1️⃣", - reacted_to_id="$skills_msg", event_id="$r1") - await handle_skill_toggle(client, "!s:m.org", reaction, store, platform, dispatcher) - content = client.room_send.call_args[0][2] - assert content.get("m.relates_to", {}).get("rel_type") == "m.replace" - - -async def test_whoami_contains_user_id(client, store, platform, dispatcher): - await _setup(store, platform) - await handle_text_setting(client, "!s:m.org", "@alice:m.org", "whoami", [], store, platform) - body = client.room_send.call_args[0][2]["body"] - assert "@alice:m.org" in body - - -async def test_status_response(client, store, platform, dispatcher): - await _setup(store, platform) - await handle_text_setting(client, "!s:m.org", "@alice:m.org", "status", [], store, platform) - body = client.room_send.call_args[0][2]["body"] - assert "Статус" in body - - -async def test_plan_shows_tokens(client, store, platform, dispatcher): - await _setup(store, platform) - await handle_text_setting(client, "!s:m.org", "@alice:m.org", "plan", [], store, platform) - body = client.room_send.call_args[0][2]["body"] - assert "Beta" in body - assert "/" in body # "0 / 1000" -``` - -- [ ] **Step 2: Run — expect ImportError** - -```bash -pytest tests/adapter/matrix/test_settings_handler.py -v -``` - -- [ ] **Step 3: Implement handlers/settings.py** - -```python -# adapter/matrix/handlers/settings.py -from __future__ import annotations -import structlog -from adapter.matrix.converter import SKILL_REACTIONS -from adapter.matrix.reactions import build_skills_text, edit_message -from adapter.matrix.store import get_room_meta, get_user_meta -from core.protocol import SettingsAction -from sdk.interface import PlatformClient - -logger = structlog.get_logger(__name__) - -_SKILL_NAMES_ORDER = ["web-search", "fetch-url", "email", "browser", - "image-gen", "video-gen", "files", "calendar"] - - -async def handle_skills( - client, room_id: str, matrix_user_id: str, store, platform: PlatformClient, dispatcher, -) -> None: - """Send skills list and store its event_id for later m.replace edits.""" - user_meta = await get_user_meta(store, matrix_user_id) or {} - platform_user_id = user_meta.get("platform_user_id", matrix_user_id) - settings = await platform.get_settings(platform_user_id) - body = build_skills_text(settings) - response = await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) - event_id = getattr(response, "event_id", None) - if event_id: - await store.set(f"matrix_skills_msg:{room_id}", {"event_id": event_id}) - - -async def handle_skill_toggle( - client, room_id: str, reaction_event, store, platform: PlatformClient, dispatcher, -) -> None: - """Toggle a skill based on numbered reaction, then edit the skills message.""" - key = reaction_event.key - if key not in SKILL_REACTIONS: - return - skill_index = SKILL_REACTIONS.index(key) - if skill_index >= len(_SKILL_NAMES_ORDER): - return - - skill_name = _SKILL_NAMES_ORDER[skill_index] - room_meta = await get_room_meta(store, room_id) - if room_meta is None: - return - - matrix_user_id = room_meta["matrix_user_id"] - user_meta = await get_user_meta(store, matrix_user_id) or {} - platform_user_id = user_meta.get("platform_user_id", matrix_user_id) - - settings = await platform.get_settings(platform_user_id) - current = settings.skills.get(skill_name, False) - action = SettingsAction(action="toggle_skill", - payload={"skill": skill_name, "enabled": not current}) - await platform.update_settings(platform_user_id, action) - - updated = await platform.get_settings(platform_user_id) - new_body = build_skills_text(updated) - - msg_data = await store.get(f"matrix_skills_msg:{room_id}") - if msg_data: - await edit_message(client, room_id, msg_data["event_id"], new_body) - else: - await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": new_body}) - - -async def handle_text_setting( - client, room_id: str, matrix_user_id: str, - command: str, args: list[str], store, platform: PlatformClient, -) -> None: - """Handle !connectors, !soul, !safety, !plan, !status, !whoami.""" - user_meta = await get_user_meta(store, matrix_user_id) or {} - platform_user_id = user_meta.get("platform_user_id", matrix_user_id) - - if command == "whoami": - name = user_meta.get("display_name") or matrix_user_id - body = f"Аккаунт: {matrix_user_id}\nПлатформа: {platform_user_id}\nИмя: {name}" - - elif command == "status": - body = f"Статус платформы: ✅ доступна\nАккаунт: {matrix_user_id}" - - elif command == "plan": - settings = await platform.get_settings(platform_user_id) - plan = settings.plan - name_plan = plan.get("name", "Beta") - used = plan.get("tokens_used", 0) - limit = plan.get("tokens_limit", 1000) - pct = used * 10 // limit if limit else 0 - bar = "━" * pct + "░" * (10 - pct) - body = f"Подписка: {name_plan}\nТокены: {used} / {limit}\n{bar} {used * 100 // limit if limit else 0}%" - - elif command == "soul": - if len(args) >= 2: - field, value = args[0], " ".join(args[1:]) - await platform.update_settings(platform_user_id, - SettingsAction(action="set_soul", - payload={"field": field, "value": value})) - body = f"✅ soul.{field} = «{value}»" - else: - settings = await platform.get_settings(platform_user_id) - lines = [f"{k}: {v}" for k, v in settings.soul.items()] if settings.soul else ["(пусто)"] - body = "Soul:\n" + "\n".join(lines) - - elif command == "safety": - if args and args[0] in ("on", "off"): - enabled = args[0] == "on" - trigger = " ".join(args[1:]) - await platform.update_settings(platform_user_id, - SettingsAction(action="set_safety", - payload={"trigger": trigger, "enabled": enabled})) - body = f"✅ safety.{trigger} = {'включено' if enabled else 'выключено'}" - else: - settings = await platform.get_settings(platform_user_id) - lines = [f"{'✅' if v else '❌'} {k}" for k, v in settings.safety.items()] - body = "Безопасность:\n" + ("\n".join(lines) if lines else "(пусто)") - - elif command == "connectors": - settings = await platform.get_settings(platform_user_id) - if settings.connectors: - lines = [f"✅ {k}" for k in settings.connectors] - body = "Коннекторы:\n" + "\n".join(lines) - else: - body = "Коннекторы:\n❌ Нет подключённых сервисов\n\n!connect gmail — подключить Gmail" - - else: - body = f"Неизвестная команда: !{command}" - - await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) -``` - -- [ ] **Step 4: Run — expect all PASS** - -```bash -pytest tests/adapter/matrix/test_settings_handler.py -v -``` - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/handlers/settings.py tests/adapter/matrix/test_settings_handler.py -git commit -m "feat(matrix): settings handler — !skills m.replace + commands" -``` - ---- - -### Task 8: Bot entry point — sync loop + event routing - -**Files:** -- Create: `adapter/matrix/bot.py` -- Create: `tests/adapter/matrix/test_bot.py` - -- [ ] **Step 1: Write failing tests** - -```python -# tests/adapter/matrix/test_bot.py -import pytest -from types import SimpleNamespace -from unittest.mock import AsyncMock -from core.store import InMemoryStore -from sdk.mock import MockPlatformClient -from adapter.matrix.bot import create_dispatcher, route_message_event, route_reaction_event -from adapter.matrix.store import set_room_meta, set_room_state, set_user_meta -from core.auth import AuthManager -from core.handler import EventDispatcher - - -@pytest.fixture -def store(): - return InMemoryStore() - - -@pytest.fixture -def platform(): - return MockPlatformClient() - - -@pytest.fixture -def dispatcher(platform, store): - return create_dispatcher(platform, store) - - -@pytest.fixture -def client(): - c = AsyncMock() - c.user_id = "@bot:m.org" - c.room_create = AsyncMock(return_value=AsyncMock(room_id="!new:m.org")) - c.room_invite = AsyncMock() - c.room_put_state = AsyncMock() - return c - - -async def _setup(store, platform, room_id="!dm:m.org", uid="@alice:m.org"): - user = await platform.get_or_create_user(uid, "matrix", "Alice") - await set_user_meta(store, uid, { - "platform_user_id": user.user_id, "display_name": "Alice", - "space_id": None, "settings_room_id": None, "next_chat_index": 2, - }) - await set_room_meta(store, room_id, { - "room_type": "chat", "chat_id": "C1", - "display_name": "Чат 1", "matrix_user_id": uid, - }) - await set_room_state(store, room_id, "idle") - await AuthManager(platform, store).confirm(uid) - - -async def test_create_dispatcher_returns_event_dispatcher(platform, store): - d = create_dispatcher(platform, store) - assert isinstance(d, EventDispatcher) - - -async def test_route_text_message(client, store, platform, dispatcher): - await _setup(store, platform) - event = SimpleNamespace(sender="@alice:m.org", body="Hello", event_id="$e1", - msgtype="m.text", replyto_event_id=None) - room = SimpleNamespace(room_id="!dm:m.org") - await route_message_event(client, room, event, store, platform, dispatcher) - client.room_send.assert_called() - body_calls = [str(c) for c in client.room_send.call_args_list] - assert any("[MOCK]" in c for c in body_calls) - - -async def test_route_new_command(client, store, platform, dispatcher): - await _setup(store, platform) - event = SimpleNamespace(sender="@alice:m.org", body="!new Test", event_id="$e2", - msgtype="m.text", replyto_event_id=None) - room = SimpleNamespace(room_id="!dm:m.org") - await route_message_event(client, room, event, store, platform, dispatcher) - client.room_create.assert_called() - - -async def test_route_skills_command(client, store, platform, dispatcher): - await _setup(store, platform) - event = SimpleNamespace(sender="@alice:m.org", body="!skills", event_id="$e3", - msgtype="m.text", replyto_event_id=None) - room = SimpleNamespace(room_id="!dm:m.org") - await route_message_event(client, room, event, store, platform, dispatcher) - body = client.room_send.call_args[0][2]["body"] - assert "web-search" in body - - -async def test_bot_ignores_own_messages(client, store, platform, dispatcher): - await _setup(store, platform) - event = SimpleNamespace(sender="@bot:m.org", body="Hello", event_id="$e4", - msgtype="m.text", replyto_event_id=None) - room = SimpleNamespace(room_id="!dm:m.org") - await route_message_event(client, room, event, store, platform, dispatcher) - client.room_send.assert_not_called() - - -async def test_route_confirm_reaction(client, store, platform, dispatcher): - await _setup(store, platform) - await set_room_state(store, "!dm:m.org", "confirm_pending") - event = SimpleNamespace(sender="@alice:m.org", key="👍", - reacted_to_id="$orig", event_id="$r1", - source={"content": {"m.relates_to": {"key": "👍", "event_id": "$orig"}}}) - room = SimpleNamespace(room_id="!dm:m.org") - await route_reaction_event(client, room, event, store, platform, dispatcher) - client.room_send.assert_called() -``` - -- [ ] **Step 2: Run — expect ImportError** - -```bash -pytest tests/adapter/matrix/test_bot.py -v -``` - -- [ ] **Step 3: Implement bot.py** - -```python -# adapter/matrix/bot.py -from __future__ import annotations -import os -import structlog -from nio import AsyncClient, InviteMemberEvent, RoomMessageText, UnknownEvent -from adapter.matrix.converter import CONFIRM_REACTIONS, SKILL_REACTIONS -from adapter.matrix.handlers.auth import handle_invite -from adapter.matrix.handlers.chat import handle_list_chats, handle_message, handle_new_chat -from adapter.matrix.handlers.confirm import handle_confirm_callback -from adapter.matrix.handlers.settings import handle_skill_toggle, handle_skills, handle_text_setting -from adapter.matrix.store import get_room_meta, get_room_state -from core.auth import AuthManager -from core.chat import ChatManager -from core.handler import EventDispatcher -from core.handlers import register_all -from core.settings import SettingsManager -from core.store import SQLiteStore -from sdk.interface import PlatformClient -from sdk.mock import MockPlatformClient - -logger = structlog.get_logger(__name__) - -_SETTINGS_COMMANDS = {"connectors", "soul", "safety", "plan", "status", "whoami"} - - -def create_dispatcher(platform: PlatformClient, store) -> EventDispatcher: - d = EventDispatcher( - platform=platform, - chat_mgr=ChatManager(platform, store), - auth_mgr=AuthManager(platform, store), - settings_mgr=SettingsManager(platform, store), - ) - register_all(d) - return d - - -async def route_message_event(client, room, event, store, platform, dispatcher) -> None: - room_id = room.room_id - sender = event.sender - if sender == client.user_id: - return - - room_meta = await get_room_meta(store, room_id) - if room_meta is None: - return - - body: str = event.body or "" - state = await get_room_state(store, room_id) - - if state == "confirm_pending" and body.startswith("!") and body[1:].split()[0] in ("yes", "no"): - await handle_confirm_callback(client, room_id, event, store, platform, dispatcher, is_reaction=False) - return - - if body.startswith("!"): - parts = body[1:].split(maxsplit=1) - cmd = parts[0].lower() - args = parts[1].split() if len(parts) > 1 else [] - - if cmd == "new": - await handle_new_chat(client, room_id, event, store, platform, dispatcher) - elif cmd == "chats": - await handle_list_chats(client, room_id, sender, store) - elif cmd == "skills": - await handle_skills(client, room_id, sender, store, platform, dispatcher) - elif cmd in _SETTINGS_COMMANDS: - await handle_text_setting(client, room_id, sender, cmd, args, store, platform) - else: - # Unknown command — treat as regular message - await handle_message(client, room_id, event, store, platform, dispatcher) - else: - await handle_message(client, room_id, event, store, platform, dispatcher) - - -async def route_reaction_event(client, room, event, store, platform, dispatcher) -> None: - room_id = room.room_id - sender = getattr(event, "sender", None) - if sender == client.user_id: - return - - # nio may give us a ReactionEvent or UnknownEvent; normalise key access - key = getattr(event, "key", None) - reacted_to_id = getattr(event, "reacted_to_id", None) - if key is None: - relates = event.source.get("content", {}).get("m.relates_to", {}) - key = relates.get("key", "") - reacted_to_id = relates.get("event_id", "") - - from types import SimpleNamespace - norm = SimpleNamespace(sender=sender, key=key, reacted_to_id=reacted_to_id, - event_id=event.event_id) - - state = await get_room_state(store, room_id) - if state == "confirm_pending" and key in CONFIRM_REACTIONS: - await handle_confirm_callback(client, room_id, norm, store, platform, dispatcher, is_reaction=True) - elif key in SKILL_REACTIONS: - await handle_skill_toggle(client, room_id, norm, store, platform, dispatcher) - - -async def main() -> None: - homeserver = os.getenv("MATRIX_HOMESERVER", "https://matrix.org") - user_id = os.getenv("MATRIX_USER_ID", "@lambda-bot:matrix.org") - password = os.getenv("MATRIX_PASSWORD", "") - - store = SQLiteStore("matrix_bot.db") - platform = MockPlatformClient() - dispatcher = create_dispatcher(platform, store) - - client = AsyncClient(homeserver, user_id) - await client.login(password) - logger.info("Logged in", user_id=user_id) - - async def on_message(room, event: RoomMessageText) -> None: - await route_message_event(client, room, event, store, platform, dispatcher) - - async def on_invite(room, event: InviteMemberEvent) -> None: - if event.membership == "invite" and event.state_key == client.user_id: - display_name = getattr(event, "display_name", None) - await handle_invite(client, room.room_id, event.sender, store, platform, display_name) - - async def on_unknown(room, event: UnknownEvent) -> None: - if event.type == "m.reaction": - await route_reaction_event(client, room, event, store, platform, dispatcher) - - client.add_event_callback(on_message, RoomMessageText) - client.add_event_callback(on_invite, InviteMemberEvent) - client.add_event_callback(on_unknown, UnknownEvent) - - logger.info("Starting sync loop") - await client.sync_forever(timeout=30000) - - -if __name__ == "__main__": - import asyncio - asyncio.run(main()) -``` - -- [ ] **Step 4: Run matrix tests** - -```bash -pytest tests/adapter/matrix/ -v -``` -Expected: all PASS. - -- [ ] **Step 5: Run full suite — verify no regressions** - -```bash -pytest tests/ -v -``` -Expected: all tests PASS including pre-existing `tests/core/` and `tests/platform/`. - -- [ ] **Step 6: Commit** - -```bash -git add adapter/matrix/bot.py tests/adapter/matrix/test_bot.py -git commit -m "feat(matrix): bot entry point — sync loop and event routing" -``` diff --git a/docs/superpowers/plans/2026-04-01-telegram-forum-redesign.md b/docs/superpowers/plans/2026-04-01-telegram-forum-redesign.md deleted file mode 100644 index 3592485..0000000 --- a/docs/superpowers/plans/2026-04-01-telegram-forum-redesign.md +++ /dev/null @@ -1,1308 +0,0 @@ -# Telegram Forum Redesign Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Rewrite the Telegram adapter to use Bot API 9.3 Threaded Mode — private chat becomes a forum, each topic is an isolated agent context, no supergroup required. - -**Architecture:** New branch `feat/telegram-forum` from `main`. Cherry-pick `keyboards/settings.py` and `keyboards/confirm.py` from `feat/telegram-adapter`. Everything else is written from scratch using `(user_id, thread_id)` as the context key, `core/store.py` for state (no aiogram FSM for topic routing), and `sdk/interface.py`'s `stream_message()` for streaming responses. - -**Tech Stack:** Python 3.11+, aiogram 3.4+, SQLite (via stdlib `sqlite3`), pytest + pytest-asyncio (asyncio_mode=auto), `sdk.mock.MockPlatformClient` as platform stub. - -**Spec:** `docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md` - ---- - -## File Map - -| File | Action | Notes | -|------|--------|-------| -| `adapter/telegram/db.py` | Rewrite | New schema: `chats(user_id, thread_id PK, ...)` | -| `adapter/telegram/converter.py` | Rewrite | context_key = `(user_id, thread_id)`, keep `_extract_attachments` | -| `adapter/telegram/handlers/start.py` | New | `/start` — create first topic, health-check existing ones | -| `adapter/telegram/handlers/topic_events.py` | New | `forum_topic_created / edited / closed` | -| `adapter/telegram/handlers/commands.py` | New | `/new`, `/archive`, `/rename`, `/settings` | -| `adapter/telegram/handlers/message.py` | New | Incoming messages with streaming | -| `adapter/telegram/handlers/settings.py` | Cherry-pick + adapt | Drop FSM state dependency for topic routing; keep SettingsState for soul modal | -| `adapter/telegram/keyboards/settings.py` | Cherry-pick | No changes needed | -| `adapter/telegram/keyboards/confirm.py` | Cherry-pick | No changes needed | -| `adapter/telegram/states.py` | Minimal | Only `SettingsState` (soul editing modal), no topic FSM | -| `adapter/telegram/bot.py` | Rewrite | New router list, same middleware pattern | -| `adapter/telegram/__init__.py` | Keep | No changes | -| `tests/adapter/test_forum_db.py` | Rewrite | Tests for new schema | -| `tests/adapter/telegram/test_converter.py` | New | | -| `tests/adapter/telegram/test_topic_events.py` | New | | -| `tests/adapter/telegram/test_commands.py` | New | | - -**Delete from `feat/telegram-adapter` (do not carry over):** -- `adapter/telegram/handlers/forum.py` — supergroup onboarding -- `adapter/telegram/handlers/chat.py` — chat switching -- `adapter/telegram/handlers/auth.py` — auth flow -- `adapter/telegram/handlers/confirm.py` — confirm modal -- `adapter/telegram/keyboards/chat.py` -- `adapter/telegram/keyboards/forum.py` - ---- - -## Task 0: Create Branch and Cherry-Pick Keyboards - -**Files:** -- Create branch: `feat/telegram-forum` -- Cherry-pick: `adapter/telegram/keyboards/settings.py` -- Cherry-pick: `adapter/telegram/keyboards/confirm.py` - -- [ ] **Step 1: Create new branch from main** - -```bash -git checkout main -git checkout -b feat/telegram-forum -``` - -- [ ] **Step 2: Copy keyboards from feat/telegram-adapter** - -```bash -mkdir -p adapter/telegram/keyboards -git show feat/telegram-adapter:adapter/telegram/keyboards/__init__.py > adapter/telegram/keyboards/__init__.py -git show feat/telegram-adapter:adapter/telegram/keyboards/settings.py > adapter/telegram/keyboards/settings.py -git show feat/telegram-adapter:adapter/telegram/keyboards/confirm.py > adapter/telegram/keyboards/confirm.py -``` - -- [ ] **Step 3: Create package stubs** - -```bash -mkdir -p adapter/telegram/handlers -touch adapter/__init__.py -touch adapter/telegram/__init__.py -touch adapter/telegram/handlers/__init__.py -``` - -- [ ] **Step 4: Verify keyboards import cleanly** - -```bash -python -c "from adapter.telegram.keyboards.settings import settings_main_keyboard; print('ok')" -``` - -Expected: `ok` - -- [ ] **Step 5: Commit** - -```bash -git add adapter/ -git commit -m "chore: init feat/telegram-forum, cherry-pick keyboards" -``` - ---- - -## Task 1: Database Layer - -**Files:** -- Create: `adapter/telegram/db.py` -- Rewrite: `tests/adapter/test_forum_db.py` - -- [ ] **Step 1: Write failing tests** - -Write `tests/adapter/test_forum_db.py`: - -```python -from __future__ import annotations - -import importlib -import pytest - - -@pytest.fixture(autouse=True) -def fresh_db(tmp_path, monkeypatch): - monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db")) - import adapter.telegram.db as db_mod - importlib.reload(db_mod) - db_mod.init_db() - return db_mod - - -def test_create_and_get_chat(fresh_db): - db = fresh_db - db.create_chat(user_id=1, thread_id=100, chat_name="Чат #1") - chat = db.get_chat(user_id=1, thread_id=100) - assert chat is not None - assert chat["chat_name"] == "Чат #1" - assert chat["archived_at"] is None - - -def test_get_chat_missing(fresh_db): - assert fresh_db.get_chat(user_id=1, thread_id=999) is None - - -def test_archive_chat(fresh_db): - db = fresh_db - db.create_chat(1, 100, "Чат #1") - db.archive_chat(1, 100) - chat = db.get_chat(1, 100) - assert chat["archived_at"] is not None - - -def test_rename_chat(fresh_db): - db = fresh_db - db.create_chat(1, 100, "Чат #1") - db.rename_chat(1, 100, "Новое имя") - assert db.get_chat(1, 100)["chat_name"] == "Новое имя" - - -def test_get_active_chats(fresh_db): - db = fresh_db - db.create_chat(1, 100, "Чат #1") - db.create_chat(1, 200, "Чат #2") - db.archive_chat(1, 100) - chats = db.get_active_chats(1) - assert len(chats) == 1 - assert chats[0]["thread_id"] == 200 - - -def test_display_number(fresh_db): - db = fresh_db - db.create_chat(1, 100, "Чат #1") - db.create_chat(1, 200, "Чат #2") - db.create_chat(1, 300, "Чат #3") - assert db.get_display_number(1, 100) == 1 - assert db.get_display_number(1, 200) == 2 - assert db.get_display_number(1, 300) == 3 - - -def test_count_active_chats(fresh_db): - db = fresh_db - db.create_chat(1, 100, "Чат #1") - db.create_chat(1, 200, "Чат #2") - db.archive_chat(1, 100) - assert db.count_active_chats(1) == 1 - - -def test_different_users_isolated(fresh_db): - db = fresh_db - db.create_chat(1, 100, "Чат #1") - db.create_chat(2, 100, "Чат #1") # same thread_id, different user - assert db.get_chat(1, 100)["chat_name"] == "Чат #1" - assert db.get_chat(2, 100)["chat_name"] == "Чат #1" - db.archive_chat(1, 100) - assert db.get_chat(1, 100)["archived_at"] is not None - assert db.get_chat(2, 100)["archived_at"] is None -``` - -- [ ] **Step 2: Run tests — verify they fail** - -```bash -pytest tests/adapter/test_forum_db.py -v -``` - -Expected: `ModuleNotFoundError` or `AttributeError` (db.py doesn't exist yet) - -- [ ] **Step 3: Implement db.py** - -Create `adapter/telegram/db.py`: - -```python -from __future__ import annotations - -import os -import sqlite3 -from contextlib import contextmanager - -DB_PATH = os.environ.get("DB_PATH", "lambda_bot.db") - - -@contextmanager -def _conn(): - con = sqlite3.connect(DB_PATH) - con.row_factory = sqlite3.Row - try: - yield con - con.commit() - finally: - con.close() - - -def init_db() -> None: - with _conn() as con: - con.executescript(""" - CREATE TABLE IF NOT EXISTS chats ( - user_id INTEGER NOT NULL, - thread_id INTEGER NOT NULL, - chat_name TEXT NOT NULL DEFAULT 'Чат #1', - archived_at DATETIME, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (user_id, thread_id) - ); - """) - - -def create_chat(user_id: int, thread_id: int, chat_name: str) -> None: - with _conn() as con: - con.execute( - "INSERT OR IGNORE INTO chats (user_id, thread_id, chat_name) VALUES (?, ?, ?)", - (user_id, thread_id, chat_name), - ) - - -def get_chat(user_id: int, thread_id: int) -> dict | None: - with _conn() as con: - row = con.execute( - "SELECT * FROM chats WHERE user_id = ? AND thread_id = ?", - (user_id, thread_id), - ).fetchone() - return dict(row) if row else None - - -def get_active_chats(user_id: int) -> list[dict]: - with _conn() as con: - rows = con.execute( - "SELECT * FROM chats WHERE user_id = ? AND archived_at IS NULL " - "ORDER BY created_at ASC", - (user_id,), - ).fetchall() - return [dict(r) for r in rows] - - -def count_active_chats(user_id: int) -> int: - with _conn() as con: - row = con.execute( - "SELECT COUNT(*) FROM chats WHERE user_id = ? AND archived_at IS NULL", - (user_id,), - ).fetchone() - return row[0] - - -def archive_chat(user_id: int, thread_id: int) -> None: - with _conn() as con: - con.execute( - "UPDATE chats SET archived_at = CURRENT_TIMESTAMP " - "WHERE user_id = ? AND thread_id = ?", - (user_id, thread_id), - ) - - -def rename_chat(user_id: int, thread_id: int, new_name: str) -> None: - with _conn() as con: - con.execute( - "UPDATE chats SET chat_name = ? WHERE user_id = ? AND thread_id = ?", - (new_name, user_id, thread_id), - ) - - -def get_display_number(user_id: int, thread_id: int) -> int: - """Return 1-based display number for a chat (by creation order).""" - with _conn() as con: - row = con.execute( - """ - SELECT rn FROM ( - SELECT thread_id, - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS rn - FROM chats - WHERE user_id = ? - ) WHERE thread_id = ? - """, - (user_id, thread_id), - ).fetchone() - return row[0] if row else 1 -``` - -- [ ] **Step 4: Run tests — verify they pass** - -```bash -pytest tests/adapter/test_forum_db.py -v -``` - -Expected: all 8 tests pass - -- [ ] **Step 5: Commit** - -```bash -git add adapter/telegram/db.py tests/adapter/test_forum_db.py -git commit -m "feat(tg): new db schema — (user_id, thread_id) PK" -``` - ---- - -## Task 2: Converter - -**Files:** -- Create: `adapter/telegram/converter.py` -- Create: `tests/adapter/telegram/test_converter.py` - -- [ ] **Step 1: Write failing tests** - -Create `tests/adapter/telegram/test_converter.py`: - -```python -from __future__ import annotations - -from types import SimpleNamespace - -from adapter.telegram.converter import from_message, format_outgoing -from core.protocol import OutgoingMessage, OutgoingUI - - -def make_message(*, text="hello", thread_id=42, user_id=1): - m = SimpleNamespace() - m.text = text - m.caption = None - m.photo = None - m.document = None - m.voice = None - m.message_thread_id = thread_id - m.from_user = SimpleNamespace(id=user_id, full_name="Alice") - return m - - -def test_from_message_in_topic(): - msg = make_message(thread_id=42, user_id=7) - result = from_message(msg) - assert result is not None - assert result.user_id == "7" - assert result.chat_id == "42" - assert result.text == "hello" - assert result.platform == "telegram" - - -def test_from_message_in_general_returns_none(): - msg = make_message(thread_id=None) - assert from_message(msg) is None - - -def test_from_message_uses_caption_if_no_text(): - msg = make_message(text=None, thread_id=10) - msg.caption = "caption text" - result = from_message(msg) - assert result.text == "caption text" - - -def test_format_outgoing_message(): - event = OutgoingMessage(chat_id="42", text="response") - assert format_outgoing(event) == "response" - - -def test_format_outgoing_ui(): - event = OutgoingUI(chat_id="42", text="choose") - assert format_outgoing(event) == "choose" -``` - -- [ ] **Step 2: Run tests — verify they fail** - -```bash -pytest tests/adapter/telegram/test_converter.py -v -``` - -Expected: `ModuleNotFoundError` - -- [ ] **Step 3: Implement converter.py** - -Create `adapter/telegram/converter.py`: - -```python -from __future__ import annotations - -from aiogram.types import Message - -from core.protocol import Attachment, IncomingMessage, OutgoingEvent, OutgoingMessage, OutgoingUI - - -def from_message(message: Message) -> IncomingMessage | None: - """Convert aiogram Message to IncomingMessage. Returns None for General topic.""" - thread_id = message.message_thread_id - if thread_id is None: - return None - return IncomingMessage( - user_id=str(message.from_user.id), - chat_id=str(thread_id), - text=message.text or message.caption or "", - attachments=_extract_attachments(message), - platform="telegram", - ) - - -def _extract_attachments(message: Message) -> list[Attachment]: - attachments: list[Attachment] = [] - if message.photo: - file = message.photo[-1] - attachments.append(Attachment( - type="image", - url=f"tg://file/{file.file_id}", - mime_type="image/jpeg", - )) - if message.document: - attachments.append(Attachment( - type="document", - url=f"tg://file/{message.document.file_id}", - mime_type=message.document.mime_type or "application/octet-stream", - filename=message.document.file_name, - )) - if message.voice: - attachments.append(Attachment( - type="audio", - url=f"tg://file/{message.voice.file_id}", - mime_type="audio/ogg", - )) - return attachments - - -def format_outgoing(event: OutgoingEvent) -> str: - """Extract text from an outgoing event for sending to Telegram.""" - if isinstance(event, (OutgoingMessage, OutgoingUI)): - return event.text - return str(event) -``` - -- [ ] **Step 4: Run tests — verify they pass** - -```bash -pytest tests/adapter/telegram/test_converter.py -v -``` - -Expected: all 5 tests pass - -- [ ] **Step 5: Commit** - -```bash -git add adapter/telegram/converter.py tests/adapter/telegram/test_converter.py -git commit -m "feat(tg): converter — context_key=(user_id, thread_id)" -``` - ---- - -## Task 3: Topic Event Handlers - -**Files:** -- Create: `adapter/telegram/handlers/topic_events.py` -- Create: `tests/adapter/telegram/test_topic_events.py` - -- [ ] **Step 1: Write failing tests** - -Create `tests/adapter/telegram/test_topic_events.py`: - -```python -from __future__ import annotations - -import importlib -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch - -import pytest - - -@pytest.fixture(autouse=True) -def fresh_db(tmp_path, monkeypatch): - monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db")) - import adapter.telegram.db as db_mod - importlib.reload(db_mod) - db_mod.init_db() - return db_mod - - -def make_service_message(*, user_id=1, thread_id=42, chat_id=1): - m = SimpleNamespace() - m.message_thread_id = thread_id - m.from_user = SimpleNamespace(id=user_id, full_name="Alice") - m.chat = SimpleNamespace(id=chat_id) - m.forum_topic_created = SimpleNamespace(name="Мой чат") - m.forum_topic_edited = SimpleNamespace(name="Новое имя") - m.forum_topic_closed = SimpleNamespace() - m.answer = AsyncMock() - return m - - -async def test_on_topic_created_registers_chat(fresh_db, monkeypatch): - from adapter.telegram.handlers.topic_events import on_topic_created - msg = make_service_message(user_id=5, thread_id=99) - await on_topic_created(msg) - chat = fresh_db.get_chat(5, 99) - assert chat is not None - assert chat["chat_name"] == "Мой чат" - - -async def test_on_topic_edited_renames_chat(fresh_db, monkeypatch): - from adapter.telegram.handlers.topic_events import on_topic_edited - fresh_db.create_chat(5, 99, "Старое имя") - msg = make_service_message(user_id=5, thread_id=99) - await on_topic_edited(msg) - assert fresh_db.get_chat(5, 99)["chat_name"] == "Новое имя" - - -async def test_on_topic_edited_unknown_chat_is_noop(fresh_db): - from adapter.telegram.handlers.topic_events import on_topic_edited - msg = make_service_message(user_id=5, thread_id=999) - await on_topic_edited(msg) # should not raise - - -async def test_on_topic_closed_archives_chat(fresh_db): - from adapter.telegram.handlers.topic_events import on_topic_closed - fresh_db.create_chat(5, 99, "Чат #1") - msg = make_service_message(user_id=5, thread_id=99) - await on_topic_closed(msg) - assert fresh_db.get_chat(5, 99)["archived_at"] is not None - - -async def test_on_topic_closed_unknown_chat_is_noop(fresh_db): - from adapter.telegram.handlers.topic_events import on_topic_closed - msg = make_service_message(user_id=5, thread_id=999) - await on_topic_closed(msg) # should not raise -``` - -- [ ] **Step 2: Run tests — verify they fail** - -```bash -pytest tests/adapter/telegram/test_topic_events.py -v -``` - -Expected: `ModuleNotFoundError` - -- [ ] **Step 3: Implement topic_events.py** - -Create `adapter/telegram/handlers/topic_events.py`: - -```python -from __future__ import annotations - -import structlog -from aiogram import F, Router -from aiogram.types import Message - -from adapter.telegram import db - -logger = structlog.get_logger(__name__) - -router = Router(name="topic_events") - - -@router.message(F.forum_topic_created) -async def on_topic_created(message: Message) -> None: - """User created a topic via Telegram UI — register it as a new chat.""" - user_id = message.from_user.id - thread_id = message.message_thread_id - name = message.forum_topic_created.name - db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=name) - logger.info("topic_created", user_id=user_id, thread_id=thread_id, name=name) - - -@router.message(F.forum_topic_edited) -async def on_topic_edited(message: Message) -> None: - """User renamed a topic via Telegram UI — sync chat_name in DB.""" - user_id = message.from_user.id - thread_id = message.message_thread_id - new_name = message.forum_topic_edited.name - existing = db.get_chat(user_id=user_id, thread_id=thread_id) - if existing is None: - return - db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name) - logger.info("topic_renamed", user_id=user_id, thread_id=thread_id, new_name=new_name) - - -@router.message(F.forum_topic_closed) -async def on_topic_closed(message: Message) -> None: - """User closed a topic via Telegram UI — auto-archive the chat.""" - user_id = message.from_user.id - thread_id = message.message_thread_id - existing = db.get_chat(user_id=user_id, thread_id=thread_id) - if existing is None: - return - db.archive_chat(user_id=user_id, thread_id=thread_id) - logger.info("topic_closed_archived", user_id=user_id, thread_id=thread_id) -``` - -- [ ] **Step 4: Run tests — verify they pass** - -```bash -pytest tests/adapter/telegram/test_topic_events.py -v -``` - -Expected: all 5 tests pass - -- [ ] **Step 5: Commit** - -```bash -git add adapter/telegram/handlers/topic_events.py tests/adapter/telegram/test_topic_events.py -git commit -m "feat(tg): handle forum_topic_created/edited/closed events" -``` - ---- - -## Task 4: Command Handlers - -**Files:** -- Create: `adapter/telegram/handlers/commands.py` -- Create: `tests/adapter/telegram/test_commands.py` - -- [ ] **Step 1: Write failing tests** - -Create `tests/adapter/telegram/test_commands.py`: - -```python -from __future__ import annotations - -import importlib -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock - -import pytest - - -@pytest.fixture(autouse=True) -def fresh_db(tmp_path, monkeypatch): - monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db")) - import adapter.telegram.db as db_mod - importlib.reload(db_mod) - db_mod.init_db() - return db_mod - - -def make_message(*, user_id=1, thread_id=42, chat_id=1, args=None): - m = SimpleNamespace() - m.from_user = SimpleNamespace(id=user_id, full_name="Alice") - m.message_thread_id = thread_id - m.chat = SimpleNamespace(id=chat_id) - m.answer = AsyncMock() - m.reply = AsyncMock() - m.bot = MagicMock() - m.bot.create_forum_topic = AsyncMock( - return_value=SimpleNamespace(message_thread_id=200) - ) - m.bot.close_forum_topic = AsyncMock() - m.bot.edit_forum_topic = AsyncMock() - m.bot.send_message = AsyncMock() - return m - - -async def test_cmd_new_creates_topic(fresh_db): - from adapter.telegram.handlers.commands import cmd_new - msg = make_message(user_id=1, thread_id=42, chat_id=100) - fresh_db.create_chat(1, 42, "Чат #1") # 1 existing chat - await cmd_new(msg) - msg.bot.create_forum_topic.assert_called_once() - call_kwargs = msg.bot.create_forum_topic.call_args - assert "Чат #2" in str(call_kwargs) - new_chat = fresh_db.get_chat(1, 200) - assert new_chat is not None - assert new_chat["chat_name"] == "Чат #2" - - -async def test_cmd_archive_closes_and_archives(fresh_db): - from adapter.telegram.handlers.commands import cmd_archive - fresh_db.create_chat(1, 42, "Чат #1") - msg = make_message(user_id=1, thread_id=42, chat_id=100) - await cmd_archive(msg) - msg.bot.close_forum_topic.assert_called_once_with( - chat_id=100, message_thread_id=42 - ) - assert fresh_db.get_chat(1, 42)["archived_at"] is not None - - -async def test_cmd_archive_unknown_topic_replies_error(fresh_db): - from adapter.telegram.handlers.commands import cmd_archive - msg = make_message(user_id=1, thread_id=999, chat_id=100) - await cmd_archive(msg) - msg.answer.assert_called_once() - assert "не найден" in msg.answer.call_args[0][0].lower() or \ - "not found" in msg.answer.call_args[0][0].lower() or \ - len(msg.answer.call_args[0][0]) > 0 # some error message - - -async def test_cmd_rename_updates_db_and_topic(fresh_db): - from adapter.telegram.handlers.commands import cmd_rename - fresh_db.create_chat(1, 42, "Чат #1") - msg = make_message(user_id=1, thread_id=42, chat_id=100) - await cmd_rename(msg, new_name="Работа") - msg.bot.edit_forum_topic.assert_called_once_with( - chat_id=100, message_thread_id=42, name="Работа" - ) - assert fresh_db.get_chat(1, 42)["chat_name"] == "Работа" -``` - -- [ ] **Step 2: Run tests — verify they fail** - -```bash -pytest tests/adapter/telegram/test_commands.py -v -``` - -Expected: `ModuleNotFoundError` - -- [ ] **Step 3: Implement commands.py** - -Create `adapter/telegram/handlers/commands.py`: - -```python -from __future__ import annotations - -import structlog -from aiogram import Router -from aiogram.filters import Command -from aiogram.types import Message - -from adapter.telegram import db -from adapter.telegram.keyboards.settings import settings_main_keyboard - -logger = structlog.get_logger(__name__) - -router = Router(name="commands") - - -@router.message(Command("new")) -async def cmd_new(message: Message) -> None: - """Create a new topic and register it as a new chat.""" - user_id = message.from_user.id - chat_id = message.chat.id - n = db.count_active_chats(user_id) + 1 - new_name = f"Чат #{n}" - topic = await message.bot.create_forum_topic(chat_id=chat_id, name=new_name) - thread_id = topic.message_thread_id - db.create_chat(user_id=user_id, thread_id=thread_id, chat_name=new_name) - await message.bot.send_message( - chat_id=chat_id, - message_thread_id=thread_id, - text=f"Создан {new_name}. Напиши что-нибудь.", - ) - logger.info("cmd_new", user_id=user_id, thread_id=thread_id, name=new_name) - - -@router.message(Command("archive")) -async def cmd_archive(message: Message) -> None: - """Archive the current topic.""" - user_id = message.from_user.id - thread_id = message.message_thread_id - chat = db.get_chat(user_id=user_id, thread_id=thread_id) - if chat is None or chat["archived_at"] is not None: - await message.answer("Этот чат не найден или уже архивирован.") - return - await message.bot.close_forum_topic( - chat_id=message.chat.id, message_thread_id=thread_id - ) - db.archive_chat(user_id=user_id, thread_id=thread_id) - logger.info("cmd_archive", user_id=user_id, thread_id=thread_id) - - -@router.message(Command("rename")) -async def cmd_rename(message: Message, new_name: str = "") -> None: - """Rename the current topic. Usage: /rename New Name""" - user_id = message.from_user.id - thread_id = message.message_thread_id - if not new_name: - # Parse from message text: /rename New Name - parts = (message.text or "").split(maxsplit=1) - new_name = parts[1].strip() if len(parts) > 1 else "" - if not new_name: - await message.answer("Использование: /rename Новое название") - return - chat = db.get_chat(user_id=user_id, thread_id=thread_id) - if chat is None: - await message.answer("Этот чат не найден.") - return - await message.bot.edit_forum_topic( - chat_id=message.chat.id, - message_thread_id=thread_id, - name=new_name[:128], - ) - db.rename_chat(user_id=user_id, thread_id=thread_id, new_name=new_name[:128]) - logger.info("cmd_rename", user_id=user_id, thread_id=thread_id, new_name=new_name) - - -@router.message(Command("settings")) -async def cmd_settings(message: Message) -> None: - """Open settings menu.""" - await message.answer("⚙️ Настройки", reply_markup=settings_main_keyboard()) -``` - -- [ ] **Step 4: Run tests — verify they pass** - -```bash -pytest tests/adapter/telegram/test_commands.py -v -``` - -Expected: all 4 tests pass - -- [ ] **Step 5: Commit** - -```bash -git add adapter/telegram/handlers/commands.py tests/adapter/telegram/test_commands.py -git commit -m "feat(tg): command handlers — /new /archive /rename /settings" -``` - ---- - -## Task 5: /start Handler - -**Files:** -- Create: `adapter/telegram/handlers/start.py` - -No separate test file — behaviour is verified via integration in Task 7. Unit testing `/start` requires heavy bot mocking; the key logic (stale topic detection) is thin enough to verify manually. - -- [ ] **Step 1: Implement start.py** - -Create `adapter/telegram/handlers/start.py`: - -```python -from __future__ import annotations - -import structlog -from aiogram import Router -from aiogram.exceptions import TelegramBadRequest -from aiogram.filters import Command, CommandStart -from aiogram.types import Message - -from adapter.telegram import db - -logger = structlog.get_logger(__name__) - -router = Router(name="start") - - -@router.message(CommandStart()) -async def cmd_start(message: Message) -> None: - """ - Bootstrap the user's forum. - - First visit: create Чат #1, hide General topic. - Returning visit: health-check all active topics, archive stale ones. - """ - user_id = message.from_user.id - chat_id = message.chat.id - - # Health-check existing topics — archive any that Telegram no longer knows about - await _check_and_prune_stale_topics(message, user_id, chat_id) - - active = db.get_active_chats(user_id) - - if not active: - # First visit or all topics were pruned — create the first one - try: - topic = await message.bot.create_forum_topic( - chat_id=chat_id, name="Чат #1" - ) - thread_id = topic.message_thread_id - db.create_chat(user_id=user_id, thread_id=thread_id, chat_name="Чат #1") - logger.info("start_created_first_topic", user_id=user_id, thread_id=thread_id) - except TelegramBadRequest as e: - if "not modified" not in str(e).lower(): - logger.warning("start_create_topic_failed", error=str(e)) - await message.answer( - "Не удалось создать топик. Убедись, что в @BotFather включён " - "Threaded Mode для этого бота." - ) - return - - # Hide General topic so it doesn't distract - try: - await message.bot.hide_general_forum_topic(chat_id=chat_id) - except TelegramBadRequest: - pass # Not critical — may not be available in all API versions - - await message.answer( - "Привет! Это твоё личное пространство с AI-агентом Lambda. " - "Каждый топик — отдельный контекст. Напиши что-нибудь." - ) - else: - await message.answer( - f"Снова привет! У тебя {len(active)} активных чатов. " - "Напиши /new чтобы создать новый." - ) - - -async def _check_and_prune_stale_topics( - message: Message, user_id: int, chat_id: int -) -> None: - """ - Send typing action to each active topic. - If Telegram returns an error — the topic was deleted; archive it. - """ - active = db.get_active_chats(user_id) - for chat in active: - thread_id = chat["thread_id"] - try: - await message.bot.send_chat_action( - chat_id=chat_id, - action="typing", - message_thread_id=thread_id, - ) - except TelegramBadRequest: - db.archive_chat(user_id=user_id, thread_id=thread_id) - logger.info("pruned_stale_topic", user_id=user_id, thread_id=thread_id) -``` - -- [ ] **Step 2: Verify it imports cleanly** - -```bash -python -c "from adapter.telegram.handlers.start import router; print('ok')" -``` - -Expected: `ok` - -- [ ] **Step 3: Commit** - -```bash -git add adapter/telegram/handlers/start.py -git commit -m "feat(tg): /start handler with topic bootstrap and stale-topic pruning" -``` - ---- - -## Task 6: Message Handler with Streaming - -**Files:** -- Create: `adapter/telegram/handlers/message.py` - -- [ ] **Step 1: Implement message.py** - -Create `adapter/telegram/handlers/message.py`: - -```python -from __future__ import annotations - -import asyncio -import time - -import structlog -from aiogram import F, Router -from aiogram.exceptions import TelegramBadRequest -from aiogram.types import Message - -from adapter.telegram import converter, db -from core.handler import EventDispatcher - -logger = structlog.get_logger(__name__) - -router = Router(name="message") - -STREAM_EDIT_INTERVAL = 1.5 # seconds between edit_text calls -STREAM_MIN_DELTA = 100 # minimum new chars before editing -TELEGRAM_MAX_LEN = 4096 - - -@router.message(F.text & F.message_thread_id) -async def handle_topic_message(message: Message, dispatcher: EventDispatcher) -> None: - """Route a text message in a topic to the platform and stream the response.""" - user_id = message.from_user.id - thread_id = message.message_thread_id - - chat = db.get_chat(user_id=user_id, thread_id=thread_id) - if chat is None or chat["archived_at"] is not None: - # Unregistered or archived topic — silently ignore - return - - incoming = converter.from_message(message) - if incoming is None: - return - - platform_user = await dispatcher._platform.get_or_create_user( - external_id=str(user_id), - platform="telegram", - display_name=message.from_user.full_name, - ) - - placeholder = await message.reply("...") - - accumulated = "" - last_edit_time = 0.0 - last_edit_len = 0 - - try: - async for chunk in dispatcher._platform.stream_message( - user_id=platform_user.user_id, - chat_id=str(thread_id), - text=incoming.text, - attachments=None, - ): - accumulated += chunk.delta - now = time.monotonic() - delta = len(accumulated) - last_edit_len - if delta >= STREAM_MIN_DELTA and (now - last_edit_time) >= STREAM_EDIT_INTERVAL: - await _safe_edit(placeholder, accumulated) - last_edit_time = now - last_edit_len = len(accumulated) - - # Final edit with complete response - await _safe_edit(placeholder, accumulated or "...") - - except TelegramBadRequest as e: - if "thread not found" in str(e).lower(): - db.archive_chat(user_id=user_id, thread_id=thread_id) - logger.warning("topic_deleted_during_message", thread_id=thread_id) - else: - logger.error("telegram_error", error=str(e)) - except Exception: - logger.exception("platform_error", user_id=user_id, thread_id=thread_id) - await _safe_edit(placeholder, "Сервис временно недоступен, попробуй позже") - - -async def _safe_edit(message: Message, text: str) -> None: - """Edit message text, truncating to Telegram limit. Swallows 'not modified'.""" - truncated = text[:TELEGRAM_MAX_LEN] - try: - await message.edit_text(truncated) - except TelegramBadRequest as e: - if "not modified" not in str(e).lower(): - raise -``` - -- [ ] **Step 2: Verify it imports cleanly** - -```bash -python -c "from adapter.telegram.handlers.message import router; print('ok')" -``` - -Expected: `ok` - -- [ ] **Step 3: Commit** - -```bash -git add adapter/telegram/handlers/message.py -git commit -m "feat(tg): message handler with streaming via sdk.stream_message" -``` - ---- - -## Task 7: Settings Handler (Cherry-Pick + Adapt) - -**Files:** -- Create: `adapter/telegram/states.py` -- Create: `adapter/telegram/handlers/settings.py` - -The settings handler from `feat/telegram-adapter` already works well. We adapt it to drop `db.get_or_create_tg_user` (no longer needed — platform resolves users by `str(tg_id)`) and remove topic-FSM dependency. - -- [ ] **Step 1: Create states.py (SettingsState only)** - -Create `adapter/telegram/states.py`: - -```python -from __future__ import annotations - -from aiogram.fsm.state import State, StatesGroup - - -class SettingsState(StatesGroup): - menu = State() - soul_editing = State() -``` - -- [ ] **Step 2: Cherry-pick settings handler** - -```bash -git show feat/telegram-adapter:adapter/telegram/handlers/settings.py > adapter/telegram/handlers/settings.py -``` - -- [ ] **Step 3: Patch settings handler — remove get_or_create_tg_user calls** - -In `adapter/telegram/handlers/settings.py`, replace all blocks that call `db.get_or_create_tg_user` with a direct string cast. Find every occurrence of: - -```python -from adapter.telegram import db as tgdb -tg_id = callback.from_user.id -tg_user = tgdb.get_or_create_tg_user(tg_id, str(tg_id), callback.from_user.full_name) -platform_user_id = tg_user.get("platform_user_id", str(tg_id)) -``` - -Replace with: - -```python -platform_user_id = str(callback.from_user.id) -``` - -And for message handlers (soul editing), replace the analogous block with: - -```python -platform_user_id = str(message.from_user.id) -``` - -Also remove the import of `ChatState` from `adapter.telegram.states` — it no longer exists: -Find: `from adapter.telegram.states import ChatState, SettingsState` -Replace: `from adapter.telegram.states import SettingsState` - -- [ ] **Step 4: Verify settings handler imports cleanly** - -```bash -python -c "from adapter.telegram.handlers.settings import router; print('ok')" -``` - -Expected: `ok` - -- [ ] **Step 5: Commit** - -```bash -git add adapter/telegram/states.py adapter/telegram/handlers/settings.py -git commit -m "feat(tg): cherry-pick settings handler, drop get_or_create_tg_user" -``` - ---- - -## Task 8: Wire Everything in bot.py - -**Files:** -- Create: `adapter/telegram/bot.py` - -- [ ] **Step 1: Implement bot.py** - -Create `adapter/telegram/bot.py`: - -```python -from __future__ import annotations - -import asyncio -import os - -import structlog -from aiogram import Bot, Dispatcher -from aiogram.fsm.storage.memory import MemoryStorage -from aiogram.types import BotCommand - -from adapter.telegram import db -from adapter.telegram.handlers import commands, message, settings, start, topic_events -from core.auth import AuthManager -from core.chat import ChatManager -from core.handler import EventDispatcher -from core.settings import SettingsManager -from core.store import InMemoryStore -from sdk.mock import MockPlatformClient - -logger = structlog.get_logger(__name__) - - -class PlatformMiddleware: - """Injects EventDispatcher (with platform inside) into every handler.""" - - def __init__(self, dispatcher: EventDispatcher) -> None: - self._dispatcher = dispatcher - - async def __call__(self, handler, event, data): - data["dispatcher"] = self._dispatcher - return await handler(event, data) - - -def build_event_dispatcher() -> EventDispatcher: - platform = MockPlatformClient() - store = InMemoryStore() - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - return EventDispatcher( - platform=platform, - chat_mgr=chat_mgr, - auth_mgr=auth_mgr, - settings_mgr=settings_mgr, - ) - - -async def main() -> None: - token = os.environ.get("BOT_TOKEN") - if not token: - raise RuntimeError("BOT_TOKEN env variable is not set") - - db.init_db() - - bot = Bot(token=token) - storage = MemoryStorage() - dp = Dispatcher(storage=storage) - - event_dispatcher = build_event_dispatcher() - - dp.message.middleware(PlatformMiddleware(event_dispatcher)) - dp.callback_query.middleware(PlatformMiddleware(event_dispatcher)) - - # Register routers — order matters (most specific first) - dp.include_router(topic_events.router) # service messages - dp.include_router(start.router) # /start - dp.include_router(commands.router) # /new /archive /rename /settings - dp.include_router(settings.router) # settings callbacks + soul FSM - dp.include_router(message.router) # text messages in topics (last) - - await bot.set_my_commands([ - BotCommand(command="start", description="Начать / восстановить сессию"), - BotCommand(command="new", description="Создать новый чат"), - BotCommand(command="archive", description="Архивировать текущий чат"), - BotCommand(command="rename", description="Переименовать текущий чат"), - BotCommand(command="settings", description="Настройки"), - ]) - - logger.info("bot_starting") - await dp.start_polling( - bot, - allowed_updates=[ - "message", - "callback_query", - ], - ) - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -- [ ] **Step 2: Verify full import chain** - -```bash -python -c "from adapter.telegram.bot import main; print('ok')" -``` - -Expected: `ok` - -- [ ] **Step 3: Run all tests** - -```bash -pytest tests/adapter/ -v -``` - -Expected: all tests pass, no import errors - -- [ ] **Step 4: Commit** - -```bash -git add adapter/telegram/bot.py -git commit -m "feat(tg): wire forum-first adapter in bot.py" -``` - ---- - -## Task 9: Final Cleanup and Module Entry Point - -**Files:** -- Verify: `adapter/telegram/__init__.py` - -- [ ] **Step 1: Ensure `python -m adapter.telegram.bot` works** - -```bash -python -m adapter.telegram.bot --help 2>&1 | head -5 || echo "needs BOT_TOKEN" -``` - -Expected: either `needs BOT_TOKEN` or a clean import error (not `ModuleNotFoundError`) - -- [ ] **Step 2: Run full test suite** - -```bash -pytest tests/ -v --tb=short -``` - -Expected: all tests pass (including core/ and matrix/ tests from main) - -- [ ] **Step 3: Final commit** - -```bash -git add -A -git status # verify no unintended files -git commit -m "feat(tg): forum-first adapter complete — threaded mode, (user_id, thread_id) context" -``` - ---- - -## Self-Review Checklist - -Spec requirements vs tasks: - -| Spec requirement | Task | -|-----------------|------| -| `(user_id, thread_id)` PK | Task 1 | -| `forum_topic_created` → register | Task 3 | -| `forum_topic_edited` → sync name | Task 3 | -| `forum_topic_closed` → auto-archive | Task 3 | -| `/new` creates topic | Task 4 | -| `/archive` closes + archives | Task 4 | -| `/rename` edits topic + DB | Task 4 | -| `/settings` global keyboard | Task 4 + Task 7 | -| `/start` bootstrap + health-check | Task 5 | -| Hide General topic | Task 5 | -| Threaded Mode not enabled → explain | Task 5 | -| Streaming via `stream_message` | Task 6 | -| General topic messages ignored | Task 6 (thread_id None guard in converter) | -| Stale topic auto-archive on send | Task 6 | -| `core/store.py` for state, no FSM | All tasks (no FSMContext in message/topic handlers) | -| platform resolves workspace | Implicit — adapter passes `str(thread_id)` as `chat_id` | diff --git a/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md b/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md deleted file mode 100644 index e9a9921..0000000 --- a/docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md +++ /dev/null @@ -1,515 +0,0 @@ -# Matrix Direct-Agent Prototype Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace `MockPlatformClient` with a real Matrix-only direct-agent prototype backend while keeping Matrix adapter logic stable and preserving a clean future migration path. - -**Architecture:** Introduce a thin compatibility layer under `sdk/` that splits direct WebSocket agent messaging from local prototype-only control-plane state. Keep `core/` and Matrix handlers on the existing `PlatformClient` contract, and limit surface changes to runtime wiring and tests. - -**Tech Stack:** Python 3.11, `aiohttp` WebSocket client, `matrix-nio`, `pydantic`, `pytest`, `pytest-asyncio` - ---- - -## File Structure - -- Create: `sdk/agent_session.py` - Purpose: Minimal direct WebSocket client for the real agent, including thread-key derivation, request/response parsing, and sync/stream helpers. - -- Create: `sdk/prototype_state.py` - Purpose: Local prototype-only user mapping and settings store kept behind a small API. - -- Create: `sdk/real.py` - Purpose: `PlatformClient` implementation that composes `AgentSessionClient` and `PrototypeStateStore`. - -- Modify: `sdk/__init__.py` - Purpose: export `RealPlatformClient` if useful for runtime imports. - -- Modify: `adapter/matrix/bot.py` - Purpose: runtime/backend selection and env-based configuration for mock vs real backend. - -- Create: `tests/platform/test_agent_session.py` - Purpose: transport-level tests for direct agent communication. - -- Create: `tests/platform/test_prototype_state.py` - Purpose: unit tests for local user/settings behavior. - -- Create: `tests/platform/test_real.py` - Purpose: contract tests for `RealPlatformClient`. - -- Modify: `tests/core/test_integration.py` - Purpose: prove the new platform implementation preserves core behavior. - -- Modify: `README.md` - Purpose: document backend selection and prototype limitations after code is working. - ---- - -### Task 1: Add Direct Agent Session Transport - -**Files:** -- Create: `sdk/agent_session.py` -- Test: `tests/platform/test_agent_session.py` - -- [ ] **Step 1: Write the failing transport tests** - -```python -import pytest - -from sdk.agent_session import AgentSessionClient, build_thread_key - - -def test_build_thread_key_uses_surface_user_and_chat_id(): - assert build_thread_key("matrix", "@alice:example.org", "C1") == "matrix:@alice:example.org:C1" - - -@pytest.mark.asyncio -async def test_send_message_collects_text_chunks_and_tokens(aiohttp_server): - ... - - -@pytest.mark.asyncio -async def test_stream_message_yields_incremental_chunks(aiohttp_server): - ... -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pytest tests/platform/test_agent_session.py -q` -Expected: FAIL with `ModuleNotFoundError: No module named 'sdk.agent_session'` - -- [ ] **Step 3: Write minimal transport implementation** - -```python -from __future__ import annotations - -from dataclasses import dataclass -from typing import AsyncIterator - -import aiohttp - -from sdk.interface import MessageChunk, MessageResponse, PlatformError - - -def build_thread_key(platform: str, user_id: str, chat_id: str) -> str: - return f"{platform}:{user_id}:{chat_id}" - - -@dataclass -class AgentSessionConfig: - base_ws_url: str - timeout_seconds: float = 30.0 - - -class AgentSessionClient: - def __init__(self, config: AgentSessionConfig) -> None: - self._config = config - - async def send_message(self, *, thread_key: str, text: str) -> MessageResponse: - chunks = [] - tokens_used = 0 - async for chunk in self.stream_message(thread_key=thread_key, text=text): - chunks.append(chunk.delta) - tokens_used = chunk.tokens_used or tokens_used - return MessageResponse( - message_id=thread_key, - response="".join(chunks), - tokens_used=tokens_used, - finished=True, - ) - - async def stream_message(self, *, thread_key: str, text: str) -> AsyncIterator[MessageChunk]: - url = f"{self._config.base_ws_url}?thread_id={thread_key}" - async with aiohttp.ClientSession() as session: - async with session.ws_connect(url, heartbeat=30) as ws: - status_msg = await ws.receive_json(timeout=self._config.timeout_seconds) - if status_msg.get("type") != "STATUS": - raise PlatformError("Agent did not send STATUS", code="AGENT_PROTOCOL_ERROR") - - await ws.send_json({"type": "USER_MESSAGE", "text": text}) - - while True: - payload = await ws.receive_json(timeout=self._config.timeout_seconds) - msg_type = payload.get("type") - if msg_type == "AGENT_EVENT_TEXT_CHUNK": - yield MessageChunk(message_id=thread_key, delta=payload["text"], finished=False) - elif msg_type == "AGENT_EVENT_END": - yield MessageChunk( - message_id=thread_key, - delta="", - finished=True, - tokens_used=payload.get("tokens_used", 0), - ) - return - elif msg_type == "ERROR": - raise PlatformError(payload.get("details", "Agent error"), code=payload.get("code", "AGENT_ERROR")) - else: - raise PlatformError(f"Unexpected agent message: {payload}", code="AGENT_PROTOCOL_ERROR") -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pytest tests/platform/test_agent_session.py -q` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add sdk/agent_session.py tests/platform/test_agent_session.py -git commit -m "feat: add direct agent session transport" -``` - ---- - -### Task 2: Add Local Prototype State For Users And Settings - -**Files:** -- Create: `sdk/prototype_state.py` -- Test: `tests/platform/test_prototype_state.py` - -- [ ] **Step 1: Write the failing state tests** - -```python -import pytest - -from core.protocol import SettingsAction -from sdk.prototype_state import PrototypeStateStore - - -@pytest.mark.asyncio -async def test_get_or_create_user_is_stable_per_surface_identity(): - ... - - -@pytest.mark.asyncio -async def test_settings_defaults_match_existing_mock_shape(): - ... - - -@pytest.mark.asyncio -async def test_update_settings_supports_toggle_skill_and_setters(): - ... -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pytest tests/platform/test_prototype_state.py -q` -Expected: FAIL with `ModuleNotFoundError: No module named 'sdk.prototype_state'` - -- [ ] **Step 3: Write minimal state implementation** - -```python -from __future__ import annotations - -from datetime import UTC, datetime - -from sdk.interface import User, UserSettings - -# Defaults are defined here, not imported from sdk.mock, to keep real backend -# isolated from the mock. Copy-paste intentional. -DEFAULT_SKILLS: dict[str, bool] = { - "web-search": True, - "fetch-url": True, - "email": False, - "browser": False, - "image-gen": False, - "files": True, -} -DEFAULT_SAFETY: dict[str, bool] = {"email-send": True, "file-delete": True, "social-post": True} -DEFAULT_SOUL: dict[str, str] = {"name": "Лямбда", "instructions": ""} -DEFAULT_PLAN: dict = {"name": "Beta", "tokens_used": 0, "tokens_limit": 1000} - - -class PrototypeStateStore: - def __init__(self) -> None: - self._users: dict[str, User] = {} - self._settings: dict[str, dict] = {} - - async def get_or_create_user( - self, - *, - external_id: str, - platform: str, - display_name: str | None = None, - ) -> User: - key = f"{platform}:{external_id}" - existing = self._users.get(key) - if existing is not None: - return existing.model_copy(update={"is_new": False}) - - user = User( - user_id=f"usr-{platform}-{external_id}", - external_id=external_id, - platform=platform, - display_name=display_name, - created_at=datetime.now(UTC), - is_new=True, - ) - self._users[key] = user.model_copy(update={"is_new": False}) - return user - - async def get_settings(self, user_id: str) -> UserSettings: - stored = self._settings.get(user_id, {}) - return UserSettings( - skills={**DEFAULT_SKILLS, **stored.get("skills", {})}, - connectors=stored.get("connectors", {}), - soul={**DEFAULT_SOUL, **stored.get("soul", {})}, - safety={**DEFAULT_SAFETY, **stored.get("safety", {})}, - plan={**DEFAULT_PLAN, **stored.get("plan", {})}, - ) - - async def update_settings(self, user_id: str, action) -> None: - settings = self._settings.setdefault(user_id, {}) - if action.action == "toggle_skill": - skills = settings.setdefault("skills", DEFAULT_SKILLS.copy()) - skills[action.payload["skill"]] = action.payload.get("enabled", True) - elif action.action == "set_soul": - soul = settings.setdefault("soul", DEFAULT_SOUL.copy()) - soul[action.payload["field"]] = action.payload["value"] - elif action.action == "set_safety": - safety = settings.setdefault("safety", DEFAULT_SAFETY.copy()) - safety[action.payload["trigger"]] = action.payload.get("enabled", True) -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pytest tests/platform/test_prototype_state.py -q` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add sdk/prototype_state.py tests/platform/test_prototype_state.py -git commit -m "feat: add prototype local state store" -``` - ---- - -### Task 3: Implement RealPlatformClient Compatibility Layer - -**Files:** -- Create: `sdk/real.py` -- Modify: `sdk/__init__.py` -- Test: `tests/platform/test_real.py` -- Test: `tests/core/test_integration.py` - -- [ ] **Step 1: Write the failing compatibility tests** - -```python -import pytest - -from core.protocol import SettingsAction -from sdk.real import RealPlatformClient - - -@pytest.mark.asyncio -async def test_real_platform_client_get_or_create_user_uses_local_state(): - ... - - -@pytest.mark.asyncio -async def test_real_platform_client_send_message_uses_thread_key(): - ... - - -@pytest.mark.asyncio -async def test_real_platform_client_settings_are_local(): - ... -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pytest tests/platform/test_real.py -q` -Expected: FAIL with `ModuleNotFoundError: No module named 'sdk.real'` - -- [ ] **Step 3: Write minimal compatibility wrapper** - -```python -from __future__ import annotations - -from typing import AsyncIterator - -from sdk.agent_session import AgentSessionClient, build_thread_key -from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformClient, User, UserSettings -from sdk.prototype_state import PrototypeStateStore - - -class RealPlatformClient(PlatformClient): - def __init__( - self, - agent_sessions: AgentSessionClient, - prototype_state: PrototypeStateStore, - platform: str = "matrix", - ) -> None: - self._agent_sessions = agent_sessions - self._prototype_state = prototype_state - self._platform = platform # surface name used in thread key; pass explicitly for future surfaces - - async def get_or_create_user( - self, - external_id: str, - platform: str, - display_name: str | None = None, - ) -> User: - return await self._prototype_state.get_or_create_user( - external_id=external_id, - platform=platform, - display_name=display_name, - ) - - async def send_message( - self, - user_id: str, - chat_id: str, - text: str, - attachments: list[Attachment] | None = None, - ) -> MessageResponse: - # user_id here is the internal id (e.g. "usr-matrix-@alice:example.org"), which is - # unique per user and stable — acceptable as thread identity for v1 prototype. - thread_key = build_thread_key(self._platform, user_id, chat_id) - return await self._agent_sessions.send_message(thread_key=thread_key, text=text) - - async def stream_message( - self, - user_id: str, - chat_id: str, - text: str, - attachments: list[Attachment] | None = None, - ) -> AsyncIterator[MessageChunk]: - thread_key = build_thread_key(self._platform, user_id, chat_id) - async for chunk in self._agent_sessions.stream_message(thread_key=thread_key, text=text): - yield chunk - - async def get_settings(self, user_id: str) -> UserSettings: - return await self._prototype_state.get_settings(user_id) - - async def update_settings(self, user_id: str, action) -> None: - await self._prototype_state.update_settings(user_id, action) -``` - -- [ ] **Step 4: Run tests to verify the contract holds** - -Run: `pytest tests/platform/test_real.py tests/core/test_integration.py -q` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add sdk/real.py sdk/__init__.py tests/platform/test_real.py tests/core/test_integration.py -git commit -m "feat: add real platform compatibility layer" -``` - ---- - -### Task 4: Wire Matrix Runtime To Real Backend And Document Usage - -**Files:** -- Modify: `adapter/matrix/bot.py` -- Modify: `README.md` -- Modify: `tests/adapter/matrix/test_dispatcher.py` - -- [ ] **Step 1: Write the failing runtime wiring tests** - -```python -import os - -from adapter.matrix.bot import build_runtime -from sdk.real import RealPlatformClient - - -def test_build_runtime_uses_real_platform_when_matrix_backend_is_real(monkeypatch): - monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real") - monkeypatch.setenv("AGENT_WS_URL", "ws://agent.example/agent_ws/") - runtime = build_runtime() - assert isinstance(runtime.platform, RealPlatformClient) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pytest tests/adapter/matrix/test_dispatcher.py -q` -Expected: FAIL because runtime still always constructs `MockPlatformClient` - -- [ ] **Step 3: Implement backend selection and docs** - -```python -# adapter/matrix/bot.py — add these imports at the top -from sdk.agent_session import AgentSessionClient, AgentSessionConfig -from sdk.interface import PlatformClient -from sdk.prototype_state import PrototypeStateStore -from sdk.real import RealPlatformClient - - -def _build_platform_from_env() -> PlatformClient: - backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock") - if backend == "real": - ws_url = os.environ["AGENT_WS_URL"] - return RealPlatformClient( - agent_sessions=AgentSessionClient(AgentSessionConfig(base_ws_url=ws_url)), - prototype_state=PrototypeStateStore(), - platform="matrix", - ) - return MockPlatformClient() - - -# Update build_runtime to use env-based selection when no platform is injected: -def build_runtime( - platform: PlatformClient | None = None, # was MockPlatformClient | None - store: StateStore | None = None, - client: AsyncClient | None = None, -) -> MatrixRuntime: - platform = platform or _build_platform_from_env() - ... # rest unchanged -``` - -Also update the `MatrixRuntime` dataclass field type from `MockPlatformClient` to `PlatformClient` so the type annotation matches the runtime behavior. - -```markdown -# README.md - -Matrix prototype backend selection: - -- `MATRIX_PLATFORM_BACKEND=mock` uses `sdk/mock.py` -- `MATRIX_PLATFORM_BACKEND=real` uses direct agent WebSocket integration -- `AGENT_WS_URL=ws://host:port/agent_ws/` is required for the real backend - -Current real-backend limitations: -- text chat only -- local settings storage -- no attachments or async task callbacks yet -``` - -- [ ] **Step 4: Run targeted verification** - -Run: `pytest tests/adapter/matrix/test_dispatcher.py tests/platform/test_agent_session.py tests/platform/test_prototype_state.py tests/platform/test_real.py -q` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/bot.py README.md tests/adapter/matrix/test_dispatcher.py -git commit -m "feat: wire matrix runtime to real backend" -``` - ---- - -## Self-Review - -- Spec coverage: - - direct-agent transport: Task 1 - - local settings/user state: Task 2 - - stable `PlatformClient` wrapper: Task 3 - - Matrix runtime wiring and docs: Task 4 -- Placeholder scan: no `TBD`, `TODO`, or “implement later” steps remain in the plan. -- Type consistency: - - `build_thread_key(platform, user_id, chat_id)` is used consistently. - - `RealPlatformClient` remains the only bot-facing implementation. - - local settings stay in `PrototypeStateStore`. - -## Execution Handoff - -Plan complete and saved to `docs/superpowers/plans/2026-04-08-matrix-direct-agent-prototype.md`. Two execution options: - -**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration - -**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints - -**Which approach?** diff --git a/docs/superpowers/plans/2026-04-19-matrix-per-chat-context.md b/docs/superpowers/plans/2026-04-19-matrix-per-chat-context.md deleted file mode 100644 index ed4b80e..0000000 --- a/docs/superpowers/plans/2026-04-19-matrix-per-chat-context.md +++ /dev/null @@ -1,480 +0,0 @@ -# Matrix Per-Chat Context Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Move the Matrix surface from a shared agent context to true per-room platform contexts mapped through `platform_chat_id`, including `!new`, `!branch`, lazy migration, and per-room context commands. - -**Architecture:** Matrix keeps owning UX chats (`C1`, `C2`, rooms, spaces). Each working room stores a `platform_chat_id` in `room_meta`, and platform-facing operations use that mapping. Legacy rooms without a mapping lazily create one on first context-aware use. - -**Tech Stack:** Python 3.11, Matrix nio adapter, local state store, `lambda_agent_api`, pytest - ---- - -### Task 1: Add `platform_chat_id` to Matrix metadata and tests - -**Files:** -- Modify: `adapter/matrix/store.py` -- Test: `tests/adapter/matrix/test_store.py` - -- [ ] **Step 1: Write the failing test** - -```python -async def test_room_meta_roundtrip_with_platform_chat_id(store: InMemoryStore): - meta = { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "platform_chat_id": "chat-platform-1", - } - await set_room_meta(store, "!r:m.org", meta) - saved = await get_room_meta(store, "!r:m.org") - assert saved is not None - assert saved["platform_chat_id"] == "chat-platform-1" -``` - -- [ ] **Step 2: Run test to verify it fails or proves missing coverage** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q` -Expected: either FAIL on missing assertion coverage or PASS after adding the new test with current generic storage behavior - -- [ ] **Step 3: Write minimal implementation** - -```python -# adapter/matrix/store.py -# No schema gate is required because room metadata is already stored as a dict. -# Keep helpers unchanged, but add focused helper functions if they reduce repeated logic: - -async def get_platform_chat_id(store: StateStore, room_id: str) -> str | None: - meta = await get_room_meta(store, room_id) - return meta.get("platform_chat_id") if meta else None - - -async def set_platform_chat_id(store: StateStore, room_id: str, platform_chat_id: str) -> None: - meta = await get_room_meta(store, room_id) or {} - meta["platform_chat_id"] = platform_chat_id - await set_room_meta(store, room_id, meta) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/store.py tests/adapter/matrix/test_store.py -git commit -m "feat: add platform chat id room metadata helpers" -``` - -### Task 2: Extend the platform wrapper to support context-aware API calls - -**Files:** -- Modify: `sdk/agent_api_wrapper.py` -- Modify: `sdk/real.py` -- Test: `tests/platform/test_real.py` - -- [ ] **Step 1: Write the failing tests** - -```python -@pytest.mark.asyncio -async def test_real_client_send_message_uses_platform_chat_id(): - api = FakeAgentApi() - client = RealPlatformClient(agent_api=api, prototype_state=PrototypeStateStore()) - - await client.send_message("@alice:example.org", "chat-platform-1", "hello") - - assert api.sent == [("chat-platform-1", "hello")] - - -@pytest.mark.asyncio -async def test_real_client_create_and_branch_context_delegate_to_agent_api(): - api = FakeAgentApi(create_ids=["chat-new", "chat-branch"]) - client = RealPlatformClient(agent_api=api, prototype_state=PrototypeStateStore()) - - created = await client.create_chat_context("@alice:example.org") - branched = await client.branch_chat_context("@alice:example.org", "chat-source") - - assert created == "chat-new" - assert branched == "chat-branch" - assert api.branch_calls == ["chat-source"] -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q` -Expected: FAIL because `RealPlatformClient` does not yet expose context-aware methods or pass a platform context id through - -- [ ] **Step 3: Write minimal implementation** - -```python -# sdk/agent_api_wrapper.py -class AgentApiWrapper(AgentApi): - async def create_chat(self) -> str: - ... - - async def branch_chat(self, chat_id: str) -> str: - ... - - async def send_message(self, chat_id: str, text: str): - ... - - async def save_context(self, chat_id: str, name: str) -> None: - ... - - async def load_context(self, chat_id: str, name: str) -> None: - ... - - -# sdk/real.py -class RealPlatformClient(PlatformClient): - async def create_chat_context(self, user_id: str) -> str: - return await self._agent_api.create_chat() - - async def branch_chat_context(self, user_id: str, from_chat_id: str) -> str: - return await self._agent_api.branch_chat(from_chat_id) - - async def save_chat_context(self, user_id: str, chat_id: str, name: str) -> None: - await self._agent_api.save_context(chat_id, name) - - async def load_chat_context(self, user_id: str, chat_id: str, name: str) -> None: - await self._agent_api.load_context(chat_id, name) - - async def stream_message(...): - async for event in self._agent_api.send_message(chat_id, text): - ... -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add sdk/agent_api_wrapper.py sdk/real.py tests/platform/test_real.py -git commit -m "feat: add context-aware real platform client methods" -``` - -### Task 3: Create Matrix-side resolver for mapped or lazy-created platform contexts - -**Files:** -- Modify: `adapter/matrix/bot.py` -- Modify: `adapter/matrix/store.py` -- Test: `tests/adapter/matrix/test_dispatcher.py` - -- [ ] **Step 1: Write the failing tests** - -```python -async def test_existing_room_without_platform_chat_id_gets_lazy_mapping_on_message(): - runtime = build_runtime(platform=FakeRealPlatformClient(create_ids=["chat-platform-1"])) - await set_room_meta(runtime.store, "!room:example.org", { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - }) - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - room = SimpleNamespace(room_id="!room:example.org") - event = SimpleNamespace(sender="@alice:example.org", body="hello") - - await bot.on_room_message(room, event) - - meta = await get_room_meta(runtime.store, "!room:example.org") - assert meta["platform_chat_id"] == "chat-platform-1" -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q` -Expected: FAIL because no lazy mapping exists - -- [ ] **Step 3: Write minimal implementation** - -```python -# adapter/matrix/bot.py -async def _ensure_platform_chat_id(self, room_id: str, user_id: str) -> str: - meta = await get_room_meta(self.runtime.store, room_id) - if meta is None: - raise ValueError("room metadata is required") - platform_chat_id = meta.get("platform_chat_id") - if platform_chat_id: - return platform_chat_id - if not hasattr(self.runtime.platform, "create_chat_context"): - raise ValueError("real platform backend required") - platform_chat_id = await self.runtime.platform.create_chat_context(user_id) - meta["platform_chat_id"] = platform_chat_id - await set_room_meta(self.runtime.store, room_id, meta) - return platform_chat_id -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/bot.py adapter/matrix/store.py tests/adapter/matrix/test_dispatcher.py -git commit -m "feat: lazily assign platform chat ids to matrix rooms" -``` - -### Task 4: Make `!new` and workspace bootstrap create independent platform contexts - -**Files:** -- Modify: `adapter/matrix/handlers/chat.py` -- Modify: `adapter/matrix/handlers/auth.py` -- Modify: `adapter/matrix/bot.py` -- Test: `tests/adapter/matrix/test_chat_space.py` -- Test: `tests/adapter/matrix/test_invite_space.py` -- Test: `tests/adapter/matrix/test_dispatcher.py` - -- [ ] **Step 1: Write the failing tests** - -```python -async def test_new_chat_assigns_new_platform_chat_id(): - client = SimpleNamespace( - room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - ) - platform = FakeRealPlatformClient(create_ids=["chat-platform-7"]) - runtime = build_runtime(platform=platform, client=client) - await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 7}) - - result = await runtime.dispatcher.dispatch( - IncomingCommand(user_id="u1", platform="matrix", chat_id="C3", command="new", args=["Research"]) - ) - - meta = await get_room_meta(runtime.store, "!r2:example") - assert meta["platform_chat_id"] == "chat-platform-7" -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py -q` -Expected: FAIL because new chats do not yet store a platform context id - -- [ ] **Step 3: Write minimal implementation** - -```python -# adapter/matrix/handlers/chat.py -# adapter/matrix/handlers/auth.py -platform_chat_id = None -if hasattr(platform, "create_chat_context"): - platform_chat_id = await platform.create_chat_context(event.user_id) - -await set_room_meta(store, room_id, { - "chat_id": chat_id, - "matrix_user_id": event.user_id, - "space_id": space_id, - "platform_chat_id": platform_chat_id, -}) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py -q` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/handlers/chat.py adapter/matrix/handlers/auth.py adapter/matrix/bot.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py -git commit -m "feat: assign platform contexts when creating matrix chats" -``` - -### Task 5: Make per-room save, load, and context use the mapped platform context - -**Files:** -- Modify: `adapter/matrix/handlers/context_commands.py` -- Modify: `adapter/matrix/bot.py` -- Modify: `sdk/prototype_state.py` -- Test: `tests/adapter/matrix/test_context_commands.py` - -- [ ] **Step 1: Write the failing tests** - -```python -@pytest.mark.asyncio -async def test_save_command_uses_room_platform_chat_id(): - platform = MatrixCommandPlatform() - runtime = build_runtime(platform=platform) - await set_room_meta(runtime.store, "!room:example.org", { - "chat_id": "C1", - "matrix_user_id": "u1", - "platform_chat_id": "chat-platform-1", - }) - event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="save", args=["session-a"]) - - result = await make_handle_save(...)(event, runtime.auth_mgr, platform, runtime.chat_mgr, runtime.settings_mgr) - - assert platform.saved_calls == [("chat-platform-1", "session-a")] - - -@pytest.mark.asyncio -async def test_context_command_reports_current_room_platform_chat_id(): - ... - assert "chat-platform-1" in result[0].text -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_context_commands.py -q` -Expected: FAIL because save/load/context do not currently use room-level platform mappings - -- [ ] **Step 3: Write minimal implementation** - -```python -# adapter/matrix/handlers/context_commands.py -room_id = await _resolve_room_id(event, chat_mgr) -meta = await get_room_meta(store, room_id) -platform_chat_id = meta.get("platform_chat_id") - -await platform.save_chat_context(event.user_id, platform_chat_id, name) -await platform.load_chat_context(event.user_id, platform_chat_id, name) - -# sdk/prototype_state.py -# store current loaded session per user+platform_chat_id instead of only per user when needed for Matrix `!context` -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_context_commands.py -q` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/handlers/context_commands.py adapter/matrix/bot.py sdk/prototype_state.py tests/adapter/matrix/test_context_commands.py -git commit -m "feat: bind matrix context commands to platform chat ids" -``` - -### Task 6: Add `!branch` and help-text updates - -**Files:** -- Modify: `adapter/matrix/handlers/chat.py` -- Modify: `adapter/matrix/handlers/__init__.py` -- Modify: `adapter/matrix/handlers/settings.py` -- Modify: `adapter/matrix/handlers/auth.py` -- Modify: `adapter/matrix/bot.py` -- Test: `tests/adapter/matrix/test_chat_space.py` -- Test: `tests/adapter/matrix/test_dispatcher.py` - -- [ ] **Step 1: Write the failing tests** - -```python -async def test_branch_creates_new_room_with_branched_platform_chat_id(): - client = SimpleNamespace( - room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r3:example")), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - ) - platform = FakeRealPlatformClient(branch_ids=["chat-platform-branch"]) - runtime = build_runtime(platform=platform, client=client) - await set_room_meta(runtime.store, "!current:example", { - "chat_id": "C2", - "matrix_user_id": "u1", - "space_id": "!space:example", - "platform_chat_id": "chat-platform-source", - }) - - result = await runtime.dispatcher.dispatch( - IncomingCommand(user_id="u1", platform="matrix", chat_id="C2", command="branch", args=["Fork"]) - ) - - meta = await get_room_meta(runtime.store, "!r3:example") - assert meta["platform_chat_id"] == "chat-platform-branch" -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_dispatcher.py -q` -Expected: FAIL because `branch` is not implemented - -- [ ] **Step 3: Write minimal implementation** - -```python -# adapter/matrix/handlers/chat.py -def make_handle_branch(client, store): - async def handle_branch(event, auth_mgr, platform, chat_mgr, settings_mgr): - source_room_id = ... - source_meta = await get_room_meta(store, source_room_id) - platform_chat_id = await platform.branch_chat_context(event.user_id, source_meta["platform_chat_id"]) - ... - await set_room_meta(store, new_room_id, { - "chat_id": new_chat_id, - "matrix_user_id": event.user_id, - "space_id": space_id, - "platform_chat_id": platform_chat_id, - }) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_dispatcher.py -q` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/handlers/chat.py adapter/matrix/handlers/__init__.py adapter/matrix/handlers/settings.py adapter/matrix/handlers/auth.py adapter/matrix/bot.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_dispatcher.py -git commit -m "feat: add matrix branch command for platform contexts" -``` - -### Task 7: Verify the full Matrix flow and clean up legacy assumptions - -**Files:** -- Modify: `tests/platform/test_real.py` -- Modify: `tests/adapter/matrix/test_dispatcher.py` -- Modify: `tests/adapter/matrix/test_context_commands.py` -- Modify: `tests/core/test_integration.py` - -- [ ] **Step 1: Add integration coverage for independent room contexts** - -```python -@pytest.mark.asyncio -async def test_two_rooms_send_messages_into_different_platform_contexts(): - platform = FakeRealPlatformClient() - runtime = build_runtime(platform=platform) - await set_room_meta(runtime.store, "!r1:example", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "chat-1"}) - await set_room_meta(runtime.store, "!r2:example", {"chat_id": "C2", "matrix_user_id": "u1", "platform_chat_id": "chat-2"}) - ... - assert platform.sent == [("chat-1", "hello"), ("chat-2", "world")] -``` - -- [ ] **Step 2: Run the focused verification suite** - -Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py tests/adapter/matrix/test_store.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/core/test_integration.py -q` -Expected: PASS - -- [ ] **Step 3: Run the full Matrix suite** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix -q` -Expected: PASS - -- [ ] **Step 4: Inspect help text and command visibility** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q` -Expected: PASS with `!branch` present in help and hidden commands still absent - -- [ ] **Step 5: Commit** - -```bash -git add tests/platform/test_real.py tests/adapter/matrix/test_store.py tests/adapter/matrix/test_chat_space.py tests/adapter/matrix/test_invite_space.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/core/test_integration.py -git commit -m "test: verify matrix per-chat platform context flow" -``` - -## Self-Review - -- Spec coverage: - - `surface_chat -> platform_chat_id` mapping is covered by Tasks 1, 3, and 4. - - `!new` independent contexts are covered by Task 4. - - `!branch` snapshot flow is covered by Task 6. - - per-room `!save`, `!load`, and `!context` are covered by Task 5. - - lazy migration for legacy rooms is covered by Task 3. - - verification across rooms is covered by Task 7. -- Placeholder scan: - - No `TODO` or `TBD` placeholders remain. - - Commands and file paths are concrete. -- Type consistency: - - The plan consistently uses `platform_chat_id` for stored mapping and `create_chat_context` / `branch_chat_context` / `save_chat_context` / `load_chat_context` for platform-facing methods. diff --git a/docs/superpowers/plans/2026-04-20-matrix-shared-workspace-file-flow.md b/docs/superpowers/plans/2026-04-20-matrix-shared-workspace-file-flow.md deleted file mode 100644 index 65c2018..0000000 --- a/docs/superpowers/plans/2026-04-20-matrix-shared-workspace-file-flow.md +++ /dev/null @@ -1,624 +0,0 @@ -# Matrix Shared Workspace File Flow Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Move the Matrix surface to a prod-like shared-workspace file flow where incoming files are downloaded into `/workspace`, passed to `platform-agent` as relative attachment paths, and outbound `send_file` events are delivered back to the Matrix room. - -**Architecture:** Keep the platform contract path-based and surface-agnostic. Extend the surface attachment model with a workspace-relative path, update the real SDK bridge to forward attachments and modern agent events, then add a Matrix-specific storage helper plus a shared-volume runtime. This preserves room/context semantics while aligning file flow with upstream `platform-agent`. - -**Tech Stack:** Python 3.11, matrix-nio, aiohttp, Docker Compose, pytest, pytest-asyncio - ---- - -## File Structure - -- Modify: `core/protocol.py` - Purpose: add a workspace-relative attachment field that future surfaces can also use. -- Modify: `sdk/interface.py` - Purpose: keep the platform-side attachment shape aligned with the surface model. -- Modify: `core/handlers/message.py` - Purpose: stop dropping attachments before platform dispatch. -- Modify: `sdk/agent_api_wrapper.py` - Purpose: accept modern upstream agent events and modern WS route semantics. -- Modify: `sdk/real.py` - Purpose: convert attachment objects into workspace-relative paths and forward them to the agent API. -- Create: `adapter/matrix/files.py` - Purpose: Matrix-specific download/upload helper for shared `/workspace`. -- Modify: `adapter/matrix/bot.py` - Purpose: persist incoming Matrix files into shared workspace and render outbound file events back to Matrix. -- Modify: `tests/core/test_integration.py` - Purpose: prove message dispatch keeps attachments and platform send path receives them. -- Modify: `tests/platform/test_real.py` - Purpose: verify attachment forwarding and outbound file events. -- Create: `tests/adapter/matrix/test_files.py` - Purpose: unit coverage for Matrix workspace path building, download handling, and outbound upload behavior. -- Modify: `tests/adapter/matrix/test_dispatcher.py` - Purpose: verify Matrix bot file receive/send integration. -- Modify: `docker-compose.yml` - Purpose: define shared `/workspace` runtime between `matrix-bot` and `platform-agent`. -- Modify: `README.md` - Purpose: document the new default runtime and file flow. -- Modify: `.env.example` - Purpose: update real-backend defaults to in-compose service URLs and workspace-aware runtime. - -### Task 1: Preserve Attachment Metadata Through Core Message Dispatch - -**Files:** -- Modify: `core/protocol.py` -- Modify: `sdk/interface.py` -- Modify: `core/handlers/message.py` -- Test: `tests/core/test_dispatcher.py` -- Test: `tests/core/test_integration.py` - -- [ ] **Step 1: Write the failing tests** - -```python -# tests/core/test_integration.py -class RecordingAgentApi: - def __init__(self) -> None: - self.calls: list[tuple[str, list[str]]] = [] - self.last_tokens_used = 0 - - async def send_message(self, text: str, attachments: list[str] | None = None): - self.calls.append((text, attachments or [])) - yield type("Chunk", (), {"text": f"[REAL] {text}"})() - self.last_tokens_used = 5 - - -async def test_full_flow_with_real_platform_forwards_workspace_attachment(real_dispatcher): - dispatcher, agent_api = real_dispatcher - - start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start") - await dispatcher.dispatch(start) - - msg = IncomingMessage( - user_id="u1", - platform="matrix", - chat_id="C1", - text="Посмотри файл", - attachments=[ - Attachment( - type="document", - filename="report.pdf", - mime_type="application/pdf", - workspace_path="surfaces/matrix/u1/room/inbox/report.pdf", - ) - ], - ) - await dispatcher.dispatch(msg) - - assert agent_api.calls == [ - ("Посмотри файл", ["surfaces/matrix/u1/room/inbox/report.pdf"]) - ] -``` - -```python -# tests/core/test_dispatcher.py -async def test_dispatch_routes_document_before_catchall(dispatcher): - async def doc_handler(event, **kwargs): - return [OutgoingMessage(chat_id=event.chat_id, text="document")] - - async def catch_all(event, **kwargs): - return [OutgoingMessage(chat_id=event.chat_id, text="text")] - - dispatcher.register(IncomingMessage, "document", doc_handler) - dispatcher.register(IncomingMessage, "*", catch_all) - - doc_msg = IncomingMessage( - user_id="u1", - platform="matrix", - chat_id="C1", - text="", - attachments=[Attachment(type="document", workspace_path="surfaces/matrix/u1/file.txt")], - ) - - assert (await dispatcher.dispatch(doc_msg))[0].text == "document" -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `PYTHONPATH=. uv run pytest tests/core/test_dispatcher.py tests/core/test_integration.py -q` - -Expected: -- FAIL because `Attachment` has no `workspace_path` -- FAIL because `handle_message(...)` still sends `attachments=[]` - -- [ ] **Step 3: Write minimal implementation** - -```python -# core/protocol.py -@dataclass -class Attachment: - type: str - url: str | None = None - content: bytes | None = None - filename: str | None = None - mime_type: str | None = None - workspace_path: str | None = None -``` - -```python -# sdk/interface.py -class Attachment(BaseModel): - url: str | None = None - mime_type: str | None = None - size: int | None = None - filename: str | None = None - workspace_path: str | None = None -``` - -```python -# core/handlers/message.py -response = await platform.send_message( - user_id=event.user_id, - chat_id=event.chat_id, - text=event.text, - attachments=event.attachments, -) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `PYTHONPATH=. uv run pytest tests/core/test_dispatcher.py tests/core/test_integration.py -q` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add core/protocol.py sdk/interface.py core/handlers/message.py tests/core/test_dispatcher.py tests/core/test_integration.py -git commit -m "feat: preserve workspace attachments through message dispatch" -``` - -### Task 2: Upgrade the Real SDK Bridge to Modern Agent File Events - -**Files:** -- Modify: `sdk/agent_api_wrapper.py` -- Modify: `sdk/real.py` -- Test: `tests/platform/test_real.py` - -- [ ] **Step 1: Write the failing tests** - -```python -# tests/platform/test_real.py -class FakeSendFileEvent: - def __init__(self, path: str) -> None: - self.path = path - - -class FakeChatAgentApi: - ... - async def send_message(self, text: str, attachments: list[str] | None = None): - self.calls.append((text, attachments or [])) - midpoint = len(text) // 2 - yield FakeChunk(text[:midpoint]) - yield FakeChunk(text[midpoint:]) - self.last_tokens_used = 3 - - -@pytest.mark.asyncio -async def test_real_platform_client_send_message_forwards_workspace_paths(): - agent_api = FakeAgentApiFactory() - client = RealPlatformClient( - agent_api=agent_api, - prototype_state=PrototypeStateStore(), - platform="matrix", - ) - - await client.send_message( - "@alice:example.org", - "chat-7", - "hello", - attachments=[ - type("Attachment", (), {"workspace_path": "surfaces/matrix/alice/room/file.pdf"})() - ], - ) - - assert agent_api.instances["chat-7"].calls == [ - ("hello", ["surfaces/matrix/alice/room/file.pdf"]) - ] - - -def test_agent_api_wrapper_treats_send_file_as_known_event(monkeypatch): - seen = [] - - class FakeSendFile: - type = "AGENT_EVENT_SEND_FILE" - path = "docs/result.pdf" - - monkeypatch.setattr( - "sdk.agent_api_wrapper.ServerMessage.validate_json", - lambda raw: FakeSendFile(), - ) - - wrapper = AgentApiWrapper(agent_id="agent-1", base_url="https://agent.example.com", chat_id="7") - wrapper.callback = seen.append - wrapper._current_queue = None - - # use the wrapper's dispatch branch directly inside _listen test harness -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q` - -Expected: -- FAIL because `RealPlatformClient` ignores attachments -- FAIL because `AgentApiWrapper` only recognizes text/end/error/disconnect events - -- [ ] **Step 3: Write minimal implementation** - -```python -# sdk/real.py -def _attachment_paths(self, attachments) -> list[str]: - if not attachments: - return [] - paths = [] - for attachment in attachments: - path = getattr(attachment, "workspace_path", None) - if path: - paths.append(path) - return paths - -async def stream_message(...): - attachment_paths = self._attachment_paths(attachments) - ... - async for event in chat_api.send_message(text, attachments=attachment_paths): - if hasattr(event, "path"): - yield MessageChunk( - message_id=user_id, - delta="", - finished=False, - ) - continue - yield MessageChunk(...) -``` - -```python -# sdk/agent_api_wrapper.py -from lambda_agent_api.server import ( - MsgError, - MsgEventCustomUpdate, - MsgEventEnd, - MsgEventSendFile, - MsgEventTextChunk, - MsgEventToolCallChunk, - MsgEventToolResult, - MsgGracefulDisconnect, - ServerMessage, -) - -KNOWN_STREAM_EVENTS = ( - MsgEventTextChunk, - MsgEventToolCallChunk, - MsgEventToolResult, - MsgEventCustomUpdate, - MsgEventSendFile, - MsgEventEnd, -) - -if isinstance(outgoing_msg, KNOWN_STREAM_EVENTS): - if isinstance(outgoing_msg, MsgEventEnd): - self.last_tokens_used = outgoing_msg.tokens_used - if self._current_queue: - await self._current_queue.put(outgoing_msg) - elif self.callback: - self.callback(outgoing_msg) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `PYTHONPATH=. uv run pytest tests/platform/test_real.py -q` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add sdk/agent_api_wrapper.py sdk/real.py tests/platform/test_real.py -git commit -m "feat: support attachment paths and file events in real sdk bridge" -``` - -### Task 3: Implement Matrix Shared-Workspace Receive/Send File Flow - -**Files:** -- Create: `adapter/matrix/files.py` -- Modify: `adapter/matrix/bot.py` -- Test: `tests/adapter/matrix/test_files.py` -- Test: `tests/adapter/matrix/test_dispatcher.py` - -- [ ] **Step 1: Write the failing tests** - -```python -# tests/adapter/matrix/test_files.py -from pathlib import Path - -import pytest - -from adapter.matrix.files import build_workspace_attachment_path - - -def test_build_workspace_attachment_path_scopes_by_surface_user_and_room(tmp_path): - rel_path, abs_path = build_workspace_attachment_path( - workspace_root=tmp_path, - matrix_user_id="@alice:example.org", - room_id="!room:example.org", - filename="report.pdf", - timestamp="20260420-153000", - ) - - assert rel_path == "surfaces/matrix/alice_example.org/room_example.org/inbox/20260420-153000-report.pdf" - assert abs_path == tmp_path / rel_path -``` - -```python -# tests/adapter/matrix/test_dispatcher.py -async def test_bot_downloads_matrix_file_to_workspace_before_dispatch(tmp_path): - runtime = build_runtime(platform=MockPlatformClient()) - await set_room_meta( - runtime.store, - "!chat1:example.org", - { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "platform_chat_id": "matrix:ctx-1", - }, - ) - client = SimpleNamespace( - user_id="@bot:example.org", - download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")), - ) - bot = MatrixBot(client, runtime) - bot._send_all = AsyncMock() - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - room = SimpleNamespace(room_id="!chat1:example.org") - event = SimpleNamespace( - sender="@alice:example.org", - body="Посмотри", - msgtype="m.file", - url="mxc://server/id", - mimetype="application/pdf", - replyto_event_id=None, - ) - - await bot.on_room_message(room, event) - - dispatched = runtime.dispatcher.dispatch.await_args.args[0] - assert dispatched.attachments[0].workspace_path.endswith(".pdf") -``` - -```python -# tests/adapter/matrix/test_dispatcher.py -async def test_send_outgoing_uploads_file_attachment_to_matrix(tmp_path): - path = tmp_path / "result.txt" - path.write_text("ready") - client = SimpleNamespace( - upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/file"), {})), - room_send=AsyncMock(), - ) - - await send_outgoing( - client, - "!room:example.org", - OutgoingMessage( - chat_id="!room:example.org", - text="Файл готов", - attachments=[ - Attachment( - type="document", - filename="result.txt", - mime_type="text/plain", - workspace_path=str(path), - ) - ], - ), - ) - - client.upload.assert_awaited() - client.room_send.assert_awaited() -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py -q` - -Expected: -- FAIL because `adapter.matrix.files` does not exist -- FAIL because Matrix bot does not persist files before dispatch -- FAIL because `send_outgoing(...)` only sends text - -- [ ] **Step 3: Write minimal implementation** - -```python -# adapter/matrix/files.py -from __future__ import annotations - -from pathlib import Path -from datetime import UTC, datetime -import re - -from core.protocol import Attachment - - -def _sanitize_component(value: str) -> str: - stripped = re.sub(r"[^A-Za-z0-9._-]+", "_", value) - return stripped.strip("._-") or "unknown" - - -def build_workspace_attachment_path( - *, - workspace_root: Path, - matrix_user_id: str, - room_id: str, - filename: str, - timestamp: str | None = None, -) -> tuple[str, Path]: - stamp = timestamp or datetime.now(UTC).strftime("%Y%m%d-%H%M%S") - safe_user = _sanitize_component(matrix_user_id.lstrip("@")) - safe_room = _sanitize_component(room_id.lstrip("!")) - safe_name = _sanitize_component(filename) or "attachment.bin" - rel_path = Path("surfaces") / "matrix" / safe_user / safe_room / "inbox" / f"{stamp}-{safe_name}" - return rel_path.as_posix(), workspace_root / rel_path -``` - -```python -# adapter/matrix/bot.py -from adapter.matrix.files import download_matrix_attachments, load_workspace_attachment - -... -incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id) -if isinstance(incoming, IncomingMessage) and incoming.attachments: - incoming = await self._materialize_attachments(room.room_id, sender, incoming) -... - -async def _materialize_attachments(...): - workspace_root = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/workspace")) - attachments = await download_matrix_attachments(...) - return IncomingMessage(..., attachments=attachments, ...) -``` - -```python -# adapter/matrix/bot.py -if isinstance(event, OutgoingMessage) and event.attachments: - for attachment in event.attachments: - if attachment.workspace_path: - await _send_matrix_file(client, room_id, attachment) - if event.text: - await client.room_send(...) - return -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py -q` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/files.py adapter/matrix/bot.py tests/adapter/matrix/test_files.py tests/adapter/matrix/test_dispatcher.py -git commit -m "feat: add matrix shared-workspace file receive and send flow" -``` - -### Task 4: Make Shared Workspace the Default Local Runtime - -**Files:** -- Modify: `docker-compose.yml` -- Modify: `README.md` -- Modify: `.env.example` - -- [ ] **Step 1: Write the failing configuration checks** - -```bash -python - <<'PY' -from pathlib import Path -text = Path("docker-compose.yml").read_text() -assert "platform-agent" in text -assert "/workspace" in text -assert "matrix-bot" in text -PY -``` - -```bash -python - <<'PY' -from pathlib import Path -readme = Path("README.md").read_text() -assert "docker compose up" in readme -assert "/workspace" in readme -assert "platform-agent" in readme -PY -``` - -- [ ] **Step 2: Run checks to verify they fail** - -Run: `python - <<'PY' ... PY` - -Expected: -- FAIL because root compose only defines `matrix-bot` -- FAIL because README still documents standalone `uvicorn` launch and old WS route - -- [ ] **Step 3: Write minimal implementation** - -```yaml -# docker-compose.yml -services: - platform-agent: - build: - context: ./external/platform-agent - target: development - additional_contexts: - agent_api: ./external/platform-agent_api - env_file: - - ./external/platform-agent/.env - volumes: - - workspace:/workspace - - ./external/platform-agent/src:/app/src - - ./external/platform-agent_api:/agent_api - ports: - - "8000:8000" - - matrix-bot: - build: . - env_file: .env - depends_on: - - platform-agent - volumes: - - workspace:/workspace - restart: unless-stopped - -volumes: - workspace: -``` - -```env -# .env.example -AGENT_WS_URL=ws://platform-agent:8000/v1/agent_ws/0/ -AGENT_BASE_URL=http://platform-agent:8000 -SURFACES_WORKSPACE_DIR=/workspace -MATRIX_PLATFORM_BACKEND=real -``` - -```md -# README.md -- make the root `docker compose up` path the primary local runtime -- describe shared `/workspace` as the file contract -- remove the statement that real backend is text-only and has no attachments -- replace the old standalone `uvicorn` instructions with compose-first instructions -``` - -- [ ] **Step 4: Run checks to verify they pass** - -Run: `python - <<'PY' ... PY` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add docker-compose.yml README.md .env.example -git commit -m "chore: make shared workspace runtime the default local setup" -``` - -## Self-Review - -- Spec coverage: - - shared `/workspace` runtime: Task 4 - - incoming Matrix file persistence: Task 3 - - attachment path propagation to agent API: Tasks 1-2 - - outbound `send_file` flow: Tasks 2-3 - - future-surface-friendly attachment contract: Task 1 -- Placeholder scan: - - no `TODO`, `TBD`, or “similar to” - - each task has explicit test, run, implementation, verify, commit steps -- Type consistency: - - `workspace_path` is introduced in both attachment models and consumed consistently in Tasks 1-3 - - path-based contract is always relative to `/workspace` until Matrix upload resolution step - -## Execution Handoff - -User already selected parallel subagent execution. Use subagent-driven development and split ownership like this: - -- Worker A: `docker-compose.yml`, `README.md`, `.env.example` -- Worker B: `sdk/agent_api_wrapper.py`, `sdk/real.py`, `tests/platform/test_real.py` -- Main session / later worker: `core/protocol.py`, `sdk/interface.py`, `core/handlers/message.py`, `adapter/matrix/files.py`, `adapter/matrix/bot.py`, matrix/core tests diff --git a/docs/superpowers/plans/2026-04-20-matrix-staged-attachments.md b/docs/superpowers/plans/2026-04-20-matrix-staged-attachments.md deleted file mode 100644 index cfa8f01..0000000 --- a/docs/superpowers/plans/2026-04-20-matrix-staged-attachments.md +++ /dev/null @@ -1,555 +0,0 @@ -# Matrix Staged Attachments Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add Matrix-side staged attachments so file-only events are stored per `(chat_id, user_id)`, acknowledged in chat, and committed to the agent on the next normal user message. - -**Architecture:** Keep the existing shared-workspace file contract unchanged and add a thin Matrix-specific staging layer above it. The Matrix adapter will store staged attachment metadata in `adapter.matrix.store`, parse short staging commands in `adapter.matrix.converter`, and orchestrate commit/remove/list behavior in `adapter.matrix.bot` before calling the existing dispatcher. - -**Tech Stack:** Python 3.11, matrix-nio, pytest, pytest-asyncio, in-memory state store, shared `/workspace` - ---- - -## File Structure - -- Modify: `adapter/matrix/store.py` - Purpose: store staged attachment state per `(room_id, user_id)`. -- Modify: `adapter/matrix/converter.py` - Purpose: parse `!list`, `!remove `, `!remove all` into explicit Matrix-side commands. -- Modify: `adapter/matrix/bot.py` - Purpose: stage file-only events, emit service acknowledgments, process staging commands, and commit staged files with the next normal message. -- Modify: `tests/adapter/matrix/test_store.py` - Purpose: verify staged attachment persistence, ordering, and clear/remove helpers. -- Modify: `tests/adapter/matrix/test_converter.py` - Purpose: verify short staging commands parse correctly. -- Modify: `tests/adapter/matrix/test_dispatcher.py` - Purpose: verify Matrix bot staging, list/remove behavior, and commit semantics. -- Modify: `README.md` - Purpose: document the Matrix staging UX and short commands. - -### Task 1: Add Per-Chat Staged Attachment Storage - -**Files:** -- Modify: `adapter/matrix/store.py` -- Test: `tests/adapter/matrix/test_store.py` - -- [ ] **Step 1: Write the failing tests** - -```python -# tests/adapter/matrix/test_store.py -from adapter.matrix.store import ( - add_staged_attachment, - clear_staged_attachments, - get_staged_attachments, - remove_staged_attachment_at, -) - - -async def test_staged_attachments_roundtrip(store: InMemoryStore): - await add_staged_attachment( - store, - room_id="!r1:example.org", - user_id="@alice:example.org", - attachment={ - "filename": "report.pdf", - "workspace_path": "surfaces/matrix/alice/r1/inbox/report.pdf", - "mime_type": "application/pdf", - }, - ) - - assert await get_staged_attachments(store, "!r1:example.org", "@alice:example.org") == [ - { - "filename": "report.pdf", - "workspace_path": "surfaces/matrix/alice/r1/inbox/report.pdf", - "mime_type": "application/pdf", - } - ] - - -async def test_staged_attachments_are_scoped_by_room_and_user(store: InMemoryStore): - await add_staged_attachment( - store, - room_id="!r1:example.org", - user_id="@alice:example.org", - attachment={"filename": "a.pdf", "workspace_path": "a.pdf"}, - ) - await add_staged_attachment( - store, - room_id="!r2:example.org", - user_id="@alice:example.org", - attachment={"filename": "b.pdf", "workspace_path": "b.pdf"}, - ) - await add_staged_attachment( - store, - room_id="!r1:example.org", - user_id="@bob:example.org", - attachment={"filename": "c.pdf", "workspace_path": "c.pdf"}, - ) - - assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@alice:example.org")] == ["a.pdf"] - assert [item["filename"] for item in await get_staged_attachments(store, "!r2:example.org", "@alice:example.org")] == ["b.pdf"] - assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@bob:example.org")] == ["c.pdf"] - - -async def test_remove_staged_attachment_by_index(store: InMemoryStore): - await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"}) - await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "b.pdf", "workspace_path": "b.pdf"}) - - removed = await remove_staged_attachment_at(store, "!r1:example.org", "@alice:example.org", 1) - - assert removed["filename"] == "b.pdf" - assert [item["filename"] for item in await get_staged_attachments(store, "!r1:example.org", "@alice:example.org")] == ["a.pdf"] - - -async def test_clear_staged_attachments(store: InMemoryStore): - await add_staged_attachment(store, "!r1:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"}) - - await clear_staged_attachments(store, "!r1:example.org", "@alice:example.org") - - assert await get_staged_attachments(store, "!r1:example.org", "@alice:example.org") == [] -``` -- [ ] **Step 2: Run tests to verify they fail** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q` - -Expected: -- FAIL because staged attachment helper functions do not exist yet - -- [ ] **Step 3: Write minimal implementation** - -```python -# adapter/matrix/store.py -STAGED_ATTACHMENTS_PREFIX = "matrix_staged_attachments:" - - -def _staged_attachments_key(room_id: str, user_id: str) -> str: - return f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}" - - -async def get_staged_attachments(store: StateStore, room_id: str, user_id: str) -> list[dict]: - return list(await store.get(_staged_attachments_key(room_id, user_id)) or []) - - -async def add_staged_attachment( - store: StateStore, - room_id: str, - user_id: str, - attachment: dict, -) -> None: - items = await get_staged_attachments(store, room_id, user_id) - items.append(attachment) - await store.set(_staged_attachments_key(room_id, user_id), items) - - -async def remove_staged_attachment_at( - store: StateStore, - room_id: str, - user_id: str, - index: int, -) -> dict | None: - items = await get_staged_attachments(store, room_id, user_id) - if index < 0 or index >= len(items): - return None - removed = items.pop(index) - await store.set(_staged_attachments_key(room_id, user_id), items) - return removed - - -async def clear_staged_attachments(store: StateStore, room_id: str, user_id: str) -> None: - await store.delete(_staged_attachments_key(room_id, user_id)) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py -q` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/store.py tests/adapter/matrix/test_store.py -git commit -m "feat: add matrix staged attachment state" -``` - -### Task 2: Parse Short Staging Commands - -**Files:** -- Modify: `adapter/matrix/converter.py` -- Test: `tests/adapter/matrix/test_converter.py` - -- [ ] **Step 1: Write the failing tests** - -```python -# tests/adapter/matrix/test_converter.py -async def test_list_command_maps_to_matrix_staging_command(): - result = from_room_event(text_event("!list"), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingCommand) - assert result.command == "matrix_list_attachments" - assert result.args == [] - - -async def test_remove_all_maps_to_matrix_staging_command(): - result = from_room_event(text_event("!remove all"), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingCommand) - assert result.command == "matrix_remove_attachment" - assert result.args == ["all"] - - -async def test_remove_index_maps_to_matrix_staging_command(): - result = from_room_event(text_event("!remove 2"), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingCommand) - assert result.command == "matrix_remove_attachment" - assert result.args == ["2"] -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_converter.py -q` - -Expected: -- FAIL because `!list` and `!remove` still parse as generic unknown commands - -- [ ] **Step 3: Write minimal implementation** - -```python -# adapter/matrix/converter.py -def from_command(body: str, sender: str, chat_id: str, room_id: str | None = None) -> IncomingEvent: - raw = body.lstrip("!").strip() - parts = raw.split() - command = parts[0].lower() if parts else "" - args = parts[1:] - - if command == "list": - return IncomingCommand( - user_id=sender, - platform=PLATFORM, - chat_id=chat_id, - command="matrix_list_attachments", - args=[], - ) - - if command == "remove": - return IncomingCommand( - user_id=sender, - platform=PLATFORM, - chat_id=chat_id, - command="matrix_remove_attachment", - args=args, - ) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_converter.py -q` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/converter.py tests/adapter/matrix/test_converter.py -git commit -m "feat: parse matrix staged attachment commands" -``` - -### Task 3: Stage File-Only Events and Handle List/Remove UX - -**Files:** -- Modify: `adapter/matrix/bot.py` -- Modify: `adapter/matrix/store.py` -- Test: `tests/adapter/matrix/test_dispatcher.py` - -- [ ] **Step 1: Write the failing tests** - -```python -# tests/adapter/matrix/test_dispatcher.py -async def test_file_only_event_is_staged_and_does_not_dispatch(): - runtime = build_runtime(platform=MockPlatformClient()) - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - bot._materialize_incoming_attachments = AsyncMock( - return_value=IncomingMessage( - user_id="@alice:example.org", - platform="matrix", - chat_id="matrix:!r:example.org", - text="", - attachments=[ - Attachment( - type="document", - filename="report.pdf", - workspace_path="surfaces/matrix/alice/r/inbox/report.pdf", - mime_type="application/pdf", - ) - ], - ) - ) - room = SimpleNamespace(room_id="!r:example.org") - event = SimpleNamespace( - sender="@alice:example.org", - body="report.pdf", - msgtype="m.file", - url="mxc://hs/id", - mimetype="application/pdf", - replyto_event_id=None, - ) - - await bot.on_room_message(room, event) - - runtime.dispatcher.dispatch.assert_not_awaited() - staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") - assert [item["filename"] for item in staged] == ["report.pdf"] - client.room_send.assert_awaited_once() - assert "Следующее сообщение" in client.room_send.await_args.args[2]["body"] - - -async def test_list_command_returns_current_staged_attachments(): - runtime = build_runtime(platform=MockPlatformClient()) - await add_staged_attachment(runtime.store, "!r:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"}) - await add_staged_attachment(runtime.store, "!r:example.org", "@alice:example.org", {"filename": "b.pdf", "workspace_path": "b.pdf"}) - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - room = SimpleNamespace(room_id="!r:example.org") - event = SimpleNamespace(sender="@alice:example.org", body="!list", msgtype="m.text", replyto_event_id=None) - - await bot.on_room_message(room, event) - - body = client.room_send.await_args.args[2]["body"] - assert "1. a.pdf" in body - assert "2. b.pdf" in body - - -async def test_remove_invalid_index_returns_short_error(): - runtime = build_runtime(platform=MockPlatformClient()) - await add_staged_attachment(runtime.store, "!r:example.org", "@alice:example.org", {"filename": "a.pdf", "workspace_path": "a.pdf"}) - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - room = SimpleNamespace(room_id="!r:example.org") - event = SimpleNamespace(sender="@alice:example.org", body="!remove 9", msgtype="m.text", replyto_event_id=None) - - await bot.on_room_message(room, event) - - assert client.room_send.await_args.args[2]["body"] == "Нет такого вложения." -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q` - -Expected: -- FAIL because file-only events still go straight to dispatcher -- FAIL because `!list` and `!remove` have no Matrix-side handling in the bot - -- [ ] **Step 3: Write minimal implementation** - -```python -# adapter/matrix/bot.py -def _is_staging_command(self, incoming: IncomingEvent) -> bool: - return isinstance(incoming, IncomingCommand) and incoming.command in { - "matrix_list_attachments", - "matrix_remove_attachment", - } - - -async def _handle_staging_command(self, room_id: str, user_id: str, incoming: IncomingCommand) -> list[OutgoingMessage]: - if incoming.command == "matrix_list_attachments": - return [OutgoingMessage(chat_id=incoming.chat_id, text=self._format_staged_attachments(room_id, user_id))] - if incoming.command == "matrix_remove_attachment" and incoming.args == ["all"]: - await clear_staged_attachments(self.runtime.store, room_id, user_id) - return [OutgoingMessage(chat_id=incoming.chat_id, text="Вложения очищены.")] -``` - -```python -# adapter/matrix/bot.py -if isinstance(incoming, IncomingMessage) and incoming.attachments and not incoming.text: - incoming = await self._materialize_incoming_attachments(room.room_id, sender, incoming) - await self._stage_attachments(room.room_id, sender, incoming.attachments) - await self._send_all(room.room_id, [OutgoingMessage(chat_id=dispatch_chat_id, text=self._format_staged_attachments(room.room_id, sender, with_hint=True))]) - return - -if self._is_staging_command(incoming): - outgoing = await self._handle_staging_command(room.room_id, sender, incoming) - await self._send_all(room.room_id, outgoing) - return -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q` - -Expected: PASS for staging/list/remove behavior - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/bot.py adapter/matrix/store.py tests/adapter/matrix/test_dispatcher.py -git commit -m "feat: add matrix staging list and remove flow" -``` - -### Task 4: Commit Staged Files With the Next Normal Message - -**Files:** -- Modify: `adapter/matrix/bot.py` -- Test: `tests/adapter/matrix/test_dispatcher.py` -- Modify: `README.md` - -- [ ] **Step 1: Write the failing tests** - -```python -# tests/adapter/matrix/test_dispatcher.py -async def test_next_normal_message_commits_staged_attachments(): - runtime = build_runtime(platform=MockPlatformClient()) - await add_staged_attachment( - runtime.store, - "!r:example.org", - "@alice:example.org", - { - "filename": "report.pdf", - "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf", - "mime_type": "application/pdf", - }, - ) - client = SimpleNamespace(user_id="@bot:example.org") - bot = MatrixBot(client, runtime) - bot._send_all = AsyncMock() - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - room = SimpleNamespace(room_id="!r:example.org") - event = SimpleNamespace(sender="@alice:example.org", body="Проанализируй", msgtype="m.text", replyto_event_id=None) - - await bot.on_room_message(room, event) - - dispatched = runtime.dispatcher.dispatch.await_args.args[0] - assert isinstance(dispatched, IncomingMessage) - assert dispatched.text == "Проанализируй" - assert [a.workspace_path for a in dispatched.attachments] == [ - "surfaces/matrix/alice/r/inbox/report.pdf" - ] - assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == [] - - -async def test_failed_commit_preserves_staged_attachments(): - runtime = build_runtime(platform=MockPlatformClient()) - await add_staged_attachment( - runtime.store, - "!r:example.org", - "@alice:example.org", - {"filename": "report.pdf", "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf"}, - ) - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - runtime.dispatcher.dispatch = AsyncMock(side_effect=PlatformError("boom")) - room = SimpleNamespace(room_id="!r:example.org") - event = SimpleNamespace(sender="@alice:example.org", body="Проанализируй", msgtype="m.text", replyto_event_id=None) - - await bot.on_room_message(room, event) - - staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") - assert [item["filename"] for item in staged] == ["report.pdf"] -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_dispatcher.py -q` - -Expected: -- FAIL because normal text messages do not yet merge staged attachments -- FAIL because staged items are never preserved/cleared based on commit outcome - -- [ ] **Step 3: Write minimal implementation** - -```python -# adapter/matrix/bot.py -async def _merge_staged_attachments( - self, - room_id: str, - user_id: str, - incoming: IncomingMessage, -) -> IncomingMessage: - staged = await get_staged_attachments(self.runtime.store, room_id, user_id) - if not staged: - return incoming - return IncomingMessage( - user_id=incoming.user_id, - platform=incoming.platform, - chat_id=incoming.chat_id, - text=incoming.text, - reply_to=incoming.reply_to, - attachments=[ - Attachment( - type="document", - filename=item.get("filename"), - mime_type=item.get("mime_type"), - workspace_path=item.get("workspace_path"), - ) - for item in staged - ], - ) -``` - -```python -# adapter/matrix/bot.py -staged_before_dispatch = False -if isinstance(incoming, IncomingMessage) and incoming.text and not incoming.attachments: - staged = await get_staged_attachments(self.runtime.store, room.room_id, sender) - if staged: - incoming = await self._merge_staged_attachments(room.room_id, sender, incoming) - staged_before_dispatch = True - -try: - outgoing = await self.runtime.dispatcher.dispatch(incoming) -except PlatformError: - ... -else: - if staged_before_dispatch: - await clear_staged_attachments(self.runtime.store, room.room_id, sender) -``` - -- [ ] **Step 4: Run targeted tests to verify they pass** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py -q` - -Expected: PASS - -- [ ] **Step 5: Update docs** - -Add to `README.md`: - -```md -### Matrix staged attachments - -If a Matrix user sends files without a text instruction, the bot stages them per chat and replies with the current list. - -- `!list` shows staged files -- `!remove ` removes one staged file by index -- `!remove all` clears all staged files - -The next normal user message is sent to the agent together with all staged files. -``` - -- [ ] **Step 6: Run broader verification** - -Run: `PYTHONPATH=. uv run pytest tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_files.py tests/adapter/matrix/test_send_outgoing.py tests/core/test_integration.py tests/platform/test_real.py -q` - -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add adapter/matrix/bot.py README.md tests/adapter/matrix/test_store.py tests/adapter/matrix/test_converter.py tests/adapter/matrix/test_dispatcher.py -git commit -m "feat: commit staged matrix attachments on next message" -``` - -## Self-Review - -- Spec coverage: - - staged per `(chat_id, user_id)`: Task 1 - - short commands `!list`, `!remove `, `!remove all`: Task 2 and Task 3 - - file-only events do not invoke agent: Task 3 - - next normal message commits staged attachments: Task 4 - - failed commit preserves staged attachments: Task 4 - - docs update: Task 4 -- Placeholder scan: - - no `TODO`, `TBD`, or deferred behavior left in task steps -- Type consistency: - - staged storage uses `dict` records with `filename`, `workspace_path`, `mime_type` - - bot reconstructs `core.protocol.Attachment` from those same keys diff --git a/docs/superpowers/plans/2026-04-22-transport-layer-thin-adapter.md b/docs/superpowers/plans/2026-04-22-transport-layer-thin-adapter.md deleted file mode 100644 index b1984ec..0000000 --- a/docs/superpowers/plans/2026-04-22-transport-layer-thin-adapter.md +++ /dev/null @@ -1,540 +0,0 @@ -# Transport Layer Thin Adapter Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the current custom transport behavior with a thin upstream-compatible adapter so Matrix uses `platform-agent_api.AgentApi` almost as-is and keeps only integration concerns on the `surfaces` side. - -**Architecture:** Keep a tiny `AgentApiWrapper` only as a construction/factory shim (`base_url` normalization + `for_chat(chat_id)`). Move all resilience logic that still belongs to `surfaces` into `RealPlatformClient`: per-chat client caching, per-chat send locks, disconnect-on-failure, and `PlatformError` mapping. Delete all custom stream semantics from the wrapper so bugs in streaming can be attributed cleanly to either upstream or our integration layer. - -**Tech Stack:** Python 3.11, `aiohttp`, upstream `lambda_agent_api`, `pytest`, `pytest-asyncio`, `ruff` - ---- - -## File Structure - -- Modify: `sdk/agent_api_wrapper.py` - Purpose: shrink the wrapper to a thin constructor/factory shim with no custom `_listen()` or `send_message()` logic. -- Modify: `sdk/real.py` - Purpose: keep integration-only behavior: cached per-chat clients, chat locks, attachment forwarding, and failure cleanup. -- Modify: `adapter/matrix/bot.py` - Purpose: instantiate the wrapper with the modern `base_url` path instead of a raw `url`, matching the pinned upstream API. -- Modify: `tests/platform/test_real.py` - Purpose: remove tests that encode custom wrapper semantics and replace them with tests that prove only the intended integration guarantees. -- Modify: `README.md` - Purpose: document that the transport layer now uses the pinned upstream `platform-agent_api` client semantics directly, with only a thin local adapter. - -### Task 1: Shrink `AgentApiWrapper` To A Thin Factory Shim - -**Files:** -- Modify: `sdk/agent_api_wrapper.py` -- Test: `tests/platform/test_real.py` - -- [ ] **Step 1: Replace wrapper-only behavior tests with thin-wrapper tests** - -Update `tests/platform/test_real.py` so it no longer asserts any custom post-END drain or wrapper-owned timeout behavior. Replace those tests with the following: - -```python -def test_agent_api_wrapper_normalizes_base_url_and_uses_modern_constructor(monkeypatch): - captured = {} - - def fake_init(self, agent_id, base_url=None, chat_id=0, **kwargs): - captured["agent_id"] = agent_id - captured["base_url"] = base_url - captured["chat_id"] = chat_id - - monkeypatch.setattr(agent_api_wrapper_module.AgentApi, "__init__", fake_init) - - wrapper = AgentApiWrapper( - agent_id="agent-1", - base_url="ws://platform-agent:8000/v1/agent_ws/", - chat_id="41", - ) - - assert wrapper.chat_id == "41" - assert wrapper._base_url == "ws://platform-agent:8000" - assert captured == { - "agent_id": "agent-1", - "base_url": "ws://platform-agent:8000", - "chat_id": "41", - } - - -def test_agent_api_wrapper_for_chat_reuses_normalized_base_url(monkeypatch): - init_calls = [] - - def fake_init(self, agent_id, base_url=None, chat_id=0, **kwargs): - self.id = agent_id - self.chat_id = chat_id - self.url = base_url - init_calls.append((agent_id, base_url, chat_id)) - - monkeypatch.setattr(agent_api_wrapper_module.AgentApi, "__init__", fake_init) - - root = AgentApiWrapper( - agent_id="agent-1", - base_url="http://platform-agent:8000/v1/agent_ws/", - chat_id="1", - ) - - child = root.for_chat("99") - - assert child is not root - assert child.chat_id == "99" - assert child._base_url == "http://platform-agent:8000" - assert init_calls == [ - ("agent-1", "http://platform-agent:8000", "1"), - ("agent-1", "http://platform-agent:8000", "99"), - ] -``` - -- [ ] **Step 2: Run tests to verify old assumptions fail** - -Run: - -```bash -/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "recovers_late_text_after_first_end or times_out_on_idle_stream or normalizes_base_url_and_uses_modern_constructor or for_chat_reuses_normalized_base_url"' -``` - -Expected: - -- FAIL because the old wrapper-behavior tests still exist -- FAIL or SKIP for the new thin-wrapper tests until code/tests are aligned - -- [ ] **Step 3: Replace `sdk/agent_api_wrapper.py` with a thin wrapper** - -Rewrite `sdk/agent_api_wrapper.py` to the minimal implementation below: - -```python -from __future__ import annotations - -import inspect -import re -import sys -from pathlib import Path -from urllib.parse import urlsplit, urlunsplit - -_api_root = Path(__file__).resolve().parents[1] / "external" / "platform-agent_api" -if str(_api_root) not in sys.path: - sys.path.insert(0, str(_api_root)) - -from lambda_agent_api.agent_api import AgentApi # noqa: E402 - - -class AgentApiWrapper(AgentApi): - """Thin construction/factory shim over the pinned upstream AgentApi.""" - - def __init__( - self, - agent_id: str, - base_url: str, - *, - chat_id: int | str = 0, - **kwargs, - ) -> None: - self._base_url = self._normalize_base_url(base_url) - self._init_kwargs = dict(kwargs) - self.chat_id = chat_id - if not self._supports_modern_constructor(): - raise RuntimeError("Pinned platform-agent_api is expected to support base_url + chat_id") - - super().__init__( - agent_id=agent_id, - base_url=self._base_url, - chat_id=chat_id, - **kwargs, - ) - - @staticmethod - def _supports_modern_constructor() -> bool: - try: - parameters = inspect.signature(AgentApi.__init__).parameters - except (TypeError, ValueError): - return False - return "base_url" in parameters and "chat_id" in parameters - - @staticmethod - def _normalize_base_url(base_url: str) -> str: - parsed = urlsplit(base_url) - path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/")) - return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) - - def for_chat(self, chat_id: int | str) -> "AgentApiWrapper": - return type(self)( - agent_id=self.id, - base_url=self._base_url, - chat_id=chat_id, - **self._init_kwargs, - ) -``` - -- [ ] **Step 4: Run the wrapper-focused tests** - -Run: - -```bash -/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "normalizes_base_url_and_uses_modern_constructor or for_chat_reuses_normalized_base_url"' -``` - -Expected: - -- PASS - -- [ ] **Step 5: Commit** - -```bash -git add sdk/agent_api_wrapper.py tests/platform/test_real.py -git commit -m "refactor: shrink agent api wrapper to thin adapter" -``` - -### Task 2: Simplify `RealPlatformClient` To The Pinned Modern API - -**Files:** -- Modify: `sdk/real.py` -- Modify: `adapter/matrix/bot.py` -- Test: `tests/platform/test_real.py` - -- [ ] **Step 1: Add failing integration tests for the desired thin-adapter contract** - -Extend `tests/platform/test_real.py` with these assertions: - -```python -@pytest.mark.asyncio -async def test_real_platform_client_passes_attachments_to_modern_send_message(): - agent_api = FakeAgentApiFactory() - client = RealPlatformClient( - agent_api=agent_api, - prototype_state=PrototypeStateStore(), - platform="matrix", - ) - - attachment = Attachment( - type="document", - filename="report.pdf", - mime_type="application/pdf", - workspace_path="surfaces/matrix/u1/r1/inbox/report.pdf", - ) - - result = await client.send_message( - "@alice:example.org", - "chat-1", - "read this", - attachments=[attachment], - ) - - assert result.response == "read this" - assert agent_api.instances["chat-1"].calls == [ - ("read this", ["surfaces/matrix/u1/r1/inbox/report.pdf"]) - ] - - -@pytest.mark.asyncio -async def test_real_platform_client_disconnects_chat_after_agent_exception(): - class ErroringChatAgentApi: - def __init__(self, chat_id: str) -> None: - self.chat_id = chat_id - self.connect_calls = 0 - self.close_calls = 0 - - async def connect(self) -> None: - self.connect_calls += 1 - - async def close(self) -> None: - self.close_calls += 1 - - async def send_message(self, text: str, attachments: list[str] | None = None): - raise agent_api_wrapper_module.AgentException("INTERNAL_ERROR", "boom") - yield - - agent_api = FakeAgentApiFactory() - erroring = ErroringChatAgentApi("chat-1") - agent_api.for_chat = lambda chat_id: erroring - client = RealPlatformClient( - agent_api=agent_api, - prototype_state=PrototypeStateStore(), - platform="matrix", - ) - - with pytest.raises(PlatformError, match="boom") as exc_info: - await client.send_message("@alice:example.org", "chat-1", "hello") - - assert exc_info.value.code == "INTERNAL_ERROR" - assert erroring.close_calls == 1 - assert "chat-1" not in client._chat_apis -``` - -- [ ] **Step 2: Run tests to verify they fail before simplification** - -Run: - -```bash -/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "passes_attachments_to_modern_send_message or disconnects_chat_after_agent_exception"' -``` - -Expected: - -- FAIL until `sdk/real.py` and Matrix runtime wiring are aligned with the pinned modern API - -- [ ] **Step 3: Simplify `sdk/real.py` and Matrix runtime construction** - -Make these exact edits: - -```python -# adapter/matrix/bot.py -def _build_platform_from_env() -> PlatformClient: - backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower() - if backend == "real": - base_url = os.environ["AGENT_BASE_URL"] - return RealPlatformClient( - agent_api=AgentApiWrapper(agent_id="matrix-bot", base_url=base_url), - prototype_state=PrototypeStateStore(), - platform="matrix", - ) - return MockPlatformClient() -``` - -```python -# sdk/real.py -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterator -from pathlib import Path - -from sdk.agent_api_wrapper import AgentApiWrapper -from sdk.interface import ( - Attachment, - MessageChunk, - MessageResponse, - PlatformClient, - PlatformError, - User, - UserSettings, -) -from sdk.prototype_state import PrototypeStateStore - - -class RealPlatformClient(PlatformClient): - def __init__( - self, - agent_api: AgentApiWrapper, - prototype_state: PrototypeStateStore, - platform: str = "matrix", - ) -> None: - self._agent_api = agent_api - self._prototype_state = prototype_state - self._platform = platform - self._chat_apis: dict[str, AgentApiWrapper] = {} - self._chat_api_lock = asyncio.Lock() - self._chat_send_locks: dict[str, asyncio.Lock] = {} - - @property - def agent_api(self) -> AgentApiWrapper: - return self._agent_api - - async def _get_chat_api(self, chat_id: str) -> AgentApiWrapper: - chat_key = str(chat_id) - chat_api = self._chat_apis.get(chat_key) - if chat_api is None: - async with self._chat_api_lock: - chat_api = self._chat_apis.get(chat_key) - if chat_api is None: - chat_api = self._agent_api.for_chat(chat_key) - await chat_api.connect() - self._chat_apis[chat_key] = chat_api - return chat_api - - def _get_chat_send_lock(self, chat_id: str) -> asyncio.Lock: - chat_key = str(chat_id) - lock = self._chat_send_locks.get(chat_key) - if lock is None: - lock = asyncio.Lock() - self._chat_send_locks[chat_key] = lock - return lock - - async def send_message( - self, - user_id: str, - chat_id: str, - text: str, - attachments: list[Attachment] | None = None, - ) -> MessageResponse: - response_parts: list[str] = [] - tokens_used = 0 - sent_attachments: list[Attachment] = [] - message_id = user_id - - lock = self._get_chat_send_lock(chat_id) - async with lock: - chat_api = await self._get_chat_api(chat_id) - try: - async for event in chat_api.send_message(text, attachments=self._attachment_paths(attachments)): - if hasattr(event, "text"): - response_parts.append(event.text) - elif event.__class__.__name__ == "MsgEventEnd": - tokens_used = getattr(event, "tokens_used", 0) - elif "SEND_FILE" in getattr(getattr(event, "type", None), "value", str(getattr(event, "type", ""))): - attachment = self._attachment_from_send_file_event(event) - if attachment is not None: - sent_attachments.append(attachment) - except Exception as exc: - await self._handle_chat_api_failure(chat_id, exc) - - await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used) - - return MessageResponse( - message_id=message_id, - response="".join(response_parts), - tokens_used=tokens_used, - finished=True, - attachments=sent_attachments, - ) - - async def stream_message( - self, - user_id: str, - chat_id: str, - text: str, - attachments: list[Attachment] | None = None, - ) -> AsyncIterator[MessageChunk]: - lock = self._get_chat_send_lock(chat_id) - async with lock: - chat_api = await self._get_chat_api(chat_id) - try: - async for event in chat_api.send_message(text, attachments=self._attachment_paths(attachments)): - if hasattr(event, "text"): - yield MessageChunk( - message_id=user_id, - delta=event.text, - finished=False, - ) - elif event.__class__.__name__ == "MsgEventEnd": - tokens_used = getattr(event, "tokens_used", 0) - await self._prototype_state.set_last_tokens_used(str(chat_id), tokens_used) - yield MessageChunk( - message_id=user_id, - delta="", - finished=True, - tokens_used=tokens_used, - ) - except Exception as exc: - await self._handle_chat_api_failure(chat_id, exc) - - async def disconnect_chat(self, chat_id: str) -> None: - chat_key = str(chat_id) - chat_api = self._chat_apis.pop(chat_key, None) - self._chat_send_locks.pop(chat_key, None) - if chat_api is not None: - await chat_api.close() - - async def close(self) -> None: - for chat_api in list(self._chat_apis.values()): - await chat_api.close() - self._chat_apis.clear() - self._chat_send_locks.clear() - - async def _handle_chat_api_failure(self, chat_id: str, exc: Exception) -> None: - await self.disconnect_chat(chat_id) - code = getattr(exc, "code", None) or "PLATFORM_CONNECTION_ERROR" - raise PlatformError(str(exc), code=code) from exc - - @staticmethod - def _attachment_paths(attachments: list[Attachment] | None) -> list[str]: - if not attachments: - return [] - return [attachment.workspace_path for attachment in attachments if attachment.workspace_path] -``` - -- [ ] **Step 4: Run the focused transport tests** - -Run: - -```bash -/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q -k "passes_attachments_to_modern_send_message or disconnects_chat_after_agent_exception or wraps_connection_closed_as_platform_error or reconnects_after_closed_chat_api"' -``` - -Expected: - -- PASS - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/bot.py sdk/real.py tests/platform/test_real.py -git commit -m "refactor: use upstream transport semantics in real client" -``` - -### Task 3: Remove Custom Transport Assumptions From Tests And Docs - -**Files:** -- Modify: `tests/platform/test_real.py` -- Modify: `README.md` - -- [ ] **Step 1: Delete tests that encode wrapper-owned stream semantics** - -Remove any tests that assert: - -- late text is recovered after the first `END` -- duplicate `END` is repaired inside our wrapper -- wrapper-owned idle timeout semantics - -The file should keep only tests for: - -- wrapper construction/factory behavior -- per-chat client reuse -- reconnect/disconnect after failure -- attachment forwarding -- per-chat send locking - -- [ ] **Step 2: Update README transport description** - -Add this text to the Matrix runtime/backend section in `README.md`: - -```md -Transport layer note: - -- `surfaces` now uses the pinned upstream `platform-agent_api.AgentApi` stream semantics directly -- local code keeps only a thin adapter for client construction and per-chat client factories -- request serialization, disconnect-on-failure, and `PlatformError` mapping remain in `sdk/real.py` -- `surfaces` no longer performs local post-END stream reconstruction -``` - -- [ ] **Step 3: Run the full verification set** - -Run: - -```bash -uv run ruff check adapter/matrix sdk tests/platform/test_real.py -/bin/zsh -lc 'PYTHONPATH=. uv run pytest tests/adapter/matrix -q' -/bin/zsh -lc 'PYTHONPATH=.:external/platform-agent_api uv run pytest tests/platform/test_real.py -q' -``` - -Expected: - -- `ruff` reports `All checks passed!` -- Matrix adapter tests PASS -- `tests/platform/test_real.py` PASS - -- [ ] **Step 4: Commit** - -```bash -git add README.md tests/platform/test_real.py -git commit -m "test: remove custom transport semantics assumptions" -``` - ---- - -## Self-Review - -- Spec coverage: - - thin adapter target: covered by Task 1 - - integration-only `RealPlatformClient`: covered by Task 2 - - removal of custom stream semantics assumptions: covered by Task 3 - - re-verification after cleanup: covered by Task 3 - -- Placeholder scan: - - no `TODO`, `TBD`, or deferred implementation placeholders remain in task steps - -- Type consistency: - - `AgentApiWrapper` remains the construction/factory type used by `RealPlatformClient` - - failure mapping still terminates in `PlatformError` - - attachment forwarding consistently uses `attachments: list[str]` diff --git a/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md b/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md deleted file mode 100644 index a5227e8..0000000 --- a/docs/superpowers/plans/2026-04-24-matrix-multi-agent-routing-and-restart-state.md +++ /dev/null @@ -1,855 +0,0 @@ -# Matrix Multi-Agent Routing And Restart State Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add Matrix multi-agent routing with user agent selection, room-level agent binding, and durable surface state that survives normal restart. - -**Architecture:** Keep the shared `PlatformClient` protocol unchanged. Add a Matrix-specific routing facade that translates local Matrix chat identity into `(agent_id, platform_chat_id)` and delegates to one `RealPlatformClient` per configured agent. Persist only durable routing state in the existing SQLite-backed surface store and deliberately drop temporary UX state on restart. - -**Tech Stack:** Python 3.11, matrix-nio, structlog, PyYAML, pytest, pytest-asyncio - ---- - -## File Structure - -- Create: `adapter/matrix/agent_registry.py` - Purpose: load and validate the YAML agent registry used by Matrix runtime. -- Create: `adapter/matrix/routed_platform.py` - Purpose: implement a Matrix-specific `PlatformClient` facade that resolves room bindings and delegates to per-agent `RealPlatformClient` instances. -- Create: `adapter/matrix/handlers/agent.py` - Purpose: implement `!agent` listing and selection behavior. -- Create: `tests/adapter/matrix/test_agent_registry.py` - Purpose: cover YAML loading and registry validation. -- Create: `tests/adapter/matrix/test_routed_platform.py` - Purpose: cover room-target resolution and per-agent delegation without changing the shared protocol. -- Create: `tests/adapter/matrix/test_agent_handler.py` - Purpose: cover `!agent` UX and persistence of `selected_agent_id`. -- Create: `tests/adapter/matrix/test_restart_persistence.py` - Purpose: prove durable user/room state and `PLATFORM_CHAT_SEQ_KEY` survive runtime recreation with SQLite. -- Create: `config/matrix-agents.example.yaml` - Purpose: document the expected agent registry format. -- Modify: `pyproject.toml` - Purpose: add YAML parsing dependency required by the runtime registry loader. -- Modify: `.env.example` - Purpose: document the config path env var for the Matrix agent registry. -- Modify: `README.md` - Purpose: document the new config file, `!agent`, and restart persistence expectations. -- Modify: `adapter/matrix/store.py` - Purpose: add helpers for `selected_agent_id`, room `agent_id`, and explicit sequence persistence semantics. -- Modify: `adapter/matrix/bot.py` - Purpose: load the agent registry, construct the routed platform facade, keep local Matrix chat ids through dispatch, and enforce stale/unbound room behavior before dispatch. -- Modify: `adapter/matrix/handlers/__init__.py` - Purpose: register the new `!agent` command. -- Modify: `adapter/matrix/handlers/chat.py` - Purpose: require a selected agent for `!new` and bind new rooms to that agent. -- Modify: `adapter/matrix/handlers/context_commands.py` - Purpose: keep context commands compatible with local chat ids and routed platform delegation. -- Modify: `adapter/matrix/handlers/settings.py` - Purpose: expose `!agent` in help text. -- Modify: `tests/adapter/matrix/test_dispatcher.py` - Purpose: cover pre-dispatch gating, stale room behavior, and `!new` semantics. -- Modify: `tests/adapter/matrix/test_context_commands.py` - Purpose: keep load/reset/context flows aligned with the routed platform facade. - ---- - -### Task 1: Add The Agent Registry And Configuration Wiring - -**Files:** -- Create: `adapter/matrix/agent_registry.py` -- Create: `tests/adapter/matrix/test_agent_registry.py` -- Create: `config/matrix-agents.example.yaml` -- Modify: `pyproject.toml` -- Modify: `.env.example` -- Modify: `README.md` - -- [ ] **Step 1: Write the failing registry tests** - -```python -# tests/adapter/matrix/test_agent_registry.py -from pathlib import Path - -import pytest - -from adapter.matrix.agent_registry import AgentRegistryError, load_agent_registry - - -def test_load_agent_registry_reads_yaml_entries(tmp_path: Path): - path = tmp_path / "agents.yaml" - path.write_text( - "agents:\n" - " - id: agent-1\n" - " label: Analyst\n" - " - id: agent-2\n" - " label: Research\n", - encoding="utf-8", - ) - - registry = load_agent_registry(path) - - assert [agent.agent_id for agent in registry.agents] == ["agent-1", "agent-2"] - assert registry.get("agent-1").label == "Analyst" - - -def test_load_agent_registry_rejects_duplicate_ids(tmp_path: Path): - path = tmp_path / "agents.yaml" - path.write_text( - "agents:\n" - " - id: agent-1\n" - " label: Analyst\n" - " - id: agent-1\n" - " label: Duplicate\n", - encoding="utf-8", - ) - - with pytest.raises(AgentRegistryError, match="duplicate agent id"): - load_agent_registry(path) -``` - -- [ ] **Step 2: Run the registry tests to verify they fail** - -Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q` - -Expected: FAIL with `ModuleNotFoundError` or `ImportError` for `adapter.matrix.agent_registry`. - -- [ ] **Step 3: Add the YAML dependency and implement the registry loader** - -```toml -# pyproject.toml -dependencies = [ - "aiogram>=3.4,<4", - "matrix-nio>=0.21", - "pydantic>=2.5", - "structlog>=24.1", - "python-dotenv>=1.0", - "httpx>=0.27", - "aiohttp>=3.9", - "PyYAML>=6.0", -] -``` - -```python -# adapter/matrix/agent_registry.py -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -import yaml - - -class AgentRegistryError(ValueError): - pass - - -@dataclass(frozen=True) -class AgentDefinition: - agent_id: str - label: str - - -class AgentRegistry: - def __init__(self, agents: list[AgentDefinition]) -> None: - self.agents = agents - self._by_id = {agent.agent_id: agent for agent in agents} - - def get(self, agent_id: str) -> AgentDefinition: - try: - return self._by_id[agent_id] - except KeyError as exc: - raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc - - -def load_agent_registry(path: str | Path) -> AgentRegistry: - raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {} - entries = raw.get("agents") - if not isinstance(entries, list) or not entries: - raise AgentRegistryError("agents registry must contain a non-empty agents list") - - agents: list[AgentDefinition] = [] - seen: set[str] = set() - for entry in entries: - agent_id = str(entry.get("id", "")).strip() - label = str(entry.get("label", "")).strip() - if not agent_id or not label: - raise AgentRegistryError("each agent entry requires id and label") - if agent_id in seen: - raise AgentRegistryError(f"duplicate agent id: {agent_id}") - seen.add(agent_id) - agents.append(AgentDefinition(agent_id=agent_id, label=label)) - return AgentRegistry(agents) -``` - -- [ ] **Step 4: Add the example config and runtime wiring docs** - -```yaml -# config/matrix-agents.example.yaml -agents: - - id: agent-1 - label: Analyst - - id: agent-2 - label: Research -``` - -```env -# .env.example -MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml -``` - -```markdown -# README.md -1. Copy `config/matrix-agents.example.yaml` to `config/matrix-agents.yaml` -2. Set `MATRIX_AGENT_REGISTRY_PATH=config/matrix-agents.yaml` -3. Use `!agent` in Matrix to select the active upstream agent -``` - -- [ ] **Step 5: Run the registry tests to verify they pass** - -Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py -q` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add pyproject.toml .env.example README.md config/matrix-agents.example.yaml adapter/matrix/agent_registry.py tests/adapter/matrix/test_agent_registry.py -git commit -m "feat: add matrix agent registry loader" -``` - ---- - -### Task 2: Add A Matrix Routing Facade Without Changing `PlatformClient` - -**Files:** -- Create: `adapter/matrix/routed_platform.py` -- Create: `tests/adapter/matrix/test_routed_platform.py` -- Modify: `adapter/matrix/bot.py` - -- [ ] **Step 1: Write the failing routed-platform tests** - -```python -# tests/adapter/matrix/test_routed_platform.py -import pytest - -from adapter.matrix.routed_platform import RoutedPlatformClient -from adapter.matrix.store import set_room_meta -from core.chat import ChatManager -from core.store import InMemoryStore -from sdk.interface import MessageResponse -from sdk.prototype_state import PrototypeStateStore - - -class FakeDelegate: - def __init__(self, agent_id: str) -> None: - self.agent_id = agent_id - self.calls = [] - - async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None): - self.calls.append((user_id, chat_id, text, attachments)) - return MessageResponse( - message_id=user_id, - response=f"{self.agent_id}:{text}", - tokens_used=0, - finished=True, - ) - - async def get_or_create_user(self, external_id: str, platform: str, display_name=None): - return await PrototypeStateStore().get_or_create_user(external_id, platform, display_name) - - async def get_settings(self, user_id: str): - return await PrototypeStateStore().get_settings(user_id) - - async def update_settings(self, user_id: str, action): - return None - - -@pytest.mark.asyncio -async def test_routed_platform_delegates_using_room_agent_and_platform_chat_id(): - store = InMemoryStore() - chat_mgr = ChatManager(None, store) - await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1") - await set_room_meta( - store, - "!room:example.org", - {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"}, - ) - - delegates = {"agent-2": FakeDelegate("agent-2")} - platform = RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates) - - response = await platform.send_message("u1", "C1", "hello") - - assert response.response == "agent-2:hello" - assert delegates["agent-2"].calls == [("u1", "41", "hello", None)] -``` - -- [ ] **Step 2: Run the routed-platform tests to verify they fail** - -Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q` - -Expected: FAIL with `ImportError` for `RoutedPlatformClient`. - -- [ ] **Step 3: Implement the routing facade and integrate runtime construction** - -```python -# adapter/matrix/routed_platform.py -from __future__ import annotations - -from sdk.interface import PlatformClient - - -class RoutedPlatformClient(PlatformClient): - def __init__(self, store, chat_mgr, delegates: dict[str, PlatformClient]) -> None: - self._store = store - self._chat_mgr = chat_mgr - self._delegates = delegates - - async def _resolve_target(self, user_id: str, local_chat_id: str) -> tuple[PlatformClient, str]: - ctx = await self._chat_mgr.get(local_chat_id, user_id=user_id) - if ctx is None: - raise ValueError(f"Chat {local_chat_id} not found for {user_id}") - room_meta = await self._store.get(f"matrix_room:{ctx.surface_ref}") - if room_meta is None or not room_meta.get("agent_id") or not room_meta.get("platform_chat_id"): - raise ValueError(f"Room {ctx.surface_ref} is not bound to an agent target") - delegate = self._delegates[room_meta["agent_id"]] - return delegate, str(room_meta["platform_chat_id"]) - - async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None): - delegate, platform_chat_id = await self._resolve_target(user_id, chat_id) - return await delegate.send_message(user_id, platform_chat_id, text, attachments) - - async def stream_message(self, user_id: str, chat_id: str, text: str, attachments=None): - delegate, platform_chat_id = await self._resolve_target(user_id, chat_id) - async for chunk in delegate.stream_message(user_id, platform_chat_id, text, attachments): - yield chunk - - async def get_or_create_user(self, external_id: str, platform: str, display_name=None): - first_delegate = next(iter(self._delegates.values())) - return await first_delegate.get_or_create_user(external_id, platform, display_name) - - async def get_settings(self, user_id: str): - first_delegate = next(iter(self._delegates.values())) - return await first_delegate.get_settings(user_id) - - async def update_settings(self, user_id: str, action): - first_delegate = next(iter(self._delegates.values())) - await first_delegate.update_settings(user_id, action) -``` - -```python -# adapter/matrix/bot.py -from adapter.matrix.agent_registry import load_agent_registry -from adapter.matrix.routed_platform import RoutedPlatformClient - - -def _build_platform_from_env(store: StateStore, chat_mgr: ChatManager) -> PlatformClient: - backend = os.environ.get("MATRIX_PLATFORM_BACKEND", "mock").strip().lower() - if backend != "real": - return MockPlatformClient() - - registry = load_agent_registry(os.environ["MATRIX_AGENT_REGISTRY_PATH"]) - delegates = { - agent.agent_id: RealPlatformClient( - agent_id=agent.agent_id, - agent_base_url=_agent_base_url_from_env(), - prototype_state=PrototypeStateStore(), - platform="matrix", - ) - for agent in registry.agents - } - return RoutedPlatformClient(store=store, chat_mgr=chat_mgr, delegates=delegates) - - -def build_runtime(...): - store = store or InMemoryStore() - chat_mgr = ChatManager(None, store) - platform = platform or _build_platform_from_env(store, chat_mgr) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - dispatcher = EventDispatcher( - platform=platform, - chat_mgr=chat_mgr, - auth_mgr=auth_mgr, - settings_mgr=settings_mgr, - ) -``` - -- [ ] **Step 4: Run the routed-platform tests to verify they pass** - -Run: `uv run pytest tests/adapter/matrix/test_routed_platform.py -q` - -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add adapter/matrix/routed_platform.py adapter/matrix/bot.py tests/adapter/matrix/test_routed_platform.py -git commit -m "feat: add matrix routed platform facade" -``` - ---- - -### Task 3: Add `!agent` Selection And Durable User Agent State - -**Files:** -- Create: `adapter/matrix/handlers/agent.py` -- Create: `tests/adapter/matrix/test_agent_handler.py` -- Modify: `adapter/matrix/store.py` -- Modify: `adapter/matrix/handlers/__init__.py` -- Modify: `adapter/matrix/handlers/settings.py` - -- [ ] **Step 1: Write the failing agent-handler tests** - -```python -# tests/adapter/matrix/test_agent_handler.py -import pytest - -from adapter.matrix.handlers.agent import make_handle_agent -from adapter.matrix.store import get_room_meta, get_selected_agent_id, set_room_meta -from core.protocol import IncomingCommand -from core.store import InMemoryStore - - -class FakeRegistry: - def __init__(self) -> None: - self.agents = [ - type("Agent", (), {"agent_id": "agent-1", "label": "Analyst"})(), - type("Agent", (), {"agent_id": "agent-2", "label": "Research"})(), - ] - - -@pytest.mark.asyncio -async def test_agent_command_lists_available_agents(): - handler = make_handle_agent(store=InMemoryStore(), registry=FakeRegistry()) - result = await handler( - IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=[]), - None, - None, - None, - None, - ) - assert "1. Analyst" in result[0].text - assert "2. Research" in result[0].text - - -@pytest.mark.asyncio -async def test_agent_command_persists_selected_agent_and_binds_unbound_room(): - store = InMemoryStore() - await set_room_meta(store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1"}) - handler = make_handle_agent(store=store, registry=FakeRegistry()) - chat_mgr = type( - "ChatMgr", - (), - {"get": staticmethod(lambda chat_id, user_id=None: type("Ctx", (), {"surface_ref": "!room:example.org"})())}, - )() - - await handler( - IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="agent", args=["2"]), - None, - None, - chat_mgr, - None, - ) - - assert await get_selected_agent_id(store, "u1") == "agent-2" - room_meta = await get_room_meta(store, "!room:example.org") - assert room_meta["agent_id"] == "agent-2" -``` - -- [ ] **Step 2: Run the agent-handler tests to verify they fail** - -Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q` - -Expected: FAIL with missing handler or store helpers. - -- [ ] **Step 3: Add durable store helpers and implement `!agent`** - -```python -# adapter/matrix/store.py -async def get_selected_agent_id(store: StateStore, matrix_user_id: str) -> str | None: - meta = await get_user_meta(store, matrix_user_id) or {} - value = meta.get("selected_agent_id") - return str(value) if value else None - - -async def set_selected_agent_id(store: StateStore, matrix_user_id: str, agent_id: str) -> None: - meta = await get_user_meta(store, matrix_user_id) or {} - meta["selected_agent_id"] = agent_id - await set_user_meta(store, matrix_user_id, meta) - - -async def set_room_agent_id(store: StateStore, room_id: str, agent_id: str) -> None: - meta = dict(await get_room_meta(store, room_id) or {}) - meta["agent_id"] = agent_id - await set_room_meta(store, room_id, meta) -``` - -```python -# adapter/matrix/handlers/agent.py -from __future__ import annotations - -from adapter.matrix.store import ( - get_room_meta, - get_selected_agent_id, - next_platform_chat_id, - set_platform_chat_id, - set_room_agent_id, - set_selected_agent_id, -) -from core.protocol import IncomingCommand, OutgoingMessage - - -def make_handle_agent(store, registry): - async def handle_agent(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr): - if not event.args: - current = await get_selected_agent_id(store, event.user_id) - lines = ["Доступные агенты:"] - for index, agent in enumerate(registry.agents, start=1): - marker = " (текущий)" if agent.agent_id == current else "" - lines.append(f"{index}. {agent.label}{marker}") - lines.append("") - lines.append("Выбери агента: !agent <номер>") - return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] - - agent = registry.agents[int(event.args[0]) - 1] - await set_selected_agent_id(store, event.user_id, agent.agent_id) - ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id) if chat_mgr else None - if ctx is not None: - room_meta = await get_room_meta(store, ctx.surface_ref) - if room_meta is not None and not room_meta.get("agent_id"): - await set_room_agent_id(store, ctx.surface_ref, agent.agent_id) - if not room_meta.get("platform_chat_id"): - await set_platform_chat_id(store, ctx.surface_ref, await next_platform_chat_id(store)) - return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Этот чат готов к работе.")] - return [OutgoingMessage(chat_id=event.chat_id, text=f"Агент переключён на {agent.label}. Для продолжения используй !new.")] - - return handle_agent -``` - -- [ ] **Step 4: Register the command and update help text** - -```python -# adapter/matrix/handlers/__init__.py -from adapter.matrix.handlers.agent import make_handle_agent - -dispatcher.register(IncomingCommand, "agent", make_handle_agent(store, registry)) -``` - -```python -# adapter/matrix/handlers/settings.py -HELP_TEXT = "\n".join( - [ - "Команды", - "", - "!agent выбрать активного агента", - "!new [название] создать новый чат", - "!chats список активных чатов", - "!rename <название> переименовать текущий чат", - "!archive архивировать текущий чат", - "!context показать текущее состояние контекста", - "!save [имя] сохранить текущий контекст", - "!load показать сохранённые контексты", - ] -) -``` - -- [ ] **Step 5: Run the agent-handler tests to verify they pass** - -Run: `uv run pytest tests/adapter/matrix/test_agent_handler.py -q` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add adapter/matrix/store.py adapter/matrix/handlers/agent.py adapter/matrix/handlers/__init__.py adapter/matrix/handlers/settings.py tests/adapter/matrix/test_agent_handler.py -git commit -m "feat: add matrix agent selection command" -``` - ---- - -### Task 4: Bind Rooms Correctly And Block Stale Chats - -**Files:** -- Modify: `adapter/matrix/bot.py` -- Modify: `adapter/matrix/handlers/chat.py` -- Modify: `adapter/matrix/handlers/context_commands.py` -- Modify: `tests/adapter/matrix/test_dispatcher.py` -- Modify: `tests/adapter/matrix/test_context_commands.py` - -- [ ] **Step 1: Write the failing dispatcher and context-command tests** - -```python -# tests/adapter/matrix/test_dispatcher.py -@pytest.mark.asyncio -async def test_bot_replies_with_agent_prompt_when_user_has_no_selected_agent(): - runtime = build_runtime(platform=MockPlatformClient()) - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "@alice:example.org"}) - - await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="@alice:example.org", body="hello")) - - client.room_send.assert_awaited_once() - assert "выбери агента" in client.room_send.call_args.args[2]["body"].lower() - - -@pytest.mark.asyncio -async def test_new_chat_requires_selected_agent_and_binds_room_meta(): - client = SimpleNamespace( - room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")), - room_put_state=AsyncMock(), - ) - runtime = build_runtime(platform=MockPlatformClient(), client=client) - await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 2, "selected_agent_id": "agent-2"}) - - result = await runtime.dispatcher.dispatch( - IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="new", args=["Research"]) - ) - - room_meta = await get_room_meta(runtime.store, "!r2:example") - assert room_meta["agent_id"] == "agent-2" - assert "Создан чат" in result[0].text -``` - -```python -# tests/adapter/matrix/test_context_commands.py -@pytest.mark.asyncio -async def test_load_selection_calls_platform_with_local_chat_id(): - platform = MatrixCommandPlatform() - runtime = build_runtime(platform=platform) - await runtime.chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org", "Chat 1") - await set_room_meta(runtime.store, "!room:example.org", {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"}) - - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - await set_load_pending(runtime.store, "u1", "!room:example.org", {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]}) - - await bot.on_room_message(SimpleNamespace(room_id="!room:example.org"), SimpleNamespace(sender="u1", body="1")) - - platform.send_message.assert_awaited_once_with("u1", "C1", LOAD_PROMPT.format(name="session-a")) -``` - -- [ ] **Step 2: Run the dispatcher and context-command tests to verify they fail** - -Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q` - -Expected: FAIL because the current runtime still injects `platform_chat_id` into normal messages and `!new` does not require or persist `agent_id`. - -- [ ] **Step 3: Implement room binding and stale-room checks in runtime** - -```python -# adapter/matrix/bot.py -from adapter.matrix.store import ( - get_selected_agent_id, - get_room_meta, - next_platform_chat_id, - set_platform_chat_id, - set_room_agent_id, -) - - -async def _ensure_active_room_target(self, room_id: str, user_id: str) -> tuple[dict | None, OutgoingMessage | None]: - room_meta = await get_room_meta(self.runtime.store, room_id) - selected_agent_id = await get_selected_agent_id(self.runtime.store, user_id) - if not selected_agent_id: - return room_meta, OutgoingMessage(chat_id=room_id, text="Сначала выбери агента через !agent.") - if room_meta is None: - return room_meta, None - if not room_meta.get("agent_id"): - await set_room_agent_id(self.runtime.store, room_id, selected_agent_id) - if not room_meta.get("platform_chat_id"): - await set_platform_chat_id(self.runtime.store, room_id, await next_platform_chat_id(self.runtime.store)) - room_meta = await get_room_meta(self.runtime.store, room_id) - return room_meta, None - if room_meta["agent_id"] != selected_agent_id: - return room_meta, OutgoingMessage(chat_id=room_id, text="Этот чат привязан к старому агенту. Используй !new.") - return room_meta, None -``` - -```python -# adapter/matrix/bot.py -local_chat_id = await resolve_chat_id(self.runtime.store, room.room_id, sender) -dispatch_chat_id = local_chat_id - -if not body.startswith("!"): - room_meta, blocking = await self._ensure_active_room_target(room.room_id, sender) - if blocking is not None: - await self._send_all(room.room_id, [blocking]) - return - -incoming = from_room_event(event, room_id=room.room_id, chat_id=dispatch_chat_id) -``` - -- [ ] **Step 4: Require selected agent for `!new` and persist room `agent_id`** - -```python -# adapter/matrix/handlers/chat.py -from adapter.matrix.store import get_selected_agent_id - -selected_agent_id = await get_selected_agent_id(store, event.user_id) -if not selected_agent_id: - return [OutgoingMessage(chat_id=event.chat_id, text="Сначала выбери агента через !agent.")] - -await set_room_meta( - store, - room_id, - { - "room_type": "chat", - "chat_id": chat_id, - "display_name": room_name, - "matrix_user_id": event.user_id, - "space_id": space_id, - "platform_chat_id": platform_chat_id, - "agent_id": selected_agent_id, - }, -) -``` - -```python -# adapter/matrix/bot.py -room_meta = await get_room_meta(self.runtime.store, room_id) -local_chat_id = room_meta.get("chat_id", room_id) if room_meta else room_id - -await self.runtime.platform.send_message( - user_id, - local_chat_id, - LOAD_PROMPT.format(name=name), -) -``` - -- [ ] **Step 5: Run the dispatcher and context-command tests to verify they pass** - -Run: `uv run pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -q` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add adapter/matrix/bot.py adapter/matrix/handlers/chat.py adapter/matrix/handlers/context_commands.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py -git commit -m "feat: bind matrix rooms to selected agents" -``` - ---- - -### Task 5: Prove Durable Restart State And Sequence Persistence - -**Files:** -- Create: `tests/adapter/matrix/test_restart_persistence.py` -- Modify: `adapter/matrix/store.py` -- Modify: `README.md` - -- [ ] **Step 1: Write the failing restart-persistence tests** - -```python -# tests/adapter/matrix/test_restart_persistence.py -import pytest - -from adapter.matrix.store import ( - get_selected_agent_id, - next_platform_chat_id, - set_room_meta, - set_selected_agent_id, -) -from core.store import SQLiteStore - - -@pytest.mark.asyncio -async def test_selected_agent_and_room_binding_survive_store_recreation(tmp_path): - db_path = tmp_path / "matrix.db" - store = SQLiteStore(str(db_path)) - await set_selected_agent_id(store, "u1", "agent-2") - await set_room_meta( - store, - "!room:example.org", - {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41", "agent_id": "agent-2"}, - ) - - reopened = SQLiteStore(str(db_path)) - assert await get_selected_agent_id(reopened, "u1") == "agent-2" - assert (await reopened.get("matrix_room:!room:example.org"))["agent_id"] == "agent-2" - assert (await reopened.get("matrix_room:!room:example.org"))["platform_chat_id"] == "41" - - -@pytest.mark.asyncio -async def test_platform_chat_sequence_survives_store_recreation(tmp_path): - db_path = tmp_path / "matrix.db" - store = SQLiteStore(str(db_path)) - - assert await next_platform_chat_id(store) == "1" - assert await next_platform_chat_id(store) == "2" - - reopened = SQLiteStore(str(db_path)) - assert await next_platform_chat_id(reopened) == "3" -``` - -- [ ] **Step 2: Run the restart-persistence tests to verify they fail** - -Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q` - -Expected: FAIL because `selected_agent_id` helpers do not exist yet or sequence persistence behavior is not explicitly covered. - -- [ ] **Step 3: Make sequence persistence explicit and document the restart boundary** - -```python -# adapter/matrix/store.py -PLATFORM_CHAT_SEQ_KEY = "matrix_platform_chat_seq" - - -async def next_platform_chat_id(store: StateStore) -> str: - async with _PLATFORM_CHAT_SEQ_LOCK: - data = await store.get(PLATFORM_CHAT_SEQ_KEY) - index = int((data or {}).get("next_platform_chat_index", 1)) - await store.set(PLATFORM_CHAT_SEQ_KEY, {"next_platform_chat_index": index + 1}) - return str(index) -``` - -```markdown -# README.md -- Matrix durable state lives in `lambda_matrix.db` and `matrix_store` -- normal restart is supported only when those paths survive container recreation -- staged attachments and pending confirmations are intentionally not restored -``` - -- [ ] **Step 4: Run the restart-persistence tests to verify they pass** - -Run: `uv run pytest tests/adapter/matrix/test_restart_persistence.py -q` - -Expected: PASS - -- [ ] **Step 5: Run the combined verification sweep** - -Run: `uv run pytest tests/adapter/matrix/test_agent_registry.py tests/adapter/matrix/test_routed_platform.py tests/adapter/matrix/test_agent_handler.py tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_context_commands.py tests/adapter/matrix/test_restart_persistence.py tests/platform/test_real.py -q` - -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add adapter/matrix/store.py README.md tests/adapter/matrix/test_restart_persistence.py -git commit -m "test: cover matrix restart state persistence" -``` - ---- - -## Self-Review - -### Spec coverage - -- Multi-agent agent registry: Task 1 -- Shared `PlatformClient` preserved via routing facade: Task 2 -- `!agent` UX and durable `selected_agent_id`: Task 3 -- Unbound room activation, `!new`, stale room rejection: Task 4 -- Restart durability for user state, room state, and `PLATFORM_CHAT_SEQ_KEY`: Task 5 - -### Placeholder scan - -- No `TODO`, `TBD`, or “implement later” markers remain. -- Each task includes exact file paths, tests, commands, and minimal code snippets. - -### Type consistency - -- `selected_agent_id` lives in user metadata throughout the plan. -- `agent_id` and `platform_chat_id` live in room metadata throughout the plan. -- `RoutedPlatformClient` keeps the existing `PlatformClient` method names intact. diff --git a/docs/superpowers/specs/2026-03-31-forum-topics-design.md b/docs/superpowers/specs/2026-03-31-forum-topics-design.md deleted file mode 100644 index 1e7cb29..0000000 --- a/docs/superpowers/specs/2026-03-31-forum-topics-design.md +++ /dev/null @@ -1,180 +0,0 @@ -# Forum Topics Mode Design - -**Date:** 2026-03-31 -**Status:** Approved — ready for implementation -**Scope:** `adapter/telegram/` — расширение существующего адаптера - ---- - -## Контекст - -Forum Topics — опциональный advanced-режим поверх существующих виртуальных DM-чатов. -Пользователь подключает свою Telegram-супергруппу с Topics — и его чаты появляются -как нативные темы Telegram. DM и Forum работают **одновременно**: один контекст, -две поверхности. - ---- - -## Принцип работы - -Каждый чат (`chat_id` = UUID) получает опциональный `forum_thread_id`. - -- Пользователь пишет в DM → бот отвечает в DM с тегом `[Чат #N]` -- Пользователь пишет в Forum-тему → бот отвечает в ту же тему (без тега) -- Контекст (`chat_id`) один и тот же — платформа видит единый разговор - ---- - -## БД — изменения схемы - -```sql -ALTER TABLE tg_users ADD COLUMN forum_group_id INTEGER; -ALTER TABLE chats ADD COLUMN forum_thread_id INTEGER; -``` - -`forum_group_id` — ID супергруппы пользователя (NULL если группа не подключена). -`forum_thread_id` — ID темы в форуме (NULL если чат создан только в DM). - -Новые функции в `db.py`: -```python -def set_forum_group(tg_user_id: int, group_id: int) -> None -def get_forum_group(tg_user_id: int) -> int | None -def set_forum_thread(chat_id: str, thread_id: int) -> None -def get_chat_by_thread(tg_user_id: int, thread_id: int) -> dict | None -``` - ---- - -## Онбординг — `/forum` - -### FSM - -```python -class ForumSetupState(StatesGroup): - waiting_for_group = State() # ждём пересылку из группы -``` - -### Флоу - -``` -/forum -→ FSM: ForumSetupState.waiting_for_group -→ "Создай супергруппу, включи Topics, добавь меня администратором - с правом управления темами. Затем перешли мне любое сообщение из группы." - -[пользователь пересылает сообщение] -→ Проверить: forward_from_chat.type == "supergroup" -→ Проверить права бота (администратор + can_manage_topics) - ❌ нет прав → объяснить что именно не так, остаться в состоянии -→ Сохранить forum_group_id в БД -→ Создать Forum-тему для каждого существующего активного DM-чата -→ Записать forum_thread_id для каждого чата -→ Ответить в DM: "✅ Группа подключена! Твои чаты теперь доступны в Forum-темах." -→ FSM: clear -``` - -### Проверка прав - -```python -async def check_forum_admin(bot: Bot, group_id: int) -> bool: - member = await bot.get_chat_member(group_id, (await bot.get_me()).id) - return ( - member.status in ("administrator", "creator") - and getattr(member, "can_manage_topics", False) - ) -``` - ---- - -## Создание чатов — синхронизация - -### `/new` в DM (группа подключена) - -1. Создать UUID-запись в `chats` (как сейчас) -2. `create_forum_topic(bot, group_id, chat_name)` → получить `thread_id` -3. Записать `forum_thread_id` в БД -4. Переключить FSM на новый чат -5. Ответить в DM: `"✅ [chat_name] создан."` - -### `/new` в DM (группа НЕ подключена) - -Без изменений — только DM-чат. - -### `/new` в Forum-теме - -1. Определить `thread_id` из `message.message_thread_id` -2. Создать UUID-запись в `chats` с `forum_thread_id = thread_id` -3. Название: из аргумента `/new Название` или из названия темы (`message.chat.forum_topic_created.name` при создании — иначе запросить у Telegram) -4. Ответить в теме: `"✅ Чат зарегистрирован. Пиши здесь!"` - ---- - -## Маршрутизация сообщений - -### Определение источника - -```python -def is_forum_message(message: Message) -> bool: - return message.message_thread_id is not None - -def resolve_chat_id(message: Message, tg_user_id: int) -> str | None: - if is_forum_message(message): - chat = db.get_chat_by_thread(tg_user_id, message.message_thread_id) - return chat["chat_id"] if chat else None - else: - # DM — берём active_chat_id из FSM StateData (как сейчас) - return None # caller reads from FSM -``` - -### Ответ - -- Пришло из DM → `bot.send_message(tg_user_id, f"[{chat_name}] {text}")` -- Пришло из Forum-темы → `bot.send_message(group_id, text, message_thread_id=thread_id)` - -В Forum-теме тег `[Чат #N]` **не нужен** — тема сама является визуальным разделителем. - ---- - -## Обработчики — изменения - -### `handlers/forum.py` (новый файл) - -```python -router = Router(name="forum") - -@router.message(Command("forum")) -async def cmd_forum(message, state): ... # запускает онбординг - -@router.message(ForumSetupState.waiting_for_group, F.forward_from_chat) -async def handle_group_forward(message, state, dispatcher): ... # регистрирует группу -``` - -### `handlers/chat.py` — изменения - -- `handle_message`: если `is_forum_message` → брать `chat_id` из БД по `thread_id`, отвечать в тему -- `cmd_new_chat`: ветвление по источнику (DM vs Forum) и наличию `forum_group_id` - -### `states.py` — добавить - -```python -class ForumSetupState(StatesGroup): - waiting_for_group = State() -``` - ---- - -## Что НЕ реализуем - -- Отслеживание создания тем пользователем без `/new` — Telegram не присылает событие создания темы в боте -- Синхронизация удаления темы ↔ архивация DM-чата (только через команды) -- Поддержка нескольких групп на одного пользователя - ---- - -## Порядок реализации - -1. `db.py` — миграция + 4 новых функции -2. `states.py` — `ForumSetupState` -3. `handlers/forum.py` — `/forum` + onboarding -4. `handlers/chat.py` — `cmd_new_chat` с ветвлением, `handle_message` с Forum-маршрутизацией -5. `converter.py` — `is_forum_message`, `resolve_chat_id` diff --git a/docs/superpowers/specs/2026-03-31-matrix-adapter-design.md b/docs/superpowers/specs/2026-03-31-matrix-adapter-design.md deleted file mode 100644 index 44ff120..0000000 --- a/docs/superpowers/specs/2026-03-31-matrix-adapter-design.md +++ /dev/null @@ -1,283 +0,0 @@ -# Matrix Adapter Design - -**Date:** 2026-03-31 -**Status:** Approved — ready for implementation -**Scope:** `adapter/matrix/` - ---- - -## Контекст - -Matrix-адаптер — внутренняя поверхность для команды Lambda Lab: разработчики, тестировщики, авторы скиллов. UX ориентирован на удобство работы, не на онбординг. - -Адаптер конвертирует matrix-nio события в `IncomingEvent` (core protocol) и отправляет `OutgoingEvent` обратно. Бизнес-логика — в `core/`, адаптер только переводит форматы и управляет Matrix API. - -Клиент: Element (web/desktop). Стек: matrix-nio (async), Python 3.11+, SQLite. - ---- - -## Онбординг — DM как первый чат (ленивый Space) - -**Решение:** DM-комната с ботом = Чат #1. Space создаётся только при первом `!new`. - -### Флоу — новый пользователь - -1. Пользователь инвайтит бота в личные сообщения -2. Бот принимает инвайт, вызывает `platform.get_or_create_user(matrix_user_id, "matrix", display_name)` -3. Бот регистрирует DM-комнату как `chat_room` с `chat_id = C1` в SQLite -4. Бот пишет приветствие в DM — пользователь сразу пишет -5. При первом `!new` — бот создаёт Space `Lambda — {display_name}`, добавляет DM-комнату в Space через `m.space.child`, создаёт новую комнату-чат - -### Флоу — возвращающийся пользователь - -Если `matrix_user_id` уже есть в БД (бот перезапустился, или пользователь пишет повторно) — `get_or_create_user` возвращает `is_new=False`. Бот не создаёт ничего заново, просто обрабатывает сообщение в контексте существующей комнаты. - -### Почему не Space сразу - -Создание Space при инвайте порождает 3 инвайта подряд (Space + Settings + Чат 1) до первого сообщения. DM-first убирает этот шум, сохраняя такой же UX как Telegram. - -### Приветствие - -``` -Привет, {display_name}! Пиши — я здесь. - -Команды: !new · !chats · !rename · !archive · !skills -``` - ---- - -## Архитектура — Room-type routing - -При получении события адаптер сначала определяет тип комнаты (`chat` / `settings`), затем маршрутизирует в соответствующий обработчик. - -``` -adapter/matrix/ - bot.py — matrix-nio клиент, sync loop - converter.py — RoomEvent → IncomingEvent, OutgoingEvent → Matrix API - room_router.py — определяет тип комнаты: chat | settings - states.py — FSM состояния (per room_id, SQLite) - - handlers/ - auth.py — invite → onboarding - chat.py — сообщения, !new, !chats, !rename, !archive - settings.py — !skills, !connectors, !soul, !safety, !plan, !status, !whoami - confirm.py — реакции 👍/❌ и команды !yes / !no - - reactions.py — helpers: add_reaction, remove_reactions, parse_reaction_event -``` - ---- - -## FSM состояния (per room_id) - -```python -class RoomState(StatesGroup): - idle = State() # ждём сообщения - waiting_response = State() # запрос ушёл на платформу - confirm_pending = State() # ждём !yes/!no или реакцию 👍/❌ - settings_active = State() # Settings-комната (не чат) -``` - -`room_type` хранится в SQLite. `room_router.py` читает его при каждом событии. - ---- - -## Команды - -Все команды на английском. Работают в любой комнате Space. - -| Команда | Действие | -|---------|---------| -| `!new [name]` | Создать чат. При первом вызове — создаёт Space, переносит DM | -| `!chats` | Список чатов с текущим активным | -| `!rename ` | Переименовать текущую комнату | -| `!archive` | Вывести комнату из Space (не удалять) | -| `!skills` | Список скиллов — реакции как тумблеры | -| `!connectors` | Коннекторы (OAuth заглушки) | -| `!soul` | Личность агента | -| `!safety` | Настройки безопасности | -| `!plan` | Подписка и токены | -| `!status` | Состояние платформы и чатов | -| `!whoami` | Текущий аккаунт | -| `!yes` / `!no` | Подтверждение / отмена действия агента | - ---- - -## Settings room - -Создаётся при первом `!new` вместе со Space. Закреплена вверху Space. - -### Скиллы — реакции как тумблеры - -`!skills` → бот отправляет список. Каждый скилл пронумерован. Реакция 1️⃣–N️⃣ переключает соответствующий скилл (toggle). Несколько скиллов могут быть включены одновременно. Бот редактирует своё сообщение через `m.replace` после каждого переключения. - -``` -✅ 1 web-search — поиск в интернете -✅ 2 fetch-url — чтение веб-страниц -✅ 3 email — чтение почты -❌ 4 browser — управление браузером -❌ 5 image-gen — генерация изображений -✅ 6 files — работа с файлами - -Реакция 1️⃣–6️⃣ = переключить скилл -``` - -### Остальные настройки - -`!connectors`, `!soul`, `!safety`, `!plan`, `!status`, `!whoami` — текстовые ответы, без интерактивных элементов. Поля задаются аргументами команды: `!soul name Lambda`, `!soul style brief`, `!safety on email-send`. - ---- - -## Подтверждение действий агента - -Агент запрашивает подтверждение → бот отправляет сообщение с описанием действия. Пользователь подтверждает **реакцией или командой** — оба способа работают. - -``` -🤖 Lambda: -Отправить письмо azamat@lambda.lab? -Тема: «Отчёт за неделю» - -👍 подтвердить · ❌ отменить -!yes — подтвердить · !no — отменить - -Истекает через 5 минут -``` - -После ответа: бот убирает реакции с сообщения, редактирует статус (`m.replace`), переходит в `idle`. - -FSM: `waiting_response` → `confirm_pending` → `idle` - ---- - -## Долгие задачи — треды - -Если задача занимает больше одного хода — бот создаёт тред от своего первого сообщения. - -``` -🤖 Lambda (основной поток): -Начинаю исследование «AI агенты 2025» — займёт 2-3 минуты. - 🧵 Прогресс (в треде): - └── ✅ Ищу источники... (12 найдено) - └── ✅ Анализирую статьи... - └── ⏳ Формирую отчёт... - └── ○ Финальная проверка -``` - -Основной поток не засоряется. Финальный результат — отдельным сообщением в основной поток. - ---- - -## Typing indicator - -`m.typing` — отправлять перед запросом к платформе. Если запрос > 5 сек — возобновлять каждые 25 сек (Matrix typing живёт ~30 сек). - ---- - -## Converter - -`adapter/matrix/converter.py` — конвертация в обе стороны. - -### matrix-nio → IncomingEvent - -```python -def from_room_message(event: RoomMessageText, room_id: str, chat_id: str) -> IncomingMessage: - return IncomingMessage( - user_id=event.sender, # @user:matrix.org - platform="matrix", - chat_id=chat_id, # C1, C2... из rooms таблицы - text=event.body, - attachments=extract_attachments(event), - reply_to=event.replyto_event_id, - ) - -def extract_attachments(event: RoomMessageText) -> list[Attachment]: - # m.image → Attachment(type="image", url=mxc_url, mime_type=...) - # m.file → Attachment(type="document", url=mxc_url, filename=..., mime_type=...) - # m.audio → Attachment(type="audio", url=mxc_url, mime_type=...) - # m.text → [] - msgtype = getattr(event, "msgtype", "m.text") - if msgtype == "m.image": - return [Attachment(type="image", url=event.url, mime_type=event.mimetype)] - elif msgtype == "m.file": - return [Attachment(type="document", url=event.url, - filename=event.body, mime_type=event.mimetype)] - elif msgtype == "m.audio": - return [Attachment(type="audio", url=event.url, mime_type=event.mimetype)] - return [] - -def from_reaction(event: ReactionEvent, room_id: str) -> IncomingCallback | None: - # Парсит m.reaction → IncomingCallback(action="toggle_skill" | "confirm" | "cancel") - ... - -def from_command(body: str, sender: str, room_id: str, chat_id: str) -> IncomingCommand | None: - # Парсит !new, !skills, !yes, !no и т.д. → IncomingCommand - ... -``` - -### OutgoingEvent → Matrix - -```python -async def send_outgoing(client: AsyncClient, room_id: str, event: OutgoingEvent) -> None: - if isinstance(event, OutgoingMessage): - await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text}) - - elif isinstance(event, OutgoingUI): - # Confirmation request — текст + подсказка по реакциям/командам - body = f"{event.text}\n\n👍 подтвердить · ❌ отменить\n!yes — подтвердить · !no — отменить" - await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body}) - await client.room_send(room_id, "m.reaction", {...}) # добавить 👍 и ❌ на сообщение - - elif isinstance(event, OutgoingTyping): - await client.room_typing(room_id, event.is_typing, timeout=25000) -``` - ---- - -## БД схема - -```sql -CREATE TABLE matrix_users ( - matrix_user_id TEXT PRIMARY KEY, -- @user:matrix.org - platform_user_id TEXT NOT NULL, -- из MockPlatformClient - display_name TEXT, - space_id TEXT, -- NULL до первого !new - settings_room_id TEXT, -- NULL до первого !new - created_at TIMESTAMP -); - -CREATE TABLE rooms ( - room_id TEXT PRIMARY KEY, -- room_id Matrix - matrix_user_id TEXT NOT NULL, - room_type TEXT NOT NULL, -- 'chat' | 'settings' - chat_id TEXT, -- C1, C2... (NULL для settings) - display_name TEXT, - created_at TIMESTAMP, - archived_at TIMESTAMP, - FOREIGN KEY(matrix_user_id) REFERENCES matrix_users(matrix_user_id) -); -``` - -`StateStore` из `core/store.py` (`SQLiteStore`) — для FSM per room_id. - ---- - -## Что НЕ реализуем в прототипе - -- Webhook от платформы (используем sync `send_message`) -- E2E encryption (nio поддерживает, но усложняет прототип) -- Экспорт истории -- `!rename`, `!archive` — добавить после основного флоу - ---- - -## Порядок реализации - -1. `bot.py` — AsyncClient, sync loop, middleware для platform client -2. `states.py` — RoomState -3. `room_router.py` — определение типа комнаты -4. `converter.py` — from_room_message, from_reaction, from_command -5. `handlers/auth.py` — invite → onboarding -6. `handlers/chat.py` — сообщения + !new + !chats -7. `reactions.py` — helpers для работы с реакциями -8. `handlers/confirm.py` — реакции 👍/❌ + !yes/!no -9. `handlers/settings.py` — !skills с m.replace + остальные команды diff --git a/docs/superpowers/specs/2026-03-31-telegram-adapter-design.md b/docs/superpowers/specs/2026-03-31-telegram-adapter-design.md index 46421a5..ea2346e 100644 --- a/docs/superpowers/specs/2026-03-31-telegram-adapter-design.md +++ b/docs/superpowers/specs/2026-03-31-telegram-adapter-design.md @@ -1,7 +1,7 @@ # Telegram Adapter Design **Date:** 2026-03-31 -**Status:** Approved — ready for implementation +**Status:** Approved — implemented in `feat/telegram-adapter` **Scope:** `adapter/telegram/` --- @@ -10,38 +10,45 @@ Telegram-адаптер — поверхность для взаимодействия пользователя с AI-агентом Lambda через Telegram. Адаптер конвертирует Telegram-события в `IncomingEvent` (core protocol) и отправляет `OutgoingEvent` обратно. -Бизнес-логика — в `core/`, адаптер только переводит форматы и управляет Telegram API. +Бизнес-логика остаётся в `core/`; адаптер отвечает за Telegram API, FSM и локальную SQLite-привязку Telegram-пользователя и чатов. --- -## Чаты: основной режим — Виртуальные чаты в DM +## Чаты: hybrid DM + Forum Topics -**Решение зафиксировано:** основной режим — виртуальные чаты прямо в личке с ботом. -Forum Topics — опциональный advanced режим (не реализуется в этом прототипе). +**Зафиксированное решение:** базовая поверхность — личка с ботом, Forum Topics — опциональный advanced-режим поверх тех же самых чатов. -### Принцип работы - -- `active_chat_id` — куда идут входящие сообщения от пользователя в данный момент -- Ответы от агента всегда приходят в общий DM-поток с тегом: `[Чат #1] Вот ответ...` -- Пользователь может переключиться до получения ответа — ответ всё равно придёт, тегирован +- В DM пользователь всегда может писать сразу после `/start` +- `active_chat_id` хранится в FSM и определяет, в какой чат идут DM-сообщения +- Если подключена Forum-группа, каждый чат может получить `forum_thread_id` +- Один и тот же `chat_id` доступен из двух поверхностей: + - DM: ответы идут с префиксом `[Название чата]` + - Forum-тема: ответы идут прямо в тему без префикса ### UX флоу -``` +```text /start -→ Приветствие + Чат #1 создан автоматически -→ Пользователь сразу пишет +→ пользователь аутентифицирован +→ создаётся или восстанавливается активный DM-чат -/new [название] -→ Новый чат создан, переключаемся на него +/new [название] в DM +→ создаётся новый чат +→ если forum уже подключён, бот создаёт и forum topic -/chats -→ Инлайн-кнопки: 1. Чат #1 2. Чат #2 3. Исследование рынка -→ Нажимает — переключился +/forum +→ бот просит переслать сообщение из супергруппы с Topics +→ проверяет admin rights +→ привязывает группу к пользователю +→ создаёт topics для существующих чатов -Сообщение в активный чат -→ Typing indicator -→ [Чат #1] Ответ агента +Сообщение в DM +→ идёт в active_chat_id +→ ответ приходит в DM как `[Чат #N] ...` + +Сообщение в forum topic +→ по `message_thread_id` определяется chat_id +→ ответ приходит в ту же тему без тега ``` --- @@ -50,339 +57,180 @@ Forum Topics — опциональный advanced режим (не реализ ### Флоу (мок) -1. `/start` → `get_or_create_user(tg_user_id, "telegram", display_name)` -2. `is_new=True` → создать Чат #1, написать приветствие -3. `is_new=False` → восстановить `active_chat_id` из БД, написать "С возвращением" +1. `/start` → `platform.get_or_create_user(external_id=tg_user_id, platform="telegram", display_name=...)` +2. Бот сохраняет `tg_user_id -> platform_user_id` в локальной БД +3. Если локальных чатов ещё нет — создаёт `Чат #1` +4. Если чат уже есть — восстанавливает последний активный чат ### FSM состояния -```python -class AuthState(StatesGroup): - # В моке состояний нет — auth мгновенный - # Зарезервировано для реального SDK (waiting_confirmation и т.п.) - pass -``` - ---- - -## FSM состояния (полная схема) - ```python class ChatState(StatesGroup): - idle = State() # В активном чате, ждём сообщения - waiting_response = State() # Запрос ушёл на платформу, ждём ответа + idle = State() + waiting_response = State() + class SettingsState(StatesGroup): - menu = State() # Главное меню настроек - soul_editing = State() # Редактирует имя/инструкции агента - confirm_action = State() # Подтверждение деструктивного действия + menu = State() + soul_editing = State() + confirm_action = State() + + +class ForumSetupState(StatesGroup): + waiting_for_group = State() ``` -**`active_chat_id` хранится в FSM StateData, не в состоянии.** +`active_chat_id` и `active_chat_name` хранятся в `FSMContext` data. --- ## Структура файлов -``` +```text adapter/telegram/ - bot.py — точка входа: Dispatcher, routers, middleware - states.py — FSM StatesGroup - converter.py — aiogram Message → IncomingEvent и обратно + bot.py — точка входа: Dispatcher, middleware, routers + converter.py — Message -> IncomingMessage, forum helpers, output formatting + db.py — SQLite schema и Telegram-specific persistence + states.py — ChatState, SettingsState, ForumSetupState handlers/ auth.py — /start - chat.py — /new, /chats, /rename, /archive, сообщения в чате - settings.py — /settings и callback_query для настроек - confirm.py — подтверждение действий агента (InlineKeyboard ✅/❌) + chat.py — /new, /chats, switch chat, входящие сообщения + confirm.py — confirm/cancel callbacks + forum.py — /forum onboarding и регистрация forum group + settings.py — /settings и callbacks настроек keyboards/ - chat.py — список чатов, управление чатом - settings.py — меню настроек, скиллы, коннекторы - confirm.py — кнопки подтверждения действия + chat.py — список чатов + confirm.py — confirm keyboard + settings.py — меню настроек ``` --- +## Persistence + +Локальная БД содержит две Telegram-специфичные сущности: + +```sql +CREATE TABLE tg_users ( + tg_user_id INTEGER PRIMARY KEY, + platform_user_id TEXT NOT NULL, + display_name TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + forum_group_id INTEGER +); + +CREATE TABLE chats ( + chat_id TEXT PRIMARY KEY, + tg_user_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + archived_at TIMESTAMP, + forum_thread_id INTEGER +); +``` + +- `forum_group_id` — привязанная супергруппа пользователя +- `forum_thread_id` — опциональная связь конкретного чата с forum topic + +--- + ## Converter -Конвертация в обе стороны — `adapter/telegram/converter.py`. - -### aiogram → IncomingEvent +### Telegram -> IncomingEvent ```python -def from_message(message: Message) -> IncomingMessage: +def from_message(message: Message, chat_id: str) -> IncomingMessage: return IncomingMessage( user_id=str(message.from_user.id), - chat_id=active_chat_id, # из FSM StateData - text=message.text or "", - attachments=extract_attachments(message), + chat_id=chat_id, + text=message.text or message.caption or "", + attachments=_extract_attachments(message), platform="telegram", - raw=message.model_dump(), ) -def extract_attachments(message: Message) -> list[Attachment]: - attachments = [] - if message.photo: - file = message.photo[-1] # наибольшее разрешение - attachments.append(Attachment( - url=f"tg://file/{file.file_id}", # резолвим через getFile при необходимости - mime_type="image/jpeg", - size=file.file_size, - )) - if message.document: - attachments.append(Attachment( - url=f"tg://file/{message.document.file_id}", - mime_type=message.document.mime_type or "application/octet-stream", - size=message.document.file_size, - filename=message.document.file_name, - )) - if message.voice: - attachments.append(Attachment( - url=f"tg://file/{message.voice.file_id}", - mime_type="audio/ogg", - size=message.voice.file_size, - )) - return attachments + +def is_forum_message(message: Message) -> bool: + return getattr(message, "message_thread_id", None) is not None + + +def resolve_forum_chat_id(message: Message, tg_user_id: int) -> str | None: + thread_id = getattr(message, "message_thread_id", None) + if thread_id is None: + return None + + chat = db.get_chat_by_thread(tg_user_id, thread_id) + return chat["chat_id"] if chat else None ``` -### OutgoingEvent → Telegram +### OutgoingEvent -> Telegram ```python -async def send_outgoing(bot: Bot, user_id: int, chat_name: str, event: OutgoingEvent) -> None: - prefix = f"[{chat_name}] " - - if isinstance(event, OutgoingMessage): - await bot.send_message(user_id, prefix + event.text) - - elif isinstance(event, OutgoingUI): - # Кнопки подтверждения действия - keyboard = build_confirm_keyboard(event) - await bot.send_message(user_id, prefix + event.text, reply_markup=keyboard) +def format_outgoing(chat_name: str, event: OutgoingEvent, *, prefix: bool = True) -> str: + rendered_prefix = f"[{chat_name}] " if prefix else "" + return rendered_prefix + event.text ``` +- DM-ответы используют `prefix=True` +- Forum-ответы используют `prefix=False` +- `OutgoingUI` отправляется с inline-кнопками подтверждения + --- ## Обработчики -### auth.py — `/start` +### `auth.py` -```python -@router.message(CommandStart()) -async def cmd_start(message: Message, state: FSMContext, platform: PlatformClient): - user = await platform.get_or_create_user( - external_id=str(message.from_user.id), - platform="telegram", - display_name=message.from_user.full_name, - ) +- `/start` создаёт или восстанавливает пользователя +- если это первый запуск, создаёт `Чат #1` +- обновляет `active_chat_id` и переводит FSM в `ChatState.idle` - if user.is_new: - chat_id = create_chat(user.user_id, "Чат #1") # в локальной БД - await state.update_data(active_chat_id=chat_id, active_chat_name="Чат #1") - await state.set_state(ChatState.idle) - await message.answer( - f"Привет, {message.from_user.first_name}! 👋\n" - f"Я создал тебе первый чат. Просто пиши.\n\n" - f"Команды: /new — новый чат, /chats — список" - ) - else: - # Восстановить последний активный чат - last_chat = get_last_chat(user.user_id) - await state.update_data(active_chat_id=last_chat.id, active_chat_name=last_chat.name) - await state.set_state(ChatState.idle) - await message.answer(f"С возвращением! Продолжаем [{last_chat.name}]") -``` +### `chat.py` -### chat.py — сообщения +- `/new`: + - в DM создаёт новый чат + - если подключён forum, пытается создать forum topic и сохранить `forum_thread_id` + - в forum-теме может зарегистрировать текущую тему как чат +- `/chats` показывает inline-список чатов +- `switch::` переключает активный DM-чат +- `handle_message`: + - в DM читает `active_chat_id` из FSM + - в forum определяет чат по `message_thread_id` + - отправляет `typing` + - прокидывает `IncomingMessage` в `EventDispatcher` + - возвращает ответ в DM или в тему -```python -@router.message(ChatState.idle, F.text) -async def handle_message(message: Message, state: FSMContext, platform: PlatformClient): - data = await state.get_data() - chat_id = data["active_chat_id"] - chat_name = data["active_chat_name"] +### `forum.py` - await state.set_state(ChatState.waiting_response) - await message.bot.send_chat_action(message.chat.id, "typing") +- `/forum` переводит FSM в `ForumSetupState.waiting_for_group` +- пересланное сообщение из супергруппы: + - валидирует, что это `supergroup` + - проверяет, что бот admin и умеет `can_manage_topics` + - сохраняет `forum_group_id` + - создаёт topics для существующих чатов без `forum_thread_id` - incoming = from_message(message, chat_id) - outgoing_events = await core_handler.handle(incoming, platform) +### `confirm.py` - await state.set_state(ChatState.idle) - - for event in outgoing_events: - await send_outgoing(message.bot, message.from_user.id, chat_name, event) -``` - -### chat.py — управление чатами - -```python -@router.message(Command("new")) -async def cmd_new_chat(message: Message, state: FSMContext): - args = message.text.split(maxsplit=1) - name = args[1] if len(args) > 1 else None - - data = await state.get_data() - user_id = ... # из платформы - count = count_chats(user_id) - chat_name = name or f"Чат #{count + 1}" - - chat_id = create_chat(user_id, chat_name) - await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) - await message.answer(f"✅ [{chat_name}] создан. Пиши!") - - -@router.message(Command("chats")) -async def cmd_list_chats(message: Message, state: FSMContext): - chats = get_user_chats(user_id) - data = await state.get_data() - active_id = data.get("active_chat_id") - - buttons = [] - for chat in chats: - mark = "● " if chat.id == active_id else "" - buttons.append([InlineKeyboardButton( - text=f"{mark}{chat.name}", - callback_data=f"switch:{chat.id}:{chat.name}" - )]) - buttons.append([InlineKeyboardButton(text="➕ Новый чат", callback_data="new_chat")]) - - await message.answer("Твои чаты:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) - - -@router.callback_query(F.data.startswith("switch:")) -async def switch_chat(callback: CallbackQuery, state: FSMContext): - _, chat_id, chat_name = callback.data.split(":", 2) - await state.update_data(active_chat_id=chat_id, active_chat_name=chat_name) - await callback.message.edit_text(f"✅ Переключился на [{chat_name}]") - await callback.answer() -``` - -### confirm.py — подтверждение действий агента - -```python -# Агент хочет выполнить действие → OutgoingUI приходит из core handler -# Бот показывает кнопки, ждёт ответа - -@router.callback_query(F.data.startswith("confirm:")) -async def handle_confirm(callback: CallbackQuery, state: FSMContext, platform: PlatformClient): - _, action_id, decision = callback.data.split(":") # "confirm" / "cancel" - data = await state.get_data() - - incoming = IncomingCallback( - user_id=..., - chat_id=data["active_chat_id"], - action="confirm" if decision == "yes" else "cancel", - payload={"action_id": action_id}, - platform="telegram", - ) - outgoing_events = await core_handler.handle(incoming, platform) - await callback.message.edit_reply_markup(reply_markup=None) - for event in outgoing_events: - await send_outgoing(callback.bot, callback.from_user.id, data["active_chat_name"], event) - await callback.answer() -``` +- обрабатывает `confirm:yes:` и `confirm:no:` +- в forum-режиме восстанавливает `chat_id` по thread +- ответ на callback отправляет обратно в тот же канал: + - DM -> в личку + - Forum -> в тот же `message_thread_id` --- -## Настройки +## Текущее покрытие -`/settings` → инлайн-меню. Структура: - -``` -⚙️ Настройки -[🧩 Скиллы] [🔗 Коннекторы] -[🧠 Личность] [🔒 Безопасность] -[💳 Подписка] -``` - -**Скиллы** — список с кнопками-переключателями ✅/❌. Нажатие → `SettingsAction(toggle_skill)`. - -**Личность** — свободные поля (имя агента, инструкции). Без пресетов стилей. -FSM: `SettingsState.soul_editing` → бот задаёт вопросы по одному полю. - -**Коннекторы** — заглушка OAuth ссылки. - -**Безопасность** — переключатели для деструктивных действий. - -**Подписка** — заглушка с токенами. +- unit-тесты на forum routing и forum onboarding: `tests/adapter/telegram/test_forum.py` +- smoke/integration на dispatcher и core handlers: + - `tests/core/test_dispatcher.py` + - `tests/core/test_integration.py` --- -## Хранилище (БД) +## Что не покрывает этот документ -Минимальная схема для прототипа: - -```sql -CREATE TABLE tg_users ( - tg_user_id INTEGER PRIMARY KEY, - platform_user_id TEXT NOT NULL, -- из MockPlatformClient - display_name TEXT, - created_at TIMESTAMP -); - -CREATE TABLE chats ( - chat_id TEXT PRIMARY KEY, -- UUID - tg_user_id INTEGER NOT NULL, - name TEXT NOT NULL, - created_at TIMESTAMP, - archived_at TIMESTAMP, - FOREIGN KEY(tg_user_id) REFERENCES tg_users(tg_user_id) -); -``` - -`StateStore` из `core/store.py` (`SQLiteStore`) — для FSM и общего состояния. - ---- - -## Typing indicator - -Отправлять `send_chat_action("typing")` перед запросом к платформе. -Если запрос > 5 сек — возобновлять каждые 4 сек (action живёт ~5 сек). - -```python -async def with_typing(bot: Bot, chat_id: int, coro): - async def renew(): - while True: - await bot.send_chat_action(chat_id, "typing") - await asyncio.sleep(4) - task = asyncio.create_task(renew()) - try: - return await coro - finally: - task.cancel() -``` - ---- - -## Обработка ответов при смене чата - -Ответ всегда приходит в DM-поток с тегом: -``` -[Чат #1] Вот мой ответ на вопрос про Python... -``` - -Пользователь мог переключить `active_chat_id` пока шёл запрос — это нормально. -`chat_name` берётся из `StateData` в момент отправки запроса (до `set_state(waiting_response)`). - ---- - -## Что НЕ реализуем в прототипе - -- Forum Topics режим (researched, отложено) -- Webhook от платформы (platform ещё не готов — используем sync `send_message`) -- `/rename`, `/archive` для чатов (добавить после основного флоу) -- Экспорт истории - ---- - -## Порядок реализации - -1. `bot.py` — Dispatcher, middleware для platform client -2. `states.py` — FSM классы -3. `converter.py` — from_message, extract_attachments -4. `handlers/auth.py` — /start -5. `handlers/chat.py` — сообщения + /new + /chats -6. `keyboards/chat.py` — список чатов -7. `handlers/settings.py` + `keyboards/settings.py` — меню настроек -8. `handlers/confirm.py` + `keyboards/confirm.py` — подтверждения +- Matrix-адаптер +- Реальный SDK платформы вместо `sdk.mock.MockPlatformClient` +- Автоматическое отслеживание вручную созданных пользователем forum topics без `/new` diff --git a/docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md b/docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md deleted file mode 100644 index 529eed1..0000000 --- a/docs/superpowers/specs/2026-04-01-telegram-forum-redesign.md +++ /dev/null @@ -1,180 +0,0 @@ -# Telegram Forum Redesign — Forum-First Architecture - -**Date:** 2026-04-01 -**Replaces:** `2026-03-31-telegram-adapter-design.md` (DM+Forum hybrid) -**Branch strategy:** New branch `feat/telegram-forum` from `main` (approach C: cherry-pick from `feat/telegram-adapter`) - ---- - -## Overview - -Redesign the Telegram adapter to use Bot API 9.3 Threaded Mode as the sole interaction model. The user's private chat with the bot becomes a forum: each topic is an isolated AI agent context. No supergroup, no onboarding flow. - ---- - -## File Structure - -**Carried over from `feat/telegram-adapter` (adapted):** -- `adapter/telegram/keyboards/settings.py` — settings inline keyboards -- `adapter/telegram/converter.py` — base conversion logic, rewritten for new context key - -**Written from scratch:** -``` -adapter/telegram/ - bot.py — entry point, router registration - db.py — SQLite schema and queries - handlers/ - start.py — /start handler - message.py — incoming messages in topics - topic_events.py — forum_topic_created / edited / closed - commands.py — /new, /archive, /rename, /settings - keyboards/ - settings.py — (from feat/telegram-adapter) -``` - -**Deleted entirely:** -- `handlers/forum.py` — old supergroup onboarding -- `handlers/chats.py` — chat switching via command -- All `forum_group_id` references in db.py and elsewhere - ---- - -## Database Schema - -```sql -CREATE TABLE chats ( - user_id INTEGER NOT NULL, - thread_id INTEGER NOT NULL, - chat_name TEXT NOT NULL DEFAULT 'Чат #1', - archived_at DATETIME, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (user_id, thread_id) -); -``` - -**Context key:** `(user_id, thread_id)` — the canonical identifier for a chat context everywhere in the adapter. - -**Display number** ("Чат #1", "Чат #2") is not stored. Computed on demand: -```sql -ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) -``` - -**workspace_id (C1/C2/C3)** is not stored. The adapter passes `thread_id` as `context_id` to the platform; the platform resolves the workspace mapping. - -**State** is managed via `core/store.py` with key `(user_id, thread_id)`. No aiogram FSM. - ---- - -## Event Handling - -### Commands (`handlers/commands.py` + `handlers/start.py`) - -| Command | Behaviour | -|---------|-----------| -| `/start` | If no active topics: `create_forum_topic("Чат #1")` + `hide_general_forum_topic`. If topics exist: greeting only. Then check all non-archived topics for validity (see Error Handling). | -| `/new` | `create_forum_topic("Чат #N")` where N = next display number. Insert row in DB. Send welcome message into new topic. | -| `/archive` | `close_forum_topic(thread_id)`. Set `archived_at = now()` in DB. | -| `/rename ` | `edit_forum_topic(thread_id, name)`. Update `chat_name` in DB. | -| `/settings` | Global settings. Works from any topic. | - -### Incoming Messages (`handlers/message.py`) - -- Message in a topic → `converter.py` → `IncomingMessage(context_id=str(thread_id))` → `EventDispatcher` -- Message in General topic (`message_thread_id is None`) → ignored silently - -### Topic UI Events (`handlers/topic_events.py`) - -| Event | Behaviour | -|-------|-----------| -| `forum_topic_created` | Register new chat in DB (native topic creation via UI) | -| `forum_topic_edited` | Update `chat_name` in DB to match new Telegram topic name | -| `forum_topic_closed` | Set `archived_at = now()` — automatic archive | - ---- - -## Data Flow with Streaming - -``` -User → Telegram → aiogram router - → message.py handler - → converter.py: Message → IncomingMessage(context_id=thread_id) - → send placeholder "..." into topic - → EventDispatcher.dispatch(incoming) - → platform/mock.py (or real SDK) - → returns AsyncIterator[str] (chunks) - → for chunk in stream: edit_text(accumulated) every ~1.5s - → final edit_text with complete response - → StateStore.set((user_id, thread_id), new state) -``` - -**`platform/interface.py` change:** -```python -class PlatformClient(Protocol): - async def send_message( - self, - context_id: str, - text: str, - on_chunk: Callable[[str], Awaitable[None]] | None = None, - ) -> str: ... -``` - -`on_chunk` is optional. If the platform does not support streaming (mock), it is ignored and the full response is returned at once. The adapter shows "..." while waiting. - ---- - -## Error Handling - -**Topic deleted by user** -- Sending to topic raises `BadRequest: message thread not found` -- Response: set `archived_at = now()` in DB, stop writing to that topic -- Prevention: on `/start`, call `send_chat_action("typing")` for all non-archived topics; treat error as deleted → set `archived_at` - -**Platform unavailable** -- Real SDK may raise connection/timeout errors -- Response: edit placeholder → "Сервис временно недоступен, попробуй позже" -- Do not archive the topic, do not change state - -**Threaded Mode not enabled** -- `create_forum_topic` raises `BadRequest` if bot doesn't have Threaded Mode on -- Response: `/start` replies with instruction to enable the mode in @BotFather -- Only case where the bot explains a configuration problem - -**General rule:** errors are caught at the handler level, logged, and surfaced to the user as a message. The placeholder never stays as "...". - ---- - -## Testing - -**Unit — `converter.py`** -- `Message(thread_id=123)` → `IncomingMessage(context_id="123")` -- `Message(thread_id=None)` (General) → `None` (ignored) - -**Unit — `db.py`** -- Topic creation, archiving, renaming -- `ROW_NUMBER()` display number computation -- Existing `tests/adapter/test_forum_db.py` covers this - -**Integration — handlers (mocked bot)** -- `/start` creates topic and hides General (`bot.create_forum_topic` mocked) -- `forum_topic_closed` → `archived_at` set -- `forum_topic_edited` → `chat_name` updated -- Message in General → `EventDispatcher` not called - -**Out of scope for now:** -- Streaming end-to-end with real Telegram -- Stale topic recovery on `/start` (requires live bot) - ---- - -## Decisions Log - -| Question | Decision | Rationale | -|----------|----------|-----------| -| Closed topic via UI | Auto-archive | Closing = intent to finish; keeping state in sync | -| Renamed topic via UI | Sync to DB | Respect user intent; `/rename` is symmetric | -| Commands | `/new`, `/archive`, `/rename`, `/settings` | UI and commands are parallel paths | -| DB context key | `(user_id, thread_id)` | `thread_id` is the real identifier in this model | -| FSM | `core/store.py` only | Avoids duplicating state logic; platform-agnostic | -| workspace mapping | Platform responsibility | Adapter passes `thread_id` as `context_id`; platform resolves | -| Streaming | In design via `on_chunk` | Proven pattern from supervisor's examples; `on_chunk` is optional | -| Branch strategy | Cherry-pick (C) | New branch from `main`; carry over keyboards + converter base only | diff --git a/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md b/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md deleted file mode 100644 index 581eb56..0000000 --- a/docs/superpowers/specs/2026-04-08-matrix-direct-agent-prototype-design.md +++ /dev/null @@ -1,243 +0,0 @@ -# Matrix Direct-Agent Prototype Design - -## Goal - -Ship a working Matrix prototype that talks to the real Lambda agent instead of `MockPlatformClient`, while preserving the current Matrix adapter logic and keeping the code expandable toward future platform versions. - -## Scope - -This design is for a Matrix-only prototype delivered from this repository on a dedicated branch. It is not a `main` branch rollout and it is not a separate prototype repo. - -The design assumes a minimal companion fork of `platform/agent` may be used, but changes there must stay as small as possible. - -## Constraints - -- Preserve the current Matrix transport logic as much as possible. -- Keep `core/` unaware of platform immaturity. -- Avoid broad changes to platform repos. -- Prefer one narrow patch to `platform/agent` over changes to both `platform/agent` and `platform/agent_api`. -- Keep the backend boundary reusable for future Telegram or other surfaces. -- Do not pretend unsupported platform capabilities are real. - -## Live Platform Findings - -Based on the live repo analysis performed on April 7, 2026: - -- `platform/master` is not yet a usable consumer-facing backend for surfaces. -- `platform/agent` exposes a working WebSocket endpoint for prompt/response exchange. -- `platform/agent_api` documents and implements text-oriented WebSocket messaging, but the bot does not need to depend on that package directly. -- `platform/agent` currently hardcodes a single shared backend memory thread via `thread_id = "default"`, which would cause all chats to share context. - -## Architecture - -The prototype remains in this repo and introduces a new real backend path behind the existing SDK boundary. - -### New files - -- `sdk/real.py` - - Exports `RealPlatformClient` - - Implements the existing `PlatformClient` contract from `sdk/interface.py` - - Composes the lower-level prototype pieces - -- `sdk/agent_session.py` - - Owns direct WebSocket communication with the real agent - - Manages connection lifecycle, request/response handling, and thread identity - -- `sdk/prototype_state.py` - - Owns local prototype-only state - - Stores user mapping, local settings, and lightweight metadata needed until a real control plane exists - -### Responsibility split - -- Matrix adapter remains transport-specific only. -- `core/` continues to depend only on `PlatformClient`. -- `RealPlatformClient` acts as the anti-corruption layer between the current bot contract and the platform’s incomplete shape. -- Local control-plane behavior remains explicit and replaceable later. - -## Message and Identity Model - -Each Matrix chat gets a stable backend session identity. - -### Surface identity - -- Surface: `matrix` -- Surface user id: Matrix MXID, for example `@alice:example.org` -- Surface chat id: logical chat id from `ChatManager`, for example `C1` -- Surface ref: Matrix room id - -### Backend thread identity - -Use a deterministic thread key: - -`matrix:{matrix_user_id}:{chat_id}` - -Example: - -`matrix:@alice:example.org:C1` - -### Mapping rules - -- One Matrix logical chat maps to one backend memory thread. -- `!new` creates a fresh logical chat and therefore a fresh backend thread. -- `!rename` only changes display metadata and does not change backend identity. -- `!archive` stops active use of the thread in the surface, but does not need to delete backend memory in v1. - -## Runtime Flow - -### Normal message flow - -1. Matrix event arrives in an existing room. -2. Existing Matrix routing resolves room to logical `chat_id`. -3. `core/handlers/message.py` calls `platform.send_message(...)`. -4. `RealPlatformClient` derives the backend thread key from `(platform, user_id, chat_id)`. -5. `AgentSessionClient` sends the prompt to the agent WebSocket using that thread key. -6. The reply is converted into the existing `MessageResponse` or `MessageChunk` contract. -7. Matrix sends the final text back to the room. - -### Settings flow - -For v1, settings remain local: - -- `get_settings()` reads from local prototype state -- `update_settings()` writes to local prototype state - -This is intentional. The prototype must not claim settings are backed by the real platform when no such platform API exists yet. - -## Feature Matrix - -### Real in v1 - -- `!start` -- Plain text messaging with the real agent -- Matrix chat lifecycle already implemented in this repo: - - `!new` - - `!chats` - - `!rename` - - `!archive` -- Per-chat conversation memory, provided the agent accepts dynamic thread identity - -### Local in v1 - -- `!settings` -- `!skills` -- `!soul` -- `!safety` -- `!status` -- user registration and local user mapping - -### Deferred - -- Attachments and file upload to the agent -- Voice input to the agent -- Image input to the agent -- Long-running task callbacks and webhook-style async completion -- Real control-plane integration through `platform/master` - -## Minimal Upstream Change - -To avoid shared memory across all conversations, make one narrow change in the forked `platform/agent` repo: - -- stop hardcoding `thread_id = "default"` -- derive thread identity from WebSocket connection context - -### Preferred mechanism - -Read `thread_id` from WebSocket query parameters rather than changing the message payload format. - -Example: - -`ws://host:port/agent_ws/?thread_id=matrix:@alice:example.org:C1` - -This is preferred because: - -- it limits the platform patch to one repo -- it avoids changing both server and SDK protocol shape -- it keeps the client message body text-only -- it makes session identity explicit and easy to reason about - -## Why Not Use `platform/agent_api` Directly - -The bot should not depend on their client package for the prototype. - -Reasons: - -- the bot already has its own internal integration boundary in `sdk/interface.py` -- a tiny local WebSocket client is enough for this protocol -- avoiding a dependency on `platform/agent_api` keeps rebasing simpler -- if upstream stabilizes later, the bot can adopt their SDK without affecting Matrix handlers - -## Repo Strategy - -### This repo - -Owns: - -- Matrix surface logic -- SDK compatibility layer -- local prototype state -- backend selection and wiring - -### Forked `platform/agent` - -Owns only: - -- minimal thread identity patch required for per-chat memory - -### Explicitly not doing - -- no separate prototype repo -- no changes to `platform/master` for v1 -- no unnecessary changes to `platform/agent_api` - -## Migration Path - -This design is intentionally expandable. - -When the platform develops further: - -- `sdk/prototype_state.py` can be replaced or reduced by a real `MasterClient` -- `sdk/agent_session.py` can remain the direct session transport if still relevant -- `RealPlatformClient` can continue to present the stable bot-facing interface -- Telegram or another surface can reuse the same backend components without rethinking the integration model - -## Risks - -### Risk: hidden platform assumptions leak upward - -Mitigation: -- keep all direct-agent logic below `RealPlatformClient` -- avoid changing `core/` contracts for prototype convenience - -### Risk: settings semantics drift from future platform reality - -Mitigation: -- make local settings behavior explicit in code and docs -- keep settings isolated in `sdk/prototype_state.py` - -### Risk: upstream `agent` fork diverges - -Mitigation: -- keep the patch minimal and narrowly scoped to thread identity - -### Risk: thread identity source is unstable - -Mitigation: -- derive thread key from existing stable bot-side identities: platform, surface user id, logical chat id - -## Testing Strategy - -- Unit tests for `sdk/agent_session.py` request/response behavior -- Unit tests for `sdk/prototype_state.py` local settings and user mapping -- Unit tests for `sdk/real.py` contract compliance with `PlatformClient` -- Matrix integration tests confirming: - - existing commands still work - - different logical chats map to different backend thread keys - - rename does not change thread identity - - archive stops reuse from the surface perspective - -## Success Criteria - -- Matrix can talk to the real agent without rewriting the Matrix adapter architecture -- Chats do not share backend memory accidentally -- Unsupported platform capabilities remain local or deferred rather than being faked as “real” -- The backend boundary remains suitable for later Telegram or other surfaces diff --git a/docs/superpowers/specs/2026-04-19-matrix-per-chat-context-design.md b/docs/superpowers/specs/2026-04-19-matrix-per-chat-context-design.md deleted file mode 100644 index 9807bd6..0000000 --- a/docs/superpowers/specs/2026-04-19-matrix-per-chat-context-design.md +++ /dev/null @@ -1,278 +0,0 @@ -# Matrix Per-Chat Context Design - -## Goal - -Move the Matrix surface from the current shared-agent-context MVP to true per-chat agent contexts using the new platform `chat_id`, while preserving the existing Matrix UX model built around rooms, spaces, and local chat labels such as `C1`, `C2`, and `C3`. - -## Core Decision - -The Matrix surface remains the owner of user-facing chat organization. - -- Matrix rooms, spaces, chat names, and archive state remain surface concerns. -- The platform agent becomes the owner of actual conversation context. -- The integration layer stores an explicit mapping from each surface chat to one platform context. - -This is the selected "Variant A" architecture: - -`surface_chat -> platform_chat_id` - -## Why This Decision - -The current Matrix adapter already has a stable UX model: - -- a user has a space -- each working room has a local chat id like `C1` -- commands such as `!new`, `!chats`, `!rename`, and `!archive` operate on that model - -Replacing that entire model with platform-native chat ids would be a broader refactor than needed. The surface and the platform solve different problems: - -- the surface organizes rooms and commands for users -- the platform persists and branches real conversation context - -Keeping those responsibilities separate lets us add true per-chat context now without rebuilding the Matrix adapter around a new identity model. - -## Scope - -This design covers: - -- true per-chat context for Matrix rooms -- a new `!branch` command -- real context-aware semantics for `!new`, `!context`, `!save`, and `!load` -- lazy migration of legacy Matrix rooms created before platform `chat_id` support - -This design does not cover: - -- end-to-end Matrix encryption support -- Telegram changes -- platform UI for browsing contexts -- a future unified cross-surface chat browser - -## Data Model - -### Surface chat identity - -The Matrix surface keeps its existing identifiers: - -- Matrix room id, for example `!room:example.org` -- local chat id, for example `C2` -- room name -- archive status -- owning space id - -These remain the source of truth for Matrix UX. - -### Platform context identity - -Each working Matrix room gets a `platform_chat_id` stored in its room metadata. - -Example `room_meta` shape: - -```json -{ - "chat_id": "C2", - "space_id": "!space:example.org", - "name": "Research", - "platform_chat_id": "chat_8f2c..." -} -``` - -Rules: - -- one working Matrix room maps to exactly one current platform context -- two Matrix rooms must not share the same `platform_chat_id` unless we intentionally implement that later -- branching creates a new `platform_chat_id`, never reuses the old one - -## Runtime Semantics - -### Normal message flow - -1. A Matrix message arrives in a working room. -2. The Matrix adapter resolves the room to local `room_meta`. -3. The integration layer reads `platform_chat_id` from that metadata. -4. `RealPlatformClient.send_message(...)` sends the message to the platform using that `platform_chat_id`. -5. The platform appends the exchange to that specific context and returns the reply. -6. The Matrix adapter sends the reply back to the room. - -The key change is that the agent no longer treats all Matrix rooms as one shared context. - -### `!new` - -`!new` creates a new user-facing chat and a new empty platform context at the same time. - -Flow: - -1. Create a new Matrix room in the user space. -2. Ask the platform to create a new blank context and return its `platform_chat_id`. -3. Store that `platform_chat_id` in the new room metadata. -4. Invite the user into the room. - -Result: - -- the new room is immediately independent -- sending the first message does not share memory with the previous room - -### `!branch` - -`!branch` creates a new room whose starting point is a snapshot of the current room context. - -Flow: - -1. Resolve the current room's `platform_chat_id`. -2. Ask the platform to create a new context branched from that source. -3. Create a new Matrix room. -4. Store the new `platform_chat_id` in the new room metadata. -5. Invite the user into the new room. - -Result: - -- the new room starts with the current history and state -- later messages diverge independently - -### `!save` - -`!save [name]` saves a snapshot of the current room's platform context under the current user. - -Semantics: - -- saves are owned by the user, not by the room -- the saved snapshot originates from the current `platform_chat_id` - -### `!load` - -`!load` shows the user's saved contexts and loads the chosen snapshot into the current room's platform context. - -Semantics: - -- a saved context created in one room can be loaded into any other room owned by the same user -- loading does not replace the Matrix room identity -- loading affects only the current room's mapped `platform_chat_id` - -### `!context` - -`!context` reports the state of the current room context, not a global user session. - -Minimum expected output: - -- current room name or local chat id -- current `platform_chat_id` presence or status -- what saved context, if any, was last loaded here -- last token usage if the platform still returns it - -## Legacy Room Migration - -Existing Matrix rooms were created before real platform `chat_id` support and therefore may not have `platform_chat_id` in their metadata. - -We need a non-destructive migration. - -### Lazy migration strategy - -For a room without `platform_chat_id`: - -1. On the first operation that requires platform context, detect the missing mapping. -2. Create a new blank platform context for that room. -3. Persist the new `platform_chat_id` into room metadata. -4. Continue the requested operation normally. - -This applies to: - -- first normal message -- `!context` -- `!save` -- `!load` -- `!branch` - -This avoids forcing users to recreate their rooms manually. - -## Interface Changes - -### Matrix metadata - -Extend Matrix `room_meta` helpers to read and write `platform_chat_id`. - -### Real platform client - -`RealPlatformClient` must stop treating the current `chat_id` parameter as a purely local label when talking to the platform. The surface-facing call can still receive the local `chat_id`, but platform calls must use the resolved `platform_chat_id`. - -Recommended integration direction: - -- Matrix resolves the room mapping before calling the platform -- `RealPlatformClient` receives the platform context id it should use - -This keeps the platform client simple and avoids giving it Matrix-specific storage responsibilities. - -### Agent API wrapper - -The wrapper must support platform calls that are explicitly context-aware: - -- create new context -- branch context -- send message into a specific context -- save current context -- load saved context into a specific context - -If upstream naming differs, the adapter layer should normalize those operations into stable local methods. - -## Command Semantics in MVP - -The MVP command set should evolve to this: - -- `!new` creates a new room with a new empty platform context -- `!branch` creates a new room with a branched platform context -- `!context` reports the current room context -- `!save` saves the current room context for the user -- `!load` loads one of the user's saved contexts into the current room - -Commands that do not have reliable backend support should remain hidden or explicitly marked unavailable. - -## Error Handling - -### Missing mapping - -If `platform_chat_id` is missing: - -- try lazy migration first -- only return an error if migration fails - -### Platform create or branch failure - -If the platform cannot create or branch a context: - -- do not create partially-initialized room metadata -- return a user-facing error in the source room -- log enough detail to diagnose the backend failure - -### Save and load failure - -The surface must not claim success before the platform confirms success. - -For MVP quality: - -- user-facing text should say "request sent" only when confirmation is not available -- once platform confirmation exists, switch to real success or failure messages - -## Testing - -Add or update tests for: - -- a new room gets a new `platform_chat_id` -- two rooms created with `!new` do not share context ids -- `!branch` creates a new room with a different `platform_chat_id` derived from the current one -- sending messages from two rooms uses different platform context ids -- saved contexts remain user-visible across rooms -- loading the same saved context into two different rooms affects those rooms independently afterward -- a legacy room without `platform_chat_id` lazily receives one on first use -- failures during create, branch, save, and load do not leave broken metadata behind - -## Migration Path - -This design preserves a clean future direction: - -- Matrix continues to own its UX model -- Telegram can adopt the same `surface_chat -> platform_chat_id` mapping later -- when the platform matures further, more of the save/load/branch logic can move from prompts or local workarounds into real platform APIs - -The key long-term boundary stays stable: - -- surfaces own presentation and routing -- the platform owns context -- the integration layer owns the mapping diff --git a/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md b/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md deleted file mode 100644 index feca84c..0000000 --- a/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md +++ /dev/null @@ -1,252 +0,0 @@ -# Matrix Shared Workspace File Flow Design - -## Goal - -Bring the Matrix surface and `platform-agent` to a single file-handling model that matches the current platform runtime contract as closely as possible. - -The result should be: - -- Matrix receives user files and makes them visible to the agent through a shared `/workspace` -- `platform-agent` receives attachment paths, not ad hoc summaries or inline payloads -- the agent can send files back to the user through the surface via `send_file` -- local development and the default deployment path use the same storage contract - -## Core Decision - -The selected architecture is: - -`Matrix surface <-> shared /workspace <-> platform-agent` - -This means: - -- the Matrix bot is responsible for downloading incoming Matrix media -- downloaded files are written into the same filesystem mounted into `platform-agent` -- the surface passes relative workspace paths to the agent as `attachments` -- the agent returns files to the user by emitting `MsgEventSendFile(path=...)` - -This is the current platform-native direction and does not require new platform endpoints. - -## Why This Decision - -The current upstream platform changes already define the file contract: - -- `MsgUserMessage.attachments` is `list[str]` -- each attachment is a path relative to `/workspace` -- the agent validates those paths against its configured backend root -- the agent can emit `send_file(path)` back to the client - -That is not an upload API and not a remote blob contract. It is an explicit shared-workspace contract. - -Trying to preserve the current separate-process launch model would force the surface to fake production behavior with inline text extraction, out-of-band path rewriting, or a future upload API that does not exist yet. That would increase the gap between our runtime and the platform runtime instead of reducing it. - -## Scope - -This design covers: - -- shared workspace runtime for Matrix bot and `platform-agent` -- incoming Matrix file handling into shared storage -- attachment path propagation to `RealPlatformClient` and `AgentApi` -- outbound file delivery from agent to Matrix user -- local compose/dev workflow and README updates - -This design does not cover: - -- Telegram file flow -- encrypted Matrix media handling -- upload APIs on the platform side -- OCR, PDF parsing, or content extraction pipelines -- long-term object storage or file lifecycle policies beyond basic cleanup boundaries - -## Runtime Contract - -### Shared filesystem - -Both containers must mount the same directory at `/workspace`. - -Requirements: - -- the Matrix bot can create files under `/workspace` -- `platform-agent` sees the same files at the same relative paths -- agent-originated files written under `/workspace` are readable by the Matrix bot - -The contract is path-based, not URL-based. - -### Attachment path format - -The surface sends attachments to the agent as relative workspace paths, for example: - -- `surfaces/matrix///inbox/20260420-153000-report.pdf` -- `surfaces/matrix///inbox/20260420-153200-photo.jpg` - -Rules: - -- paths must be relative to `/workspace` -- paths must be normalized before sending to the agent -- surface-owned uploads must live under a dedicated namespace to avoid collisions with agent-created files - -## Data Flow - -### Incoming file from Matrix user - -1. Matrix receives `m.file`, `m.image`, `m.audio`, or `m.video`. -2. The Matrix bot resolves the target room and platform chat context as usual. -3. The Matrix bot downloads the media from Matrix. -4. The file is stored under `/workspace/surfaces/matrix/.../inbox/...`. -5. The outgoing platform call includes: - - original user text - - `attachments=[relative_path_1, ...]` -6. `platform-agent` validates that those files exist and exposes them to the agent through the upstream attachment mechanism. - -Important detail: - -- the surface should not rewrite the user message into a synthetic file summary unless the message body is empty -- when body is empty, the surface may send a minimal synthetic text such as `User sent one or more attachments.` - -### Outbound file from agent to Matrix user - -1. The agent uses `send_file(path)`. -2. `platform-agent` emits `MsgEventSendFile(path=...)`. -3. The Matrix integration catches that event. -4. The Matrix bot resolves the file inside shared `/workspace`. -5. The Matrix bot uploads the file to Matrix and sends the appropriate media message to the room. - -Surface behavior: - -- if MIME type and extension are known, send the closest native Matrix media type -- otherwise send as `m.file` -- user-visible failures must be explicit if the referenced file does not exist or cannot be uploaded - -## Filesystem Layout - -The Matrix surface owns a dedicated subtree: - -```text -/workspace/ - surfaces/ - matrix/ - / - / - inbox/ - 20260420-153000-report.pdf -``` - -Design constraints: - -- sanitize user ids and room ids before using them as path components -- preserve the original filename in the final basename where possible -- prefix filenames with a timestamp or unique id to avoid collisions - -This layout is intentionally surface-scoped. The agent may read these files, but the surface remains the owner of how inbound messenger files are organized. - -## Components - -### Matrix attachment storage helper - -Add a focused helper module responsible for: - -- building stable workspace-relative paths -- sanitizing path components -- downloading Matrix media into `/workspace` -- returning attachment metadata needed by the platform layer - -This helper should not know about agent transport details beyond the final relative path output. - -### Real platform client - -`RealPlatformClient` must pass attachment relative paths through to `AgentApi.send_message(...)`. - -It must also surface non-text agent events needed by the Matrix adapter, especially `MsgEventSendFile`. - -### Agent API wrapper - -`AgentApiWrapper` must be compatible with the modern upstream protocol: - -- `/v1/agent_ws/{chat_id}/` -- `attachments` on outgoing user messages -- `MsgEventToolCallChunk` -- `MsgEventToolResult` -- `MsgEventCustomUpdate` -- `MsgEventSendFile` -- `MsgEventEnd` - -### Matrix bot outbound renderer - -The Matrix adapter must support sending files back to the room. - -At minimum it needs: - -- path resolution inside shared workspace -- Matrix upload of the local file -- send of an `m.file` or native media event with filename and MIME type - -## Deployment Changes - -### Compose - -The repository root `docker-compose.yml` becomes the primary prod-like local runtime. - -It should define at least: - -- `matrix-bot` -- `platform-agent` -- one shared volume mounted as `/workspace` into both services - -The default developer workflow should stop describing `platform-agent` as a separately started side process. - -### Environment - -The Matrix bot must connect to the in-compose `platform-agent` service by service name, not by assuming a separately launched localhost process. - -The agent WebSocket configuration in docs and examples must match the modern upstream route. - -## Error Handling - -### Incoming files - -If the Matrix bot cannot download or persist the file: - -- do not send a broken attachment path to the agent -- return a user-visible error in the room -- log the Matrix event id, room id, and failure reason - -### Outbound files - -If the agent asks to send a missing file: - -- log a structured warning with the requested path -- send a user-visible message that the file could not be delivered - -### Shared workspace mismatch - -If the runtime is misconfigured and `/workspace` is not actually shared: - -- inbound attachments will fail agent-side path validation -- outbound `send_file` will fail surface-side file resolution - -The implementation should make such failures obvious in logs rather than silently degrading to text-only behavior. - -## Testing - -The implementation must cover: - -- Matrix media download writes into the expected workspace-relative path -- `RealPlatformClient` forwards attachment relative paths to the agent API -- Matrix plain messages with attachments preserve the original text while adding attachment paths -- empty-body attachment-only messages produce the synthetic text fallback -- `AgentApiWrapper` accepts `MsgEventSendFile` without treating it as unknown -- Matrix outbound file handling converts `MsgEventSendFile` into a Matrix upload/send call -- compose configuration mounts the same workspace into both containers - -## Non-Goals - -- no inline text extraction MVP -- no temporary URL-passing contract to the agent -- no fake “prod” mode with separate local filesystems -- no platform API additions in this phase - -## Success Criteria - -- the default local runtime uses a shared `/workspace` -- a user can send a file in Matrix and the agent receives it through upstream `attachments` -- the agent can emit `send_file(path)` and the Matrix user receives the file in the same room -- our runtime behavior matches the current platform contract closely enough that moving from local compose to production does not require redesigning file flow diff --git a/docs/superpowers/specs/2026-04-20-matrix-staged-attachments-design.md b/docs/superpowers/specs/2026-04-20-matrix-staged-attachments-design.md deleted file mode 100644 index ae8a11a..0000000 --- a/docs/superpowers/specs/2026-04-20-matrix-staged-attachments-design.md +++ /dev/null @@ -1,262 +0,0 @@ -# Matrix Staged Attachments Design - -## Goal - -Make file sending in the Matrix surface usable for an AI agent despite current Matrix client behavior, especially in Element where media is often sent immediately as separate events without a shared text composer. - -The result should be: - -- files can arrive before the user writes the actual instruction -- the surface stages those files instead of immediately sending them to the agent -- the next normal user message in the same chat commits all staged files as one agent turn -- the user can inspect and remove staged files with short chat commands - -## Core Decision - -The selected UX model is: - -`incoming Matrix media -> staged attachments for (chat_id, user_id) -> next normal message commits them` - -This means: - -- attachment-only events do not immediately invoke the agent -- the bot acknowledges staged files with a service message -- the next normal user message sends text plus all currently staged files to the agent -- staged files are then cleared - -## Why This Decision - -Matrix natively models messages as separate events, and common clients do not provide a reliable "one text message with many attachments" composer flow. - -In practice this causes two UX failures for an AI bot: - -- users may send files first and only then write the task -- users may send multiple files as multiple independent Matrix events - -If the surface treats each incoming file as a full agent turn, the bot becomes noisy and context-fragmented. If it ignores file-only messages, file handling feels broken. - -Staging is the smallest surface-side abstraction that fixes both problems without fighting the Matrix event model. - -## Scope - -This design covers: - -- staging inbound Matrix attachments before agent submission -- per-chat attachment state for a specific user -- user-facing service messages for staged attachments -- short commands for listing and removing staged files -- commit behavior on the next normal message - -This design does not cover: - -- edits or redactions of original Matrix media events as attachment controls -- cross-surface shared staging -- thread-aware staging beyond the existing `chat_id` boundary -- changes to the platform attachment contract - -## State Model - -### Staging key - -Staged attachments are isolated by: - -- `chat_id` -- `user_id` - -This means: - -- files staged by a user in one chat never appear in another chat -- files staged by one user do not mix with another user's files in the same room - -### Staged attachment record - -Each staged attachment must track at least: - -- stable internal id -- display filename -- workspace-relative path -- MIME type if known -- created timestamp - -User-visible commands operate on the current ordered list, not on internal ids. - -### Lifecycle - -A staged attachment is in exactly one of these states: - -1. `staged` -2. `committed` -3. `removed` - -Rules: - -- only `staged` attachments appear in `!list` -- `committed` attachments are no longer user-removable -- `removed` attachments are excluded from future commits - -## Inbound Behavior - -### Attachment-only event - -If the Matrix surface receives one or more file/media events from a user without a normal text message to commit them: - -1. download each file into shared `/workspace` -2. add each file to the staged set for `(chat_id, user_id)` -3. do not call the agent yet -4. send a service acknowledgment message - -### Service acknowledgment - -The service message must communicate: - -- the current staged attachment list with indices -- that the next normal message will be sent to the agent together with those files -- available commands: `!list`, `!remove `, `!remove all` - -Example shape: - -```text -Staged attachments: -1. screenshot.png -2. invoice.pdf - -Your next message will be sent to the agent with these files. -Commands: !list, !remove , !remove all -``` - -### Burst handling - -Matrix clients may send multiple files as separate consecutive events. - -To avoid bot spam, service acknowledgments should be debounced over a short window and aggregated into one reply where feasible. - -The acknowledgment must reflect the full current staged set, not only the most recently received file. - -## Commit Behavior - -### Commit trigger - -The commit trigger is: - -- the next normal user message in the same `(chat_id, user_id)` scope - -Normal user message means: - -- not a staging control command -- not a pure attachment event being staged - -### Commit action - -When a commit-triggering message arrives: - -1. collect all currently staged attachments for `(chat_id, user_id)` -2. send the user text plus those attachments to the agent as one turn -3. mark all included staged attachments as `committed` -4. clear the staged set - -After commit: - -- the just-sent attachments must no longer appear in `!list` -- a later file upload starts a new staged set - -## Commands - -### `!list` - -Shows the current staged attachment list for the user in the current chat. - -If the list is empty, the response should be short and explicit. - -### `!remove ` - -Removes the staged attachment at the current 1-based index. - -Behavior: - -- if the index is valid, remove that staged attachment and return the updated staged list -- if the index is invalid, return a short error without repeating the list - -### `!remove all` - -Clears the entire staged set for the user in the current chat. - -The response should be short and explicit. - -## Ordering Rules - -The staged list is ordered by staging time. - -User-facing indices: - -- are 1-based -- are recalculated from the current staged set -- may change after removals - -Therefore: - -- `!list` always shows the current authoritative numbering -- after a successful `!remove `, the bot should reply with the refreshed list - -## Error Handling - -### Download failure - -If a file cannot be downloaded or stored: - -- do not add it to the staged set -- do not pretend it will be sent later -- send a short user-visible failure message - -### Invalid command - -If the command is malformed or uses an invalid index: - -- return a short error -- do not commit staged attachments -- do not clear the staged set - -### Agent submission failure - -If commit fails when sending the text plus staged files to the agent: - -- staged attachments must remain available for retry unless the failure is known to be irreversible -- the user-visible error should make it clear that the files were not consumed - -This prevents silent loss of staged context. - -## Interaction with Shared Workspace Design - -This design assumes the shared-workspace contract defined in -[2026-04-20-matrix-shared-workspace-file-flow-design.md](/Users/a/MAI/sem2/lambda/surfaces-bot/docs/superpowers/specs/2026-04-20-matrix-shared-workspace-file-flow-design.md). - -Specifically: - -- staged files are stored in shared `/workspace` -- the final commit still passes workspace-relative paths to `platform-agent` -- staging changes only when the surface chooses to invoke the agent, not how attachments are represented - -## Testing - -The implementation must cover: - -- file-only Matrix events are staged and do not immediately invoke the agent -- service acknowledgment includes staged filenames and command hints -- `!list` returns the current staged set for the correct `(chat_id, user_id)` -- `!remove ` removes the correct staged attachment and refreshes numbering -- `!remove all` clears the staged set -- invalid `!remove ` returns a short error and keeps state unchanged -- the next normal message commits all staged attachments with the text as one agent turn -- committed attachments disappear from staging after success -- failed commits preserve staged attachments -- staging in one chat does not leak into another chat -- staging for one user does not leak to another user in the same room - -## Non-Goals - -This design intentionally does not attempt to: - -- emulate Telegram-style albums in Matrix -- rely on special support from Element or other Matrix clients -- introduce a rich interactive attachment management UI - -The goal is a reliable chat-native workflow that works within Matrix's actual event model. diff --git a/docs/superpowers/specs/2026-04-22-transport-layer-thin-adapter-design.md b/docs/superpowers/specs/2026-04-22-transport-layer-thin-adapter-design.md deleted file mode 100644 index 5fab5ef..0000000 --- a/docs/superpowers/specs/2026-04-22-transport-layer-thin-adapter-design.md +++ /dev/null @@ -1,318 +0,0 @@ -# Transport Layer Thin Adapter Design - -## Цель - -Упростить transport layer между Matrix surface и `platform-agent` до максимально production-like вида: - -- использовать upstream `platform-agent_api.AgentApi` почти как есть -- убрать из surface-side клиента собственную интерпретацию stream semantics -- оставить в нашем коде только integration concerns: - - per-chat lifecycle - - per-chat serialization - - attachment path forwarding - - exception mapping в `PlatformError` - -Это нужно, чтобы: - -- восстановить чёткую границу ответственности между `surfaces` и платформой -- убрать из диагностики ложные факторы, внесённые нашей кастомной обёрткой -- получить честную картину реальных platform bugs до добавления любых policy-надстроек - -## Контекст - -Сейчас transport path состоит из: - -- [sdk/agent_api_wrapper.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_api_wrapper.py) -- [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py) - -Изначально `AgentApiWrapper` был создан по разумным причинам: - -- поддержка переходного периода между разными версиями `platform-agent_api` -- унификация `base_url/url` -- создание per-chat client instances через `for_chat()` -- локальный учёт `tokens_used` - -Позже в этот слой были добавлены уже не compatibility-функции, а собственные transport semantics: - -- custom `_listen()` -- custom `send_message()` -- post-END drain window -- custom idle timeout -- event-kind reclassification - -После этого `surfaces` перестал быть тонким клиентом платформы и начал вести себя как альтернативная реализация transport layer. Это делает диагностику platform bugs нечистой. - -## Принципы дизайна - -### 1. Transport должен быть скучным - -Transport layer не должен: - -- спасать поздние chunks -- лечить duplicate `END` -- придумывать собственные правила границы ответа -- по-своему классифицировать stream events сверх upstream client behavior - -Если upstream stream protocol повреждён, мы должны видеть это как platform issue, а не скрывать его кастомной очередью. - -### 2. Policy и transport разделяются - -Transport: - -- говорит с upstream API -- доставляет события -- закрывает соединение - -Policy: - -- решает, что считать recoverable failure -- нужна ли повторная попытка -- как сообщать ошибку пользователю -- нужно ли сбрасывать chat session - -На первом этапе policy не расширяется. Мы сначала приводим transport к тонкому адаптеру, потом заново оцениваем реальные проблемы. - -### 3. Session lifecycle остаётся на нашей стороне - -Даже в thin-adapter модели `surfaces` по-прежнему отвечает за: - -- кеширование client per chat -- один send lock на chat -- сброс мёртвой chat session после failure -- mapping upstream exceptions в `PlatformError` - -Это не transport semantics, а integration lifecycle. - -## Варианты - -### Вариант A. Оставить текущий кастомный wrapper - -Плюсы: - -- уже работает на части сценариев -- содержит built-in mitigations против observed failures - -Минусы: - -- нарушает границу ответственности -- усложняет диагностику -- делает platform bug reports спорными -- содержит symptom-fix логику в transport layer - -Вердикт: не подходит как production-like target. - -### Вариант B. Thin upstream adapter - -Плюсы: - -- чистая архитектура -- честная диагностика upstream проблем -- минимальная собственная магия - -Минусы: - -- локальные mitigations исчезают -- если upstream client несовершенен, это сразу проявится - -Вердикт: правильный первый этап. - -### Вариант C. Thin adapter сейчас, outer policy layer потом - -Плюсы: - -- даёт production-like эволюцию -- не смешивает transport и resilience policy -- позволяет сначала увидеть реальные проблемы, потом адресовать только нужные - -Минусы: - -- требует двух фаз вместо одной - -Вердикт: рекомендуемый путь. - -## Рекомендуемая архитектура - -### Слой 1. Upstream client - -Источник истины: - -- [external/platform-agent_api/lambda_agent_api/agent_api.py](/Users/a/MAI/sem2/lambda/surfaces-bot/external/platform-agent_api/lambda_agent_api/agent_api.py) - -Мы принимаем его stream semantics как authoritative behavior. - -### Слой 2. Thin adapter - -Файл: - -- [sdk/agent_api_wrapper.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_api_wrapper.py) - -После cleanup он должен содержать только: - -- создание клиента через modern constructor -- `base_url` normalization, если это действительно нужно для наших env -- `for_chat(chat_id)` как factory convenience -- опционально thin storage для `last_tokens_used`, если это можно сделать без переопределения stream semantics - -Он не должен переопределять: - -- `_listen()` -- `send_message()` -- queue lifecycle -- post-END behavior -- timeout behavior - -### Слой 3. Integration/session layer - -Файл: - -- [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py) - -Ответственность: - -- кешировать chat client instances -- сериализовать sends по chat lock -- вызывать `disconnect_chat(chat_id)` после transport failure -- превращать upstream exceptions в `PlatformError` -- форвардить `attachments` как relative workspace paths -- собирать `MessageResponse` / `MessageChunk` для остального приложения - -Этот слой не должен заниматься: - -- исправлением broken stream boundaries -- custom post-END reconstruction -- поздним дренированием очереди - -## Что удаляем - -Из [sdk/agent_api_wrapper.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/agent_api_wrapper.py): - -- custom `_listen()` -- custom `send_message()` -- `_drain_post_end_events()` -- `_event_kind()` -- `_is_kind()` -- `_is_text_event()` -- `_is_end_event()` -- `_is_send_file_event()` -- `_POST_END_DRAIN_MS` -- `_STREAM_IDLE_TIMEOUT_MS` -- debug logging, завязанное на наш собственный queue lifecycle - -## Что оставляем - -В thin adapter: - -- `__init__()` для modern `base_url/chat_id` -- `_normalize_base_url()` только если нужен стабильный env input -- `for_chat(chat_id)` - -В [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py): - -- `_get_chat_api()` -- `_get_chat_send_lock()` -- `_attachment_paths()` -- `disconnect_chat()` -- `_handle_chat_api_failure()` -- `send_message()` -- `stream_message()` - -## Дополнительное упрощение - -Если после cleanup мы считаем pinned upstream API обязательным, то из [sdk/real.py](/Users/a/MAI/sem2/lambda/surfaces-bot/sdk/real.py) можно убрать legacy-minded probing: - -- `inspect.signature(send_message)` -- conditional fallback на старый `send_message(text)` без `attachments` - -В этом случае `RealPlatformClient` всегда использует современный контракт: - -- `send_message(text, attachments=...)` - -Это ещё сильнее уменьшит ambiguity. - -## Этапы миграции - -### Этап 1. Cleanup до thin adapter - -Делаем: - -- сжимаем `sdk/agent_api_wrapper.py` до thin shim -- переносим всю допустимую resilience logic только в `sdk/real.py` -- удаляем тесты, которые закрепляют наши кастомные transport semantics - -### Этап 2. Повторная верификация - -Заново прогоняем: - -- text-only flow -- staged attachments flow -- large image failure -- duplicate `END` behavior -- behavior after transport disconnect - -На этом этапе мы честно увидим, что реально делает upstream transport. - -### Этап 3. Опциональный outer policy layer - -Только если после Этапа 2 это действительно нужно, добавляем policy поверх transport: - -- request timeout целиком -- retry policy -- circuit-breaker-like behavior - -Но это должно жить не в client wrapper, а выше, в integration layer. - -## Тестовая стратегия - -### Удаляем как нецелевые тесты - -Больше не считаем нормой: - -- post-END drain behavior -- recovery late chunks после `END` -- idle timeout внутри wrapper как часть client contract - -### Оставляем и добавляем - -Нужные guarantees: - -1. создаётся отдельный client per chat -2. один chat сериализуется через lock -3. разные чаты не делят client instance -4. attachment paths уходят в `send_message(..., attachments=...)` -5. transport failure приводит к `disconnect_chat(chat_id)` -6. следующий запрос после failure открывает новую chat session -7. upstream exception превращается в `PlatformError` - -## Риски - -### 1. Может снова проявиться реальный upstream bug - -Это не regression дизайна, а полезный результат cleanup. - -### 2. Может исчезнуть локальная защита от зависших стримов - -Это допустимо на первом этапе. -Если она действительно нужна, она должна вернуться как outer policy, а не как переписанный client transport. - -### 3. Может выясниться, что даже thin wrapper не нужен - -Если modern upstream `AgentApi` уже полностью покрывает наш use case, файл `sdk/agent_api_wrapper.py` можно будет заменить на маленький factory helper или убрать совсем. - -## Критерии успеха - -Результат считается успешным, если: - -- transport layer в `surfaces` перестаёт иметь собственную stream semantics -- platform bug reports снова можно формулировать без сильного caveat про кастомный клиент -- Matrix real backend продолжает работать на text-only и attachments scenarios -- failure handling остаётся контролируемым, но больше не маскирует transport behavior платформы - -## Решение - -Принять путь: - -- `Thin upstream adapter now` -- `Observe real behavior` -- `Add outer policy later only if needed` - -Это наиболее близкий к production best practice вариант для текущего состояния проекта. diff --git a/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md b/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md deleted file mode 100644 index 02cc89f..0000000 --- a/docs/superpowers/specs/2026-04-24-matrix-multi-agent-routing-design.md +++ /dev/null @@ -1,336 +0,0 @@ -# Matrix Multi-Agent Routing Design - -## Goal - -Move the Matrix surface from a single hardcoded upstream agent to a user-selectable multi-agent model, while preserving the existing room-based UX and the current `PlatformClient` boundary. - -The result should be: - -- one Matrix bot can work with multiple upstream agents -- users can choose an agent from the full configured list -- each chat is bound to exactly one agent -- switching the selected agent does not silently retarget an existing chat - -## Core Decision - -The selected routing model is: - -`user.selected_agent_id + room.agent_id + room.platform_chat_id` - -This means: - -- the user has one current selected agent -- each Matrix working room stores the agent it is bound to -- each Matrix working room stores its own `platform_chat_id` -- a room never changes agent implicitly -- the shared `PlatformClient` protocol remains unchanged -- Matrix multi-agent routing is implemented by a single routing facade that delegates to per-agent real clients - -## Why This Decision - -The current Matrix adapter already separates: - -- user-facing room organization -- local chat labels such as `C1`, `C2`, `C3` -- platform-facing conversation identity via `platform_chat_id` - -Adding multi-agent support should preserve that shape instead of replacing it. - -If routing depended only on the current user selection, then an old room could start talking to a different agent after a switch. That would make room history and backend context hard to reason about. Binding an agent to the room keeps the conversation model explicit. - -## Scope - -This design covers: - -- agent selection by the user inside the Matrix surface -- durable storage of the selected agent -- durable storage of the room-bound agent -- routing normal messages and context commands to the correct upstream agent -- behavior when a room becomes stale after an agent switch - -This design does not cover: - -- per-agent workspace isolation -- platform-side agent lifecycle or memory persistence -- per-user allowlists for available agents -- Telegram or other surfaces - -## Configuration Model - -### Agent registry - -Available agents are defined in a local config file loaded once at bot startup. - -Example: - -```yaml -agents: - - id: agent-1 - label: Analyst - - id: agent-2 - label: Research - - id: agent-3 - label: Ops -``` - -Rules: - -- every entry must have a stable `id` -- every entry must have a user-visible `label` -- all configured agents are selectable by all users -- config changes apply only after bot restart - -### Startup validation - -If the agent config is missing, empty, or invalid, the Matrix bot must fail fast on startup with a clear operator error. - -## Durable State Model - -### User-level state - -User metadata keeps the current selected agent. - -Example `matrix_user:*` shape: - -```json -{ - "space_id": "!space:example.org", - "next_chat_index": 4, - "selected_agent_id": "agent-2" -} -``` - -Meaning: - -- `selected_agent_id` controls future chat creation and activation of an unbound room -- `selected_agent_id` does not rewrite already bound rooms - -### Room-level state - -Room metadata stores the agent bound to that chat. - -Example `matrix_room:*` shape: - -```json -{ - "room_type": "chat", - "chat_id": "C3", - "display_name": "Чат 3", - "matrix_user_id": "@alice:example.org", - "space_id": "!space:example.org", - "platform_chat_id": "42", - "agent_id": "agent-2" -} -``` - -Rules: - -- one room binds to exactly one `agent_id` -- one room binds to exactly one current `platform_chat_id` -- once a room becomes stale after an agent switch, it never becomes active again - -## Runtime Semantics - -### `!start` - -`!start` remains lightweight: - -- if no agent is selected, the bot explains that an agent must be selected before normal messaging -- if an agent is already selected, the bot reports the current selection and reminds the user that `!new` creates a new room under that agent - -### `!agent` - -Introduce an agent-selection command. - -Behavior: - -- `!agent` shows the available agent list -- agent selection stores `selected_agent_id` in user metadata -- after a successful switch, the bot tells the user that existing chats bound to another agent are stale and that `!new` is required for continued work - -The exact UI can be text-first for MVP. A richer UI can be added later without changing the state model. - -### Normal message without selected agent - -If the user has not selected an agent yet: - -- do not call the platform -- return the available agent list -- ask the user to choose one first - -This is an intentional one-time routing handshake, not an accidental fallback. -In a multi-agent deployment, the surface must not silently guess which agent an unbound user should talk to. - -### Selecting an agent inside an unbound chat - -If the current room has never been bound to any agent: - -- store the new `selected_agent_id` for the user -- bind the current room to that same `agent_id` -- allow the room to become the active working chat immediately - -This avoids forcing `!new` for the user's first usable chat. - -### `!new` - -`!new` creates a new working room under the current selected agent. - -Behavior: - -1. require `selected_agent_id` -2. create the new Matrix room -3. allocate a new `platform_chat_id` -4. store `agent_id = selected_agent_id` in the new room metadata - -### Normal message in an unbound room with selected agent - -If a room exists but has no `agent_id` yet and the user already has `selected_agent_id`: - -- bind the room to `selected_agent_id` -- ensure it has `platform_chat_id` -- continue normal message dispatch - -### Normal message in a bound room - -If the room already has `agent_id` and it matches the current selected agent: - -- route the message to that `agent_id` -- use the room's `platform_chat_id` - -### Stale room after agent switch - -If the room's bound `agent_id` differs from the user's current `selected_agent_id`: - -- do not call the platform -- treat the room as stale -- return a short message telling the user that this chat belongs to the old agent and that they must use `!new` - -### Returning to a previously selected agent - -If the user later selects an old agent again: - -- previously stale rooms do not become valid again -- the user must still create a fresh room via `!new` - -## Routing and Component Changes - -### Agent registry loader - -Add a small loader responsible for: - -- reading `agents.yaml` -- validating ids and labels -- exposing a read-only registry to runtime code - -The runtime should not parse YAML ad hoc during message handling. - -### Matrix runtime pre-check - -Before dispatching a normal message, the Matrix runtime must resolve: - -- whether the user has `selected_agent_id` -- whether the current room already has `agent_id` -- whether the room can be bound now -- whether the room is stale - -This pre-check happens before handing the message to the existing dispatcher path. - -### Routed platform client - -The selected implementation keeps the shared `PlatformClient` protocol unchanged. - -The Matrix runtime owns one routing-aware facade, for example `RoutedPlatformClient`, that implements `PlatformClient` and delegates to agent-specific real clients. - -Responsibilities: - -- resolve the current room binding from local Matrix metadata -- translate a local Matrix logical chat id into the room's `platform_chat_id` -- choose the correct per-agent delegate for the room's bound `agent_id` -- keep `get_or_create_user`, `get_settings`, and `update_settings` behavior stable for the rest of the runtime - -This keeps the multi-agent logic inside the Matrix integration boundary instead of pushing agent selection into the shared protocol. - -### Real platform bridge delegates - -The current real backend path hardcodes a single runtime-level `agent_id`. -That must be replaced with per-agent delegates hidden behind the routing facade. - -The selected design is: - -- `RealPlatformClient` remains the low-level direct-agent delegate for one configured `agent_id` -- the routing facade holds or creates one `RealPlatformClient` delegate per configured agent -- `send_message(...)` and `stream_message(...)` on the facade resolve the room target and forward the call to the matching delegate -- the delegate creates a fresh upstream `AgentApi` for its configured `agent_id` -- no long-lived `AgentApi` instances are cached by user - -This preserves the current fresh-connection-per-request behavior while avoiding a protocol break for Telegram or other surfaces. - -## Error Handling - -### Missing or invalid selected agent - -If `selected_agent_id` is absent: - -- ask the user to select an agent - -If `selected_agent_id` points to an agent that no longer exists in config: - -- treat the selection as invalid -- ask the user to select again - -### Missing room binding - -If the room has no `agent_id`: - -- bind it only when the user has a valid current selection -- otherwise return the selection prompt - -### Stale room - -If the room is stale: - -- do not attempt fallback routing -- do not silently rewrite room metadata -- instruct the user to run `!new` - -### Invalid config - -If the bot cannot load a valid agent registry: - -- fail at startup -- do not start in degraded single-agent mode - -## Testing Expectations - -Tests for this design should prove: - -- config parsing and startup validation -- selecting an agent persists `selected_agent_id` -- selecting an agent inside an unbound room activates that room -- `!new` binds the new room to the selected agent -- messages in a bound room use that room's `agent_id` -- stale rooms reject normal messaging with a clear `!new` instruction -- returning to the same agent later does not revive stale rooms - -## Migration Notes - -Existing rooms may have `platform_chat_id` but no `agent_id`. - -For this MVP, treat those rooms as legacy-unbound rooms: - -- if the user has a valid selected agent, the room may be bound on first use -- if no agent is selected, the room prompts for selection first - -No automatic migration across agents is introduced. - -### Existing users without `selected_agent_id` - -Existing users upgraded from the single-agent model may have working rooms but no stored `selected_agent_id`. - -For this MVP, that is handled explicitly: - -- normal messaging is paused until the user selects an agent -- the first valid selection can bind an unbound room immediately -- the surface does not auto-assign a default agent in a multi-agent config - -This is intentional. Once more than one agent exists, silent migration would be ambiguous and could route a user to the wrong backend target. diff --git a/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md b/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md deleted file mode 100644 index 1f1cc7b..0000000 --- a/docs/superpowers/specs/2026-04-24-matrix-surface-restart-state-persistence-design.md +++ /dev/null @@ -1,258 +0,0 @@ -# Matrix Surface Restart State Persistence Design - -## Goal - -Make the Matrix surface survive a normal restart or container recreate without losing the minimal state required to keep working as a bot. - -The result should be: - -- after restart, the bot can still answer messages and execute commands -- the bot remembers the selected agent for each user -- the bot remembers which agent and `platform_chat_id` each room is bound to -- temporary UX flows may be lost without being treated as a bug - -## Core Decision - -The selected persistence model is: - -`durable surface state only` - -This means: - -- persist only the state needed for routing and normal command handling -- do not persist temporary UI and wizard state -- require persistent local storage for the surface -- do not attempt recovery if those volumes are lost - -## Why This Decision - -The Matrix surface already has two different classes of state: - -- stable local state that defines how rooms and users are routed -- temporary UX state that exists only to complete short-lived interactions - -Trying to make all temporary UX state survive restart would add complexity and edge cases without improving the core requirement: the bot should still function normally after restart. - -The chosen design keeps persistence aligned with what the surface actually owns: - -- Matrix-side metadata and routing state are durable -- agent conversation memory is the platform's responsibility -- lost local volumes are treated as environment reset, not as an auto-recovery scenario - -## Scope - -This design covers: - -- which Matrix surface data must persist across restart -- where that data lives -- how restart behavior interacts with multi-agent routing -- what state is intentionally non-durable - -This design does not cover: - -- platform-side persistence of agent memory -- workspace isolation between multiple agents -- automatic reconstruction after total local volume loss -- persistence of temporary UX flows - -## Persistence Boundary - -### Durable state - -The Matrix surface must persist: - -- `matrix_user:*` -- `matrix_room:*` -- `chat:*` -- `PLATFORM_CHAT_SEQ_KEY` -- `selected_agent_id` -- room-bound `agent_id` -- room-bound `platform_chat_id` - -This is the minimal state required so that, after restart, the surface can: - -- identify the user -- identify the room -- determine which agent should receive a message -- determine which `platform_chat_id` should be used -- continue allocating new `platform_chat_id` values without reusing an already issued sequence number - -### Non-durable state - -The Matrix surface does not need to persist: - -- staged attachments -- pending `!load` selection -- pending `!yes/!no` confirmation -- any temporary service UI step -- live `AgentApi` instances or connection objects - -After restart, those flows may be lost. The bot only needs to remain operational. - -## Storage Model - -### Surface durable storage - -The Matrix surface must use persistent storage for: - -- `lambda_matrix.db` -- `matrix_store` - -`lambda_matrix.db` stores the local key-value state used by the surface. -`matrix_store` stores Matrix client state needed by `nio`. - -These paths must be backed by persistent container storage in normal deployments. - -### Shared `/workspace` - -The current local runtime also uses `/workspace`, but workspace behavior is outside the scope of this design. - -For this document, the only requirement is: - -- do not make restart persistence depend on solving per-agent workspace isolation first - -## Restart Assumptions - -This design assumes: - -- normal restart or redeploy with persistent local volumes still present - -This design does not assume: - -- automatic recovery after deleting or losing those volumes - -If the relevant volumes are lost, the environment is treated as reset. - -## Data Model Requirements - -### User metadata - -User metadata remains the durable location for user-level routing state. - -Example: - -```json -{ - "space_id": "!space:example.org", - "next_chat_index": 4, - "selected_agent_id": "agent-2" -} -``` - -### Room metadata - -Room metadata remains the durable location for room-level routing state. - -Example: - -```json -{ - "room_type": "chat", - "chat_id": "C3", - "display_name": "Чат 3", - "matrix_user_id": "@alice:example.org", - "space_id": "!space:example.org", - "platform_chat_id": "42", - "agent_id": "agent-2" -} -``` - -### Platform chat sequence - -The global `PLATFORM_CHAT_SEQ_KEY` remains part of durable surface state. - -Its purpose is: - -- allocate monotonically increasing `platform_chat_id` values -- avoid reusing a previously issued platform chat identifier during normal restart or redeploy - -This sequence must be stored in the same durable surface store as the room and user metadata. - -## Runtime Semantics After Restart - -After restart, the Matrix surface must: - -1. load the durable Matrix store -2. load the durable surface key-value state -3. load the agent registry config -4. resume normal room routing using persisted `selected_agent_id`, `agent_id`, and `platform_chat_id` - -Expected behavior: - -- a user with a valid previously selected agent does not need to reselect it -- a room previously bound to an agent remains bound to that agent -- normal messages and commands continue to work - -### Lost temporary UX state - -If the bot restarts during a transient UX flow: - -- staged attachments may disappear -- pending `!load` selections may disappear -- pending confirmations may disappear - -This is acceptable and should not block normal operation after restart. - -## Interaction With Multi-Agent Routing - -The multi-agent design introduces new durable state that must survive restart: - -- `selected_agent_id` on the user -- `agent_id` on the room -- `PLATFORM_CHAT_SEQ_KEY` in the surface store - -Restart persistence and multi-agent routing therefore belong together. - -Without durable storage for those fields, a restart would make room routing ambiguous. - -## Failure Handling - -### Missing durable surface store - -If the durable store paths are missing because the environment was reset: - -- do not attempt to reconstruct a full working state from scratch in this design -- treat startup as a clean environment -- allow normal onboarding flows to begin again - -### Invalid durable references - -If persisted `selected_agent_id` or room `agent_id` references an agent no longer present in config: - -- do not crash -- treat the selection or room binding as invalid -- ask the user to select a valid agent again - -### Platform conversation memory - -If the upstream platform loses agent memory across restart: - -- that is outside the surface persistence boundary -- the surface must still route correctly -- platform memory persistence remains a platform responsibility - -## Testing Expectations - -Tests for this design should prove: - -- `selected_agent_id` survives restart through durable local storage -- room `agent_id` and `platform_chat_id` survive restart through durable local storage -- the bot can route messages correctly after restart without user reconfiguration -- missing temporary UX state does not break normal messaging and command handling -- invalid persisted agent references degrade into reselection prompts rather than crashes - -## Operational Notes - -For the Matrix surface to survive restart in the intended way, deployment must persist: - -- `lambda_matrix.db` -- `matrix_store` - -This is a deployment requirement, not an optional optimization. - -The design intentionally stops there. It does not require: - -- hot reload of agent config -- recovery after total local state loss -- persistence of temporary UX flows -- a solved multi-agent workspace story diff --git a/docs/surface-protocol.md b/docs/surface-protocol.md index f2bd7b1..ca66000 100644 --- a/docs/surface-protocol.md +++ b/docs/surface-protocol.md @@ -38,10 +38,9 @@ surfaces-bot/ converter.py — matrix-nio Event → IncomingEvent, OutgoingEvent → Matrix API bot.py — точка входа, клиент - sdk/ - interface.py — Protocol: PlatformClient (контракт к SDK) - real.py — RealPlatformClient (через AgentApi) - mock.py — MockPlatformClient (для локальных тестов) + platform/ + interface.py — Protocol: PlatformClient + mock.py — MockPlatformClient ``` --- @@ -141,7 +140,7 @@ class UIButton: ``` Telegram рендерит это как InlineKeyboard. -Matrix рендерит как текст (в MVP). +Matrix рендерит как текст с описанием реакций или HTML-кнопки. ### OutgoingNotification Асинхронное уведомление — агент закончил долгую задачу. @@ -210,7 +209,7 @@ class ConfirmationRequest: ``` Telegram показывает как Inline-кнопки. -Matrix показывает как запрос для `!yes` / `!no`. +Matrix показывает как реакции 👍 / ❌. Ядро не знает как именно — только получает `IncomingCallback` с `action: "confirm"`. --- @@ -305,9 +304,9 @@ class PlatformClient(Protocol): async def update_settings(self, user_id: str, action: Any) -> None: ... ``` -Бот **не управляет lifecycle контейнеров** агентов. Запуск/перезапуск агентов — ответственность платформы. -Бот передаёт `user_id` + `chat_id` + текст. +Бот **не управляет lifecycle контейнеров** — это делает Master (платформа). +Бот передаёт `user_id` + `chat_id` + текст; Master сам решает нужно ли поднять контейнер, смонтировать `C1/`/`C2/`, запустить агента. -`MockPlatformClient` реализует этот протокол для локальных тестов. -Реальный SDK используется через `RealPlatformClient` (`sdk/real.py`), который подключается к `AgentApi` по WebSocket. -Адаптеры поверхностей и ядро не меняются вообще, привязка идёт через `config/matrix-agents.yaml`. +`MockPlatformClient` реализует этот протокол сейчас. +Реальный SDK — тоже реализует этот протокол, заменяя один файл. +Адаптеры поверхностей и ядро не меняются вообще. diff --git a/docs/telegram-prototype.md b/docs/telegram-prototype.md index 17f93cf..b739843 100644 --- a/docs/telegram-prototype.md +++ b/docs/telegram-prototype.md @@ -1,18 +1,18 @@ # Telegram — описание прототипа -> **ВНИМАНИЕ: Telegram-адаптер не является частью текущего MVP-деплоя.** -> Код Telegram-поверхности находится в отдельной ветке `feat/telegram-adapter`. Данный документ описывает возможности этого адаптера, но многие концепции (например, AuthFlow и MockPlatformClient) устарели по отношению к актуальной архитектуре `main`. - ## Концепция -Один бот, несколько чатов через Topics в Forum-группе. +Один бот, несколько чатов, две поверхности: -При первом запуске бот создаёт для пользователя персональную Forum-группу -(супергруппу с включёнными темами). Каждый новый чат с агентом — отдельная тема -внутри группы. Пользователь видит это как список чатов в одном месте. +- базовая поверхность — личка с ботом (DM) +- опциональная advanced-поверхность — Topics в пользовательской Forum-группе -Бот управляет группой от имени пользователя через Telegram Bot API: -создаёт темы, переименовывает, архивирует. +При первом запуске пользователь начинает в DM: бот создаёт первый чат и +переключает пользователя в него. Если позже пользователь подключает Forum-группу +через `/forum`, существующие чаты получают соответствующие темы в супергруппе. + +DM и Forum используют один и тот же `chat_id`: пользователь может писать +либо в личке, либо в forum topic, а платформа видит единый разговор. --- @@ -32,44 +32,51 @@ --- -## Чаты через Forum Topics (вариант В) +## Чаты в DM и Forum Topics ### Как это работает -- Бот создаёт супергруппу с Topics для каждого нового пользователя -- Каждый чат = отдельная тема (Topic) в этой группе -- История хранится нативно в Telegram (в самой теме) -- Переключение между чатами = переключение между темами +- После `/start` бот создаёт `Чат #1` в DM +- В DM активный чат хранится как `active_chat_id` +- Ответы в личке приходят в общий поток с тегом `[Чат #N]` +- После `/forum` пользователь привязывает свою супергруппу с Topics +- Каждый чат может получить соответствующую тему (`forum_thread_id`) +- В forum-теме ответы приходят без тега, прямо в тему +- История forum-разговоров хранится нативно в Telegram ### Управление чатами -Внутри каждой темы доступны команды: +В DM доступны команды: | Команда | Действие | |---|---| -| `/new` | Создать новый чат (новую тему) | -| `/rename Название` | Переименовать текущий чат | -| `/archive` | Архивировать текущий чат | +| `/new` | Создать новый чат | | `/chats` | Показать список всех чатов | +| `/forum` | Подключить Forum-группу | + +В forum-темах поддерживается тот же разговорный контекст, а `/new` может +зарегистрировать текущую тему как отдельный чат. ### Создание нового чата -1. Пользователь пишет `/new` или нажимает кнопку -2. Бот спрашивает название (опционально, можно пропустить) -3. Бот создаёт новую тему в группе: «Чат 1», «Чат 2» и т.д. -4. Бот отправляет в новую тему приветствие; при первом сообщении платформа автоматически поднимает контейнер +1. Пользователь пишет `/new [название]` или нажимает кнопку +2. Бот создаёт новый чат в локальной БД: `Чат #N` или указанное название +3. Если Forum уже подключён, бот дополнительно создаёт новую тему в привязанной группе +4. В DM бот переключает `active_chat_id` на новый чат ### В моке -- Группа и темы создаются реально через Bot API -- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...) -- История в темах хранится нативно в Telegram, ничего не нужно делать +- DM-чаты работают сразу после `/start` +- Если Forum подключён, темы создаются реально через Bot API +- Сообщения из DM и forum topic передаются в `MockPlatformClient` с одним и тем же `chat_id` --- ## Основной диалог ### Флоу сообщения -1. Пользователь пишет текст в тему +1. Пользователь пишет текст в DM или в forum topic 2. Бот показывает `typing...` 3. Запрос уходит в платформу (сейчас — MockPlatformClient) -4. Бот отвечает текстом агента +4. Бот отвечает: + - в DM: с тегом `[Чат #N]` + - в forum topic: без тега, в ту же тему ### Вложения - Фото, документы, голосовые — передаются в платформу как `attachments` @@ -95,7 +102,7 @@ ## Настройки -Доступны через `/settings` в любой теме или в главном меню бота. +Доступны через `/settings` в личке или в forum topic. Реализованы как цепочка инлайн-кнопок. ### Главное меню настроек @@ -201,16 +208,14 @@ ## FSM состояния -``` -[Start] → AuthPending → AuthConfirmed - ↓ - GroupSetup → Idle - ↓ - ReceivingMessage → WaitingResponse → Idle - ↓ - ConfirmAction → [Confirmed/Cancelled] → Idle - ↓ - Settings → [подменю] → Idle +```text +[Start] -> ChatState.idle + ↓ + ForumSetupState.waiting_for_group + ↓ + ChatState.waiting_response -> ChatState.idle + ↓ + SettingsState.* ``` --- @@ -219,6 +224,6 @@ - Python 3.11+ - aiogram 3.x (Router, FSM, InlineKeyboard, Forum Topics API) -- MockPlatformClient → `platform/interface.py` +- MockPlatformClient → `sdk/mock.py` - structlog для логирования -- SQLite для хранения `tg_user_id → platform_user_id` и состояния скиллов +- SQLite для хранения `tg_user_id → platform_user_id`, чатов и forum bindings diff --git a/docs/user-flow.md b/docs/user-flow.md new file mode 100644 index 0000000..efe22f1 --- /dev/null +++ b/docs/user-flow.md @@ -0,0 +1,65 @@ +# User Flow — Lambda Bot + +> **Статус:** ШАБЛОН — заполняет @architect после исследований +> **Зависит от:** docs/research/telegram-flows.md, docs/research/competitor-ux.md + +--- + +## Основной сценарий (happy path) + +```mermaid +sequenceDiagram + actor User + participant Bot as Telegram/Matrix Bot + participant Platform as Lambda Platform (Master) + + User->>Bot: /start + Bot->>Platform: GET /users/{tg_id}?platform=telegram + Platform-->>Bot: {user_id, is_new} + + alt Новый пользователь + Bot->>User: Приветствие + инструкция + else Существующий пользователь + Bot->>User: Добро пожаловать обратно + end + + loop Диалог (бот не управляет сессиями — Master делает это автоматически) + User->>Bot: Сообщение в чат C1/C2/... + Bot->>Platform: POST /users/{user_id}/chats/{chat_id}/messages + Note over Platform: Master поднимает контейнер,
монтирует нужный чат, запускает агента + Platform-->>Bot: {message_id, response, tokens_used} + Bot->>User: Ответ агента + end +``` + +--- + +## Состояния FSM (Telegram) + +```mermaid +stateDiagram-v2 + [*] --> Unauthenticated: первый контакт + + Unauthenticated --> Idle: /start (auth confirmed) + + Idle --> WaitingResponse: сообщение пользователя + WaitingResponse --> Idle: ответ получен + WaitingResponse --> Error: ошибка платформы + + Idle --> Idle: /new (создан новый чат) + Idle --> ConfirmAction: агент запрашивает подтверждение + ConfirmAction --> Idle: подтверждено / отменено + + Error --> Idle: /start +``` + +--- + +## Открытые вопросы + +> Заполняет @researcher и @architect после исследований + +- [ ] Как выглядит онбординг новых пользователей у конкурентов? +- [ ] Нужна ли кнопка "Новая сессия" или сессия стартует автоматически? +- [ ] Что показываем пока агент думает (typing indicator)? +- [ ] Как обрабатываем timeout ответа от платформы? diff --git a/forum_topics_research.md b/forum_topics_research.md deleted file mode 100644 index b09c695..0000000 --- a/forum_topics_research.md +++ /dev/null @@ -1,363 +0,0 @@ -# Telegram-бот как форум для AI-агента: полный технический разбор - -С выходом **Bot API 9.3 (31 декабря 2025) и 9.4 (9 февраля 2026)** Telegram действительно позволяет боту «стать форумом» без отдельной supergroup — через режим **Threaded Mode**, включаемый в @BotFather. Личный чат пользователя с ботом получает полноценные forum topics, каждый из которых выступает изолированным контекстом разговора. Параллельно сохраняется классическая архитектура «бот-админ в supergroup с включёнными Topics», обкатанная с Bot API 6.3 (ноябрь 2022). Оба подхода дают `message_thread_id` для маршрутизации сообщений к нужному контексту AI-агента, но отличаются по сценариям применения, ограничениям и настройке. - ---- - -## Threaded Mode — бот сам становится форумом - -Начиная с Bot API 9.3, в @BotFather появилась настройка **Threaded Mode** (Bot Settings → Threaded Mode). После её включения личный чат пользователя с ботом превращается в форум: сообщения несут `message_thread_id` и `is_topic_message`, точно как в supergroup-форумах. - -Ключевые поля и возможности нового режима: - -- **`User.has_topics_enabled`** (bool) — показывает, включён ли Threaded Mode у бота для данного пользователя. -- **`User.allows_users_to_create_topics`** (bool, API 9.4) — может ли пользователь сам создавать топики, или это право только у бота. Управляется через настройку @BotFather Mini App. -- Бот вызывает **`createForumTopic(chat_id=user_id, name="...")`** прямо в личном чате — без supergroup, без админ-прав (API 9.4). -- Работают **`editForumTopic`**, **`deleteForumTopic`**, **`unpinAllForumTopicMessages`** — подтверждено для private chats с API 9.3. -- Все методы отправки (`sendMessage`, `sendPhoto`, `sendDocument` и т.д.) принимают `message_thread_id` в личных чатах. - -Это и есть ответ на вопрос «бот становится форумом» — **никакой отдельной группы не нужно**. Пользователь открывает чат с ботом и видит структуру топиков. Каждый топик — отдельный «разговор» с AI-агентом. - -Классическая архитектура «supergroup + бот-админ» по-прежнему актуальна для многопользовательских сценариев, где несколько людей работают с агентом в одном пространстве. Но для **персонального AI-ассистента** Threaded Mode — технически чистое решение. - ---- - -## Полный справочник Forum Topics API - -### Основные методы - -| Метод | Параметры | Возврат | Права | -|-------|-----------|---------|-------| -| `createForumTopic` | `chat_id`, `name` (1–128 символов), `icon_color`?, `icon_custom_emoji_id`? | `ForumTopic` | `can_manage_topics` (supergroup) / не нужны (private) | -| `editForumTopic` | `chat_id`, `message_thread_id`, `name`?, `icon_custom_emoji_id`? | `True` | `can_manage_topics` или создатель топика | -| `closeForumTopic` | `chat_id`, `message_thread_id` | `True` | `can_manage_topics` или создатель | -| `reopenForumTopic` | `chat_id`, `message_thread_id` | `True` | `can_manage_topics` или создатель | -| `deleteForumTopic` | `chat_id`, `message_thread_id` | `True` | **`can_delete_messages`** (не `can_manage_topics`!) | -| `unpinAllForumTopicMessages` | `chat_id`, `message_thread_id` | `True` | `can_pin_messages` | -| `getForumTopicIconStickers` | — | `Array of Sticker` | не нужны | - -### Методы General-топика (только supergroup) - -| Метод | Описание | -|-------|----------| -| `editGeneralForumTopic(chat_id, name)` | Переименовать General-топик | -| `closeGeneralForumTopic(chat_id)` | Закрыть General | -| `reopenGeneralForumTopic(chat_id)` | Открыть General | -| `hideGeneralForumTopic(chat_id)` | Скрыть General (автоматически закрывает) | -| `unhideGeneralForumTopic(chat_id)` | Показать General | -| `unpinAllGeneralForumTopicMessages(chat_id)` | Открепить все сообщения в General | - -Все требуют `can_manage_topics`, кроме `unpinAll...` — там нужен `can_pin_messages`. - -### Объект ForumTopic - -```python -class ForumTopic: - message_thread_id: int # уникальный ID топика - name: str # название (1–128 символов) - icon_color: int # RGB-цвет иконки - icon_custom_emoji_id: str # кастомный эмодзи (опционально) - is_name_implicit: bool # имя назначено автоматически (API 9.3+) -``` - -**Допустимые значения `icon_color`**: `0x6FB9F0` (голубой), `0xFFD67E` (жёлтый), `0xCB86DB` (фиолетовый), `0x8EEE98` (зелёный), `0xFF93B2` (розовый), `0xFB6F5F` (красный) — ровно 6 цветов, других API не принимает. - -### Как работает message_thread_id - -При отправке через `sendMessage` (и все остальные send-методы) параметр `message_thread_id` направляет сообщение в конкретный топик. Входящие сообщения из топиков содержат два поля: **`message_thread_id`** (int) и **`is_topic_message`** (bool = True). Для General-топика `is_topic_message` **не устанавливается** — это ключевое отличие. - ---- - -## General-топик: коварная деталь - -General-топик имеет фиксированный **`id = 1`** на уровне MTProto API. Однако в Bot API его поведение отличается от кастомных топиков: сообщения в General **не несут `is_topic_message = true`**, а `message_thread_id` может быть `None` или отсутствовать. При этом отправка с `message_thread_id=1` часто возвращает **`400 Bad Request: message thread not found`**. Корректный подход — **просто опустить `message_thread_id`** при отправке в General. - -Логика маршрутизации для AI-агента должна учитывать это: - -```python -if message.is_topic_message and message.message_thread_id: - # Кастомный топик → изолированный контекст - context_key = (chat_id, message.message_thread_id) -elif getattr(message.chat, 'is_forum', False): - # Форум, но не is_topic_message → General-топик - context_key = (chat_id, "general") -else: - # Обычный чат / личное сообщение - context_key = (chat_id, None) -``` - -General-топик **нельзя удалить**, но можно скрыть через `hideGeneralForumTopic`. Для AI-бота рекомендуется скрыть General и направлять все взаимодействия через кастомные топики — это устраняет edge case с маршрутизацией. - ---- - -## Рабочий бот на aiogram 3.x с полной изоляцией контекстов - -Ниже — **полный минимальный бот**, который создаёт топики по команде `/new`, ведёт изолированную историю для каждого топика и интегрируется с LLM. Код проверен по документации aiogram 3.26. - -```python -""" -AI-агент с forum topics — aiogram 3.x -pip install aiogram>=3.20 openai aiosqlite -""" - -import asyncio -import logging -import os -from collections import defaultdict - -from aiogram import Bot, Dispatcher, F, Router -from aiogram.filters import Command, CommandStart -from aiogram.types import Message, ForumTopic -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode -from aiogram.fsm.storage.memory import MemoryStorage -from aiogram.fsm.strategy import FSMStrategy - -# ── Конфигурация ────────────────────────────────────────────── -TOKEN = os.getenv("BOT_TOKEN") -GROUP_ID = int(os.getenv("GROUP_ID", "0")) # ID supergroup-форума - -router = Router() - -# ── Хранилище контекстов: {(chat_id, topic_id): [messages]} ── -contexts: dict[tuple[int, int | None], list[dict]] = defaultdict(list) - - -# ── /start — приветствие в любом топике ─────────────────────── -@router.message(CommandStart()) -async def cmd_start(message: Message): - topic = message.message_thread_id - await message.answer( - f"👋 AI-агент активен.\n" - f"Топик: {topic or 'General'}\n\n" - f"/new <имя> — новый разговор\n" - f"/clear — очистить контекст\n" - f"/close — закрыть топик" - ) - - -# ── /new <имя> — создание нового топика-контекста ───────────── -@router.message(Command("new")) -async def cmd_new(message: Message, bot: Bot): - args = message.text.split(maxsplit=1) - name = args[1] if len(args) > 1 else f"Чат #{message.message_id}" - - try: - topic: ForumTopic = await bot.create_forum_topic( - chat_id=message.chat.id, - name=name, - icon_color=0x6FB9F0, - ) - # Приветственное сообщение внутри нового топика - await bot.send_message( - chat_id=message.chat.id, - text=f"✅ Контекст «{name}» создан. Пишите сюда — " - f"я помню только этот разговор.", - message_thread_id=topic.message_thread_id, - ) - except Exception as e: - await message.answer(f"❌ Ошибка: {e}") - - -# ── /clear — сброс контекста текущего топика ────────────────── -@router.message(Command("clear")) -async def cmd_clear(message: Message): - key = (message.chat.id, message.message_thread_id) - contexts[key].clear() - await message.answer("🗑 Контекст очищен.") - - -# ── /close — закрытие текущего топика ───────────────────────── -@router.message(Command("close"), F.message_thread_id) -async def cmd_close(message: Message, bot: Bot): - try: - await bot.close_forum_topic( - chat_id=message.chat.id, - message_thread_id=message.message_thread_id, - ) - # Чистим контекст закрытого топика - key = (message.chat.id, message.message_thread_id) - contexts.pop(key, None) - except Exception as e: - await message.answer(f"❌ {e}") - - -# ── Обработка текстовых сообщений — маршрутизация по топику ─── -@router.message(F.text, ~F.text.startswith("/")) -async def handle_user_message(message: Message): - key = (message.chat.id, message.message_thread_id) - history = contexts[key] - - # Сохраняем сообщение пользователя - history.append({"role": "user", "content": message.text}) - - # ── Вызов LLM (заглушка — заменить на реальный вызов) ── - reply = await call_llm(history) - - # Сохраняем ответ ассистента - history.append({"role": "assistant", "content": reply}) - - # Ограничиваем историю (скользящее окно) - if len(history) > 100: - contexts[key] = history[-100:] - - # message.answer() автоматически сохраняет message_thread_id - await message.answer(reply) - - -# ── Заглушка LLM (заменить на OpenAI / Anthropic / etc.) ───── -async def call_llm(history: list[dict]) -> str: - """ - Реальная интеграция: - - from openai import AsyncOpenAI - client = AsyncOpenAI() - - messages = [{"role": "system", "content": "Ты полезный ассистент."}] - messages += [{"role": m["role"], "content": m["content"]} - for m in history[-20:]] - - resp = await client.chat.completions.create( - model="gpt-4o", messages=messages - ) - return resp.choices[0].message.content - """ - return f"[Echo] {history[-1]['content']} (сообщений в контексте: {len(history)})" - - -# ── Точка входа ─────────────────────────────────────────────── -async def main(): - logging.basicConfig(level=logging.INFO) - bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) - - dp = Dispatcher( - storage=MemoryStorage(), - fsm_strategy=FSMStrategy.CHAT_TOPIC, # изоляция FSM по топикам - ) - dp.include_router(router) - await dp.start_polling(bot) - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -### Критически важная деталь: FSMStrategy.CHAT_TOPIC - -Встроенная в aiogram стратегия `FSMStrategy.CHAT_TOPIC` хранит состояния FSM с ключом `(chat_id, chat_id, thread_id)` — каждый топик получает **собственное** изолированное состояние. Это появилось в aiogram 3.4.0 и специально предназначено для форумных ботов. Без этой стратегии FSM-состояния будут общими для всех топиков в одном чате. - ---- - -## Хранение контекстов: от прототипа к продакшену - -### In-memory dict — для разработки - -Простой `defaultdict(list)` из примера выше теряет данные при перезапуске, но позволяет мгновенно начать работу. Ключ — кортеж `(chat_id, topic_id)`. - -### Redis — для продакшена - -Redis даёт **нативный TTL** (автоочистка неактивных контекстов), **атомарные операции** (безопасность при конкурентных сообщениях) и **персистентность**. Паттерн хранения: - -```python -import json -import redis.asyncio as redis - -r = redis.from_url("redis://localhost:6379") - -async def get_history(chat_id: int, topic_id: int | None) -> list[dict]: - key = f"ctx:{chat_id}:{topic_id or 'general'}" - raw = await r.get(key) - return json.loads(raw) if raw else [] - -async def append_and_trim(chat_id: int, topic_id: int | None, msg: dict): - key = f"ctx:{chat_id}:{topic_id or 'general'}" - history = await get_history(chat_id, topic_id) - history.append(msg) - history = history[-50:] # скользящее окно - await r.set(key, json.dumps(history), ex=86400 * 7) # TTL 7 дней -``` - -### SQLite — компромисс - -Для однопроцессных развёртываний без инфраструктуры Redis: - -```python -import aiosqlite - -async def init_db(): - async with aiosqlite.connect("contexts.db") as db: - await db.execute(""" - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - chat_id INTEGER NOT NULL, - topic_id INTEGER, - role TEXT NOT NULL, - content TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - await db.execute( - "CREATE INDEX IF NOT EXISTS idx_ctx ON messages(chat_id, topic_id)" - ) - await db.commit() -``` - ---- - -## Настройка supergroup с forum mode - -Включить режим форума **через Bot API невозможно** — нет соответствующего метода. Два способа активации: - -Для **Threaded Mode в личных чатах**: @BotFather → выбрать бота → Bot Settings → Threaded Mode → включить. Всё. Никаких supergroup не нужно. - -Для **supergroup-форума** — шаги через Telegram-клиент: - -1. Создать группу (или использовать существующую). -2. Открыть настройки группы → Edit → включить **Topics**. Telegram автоматически конвертирует группу в supergroup (ID чата изменится). -3. Добавить бота в группу. -4. Назначить бота администратором с правами: **`can_manage_topics`** (создание/редактирование/закрытие топиков), **`can_delete_messages`** (удаление топиков), **`can_pin_messages`** (работа с закреплёнными сообщениями). - -Минимально необходимое право — `can_manage_topics`. Без него бот не сможет вызвать `createForumTopic`. - -MTProto API имеет `channels.toggleForum(enabled=true)`, но это доступно только пользовательским аккаунтам с правами владельца, а не ботам. - ---- - -## Лимиты, edge cases и важные ограничения - -**До 1 000 000 топиков** в одной supergroup — практически неограниченный потолок. **5 закреплённых топиков** максимум. Общие rate limits Bot API (~30 запросов/сек) распространяются и на создание топиков. - -**При удалении топика** все сообщения внутри него **удаляются безвозвратно**, `message_thread_id` становится невалидным. Критическая проблема: **Bot API не доставляет webhook-событие об удалении топика**. Нет поля `forum_topic_deleted` в объекте Message. Для очистки контекстов в хранилище используйте одну из стратегий: TTL-based expiry в Redis, ошибку при попытке отправки в несуществующий thread (error-based cleanup), или ручную очистку, если удаление инициирует сам бот. - -**Bot API не предоставляет метод для получения списка существующих топиков.** Нет `getForumTopics`. Бот должен запоминать ID топиков при создании через `createForumTopic` или через service messages `ForumTopicCreated`. - -### python-telegram-bot v21 — для сравнения - -Эквивалентный вызов создания топика: - -```python -from telegram import Update, ForumTopic -from telegram.ext import Application, CommandHandler - -async def new_topic(update: Update, context): - topic: ForumTopic = await context.bot.create_forum_topic( - chat_id=update.effective_chat.id, - name="Новый разговор", - icon_color=0x6FB9F0, - ) - await context.bot.send_message( - chat_id=update.effective_chat.id, - text="Топик создан!", - message_thread_id=topic.message_thread_id, - ) -``` - -Ключевое отличие: python-telegram-bot **не имеет встроенных FSM-стратегий** для топиков. Изоляцию состояний по `message_thread_id` нужно реализовывать вручную. Фильтры service-сообщений: `filters.StatusUpdate.FORUM_TOPIC_CREATED`, `.FORUM_TOPIC_CLOSED`, `.FORUM_TOPIC_REOPENED`. - ---- - -## Заключение - -**Threaded Mode — прорывная возможность** для AI-ботов, появившаяся буквально в конце 2025-го. До этого «бот как форум» требовал обязательной supergroup-обёртки. Теперь личный чат с ботом является полноценным форумом, где каждый топик — изолированный контекст разговора с агентом. - -Архитектурная формула проста: `context_key = (chat_id, message_thread_id)` + `FSMStrategy.CHAT_TOPIC` в aiogram дают полную изоляцию из коробки. Для продакшена — Redis с TTL, для прототипа — `defaultdict(list)`. Три граблей, которые нужно знать заранее: General-топик не принимает `message_thread_id=1` при отправке, Bot API не уведомляет об удалении топиков, и получить список существующих топиков нельзя — только запоминать при создании. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 73dfbd7..8f4978b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,15 +15,12 @@ dependencies = [ "structlog>=24.1", "python-dotenv>=1.0", "httpx>=0.27", - "aiohttp>=3.9", - "pyyaml>=6.0", ] [project.optional-dependencies] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", - "pytest-aiohttp>=1.0", "pytest-cov>=4.1", "ruff>=0.3", "mypy>=1.8", diff --git a/sdk/__init__.py b/sdk/__init__.py index f7939f7..e69de29 100644 --- a/sdk/__init__.py +++ b/sdk/__init__.py @@ -1,9 +0,0 @@ -__all__ = ["RealPlatformClient"] - - -def __getattr__(name: str): - if name == "RealPlatformClient": - from sdk.real import RealPlatformClient - - return RealPlatformClient - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sdk/agent_session.py b/sdk/agent_session.py deleted file mode 100644 index 187b88a..0000000 --- a/sdk/agent_session.py +++ /dev/null @@ -1 +0,0 @@ -"""Compatibility stub: AgentSessionClient was replaced by direct AgentApi usage in Phase 4.""" diff --git a/sdk/interface.py b/sdk/interface.py index 7b43b1b..e1ff12e 100644 --- a/sdk/interface.py +++ b/sdk/interface.py @@ -1,11 +1,10 @@ # platform/interface.py from __future__ import annotations -from collections.abc import AsyncIterator from datetime import datetime -from typing import Any, Literal, Protocol +from typing import Any, AsyncIterator, Literal, Protocol -from pydantic import BaseModel, Field +from pydantic import BaseModel class User(BaseModel): @@ -18,11 +17,10 @@ class User(BaseModel): class Attachment(BaseModel): - url: str | None = None - mime_type: str | None = None + url: str + mime_type: str size: int | None = None filename: str | None = None - workspace_path: str | None = None class MessageResponse(BaseModel): @@ -30,12 +28,10 @@ class MessageResponse(BaseModel): response: str tokens_used: int finished: bool - attachments: list[Attachment] = Field(default_factory=list) class MessageChunk(BaseModel): """Один кусок стримингового ответа. При sync-режиме — единственный чанк с finished=True.""" - message_id: str delta: str finished: bool @@ -52,7 +48,6 @@ class UserSettings(BaseModel): class AgentEvent(BaseModel): """Webhook-уведомление от платформы — агент закончил долгую задачу.""" - event_id: str user_id: str chat_id: str @@ -99,5 +94,4 @@ class PlatformClient(Protocol): class WebhookReceiver(Protocol): """Регистрируется в боте. Платформа зовёт нас когда агент закончил долгую задачу.""" - async def on_agent_event(self, event: AgentEvent) -> None: ... diff --git a/sdk/mock.py b/sdk/mock.py index 06e49ac..353a774 100644 --- a/sdk/mock.py +++ b/sdk/mock.py @@ -4,9 +4,8 @@ from __future__ import annotations import asyncio import random import uuid -from collections.abc import AsyncIterator from datetime import UTC, datetime -from typing import Any, Literal +from typing import Any, AsyncIterator, Literal import structlog @@ -23,30 +22,6 @@ from sdk.interface import ( logger = structlog.get_logger(__name__) -DEFAULT_SKILLS = { - "web-search": True, - "fetch-url": True, - "email": False, - "browser": False, - "image-gen": False, - "files": True, -} - -DEFAULT_SAFETY = { - "email-send": True, - "file-delete": True, - "social-post": True, -} - -DEFAULT_SOUL = {"name": "Лямбда", "instructions": ""} - -DEFAULT_PLAN = { - "name": "Beta", - "tokens_used": 0, - "tokens_limit": 1000, -} - - class MockPlatformClient: """ Заглушка SDK платформы Lambda. @@ -124,19 +99,23 @@ class MockPlatformClient: attachments: list[Attachment] | None = None, ) -> AsyncIterator[MessageChunk]: """ - Сейчас: один чанк с полным ответом. - При реальном SDK: заменить на SSE/WebSocket итератор. + Сейчас: один чанк с полным ответом (sync под капотом). + При реальном SDK: заменить на SSE/WebSocket итератор в platform/mock.py. Адаптеры переписывать не нужно. """ await self._latency(200, 600) message_id, response, tokens = self._build_response(user_id, chat_id, text, attachments) logger.info("stream_message", user_id=user_id, chat_id=chat_id, message_id=message_id) - yield MessageChunk( - message_id=message_id, - delta=response, - finished=True, - tokens_used=tokens, - ) + + async def _gen() -> AsyncIterator[MessageChunk]: + yield MessageChunk( + message_id=message_id, + delta=response, + finished=True, + tokens_used=tokens, + ) + + return _gen() # --------------------------------------------------------------- settings @@ -144,11 +123,26 @@ class MockPlatformClient: await self._latency() stored = self._settings.get(user_id, {}) return UserSettings( - skills={**DEFAULT_SKILLS, **stored.get("skills", {})}, + skills=stored.get("skills", { + "web-search": True, + "fetch-url": True, + "email": False, + "browser": False, + "image-gen": False, + "files": True, + }), connectors=stored.get("connectors", {}), - soul={**DEFAULT_SOUL, **stored.get("soul", {})}, - safety={**DEFAULT_SAFETY, **stored.get("safety", {})}, - plan={**DEFAULT_PLAN, **stored.get("plan", {})}, + soul=stored.get("soul", {"name": "Лямбда", "instructions": ""}), + safety=stored.get("safety", { + "email-send": True, + "file-delete": True, + "social-post": True, + }), + plan=stored.get("plan", { + "name": "Beta", + "tokens_used": 0, + "tokens_limit": 1000, + }), ) async def update_settings(self, user_id: str, action: Any) -> None: @@ -156,13 +150,13 @@ class MockPlatformClient: settings = self._settings.setdefault(user_id, {}) if action.action == "toggle_skill": - skills = settings.setdefault("skills", DEFAULT_SKILLS.copy()) + skills = settings.setdefault("skills", {}) skills[action.payload["skill"]] = action.payload.get("enabled", True) elif action.action == "set_soul": - soul = settings.setdefault("soul", DEFAULT_SOUL.copy()) + soul = settings.setdefault("soul", {}) soul[action.payload["field"]] = action.payload["value"] elif action.action == "set_safety": - safety = settings.setdefault("safety", DEFAULT_SAFETY.copy()) + safety = settings.setdefault("safety", {}) safety[action.payload["trigger"]] = action.payload.get("enabled", True) logger.info("Settings updated", user_id=user_id, action=action.action) @@ -223,16 +217,14 @@ class MockPlatformClient: response = f"[MOCK] Ответ на: «{preview}»{attachment_note}" tokens = len(text.split()) * 2 - self._messages[key].append( - { - "message_id": message_id, - "user_text": text, - "response": response, - "tokens_used": tokens, - "finished": True, - "created_at": datetime.now(UTC).isoformat(), - } - ) + self._messages[key].append({ + "message_id": message_id, + "user_text": text, + "response": response, + "tokens_used": tokens, + "finished": True, + "created_at": datetime.now(UTC).isoformat(), + }) return message_id, response, tokens async def _latency(self, min_ms: int = 10, max_ms: int = 80) -> None: diff --git a/sdk/prototype_state.py b/sdk/prototype_state.py deleted file mode 100644 index 6e5fd41..0000000 --- a/sdk/prototype_state.py +++ /dev/null @@ -1,129 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime -from typing import Any - -from sdk.interface import User, UserSettings - -# Keep the prototype backend self-contained; do not import these from sdk.mock. -DEFAULT_SKILLS: dict[str, bool] = { - "web-search": True, - "fetch-url": True, - "email": False, - "browser": False, - "image-gen": False, - "files": True, -} -DEFAULT_SAFETY: dict[str, bool] = { - "email-send": True, - "file-delete": True, - "social-post": True, -} -DEFAULT_SOUL: dict[str, str] = {"name": "Лямбда", "instructions": ""} -DEFAULT_PLAN: dict[str, Any] = { - "name": "Beta", - "tokens_used": 0, - "tokens_limit": 1000, -} - - -class PrototypeStateStore: - def __init__(self) -> None: - self._users: dict[str, User] = {} - self._settings: dict[str, dict[str, Any]] = {} - self._saved_sessions: dict[str, list[dict[str, str]]] = {} - self._context_last_tokens_used: dict[str, int] = {} - self._context_current_session: dict[str, str] = {} - - async def get_or_create_user( - self, - external_id: str, - platform: str, - display_name: str | None = None, - ) -> User: - key = f"{platform}:{external_id}" - existing = self._users.get(key) - if existing is not None: - stored = existing.model_copy(update={"is_new": False}) - self._users[key] = stored - return stored.model_copy() - - user = User( - user_id=f"usr-{platform}-{external_id}", - external_id=external_id, - platform=platform, - display_name=display_name, - created_at=datetime.now(UTC), - is_new=True, - ) - self._users[key] = user.model_copy(update={"is_new": False}) - return user.model_copy() - - async def get_settings(self, user_id: str) -> UserSettings: - stored = self._settings.get(user_id, {}) - return UserSettings( - skills={**DEFAULT_SKILLS, **stored.get("skills", {})}, - connectors=dict(stored.get("connectors", {})), - soul={**DEFAULT_SOUL, **stored.get("soul", {})}, - safety={**DEFAULT_SAFETY, **stored.get("safety", {})}, - plan={**DEFAULT_PLAN, **stored.get("plan", {})}, - ) - - async def update_settings(self, user_id: str, action: Any) -> None: - settings = self._settings.setdefault(user_id, {}) - - if action.action == "toggle_skill": - skills = settings.setdefault("skills", DEFAULT_SKILLS.copy()) - skills[action.payload["skill"]] = action.payload.get("enabled", True) - elif action.action == "set_soul": - soul = settings.setdefault("soul", DEFAULT_SOUL.copy()) - soul[action.payload["field"]] = action.payload["value"] - elif action.action == "set_safety": - safety = settings.setdefault("safety", DEFAULT_SAFETY.copy()) - safety[action.payload["trigger"]] = action.payload.get("enabled", True) - - async def add_saved_session( - self, - user_id: str, - name: str, - *, - source_context_id: str | None = None, - ) -> None: - sessions = self._saved_sessions.setdefault(user_id, []) - session = {"name": name, "created_at": datetime.now(UTC).isoformat()} - if source_context_id is not None: - session["source_context_id"] = source_context_id - sessions.append(session) - - async def list_saved_sessions(self, user_id: str) -> list[dict[str, str]]: - return [dict(session) for session in self._saved_sessions.get(user_id, [])] - - async def get_last_tokens_used_for_context(self, context_id: str) -> int: - return self._context_last_tokens_used.get(context_id, 0) - - async def set_last_tokens_used_for_context(self, context_id: str, tokens: int) -> None: - self._context_last_tokens_used[context_id] = tokens - - async def get_current_session_for_context(self, context_id: str) -> str | None: - return self._context_current_session.get(context_id) - - async def set_current_session_for_context(self, context_id: str, name: str) -> None: - self._context_current_session[context_id] = name - - async def clear_current_session_for_context(self, context_id: str) -> None: - self._context_current_session.pop(context_id, None) - - async def get_last_tokens_used(self, context_id: str) -> int: - return await self.get_last_tokens_used_for_context(context_id) - - async def set_last_tokens_used(self, context_id: str, tokens: int) -> None: - await self.set_last_tokens_used_for_context(context_id, tokens) - - async def get_current_session(self, context_id: str) -> str | None: - return await self.get_current_session_for_context(context_id) - - async def set_current_session(self, context_id: str, name: str) -> None: - await self.set_current_session_for_context(context_id, name) - - async def clear_current_session(self, context_id: str) -> None: - await self.clear_current_session_for_context(context_id) diff --git a/sdk/real.py b/sdk/real.py deleted file mode 100644 index 47f639a..0000000 --- a/sdk/real.py +++ /dev/null @@ -1,273 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -import re -from collections.abc import AsyncIterator -from pathlib import Path -from urllib.parse import urljoin, urlsplit, urlunsplit - -import structlog - -from sdk.interface import ( - Attachment, - MessageChunk, - MessageResponse, - PlatformClient, - PlatformError, - User, - UserSettings, -) -from sdk.prototype_state import PrototypeStateStore -from sdk.upstream_agent_api import AgentApi, MsgEventSendFile, MsgEventTextChunk - -logger = structlog.get_logger(__name__) - - -def _ws_debug_enabled() -> bool: - value = os.environ.get("SURFACES_DEBUG_WS", "") - return value.strip().lower() in {"1", "true", "yes", "on"} - - -class RealPlatformClient(PlatformClient): - def __init__( - self, - agent_id: str, - agent_base_url: str, - prototype_state: PrototypeStateStore, - platform: str = "matrix", - agent_api_cls=AgentApi, - ) -> None: - self._agent_id = agent_id - self._raw_agent_base_url = agent_base_url - self._agent_base_url = self._normalize_agent_base_url(agent_base_url) - self._agent_api_cls = agent_api_cls - self._prototype_state = prototype_state - self._platform = platform - self._chat_send_locks: dict[str, asyncio.Lock] = {} - if _ws_debug_enabled(): - logger.warning( - "agent_client_initialized", - agent_id=self._agent_id, - platform=self._platform, - raw_base_url=self._raw_agent_base_url, - normalized_base_url=self._agent_base_url, - ) - - @property - def agent_id(self) -> str: - return self._agent_id - - @property - def agent_base_url(self) -> str: - return self._agent_base_url - - def _get_chat_send_lock(self, chat_id: str) -> asyncio.Lock: - chat_key = str(chat_id) - lock = self._chat_send_locks.get(chat_key) - if lock is None: - lock = asyncio.Lock() - self._chat_send_locks[chat_key] = lock - return lock - - async def get_or_create_user( - self, - external_id: str, - platform: str, - display_name: str | None = None, - ) -> User: - return await self._prototype_state.get_or_create_user( - external_id=external_id, - platform=platform, - display_name=display_name, - ) - - async def send_message( - self, - user_id: str, - chat_id: str, - text: str, - attachments: list[Attachment] | None = None, - ) -> MessageResponse: - response_parts: list[str] = [] - sent_attachments: list[Attachment] = [] - message_id = user_id - - lock = self._get_chat_send_lock(chat_id) - async with lock: - chat_api = self._build_chat_api(chat_id) - try: - await chat_api.connect() - async for event in self._stream_agent_events( - chat_api, text, attachments=attachments - ): - message_id = user_id - if isinstance(event, MsgEventTextChunk) and event.text: - response_parts.append(event.text) - elif isinstance(event, MsgEventSendFile): - attachment = self._attachment_from_send_file_event(event) - if attachment is not None: - sent_attachments.append(attachment) - except Exception as exc: - raise self._to_platform_error(exc) from exc - finally: - await self._close_chat_api(chat_api) - await self._prototype_state.set_last_tokens_used(str(chat_id), 0) - - response_kwargs = { - "message_id": message_id, - "response": "".join(response_parts), - "tokens_used": 0, - "finished": True, - "attachments": sent_attachments, - } - return MessageResponse(**response_kwargs) - - async def stream_message( - self, - user_id: str, - chat_id: str, - text: str, - attachments: list[Attachment] | None = None, - ) -> AsyncIterator[MessageChunk]: - lock = self._get_chat_send_lock(chat_id) - async with lock: - chat_api = self._build_chat_api(chat_id) - try: - await chat_api.connect() - async for event in self._stream_agent_events( - chat_api, text, attachments=attachments - ): - if isinstance(event, MsgEventTextChunk): - yield MessageChunk( - message_id=user_id, - delta=event.text, - finished=False, - ) - elif isinstance(event, MsgEventSendFile): - continue - except Exception as exc: - raise self._to_platform_error(exc) from exc - finally: - await self._close_chat_api(chat_api) - await self._prototype_state.set_last_tokens_used(str(chat_id), 0) - yield MessageChunk( - message_id=user_id, - delta="", - finished=True, - tokens_used=0, - ) - - async def get_settings(self, user_id: str) -> UserSettings: - return await self._prototype_state.get_settings(user_id) - - async def update_settings(self, user_id: str, action) -> None: - await self._prototype_state.update_settings(user_id, action) - - async def disconnect_chat(self, chat_id: str) -> None: - self._chat_send_locks.pop(str(chat_id), None) - - async def close(self) -> None: - self._chat_send_locks.clear() - - async def _stream_agent_events( - self, - chat_api, - text: str, - attachments: list[Attachment] | None = None, - ) -> AsyncIterator[object]: - attachment_paths = self._attachment_paths(attachments) - event_stream = chat_api.send_message(text, attachments=attachment_paths or None) - chunk_index = 0 - async for event in event_stream: - if isinstance(event, MsgEventTextChunk): - logger.debug("agent_chunk", index=chunk_index, text=repr(event.text[:40])) - chunk_index += 1 - else: - logger.debug("agent_event", index=chunk_index, type=type(event).__name__) - yield event - - def _build_chat_api(self, chat_id: str): - if _ws_debug_enabled(): - logger.warning( - "agent_chat_api_build", - agent_id=self._agent_id, - chat_id=str(chat_id), - normalized_base_url=self._agent_base_url, - ws_url=urljoin(self._agent_base_url, f"v1/agent_ws/{chat_id}/"), - ) - return self._agent_api_cls( - agent_id=self._agent_id, - base_url=self._agent_base_url, - chat_id=str(chat_id), - ) - - @staticmethod - def _normalize_agent_base_url(base_url: str) -> str: - parsed = urlsplit(base_url) - path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/")) - if path: - path = f"{path}/" - return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) - - @staticmethod - async def _close_chat_api(chat_api) -> None: - close = getattr(chat_api, "close", None) - if callable(close): - try: - await close() - except Exception: - pass - - @staticmethod - def _to_platform_error(exc: Exception) -> PlatformError: - code = getattr(exc, "code", None) or "PLATFORM_CONNECTION_ERROR" - return PlatformError(str(exc), code=code) - - @staticmethod - def _normalize_workspace_path(location: str) -> str | None: - if not location: - return None - - path = Path(location) - if not path.is_absolute(): - normalized = path.as_posix() - return normalized or None - - parts = path.parts - if len(parts) >= 2 and parts[1] == "workspace": - relative = Path(*parts[2:]).as_posix() - return relative or None - if len(parts) >= 3 and parts[1] == "agents": - relative = Path(*parts[3:]).as_posix() - return relative or None - - relative = path.as_posix().lstrip("/") - return relative or None - - @staticmethod - def _attachment_paths(attachments: list[Attachment] | None) -> list[str]: - if not attachments: - return [] - paths = [] - for attachment in attachments: - if attachment.workspace_path: - normalized = RealPlatformClient._normalize_workspace_path( - attachment.workspace_path - ) - if normalized: - paths.append(normalized) - return paths - - @staticmethod - def _attachment_from_send_file_event(event: MsgEventSendFile) -> Attachment: - location = str(event.path) - filename = Path(location).name or None - workspace_path = RealPlatformClient._normalize_workspace_path(location) - return Attachment( - url=location, - mime_type="application/octet-stream", - size=None, - filename=filename, - workspace_path=workspace_path or None, - ) diff --git a/sdk/upstream_agent_api.py b/sdk/upstream_agent_api.py deleted file mode 100644 index d0bfdd7..0000000 --- a/sdk/upstream_agent_api.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -import sys -from pathlib import Path - -_api_root = Path(__file__).resolve().parents[1] / "external" / "platform-agent_api" -if str(_api_root) not in sys.path: - sys.path.insert(0, str(_api_root)) - -from lambda_agent_api.agent_api import AgentApi, AgentBusyException, AgentException # noqa: E402 -from lambda_agent_api.server import MsgEventSendFile, MsgEventTextChunk # noqa: E402 - -__all__ = [ - "AgentApi", - "AgentBusyException", - "AgentException", - "MsgEventSendFile", - "MsgEventTextChunk", -] diff --git a/telegram-cloud-photo-size-2-5440546240941724952-y.jpg b/telegram-cloud-photo-size-2-5440546240941724952-y.jpg deleted file mode 100644 index af4606d..0000000 Binary files a/telegram-cloud-photo-size-2-5440546240941724952-y.jpg and /dev/null differ diff --git a/tests/adapter/__init__.py b/tests/adapter/__init__.py index e69de29..8b13789 100644 --- a/tests/adapter/__init__.py +++ b/tests/adapter/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/adapter/matrix/__init__.py b/tests/adapter/matrix/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/tests/adapter/matrix/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/tests/adapter/matrix/test_agent_registry.py b/tests/adapter/matrix/test_agent_registry.py deleted file mode 100644 index a918f84..0000000 --- a/tests/adapter/matrix/test_agent_registry.py +++ /dev/null @@ -1,199 +0,0 @@ -from pathlib import Path - -import pytest - -from adapter.matrix.agent_registry import AgentRegistryError, load_agent_registry - - -def test_load_agent_registry_reads_yaml_entries(tmp_path: Path): - path = tmp_path / "agents.yaml" - path.write_text( - "agents:\n" - " - id: agent-1\n" - " label: Analyst\n" - " - id: agent-2\n" - " label: Research\n", - encoding="utf-8", - ) - - registry = load_agent_registry(path) - - assert [agent.agent_id for agent in registry.agents] == ["agent-1", "agent-2"] - assert registry.get("agent-1").label == "Analyst" - - -def test_agent_registry_agents_sequence_is_immutable(tmp_path: Path): - path = tmp_path / "agents.yaml" - path.write_text( - "agents:\n" - " - id: agent-1\n" - " label: Analyst\n", - encoding="utf-8", - ) - - registry = load_agent_registry(path) - - with pytest.raises(AttributeError): - registry.agents.append( # type: ignore[attr-defined] - registry.agents[0] - ) - - -def test_load_agent_registry_rejects_duplicate_ids(tmp_path: Path): - path = tmp_path / "agents.yaml" - path.write_text( - "agents:\n" - " - id: agent-1\n" - " label: Analyst\n" - " - id: agent-1\n" - " label: Duplicate\n", - encoding="utf-8", - ) - - with pytest.raises(AgentRegistryError, match="duplicate agent id"): - load_agent_registry(path) - - -def test_load_agent_registry_rejects_non_mapping_entries(tmp_path: Path): - path = tmp_path / "agents.yaml" - path.write_text( - "agents:\n" - " - agent-1\n", - encoding="utf-8", - ) - - with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"): - load_agent_registry(path) - - -@pytest.mark.parametrize( - "content", - [ - "", - "agents: []\n", - "agents: agent-1\n", - "foo: bar\n", - ], -) -def test_load_agent_registry_rejects_missing_non_list_and_empty_agents( - tmp_path: Path, content: str -): - path = tmp_path / "agents.yaml" - path.write_text(content, encoding="utf-8") - - with pytest.raises(AgentRegistryError, match="agents registry must contain a non-empty agents list"): - load_agent_registry(path) - - -@pytest.mark.parametrize( - "content, expected", - [ - ( - "agents:\n" - " - label: Analyst\n", - "each agent entry requires id and label", - ), - ( - "agents:\n" - " - id: agent-1\n", - "each agent entry requires id and label", - ), - ], -) -def test_load_agent_registry_rejects_missing_id_or_label(tmp_path: Path, content: str, expected: str): - path = tmp_path / "agents.yaml" - path.write_text(content, encoding="utf-8") - - with pytest.raises(AgentRegistryError, match=expected): - load_agent_registry(path) - - -def test_load_agent_registry_rejects_invalid_top_level_yaml(tmp_path: Path): - path = tmp_path / "agents.yaml" - path.write_text( - "- id: agent-1\n" - " label: Analyst\n", - encoding="utf-8", - ) - - with pytest.raises(AgentRegistryError, match="agent registry must be a mapping with an agents list"): - load_agent_registry(path) - - -def test_load_agent_registry_rejects_malformed_yaml(tmp_path: Path): - path = tmp_path / "agents.yaml" - path.write_text( - "agents:\n" - " - id: agent-1\n" - " label: Analyst\n" - " - id: agent-2\n" - " label: Research\n" - " - [\n", - encoding="utf-8", - ) - - with pytest.raises(AgentRegistryError, match="invalid agent registry YAML"): - load_agent_registry(path) - - -@pytest.mark.parametrize( - "content", - [ - "agents:\n" - " - id: null\n" - " label: Analyst\n", - "agents:\n" - " - id: agent-1\n" - " label: null\n", - ], -) -def test_load_agent_registry_rejects_null_id_or_label(tmp_path: Path, content: str): - path = tmp_path / "agents.yaml" - path.write_text(content, encoding="utf-8") - - with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"): - load_agent_registry(path) - - -@pytest.mark.parametrize( - "content", - [ - "agents:\n" - " - id: ' '\n" - " label: Analyst\n", - "agents:\n" - " - id: agent-1\n" - " label: ' '\n", - ], -) -def test_load_agent_registry_rejects_blank_id_or_label(tmp_path: Path, content: str): - path = tmp_path / "agents.yaml" - path.write_text(content, encoding="utf-8") - - with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"): - load_agent_registry(path) - - -@pytest.mark.parametrize( - "content", - [ - "agents:\n" - " - id: 123\n" - " label: Analyst\n", - "agents:\n" - " - id: agent-1\n" - " label: 456\n", - "agents:\n" - " - id: true\n" - " label: Analyst\n", - "agents:\n" - " - id: agent-1\n" - " label: false\n", - ], -) -def test_load_agent_registry_rejects_non_string_id_or_label(tmp_path: Path, content: str): - path = tmp_path / "agents.yaml" - path.write_text(content, encoding="utf-8") - - with pytest.raises(AgentRegistryError, match="each agent entry requires id and label"): - load_agent_registry(path) diff --git a/tests/adapter/matrix/test_chat_space.py b/tests/adapter/matrix/test_chat_space.py deleted file mode 100644 index e33fb98..0000000 --- a/tests/adapter/matrix/test_chat_space.py +++ /dev/null @@ -1,202 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from nio.api import RoomVisibility -from nio.responses import RoomCreateError - -from adapter.matrix.handlers.chat import ( - make_handle_archive, - make_handle_new_chat, - make_handle_rename, -) -from adapter.matrix.store import get_room_meta, set_user_meta -from core.auth import AuthManager -from core.chat import ChatManager -from core.protocol import IncomingCommand, OutgoingMessage -from core.settings import SettingsManager -from core.store import InMemoryStore -from sdk.mock import MockPlatformClient - - -async def _setup(): - platform = MockPlatformClient() - store = InMemoryStore() - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - await auth_mgr.confirm("@alice:example.org") - return platform, store, chat_mgr, auth_mgr, settings_mgr - - -async def test_mat04_new_chat_calls_room_put_state_with_space_id(): - platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() - await set_user_meta( - store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2} - ) - - client = SimpleNamespace( - room_create=AsyncMock(return_value=SimpleNamespace(room_id="!newroom:ex")), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - ) - handler = make_handle_new_chat(client, store) - event = IncomingCommand( - user_id="@alice:example.org", - platform="matrix", - chat_id="C1", - command="new", - args=["Test"], - ) - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - client.room_create.assert_awaited_once_with( - name="Test", - visibility=RoomVisibility.private, - is_direct=False, - invite=["@alice:example.org"], - ) - client.room_put_state.assert_awaited_once() - client.room_invite.assert_not_awaited() - kwargs = client.room_put_state.call_args.kwargs - assert kwargs.get("room_id") == "!space:ex" - assert kwargs.get("event_type") == "m.space.child" - assert kwargs.get("state_key") == "!newroom:ex" - room_meta = await get_room_meta(store, "!newroom:ex") - assert room_meta is not None - assert room_meta["platform_chat_id"] == "1" - assert any(isinstance(item, OutgoingMessage) and "Test" in item.text for item in result) - - -async def test_mat05_new_chat_without_space_id_returns_error(): - platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() - await set_user_meta(store, "@alice:example.org", {"next_chat_index": 1}) - - client = SimpleNamespace( - room_create=AsyncMock(), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - ) - handler = make_handle_new_chat(client, store) - event = IncomingCommand( - user_id="@alice:example.org", - platform="matrix", - chat_id="C1", - command="new", - ) - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - assert len(result) == 1 - assert isinstance(result[0], OutgoingMessage) - assert "Space" in result[0].text or "ошибка" in result[0].text.lower() - client.room_create.assert_not_awaited() - - -async def test_mat10_archive_calls_chat_mgr_archive(): - platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() - - client = SimpleNamespace(room_leave=AsyncMock()) - handler = make_handle_archive(client, store) - event = IncomingCommand( - user_id="@alice:example.org", - platform="matrix", - chat_id="C1", - command="archive", - ) - await chat_mgr.get_or_create( - user_id="@alice:example.org", - chat_id="C1", - platform="matrix", - surface_ref="!room:ex", - name="Test", - ) - - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - assert len(result) == 1 - assert "архивирован" in result[0].text - client.room_leave.assert_awaited_once_with("!room:ex") - chats = await chat_mgr.list_active("@alice:example.org") - assert chats == [] - - -async def test_mat11_rename_updates_matrix_room_name_via_state_event(): - platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() - await chat_mgr.get_or_create( - user_id="@alice:example.org", - chat_id="C1", - platform="matrix", - surface_ref="!room:ex", - name="Old", - ) - - client = SimpleNamespace(room_put_state=AsyncMock()) - handler = make_handle_rename(client, store) - event = IncomingCommand( - user_id="@alice:example.org", - platform="matrix", - chat_id="C1", - command="rename", - args=["New", "Name"], - ) - - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - client.room_put_state.assert_awaited_once_with( - room_id="!room:ex", - event_type="m.room.name", - content={"name": "New Name"}, - state_key="", - ) - assert len(result) == 1 - assert "Переименован" in result[0].text - - -async def test_mat11b_rename_from_unregistered_room_returns_error_message(): - platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() - - client = SimpleNamespace(room_put_state=AsyncMock()) - handler = make_handle_rename(client, store) - event = IncomingCommand( - user_id="@alice:example.org", - platform="matrix", - chat_id="unregistered:!old:example.org", - command="rename", - args=["New"], - ) - - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - client.room_put_state.assert_not_awaited() - assert len(result) == 1 - assert "не найден" in result[0].text.lower() or "примите приглашение" in result[0].text.lower() - - -async def test_mat12_room_create_error_returns_user_message(): - platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup() - await set_user_meta( - store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2} - ) - - client = SimpleNamespace( - room_create=AsyncMock( - return_value=RoomCreateError(message="rate limited", status_code="429") - ), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - ) - handler = make_handle_new_chat(client, store) - event = IncomingCommand( - user_id="@alice:example.org", - platform="matrix", - chat_id="C1", - command="new", - args=["Fail"], - ) - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - assert len(result) == 1 - assert isinstance(result[0], OutgoingMessage) - assert "Не удалось" in result[0].text or "не удалось" in result[0].text - client.room_put_state.assert_not_awaited() diff --git a/tests/adapter/matrix/test_confirm.py b/tests/adapter/matrix/test_confirm.py deleted file mode 100644 index bf52613..0000000 --- a/tests/adapter/matrix/test_confirm.py +++ /dev/null @@ -1,130 +0,0 @@ -from __future__ import annotations - -from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_confirm -from adapter.matrix.store import get_pending_confirm, set_pending_confirm -from core.auth import AuthManager -from core.chat import ChatManager -from core.protocol import IncomingCallback, OutgoingMessage -from core.settings import SettingsManager -from core.store import InMemoryStore -from sdk.mock import MockPlatformClient - - -async def test_mat09_yes_reads_pending_confirm(): - store = InMemoryStore() - platform = MockPlatformClient() - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - - await set_pending_confirm( - store, - "@alice:example.org", - "!confirm:example.org", - { - "action_id": "delete_file", - "description": "Удалить файл config.yaml", - "payload": {}, - }, - ) - - handler = make_handle_confirm(store) - event = IncomingCallback( - user_id="@alice:example.org", - platform="matrix", - chat_id="C7", - action="confirm", - payload={"source": "command", "command": "yes", "room_id": "!confirm:example.org"}, - ) - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - assert len(result) == 1 - assert isinstance(result[0], OutgoingMessage) - assert "Удалить файл config.yaml" in result[0].text - assert await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") is None - - -async def test_no_clears_pending_confirm(): - store = InMemoryStore() - platform = MockPlatformClient() - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - - await set_pending_confirm( - store, - "@alice:example.org", - "!confirm:example.org", - { - "action_id": "delete_file", - "description": "Удалить файл", - "payload": {}, - }, - ) - - handler = make_handle_cancel(store) - event = IncomingCallback( - user_id="@alice:example.org", - platform="matrix", - chat_id="C7", - action="cancel", - payload={"source": "command", "command": "no", "room_id": "!confirm:example.org"}, - ) - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - assert len(result) == 1 - assert "отменено" in result[0].text.lower() - assert await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") is None - - -async def test_yes_without_pending_returns_no_pending(): - store = InMemoryStore() - platform = MockPlatformClient() - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - - handler = make_handle_confirm(store) - event = IncomingCallback( - user_id="@alice:example.org", - platform="matrix", - chat_id="C1", - action="confirm", - payload={}, - ) - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - assert len(result) == 1 - assert "Нет ожидающих" in result[0].text - - -async def test_yes_falls_back_to_legacy_chat_key_without_room_payload(): - store = InMemoryStore() - platform = MockPlatformClient() - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - - await set_pending_confirm( - store, - "legacy-chat", - { - "action_id": "delete_file", - "description": "Legacy confirm", - "payload": {}, - }, - ) - - handler = make_handle_confirm(store) - event = IncomingCallback( - user_id="@alice:example.org", - platform="matrix", - chat_id="legacy-chat", - action="confirm", - payload={"source": "command", "command": "yes"}, - ) - result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr) - - assert len(result) == 1 - assert "Legacy confirm" in result[0].text - assert await get_pending_confirm(store, "legacy-chat") is None diff --git a/tests/adapter/matrix/test_context_commands.py b/tests/adapter/matrix/test_context_commands.py deleted file mode 100644 index 9264a06..0000000 --- a/tests/adapter/matrix/test_context_commands.py +++ /dev/null @@ -1,350 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock - -import pytest - -from adapter.matrix.bot import MatrixBot, build_runtime -from adapter.matrix.handlers import register_matrix_handlers -from adapter.matrix.handlers.context_commands import ( - make_handle_context, - make_handle_load, - make_handle_reset, - make_handle_save, -) -from adapter.matrix.store import ( - get_load_pending, - set_load_pending, - set_room_meta, -) -from core.protocol import IncomingCommand, OutgoingMessage -from core.store import InMemoryStore -from sdk.interface import MessageResponse -from sdk.mock import MockPlatformClient -from sdk.prototype_state import PrototypeStateStore - - -class MatrixCommandPlatform(MockPlatformClient): - def __init__(self) -> None: - super().__init__() - self._prototype_state = PrototypeStateStore() - self._agent_api = object() - self.disconnect_chat = AsyncMock() - self.send_message = AsyncMock( - return_value=MessageResponse( - message_id="msg-1", - response="ok", - tokens_used=0, - finished=True, - ) - ) - - -@pytest.fixture(autouse=True) -def clear_matrix_registry_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False) - monkeypatch.delenv("MATRIX_PLATFORM_BACKEND", raising=False) - - -@pytest.mark.asyncio -async def test_save_command_auto_name_records_session(): - platform = MatrixCommandPlatform() - store = InMemoryStore() - await set_room_meta( - store, - "!room:example.org", - {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"}, - ) - handler = make_handle_save( - agent_api=platform._agent_api, - store=store, - prototype_state=platform._prototype_state, - ) - event = IncomingCommand( - user_id="u1", - platform="matrix", - chat_id="!room:example.org", - command="save", - args=[], - ) - - result = await handler(event, None, platform, None, None) - - assert len(result) == 1 - assert isinstance(result[0], OutgoingMessage) - assert "Запрос на сохранение отправлен агенту" in result[0].text - sessions = await platform._prototype_state.list_saved_sessions("u1") - assert len(sessions) == 1 - assert sessions[0]["name"].startswith("context-") - assert sessions[0]["source_context_id"] == "41" - - -@pytest.mark.asyncio -async def test_save_command_with_name_uses_given_name(): - platform = MatrixCommandPlatform() - store = InMemoryStore() - await set_room_meta( - store, - "!room:example.org", - {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"}, - ) - handler = make_handle_save( - agent_api=platform._agent_api, - store=store, - prototype_state=platform._prototype_state, - ) - event = IncomingCommand( - user_id="u1", - platform="matrix", - chat_id="!room:example.org", - command="save", - args=["my-session"], - ) - - await handler(event, None, platform, None, None) - - sessions = await platform._prototype_state.list_saved_sessions("u1") - assert [session["name"] for session in sessions] == ["my-session"] - - -@pytest.mark.asyncio -async def test_load_command_shows_numbered_list_and_sets_pending(): - platform = MatrixCommandPlatform() - runtime = build_runtime(platform=platform) - await runtime.chat_mgr.get_or_create( - user_id="u1", - chat_id="C1", - platform="matrix", - surface_ref="!room:example.org", - name="Chat 1", - ) - await platform._prototype_state.add_saved_session("u1", "session-a") - await platform._prototype_state.add_saved_session("u1", "session-b") - - handler = make_handle_load(store=runtime.store, prototype_state=platform._prototype_state) - event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="load", args=[]) - - result = await handler( - event, - runtime.auth_mgr, - platform, - runtime.chat_mgr, - runtime.settings_mgr, - ) - - assert "1. session-a" in result[0].text - assert "2. session-b" in result[0].text - pending = await get_load_pending(runtime.store, "u1", "!room:example.org") - assert pending is not None - assert [session["name"] for session in pending["saves"]] == ["session-a", "session-b"] - - -@pytest.mark.asyncio -async def test_load_command_without_saved_sessions_reports_empty(): - platform = MatrixCommandPlatform() - store = InMemoryStore() - handler = make_handle_load(store=store, prototype_state=platform._prototype_state) - event = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="load", args=[]) - - result = await handler(event, None, platform, None, None) - - assert "Нет сохранённых сессий" in result[0].text - - -@pytest.mark.asyncio -async def test_reset_command_assigns_new_platform_chat_id(): - from adapter.matrix.store import get_platform_chat_id, set_room_meta - from sdk.prototype_state import PrototypeStateStore - - prototype_state = PrototypeStateStore() - platform = MatrixCommandPlatform() - runtime = build_runtime(platform=platform) - store = runtime.store - - await set_room_meta(store, "!room:example.org", {"platform_chat_id": "7"}) - - handler = make_handle_reset(store=store, prototype_state=prototype_state) - event = IncomingCommand( - user_id="u1", - platform="matrix", - chat_id="!room:example.org", - command="reset", - args=[], - ) - - result = await handler( - event, - runtime.auth_mgr, - platform, - runtime.chat_mgr, - runtime.settings_mgr, - ) - - new_id = await get_platform_chat_id(store, "!room:example.org") - assert new_id != "7" - assert new_id == "1" - assert "сброшен" in result[0].text.lower() - - -@pytest.mark.asyncio -async def test_clear_command_rotates_only_current_room_and_disconnects_old_upstream_chat(): - from adapter.matrix.store import get_platform_chat_id - - platform = MatrixCommandPlatform() - runtime = build_runtime(platform=platform) - await runtime.chat_mgr.get_or_create( - user_id="u1", - chat_id="C1", - platform="matrix", - surface_ref="!room-a:example.org", - name="Chat A", - ) - await runtime.chat_mgr.get_or_create( - user_id="u1", - chat_id="C2", - platform="matrix", - surface_ref="!room-b:example.org", - name="Chat B", - ) - await set_room_meta( - runtime.store, - "!room-a:example.org", - {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"}, - ) - await set_room_meta( - runtime.store, - "!room-b:example.org", - {"chat_id": "C2", "matrix_user_id": "u1", "platform_chat_id": "99"}, - ) - - handler = make_handle_reset(store=runtime.store, prototype_state=platform._prototype_state) - event = IncomingCommand( - user_id="u1", - platform="matrix", - chat_id="C1", - command="clear", - args=[], - ) - - result = await handler( - event, - runtime.auth_mgr, - platform, - runtime.chat_mgr, - runtime.settings_mgr, - ) - - room_a_chat_id = await get_platform_chat_id(runtime.store, "!room-a:example.org") - room_b_chat_id = await get_platform_chat_id(runtime.store, "!room-b:example.org") - assert room_a_chat_id == "1" - assert room_a_chat_id != "41" - assert room_b_chat_id == "99" - platform.disconnect_chat.assert_awaited_once_with("41") - assert "сброшен" in result[0].text.lower() - - -def test_register_matrix_handlers_exposes_clear_and_optional_reset_alias(): - dispatcher = SimpleNamespace(register=Mock()) - - register_matrix_handlers( - dispatcher, - client=object(), - store=object(), - registry=None, - prototype_state=PrototypeStateStore(), - ) - - clear_calls = [ - call - for call in dispatcher.register.call_args_list - if call.args[:2] == (IncomingCommand, "clear") - ] - reset_calls = [ - call - for call in dispatcher.register.call_args_list - if call.args[:2] == (IncomingCommand, "reset") - ] - assert clear_calls - assert len(reset_calls) <= 1 - - -@pytest.mark.asyncio -async def test_context_command_shows_current_snapshot(): - platform = MatrixCommandPlatform() - runtime = build_runtime(platform=platform) - await runtime.chat_mgr.get_or_create( - user_id="u1", - chat_id="C1", - platform="matrix", - surface_ref="!room:example.org", - name="Chat 1", - ) - await set_room_meta( - runtime.store, - "!room:example.org", - {"chat_id": "C1", "matrix_user_id": "u1", "platform_chat_id": "41"}, - ) - await platform._prototype_state.set_current_session("41", "session-a") - await platform._prototype_state.set_last_tokens_used("41", 99) - await platform._prototype_state.add_saved_session("u1", "session-a") - handler = make_handle_context(store=runtime.store, prototype_state=platform._prototype_state) - event = IncomingCommand( - user_id="u1", - platform="matrix", - chat_id="C1", - command="context", - args=[], - ) - - result = await handler( - event, - runtime.auth_mgr, - platform, - runtime.chat_mgr, - runtime.settings_mgr, - ) - - assert "Контекст чата: 41" in result[0].text - assert "Сессия: session-a" in result[0].text - assert "Токены (последний ответ): 99" in result[0].text - assert "session-a" in result[0].text - - -@pytest.mark.asyncio -async def test_bot_intercepts_numeric_load_selection(): - platform = MatrixCommandPlatform() - runtime = build_runtime(platform=platform) - await set_room_meta( - runtime.store, - "!room:example.org", - { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "platform_chat_id": "41", - }, - ) - client = SimpleNamespace( - user_id="@bot:example.org", - room_send=AsyncMock(), - ) - bot = MatrixBot(client, runtime) - await set_load_pending( - runtime.store, - "@alice:example.org", - "!room:example.org", - {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]}, - ) - room = SimpleNamespace(room_id="!room:example.org") - event = SimpleNamespace(sender="@alice:example.org", body="1") - - await bot.on_room_message(room, event) - - platform.send_message.assert_awaited_once() - assert await platform._prototype_state.get_current_session("41") == "session-a" - assert await platform._prototype_state.get_current_session("C1") == "session-a" - client.room_send.assert_awaited_once_with( - "!room:example.org", - "m.room.message", - {"msgtype": "m.text", "body": "Запрос на загрузку отправлен агенту: session-a"}, - ) diff --git a/tests/adapter/matrix/test_converter.py b/tests/adapter/matrix/test_converter.py deleted file mode 100644 index 3513913..0000000 --- a/tests/adapter/matrix/test_converter.py +++ /dev/null @@ -1,179 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace - -import adapter.matrix.converter as converter -from adapter.matrix.converter import from_command, from_room_event -from core.protocol import IncomingCallback, IncomingCommand, IncomingMessage - - -def text_event(body: str, sender: str = "@a:m.org", event_id: str = "$e1"): - return SimpleNamespace( - sender=sender, body=body, event_id=event_id, msgtype="m.text", replyto_event_id=None - ) - - -def file_event(url: str = "mxc://x/y", filename: str = "doc.pdf", mime: str = "application/pdf"): - return SimpleNamespace( - sender="@a:m.org", - body=filename, - event_id="$e2", - msgtype="m.file", - replyto_event_id=None, - url=url, - mimetype=mime, - ) - - -def image_event(url: str = "mxc://x/img", mime: str = "image/jpeg"): - return SimpleNamespace( - sender="@a:m.org", - body="img.jpg", - event_id="$e3", - msgtype="m.image", - replyto_event_id=None, - url=url, - mimetype=mime, - ) - - -def content_file_event(): - return SimpleNamespace( - sender="@a:m.org", - body="doc.pdf", - event_id="$e4", - msgtype=None, - replyto_event_id=None, - content={ - "msgtype": "m.file", - "body": "nested.pdf", - "url": "mxc://x/nested", - "info": {"mimetype": "application/pdf"}, - }, - ) - - -def source_only_content_file_event(): - return SimpleNamespace( - sender="@a:m.org", - body="doc.pdf", - event_id="$e5", - msgtype=None, - replyto_event_id=None, - source={ - "content": { - "msgtype": "m.file", - "body": "source-only.pdf", - "url": "mxc://x/source-only", - "info": {"mimetype": "application/pdf"}, - } - }, - ) - - -def test_plain_text_to_incoming_message(): - result = from_room_event(text_event("Hello"), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingMessage) - assert result.text == "Hello" - assert result.platform == "matrix" - assert result.chat_id == "C1" - assert result.attachments == [] - - -def test_bang_command_to_incoming_command(): - result = from_room_event(text_event("!new Analysis"), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingCommand) - assert result.command == "new" - assert result.args == ["Analysis"] - - -def test_list_command_maps_to_matrix_list_attachments(): - result = from_room_event(text_event("!list"), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingCommand) - assert result.command == "matrix_list_attachments" - assert result.args == [] - - -def test_remove_all_maps_to_matrix_remove_attachment(): - result = from_room_event(text_event("!remove all"), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingCommand) - assert result.command == "matrix_remove_attachment" - assert result.args == ["all"] - - -def test_remove_index_maps_to_matrix_remove_attachment(): - result = from_room_event(text_event("!remove 2"), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingCommand) - assert result.command == "matrix_remove_attachment" - assert result.args == ["2"] - - -def test_remove_arbitrary_index_maps_to_matrix_remove_attachment(): - result = from_room_event(text_event("!remove 99"), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingCommand) - assert result.command == "matrix_remove_attachment" - assert result.args == ["99"] - - -def test_skills_alias_to_settings_command(): - result = from_command("!skills", sender="@a:m.org", chat_id="C1") - assert isinstance(result, IncomingCommand) - assert result.command == "settings_skills" - - -def test_yes_to_callback(): - result = from_room_event(text_event("!yes"), room_id="!room:example.org", chat_id="C7") - assert isinstance(result, IncomingCallback) - assert result.action == "confirm" - assert result.chat_id == "C7" - assert result.payload["room_id"] == "!room:example.org" - - -def test_no_to_callback(): - result = from_room_event(text_event("!no"), room_id="!room:example.org", chat_id="C7") - assert isinstance(result, IncomingCallback) - assert result.action == "cancel" - assert result.chat_id == "C7" - assert result.payload["room_id"] == "!room:example.org" - - -def test_file_attachment(): - result = from_room_event(file_event(), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingMessage) - assert len(result.attachments) == 1 - a = result.attachments[0] - assert a.type == "document" - assert a.url == "mxc://x/y" - assert a.filename == "doc.pdf" - assert a.mime_type == "application/pdf" - - -def test_image_attachment(): - result = from_room_event(image_event(), room_id="!r:m.org", chat_id="C1") - assert result.attachments[0].type == "image" - assert result.attachments[0].filename == "img.jpg" - assert result.attachments[0].mime_type == "image/jpeg" - - -def test_attachment_falls_back_to_content_payload(): - result = from_room_event(content_file_event(), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingMessage) - a = result.attachments[0] - assert a.type == "document" - assert a.url == "mxc://x/nested" - assert a.filename == "nested.pdf" - assert a.mime_type == "application/pdf" - - -def test_attachment_falls_back_to_source_content_payload(): - result = from_room_event(source_only_content_file_event(), room_id="!r:m.org", chat_id="C1") - assert isinstance(result, IncomingMessage) - a = result.attachments[0] - assert a.type == "document" - assert a.url == "mxc://x/source-only" - assert a.filename == "source-only.pdf" - assert a.mime_type == "application/pdf" - - -def test_converter_module_does_not_expose_reaction_callbacks(): - assert not hasattr(converter, "from_reaction") diff --git a/tests/adapter/matrix/test_dispatcher.py b/tests/adapter/matrix/test_dispatcher.py deleted file mode 100644 index 1240f86..0000000 --- a/tests/adapter/matrix/test_dispatcher.py +++ /dev/null @@ -1,1110 +0,0 @@ -from __future__ import annotations - -import importlib -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest -from nio import ( - RoomMessageAudio, - RoomMessageFile, - RoomMessageImage, - RoomMessageText, - RoomMessageVideo, -) -from nio.api import RoomVisibility -from nio.responses import SyncResponse - -from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry -from adapter.matrix.bot import MatrixBot, build_runtime, prepare_live_sync -from adapter.matrix.handlers.auth import handle_invite -from adapter.matrix.routed_platform import RoutedPlatformClient -from adapter.matrix.store import ( - add_staged_attachment, - get_platform_chat_id, - get_room_meta, - get_staged_attachments, - get_user_meta, - set_load_pending, - set_room_meta, - set_user_meta, -) -from core.protocol import ( - Attachment, - IncomingCallback, - IncomingCommand, - IncomingMessage, - OutgoingMessage, -) -from sdk.interface import PlatformError -from sdk.mock import MockPlatformClient - - -async def test_matrix_dispatcher_registers_custom_handlers(): - runtime = build_runtime(platform=MockPlatformClient()) - current_chat_id = "C9" - - start = IncomingCommand( - user_id="u1", platform="matrix", chat_id=current_chat_id, command="start" - ) - await runtime.dispatcher.dispatch(start) - - new = IncomingCommand( - user_id="u1", platform="matrix", chat_id=current_chat_id, command="new", args=["Research"] - ) - result = await runtime.dispatcher.dispatch(new) - assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result) - - chats = await runtime.chat_mgr.list_active("u1") - assert [c.chat_id for c in chats] == ["C1"] - assert [c.surface_ref for c in chats] == [current_chat_id] - - new2 = IncomingCommand( - user_id="u1", platform="matrix", chat_id=current_chat_id, command="new", args=["Ops"] - ) - await runtime.dispatcher.dispatch(new2) - chats = await runtime.chat_mgr.list_active("u1") - assert [c.chat_id for c in chats] == ["C1", "C2"] - - skills = IncomingCommand( - user_id="u1", platform="matrix", chat_id=current_chat_id, command="settings_skills" - ) - result = await runtime.dispatcher.dispatch(skills) - assert any(isinstance(r, OutgoingMessage) and "mvp" in r.text.lower() for r in result) - - toggle = IncomingCallback( - user_id="u1", - platform="matrix", - chat_id="C1", - action="toggle_skill", - payload={"skill_index": 2}, - ) - result = await runtime.dispatcher.dispatch(toggle) - assert any(isinstance(r, OutgoingMessage) and "mvp" in r.text.lower() for r in result) - - -async def test_new_chat_creates_real_matrix_room_when_client_available(): - client = SimpleNamespace( - room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - ) - runtime = build_runtime(platform=MockPlatformClient(), client=client) - await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 7}) - - start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C3", command="start") - await runtime.dispatcher.dispatch(start) - - new = IncomingCommand( - user_id="u1", - platform="matrix", - chat_id="C3", - command="new", - args=["Research"], - ) - result = await runtime.dispatcher.dispatch(new) - - # room_create is now called with agent_id=None when registry is not configured - assert client.room_create.await_count >= 1 - client.room_put_state.assert_awaited_once() - put_call = client.room_put_state.call_args - assert ( - put_call.kwargs.get("room_id") == "!space:example" - or put_call.args[0] == "!space:example" - ) - chats = await runtime.chat_mgr.list_active("u1") - assert [c.chat_id for c in chats] == ["C7"] - assert [c.surface_ref for c in chats] == ["!r2:example"] - assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result) - - -async def test_invite_event_creates_space_and_chat_room(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 4}) - space_resp = SimpleNamespace(room_id="!space:example.org") - chat_resp = SimpleNamespace(room_id="!chat1:example.org") - client = SimpleNamespace( - join=AsyncMock(), - room_create=AsyncMock(side_effect=[space_resp, chat_resp]), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - room_send=AsyncMock(), - ) - room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice") - event = SimpleNamespace(sender="@alice:example.org", membership="invite") - - await handle_invite( - client, - room, - event, - runtime.platform, - runtime.store, - runtime.auth_mgr, - runtime.chat_mgr, - ) - - assert client.room_create.await_count == 2 - first_call = client.room_create.call_args_list[0] - assert first_call.kwargs.get("space") is True or ( - len(first_call.args) > 0 and first_call.kwargs.get("space") is True - ) - assert first_call.kwargs.get("visibility") is RoomVisibility.private - assert first_call.kwargs.get("invite") == ["@alice:example.org"] - second_call = client.room_create.call_args_list[1] - assert second_call.kwargs.get("visibility") is RoomVisibility.private - assert second_call.kwargs.get("invite") == ["@alice:example.org"] - client.room_invite.assert_not_awaited() - - client.room_put_state.assert_awaited_once() - put_state_call = client.room_put_state.call_args - assert ( - put_state_call.kwargs.get("event_type") == "m.space.child" - or put_state_call.args[1] == "m.space.child" - ) - - user_meta = await get_user_meta(runtime.store, "@alice:example.org") - assert user_meta is not None - assert user_meta.get("space_id") == "!space:example.org" - - room_meta = await get_room_meta(runtime.store, "!chat1:example.org") - assert room_meta is not None - assert room_meta["chat_id"] == "C4" - assert room_meta["space_id"] == "!space:example.org" - - assert await runtime.auth_mgr.is_authenticated("@alice:example.org") is True - assert user_meta.get("next_chat_index") == 5 - client.room_send.assert_awaited_once() - - -async def test_invite_event_is_idempotent_per_user(): - runtime = build_runtime(platform=MockPlatformClient()) - space_resp = SimpleNamespace(room_id="!space:example.org") - chat_resp = SimpleNamespace(room_id="!chat1:example.org") - client = SimpleNamespace( - join=AsyncMock(), - room_create=AsyncMock(side_effect=[space_resp, chat_resp]), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - room_send=AsyncMock(), - ) - room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice") - event = SimpleNamespace(sender="@alice:example.org", membership="invite") - - await handle_invite( - client, - room, - event, - runtime.platform, - runtime.store, - runtime.auth_mgr, - runtime.chat_mgr, - ) - await handle_invite( - client, - room, - event, - runtime.platform, - runtime.store, - runtime.auth_mgr, - runtime.chat_mgr, - ) - - assert client.join.await_count == 2 - assert client.room_create.await_count == 2 - assert client.room_send.await_count == 2 - - -async def test_bot_ignores_its_own_messages(): - runtime = build_runtime(platform=MockPlatformClient()) - client = SimpleNamespace(user_id="@bot:example.org") - bot = MatrixBot(client, runtime) - bot._send_all = AsyncMock() - runtime.dispatcher.dispatch = AsyncMock() - room = SimpleNamespace(room_id="!dm:example.org") - event = SimpleNamespace(sender="@bot:example.org", body="hello") - - await bot.on_room_message(room, event) - - runtime.dispatcher.dispatch.assert_not_awaited() - bot._send_all.assert_not_awaited() - - -async def test_bot_degrades_platform_errors_to_user_reply(): - runtime = build_runtime(platform=MockPlatformClient()) - client = SimpleNamespace( - user_id="@bot:example.org", - room_send=AsyncMock(), - ) - bot = MatrixBot(client, runtime) - runtime.dispatcher.dispatch = AsyncMock( - side_effect=PlatformError("Missing Authentication header", code="401") - ) - room = SimpleNamespace(room_id="!dm:example.org") - event = SimpleNamespace(sender="@alice:example.org", body="hello") - - await bot.on_room_message(room, event) - - client.room_send.assert_awaited_once_with( - "!dm:example.org", - "m.room.message", - { - "msgtype": "m.text", - "body": "Сервис временно недоступен. Попробуйте ещё раз позже.", - }, - ) - - -async def test_bot_assigns_platform_chat_id_for_existing_managed_room(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_room_meta( - runtime.store, - "!chat1:example.org", - {"chat_id": "C1", "matrix_user_id": "@alice:example.org"}, - ) - client = SimpleNamespace(user_id="@bot:example.org") - bot = MatrixBot(client, runtime) - bot._send_all = AsyncMock() - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - room = SimpleNamespace(room_id="!chat1:example.org") - event = SimpleNamespace(sender="@alice:example.org", body="hello") - - await bot.on_room_message(room, event) - - assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "1" - runtime.dispatcher.dispatch.assert_awaited_once() - - -async def test_bot_keeps_local_chat_id_for_plain_messages(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_room_meta( - runtime.store, - "!chat1:example.org", - { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "platform_chat_id": "41", - }, - ) - client = SimpleNamespace(user_id="@bot:example.org") - bot = MatrixBot(client, runtime) - bot._send_all = AsyncMock() - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - room = SimpleNamespace(room_id="!chat1:example.org") - event = SimpleNamespace(sender="@alice:example.org", body="hello") - - await bot.on_room_message(room, event) - - dispatched = runtime.dispatcher.dispatch.await_args.args[0] - assert dispatched.chat_id == "C1" - assert dispatched.text == "hello" - - -async def test_bot_downloads_matrix_file_to_workspace_before_staging(tmp_path, monkeypatch): - monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path)) - runtime = build_runtime(platform=MockPlatformClient()) - await set_room_meta( - runtime.store, - "!chat1:example.org", - { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "platform_chat_id": "41", - }, - ) - client = SimpleNamespace( - user_id="@bot:example.org", - download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")), - ) - bot = MatrixBot(client, runtime) - bot._send_all = AsyncMock() - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - room = SimpleNamespace(room_id="!chat1:example.org") - event = SimpleNamespace( - sender="@alice:example.org", - body="report.pdf", - msgtype="m.file", - replyto_event_id=None, - url="mxc://server/id", - mimetype="application/pdf", - ) - - await bot.on_room_message(room, event) - - runtime.dispatcher.dispatch.assert_not_awaited() - staged = await get_staged_attachments(runtime.store, "!chat1:example.org", "@alice:example.org") - assert staged[0]["workspace_path"] is not None - assert (tmp_path / staged[0]["workspace_path"]).read_bytes() == b"%PDF-1.7" - bot._send_all.assert_not_awaited() - - -async def test_bot_downloads_matrix_file_to_configured_agent_workspace(tmp_path, monkeypatch): - monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents")) - runtime = build_runtime(platform=MockPlatformClient()) - runtime.registry = AgentRegistry( - [ - AgentDefinition( - agent_id="agent-17", - label="Agent 17", - base_url="http://lambda.coredump.ru:7000/agent_17/", - workspace_path=str(tmp_path / "agents" / "17"), - ) - ], - user_agents={"@alice:example.org": "agent-17"}, - ) - await set_room_meta( - runtime.store, - "!chat17:example.org", - { - "chat_id": "C17", - "matrix_user_id": "@alice:example.org", - "platform_chat_id": "17", - "agent_id": "agent-17", - }, - ) - client = SimpleNamespace( - user_id="@bot:example.org", - download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")), - ) - bot = MatrixBot(client, runtime) - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - room = SimpleNamespace(room_id="!chat17:example.org") - event = SimpleNamespace( - sender="@alice:example.org", - body="report.pdf", - msgtype="m.file", - replyto_event_id=None, - url="mxc://server/id", - mimetype="application/pdf", - ) - - await bot.on_room_message(room, event) - - staged = await get_staged_attachments( - runtime.store, "!chat17:example.org", "@alice:example.org" - ) - assert staged[0]["workspace_path"] == "report.pdf" - assert ( - tmp_path / "agents" / "17" / staged[0]["workspace_path"] - ).read_bytes() == b"%PDF-1.7" - - -async def test_bot_uploads_agent_output_from_configured_agent_workspace(tmp_path, monkeypatch): - monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents")) - output_file = tmp_path / "agents" / "17" / "result.txt" - output_file.parent.mkdir(parents=True) - output_file.write_text("ready", encoding="utf-8") - runtime = build_runtime(platform=MockPlatformClient()) - runtime.registry = AgentRegistry( - [ - AgentDefinition( - agent_id="agent-17", - label="Agent 17", - base_url="http://lambda.coredump.ru:7000/agent_17/", - workspace_path=str(tmp_path / "agents" / "17"), - ) - ], - user_agents={"@alice:example.org": "agent-17"}, - ) - await set_room_meta( - runtime.store, - "!chat17:example.org", - { - "chat_id": "C17", - "matrix_user_id": "@alice:example.org", - "platform_chat_id": "17", - "agent_id": "agent-17", - }, - ) - client = SimpleNamespace( - user_id="@bot:example.org", - upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/result"), {})), - room_send=AsyncMock(), - ) - bot = MatrixBot(client, runtime) - runtime.dispatcher.dispatch = AsyncMock( - return_value=[ - OutgoingMessage( - chat_id="C17", - text="Файл готов", - attachments=[ - Attachment( - type="document", - filename="result.txt", - mime_type="text/plain", - workspace_path="result.txt", - ) - ], - ) - ] - ) - room = SimpleNamespace(room_id="!chat17:example.org") - event = SimpleNamespace( - sender="@alice:example.org", - body="сделай отчёт", - msgtype="m.text", - replyto_event_id=None, - ) - - await bot.on_room_message(room, event) - - uploaded_handle = client.upload.await_args.args[0] - assert uploaded_handle.name == str(output_file) - assert client.room_send.await_args_list[1].args[2]["url"] == "mxc://server/result" - - -async def test_file_only_event_is_staged_and_does_not_dispatch(): - runtime = build_runtime(platform=MockPlatformClient()) - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - bot._materialize_incoming_attachments = AsyncMock( - return_value=IncomingMessage( - user_id="@alice:example.org", - platform="matrix", - chat_id="!r:example.org", - text="", - attachments=[ - Attachment( - type="document", - filename="report.pdf", - workspace_path="surfaces/matrix/alice/r/inbox/report.pdf", - mime_type="application/pdf", - ) - ], - ) - ) - room = SimpleNamespace(room_id="!r:example.org") - event = SimpleNamespace( - sender="@alice:example.org", - body="report.pdf", - msgtype="m.file", - url="mxc://hs/id", - mimetype="application/pdf", - replyto_event_id=None, - ) - - await bot.on_room_message(room, event) - - runtime.dispatcher.dispatch.assert_not_awaited() - staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") - assert [item["filename"] for item in staged] == ["report.pdf"] - client.room_send.assert_not_awaited() - - -async def test_list_command_returns_current_staged_attachments(): - runtime = build_runtime(platform=MockPlatformClient()) - await add_staged_attachment( - runtime.store, - "!r:example.org", - "@alice:example.org", - {"filename": "a.pdf", "workspace_path": "a.pdf"}, - ) - await add_staged_attachment( - runtime.store, - "!r:example.org", - "@alice:example.org", - {"filename": "b.pdf", "workspace_path": "b.pdf"}, - ) - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - room = SimpleNamespace(room_id="!r:example.org") - event = SimpleNamespace( - sender="@alice:example.org", body="!list", msgtype="m.text", replyto_event_id=None - ) - - await bot.on_room_message(room, event) - - runtime.dispatcher.dispatch.assert_not_awaited() - body = client.room_send.await_args.args[2]["body"] - assert "1. a.pdf" in body - assert "2. b.pdf" in body - - -async def test_remove_invalid_index_returns_short_error(): - runtime = build_runtime(platform=MockPlatformClient()) - await add_staged_attachment( - runtime.store, - "!r:example.org", - "@alice:example.org", - {"filename": "a.pdf", "workspace_path": "a.pdf"}, - ) - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - room = SimpleNamespace(room_id="!r:example.org") - event = SimpleNamespace( - sender="@alice:example.org", body="!remove 9", msgtype="m.text", replyto_event_id=None - ) - - await bot.on_room_message(room, event) - - runtime.dispatcher.dispatch.assert_not_awaited() - assert client.room_send.await_args.args[2]["body"] == "Нет такого вложения." - - -async def test_remove_attachment_updates_list_and_state(): - runtime = build_runtime(platform=MockPlatformClient()) - await add_staged_attachment( - runtime.store, - "!r:example.org", - "@alice:example.org", - {"filename": "a.pdf", "workspace_path": "a.pdf"}, - ) - await add_staged_attachment( - runtime.store, - "!r:example.org", - "@alice:example.org", - {"filename": "b.pdf", "workspace_path": "b.pdf"}, - ) - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - room = SimpleNamespace(room_id="!r:example.org") - event = SimpleNamespace( - sender="@alice:example.org", body="!remove 1", msgtype="m.text", replyto_event_id=None - ) - - await bot.on_room_message(room, event) - - runtime.dispatcher.dispatch.assert_not_awaited() - staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") - assert [item["filename"] for item in staged] == ["b.pdf"] - body = client.room_send.await_args.args[2]["body"] - assert "1. b.pdf" in body - assert "a.pdf" not in body - - -async def test_remove_all_clears_state(): - runtime = build_runtime(platform=MockPlatformClient()) - await add_staged_attachment( - runtime.store, - "!r:example.org", - "@alice:example.org", - {"filename": "a.pdf", "workspace_path": "a.pdf"}, - ) - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - room = SimpleNamespace(room_id="!r:example.org") - event = SimpleNamespace( - sender="@alice:example.org", - body="!remove all", - msgtype="m.text", - replyto_event_id=None, - ) - - await bot.on_room_message(room, event) - - runtime.dispatcher.dispatch.assert_not_awaited() - assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == [] - assert client.room_send.await_args.args[2]["body"] == "Все вложения удалены." - - -async def test_staged_attachment_commands_are_scoped_by_room_and_user(): - runtime = build_runtime(platform=MockPlatformClient()) - await add_staged_attachment( - runtime.store, - "!r-one:example.org", - "@alice:example.org", - {"filename": "alice-room-one.pdf", "workspace_path": "alice-room-one.pdf"}, - ) - await add_staged_attachment( - runtime.store, - "!r-two:example.org", - "@alice:example.org", - {"filename": "alice-room-two.pdf", "workspace_path": "alice-room-two.pdf"}, - ) - await add_staged_attachment( - runtime.store, - "!r-one:example.org", - "@bob:example.org", - {"filename": "bob-room-one.pdf", "workspace_path": "bob-room-one.pdf"}, - ) - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - room = SimpleNamespace(room_id="!r-one:example.org") - event = SimpleNamespace( - sender="@alice:example.org", - body="!list", - msgtype="m.text", - replyto_event_id=None, - ) - - await bot.on_room_message(room, event) - - runtime.dispatcher.dispatch.assert_not_awaited() - body = client.room_send.await_args.args[2]["body"] - assert "alice-room-one.pdf" in body - assert "alice-room-two.pdf" not in body - assert "bob-room-one.pdf" not in body - - -async def test_next_normal_message_commits_staged_attachments(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_room_meta( - runtime.store, - "!r:example.org", - { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "platform_chat_id": "41", - }, - ) - await add_staged_attachment( - runtime.store, - "!r:example.org", - "@alice:example.org", - { - "type": "document", - "filename": "report.pdf", - "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf", - "mime_type": "application/pdf", - }, - ) - client = SimpleNamespace(user_id="@bot:example.org") - bot = MatrixBot(client, runtime) - bot._send_all = AsyncMock() - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - room = SimpleNamespace(room_id="!r:example.org") - event = SimpleNamespace( - sender="@alice:example.org", - body="Проанализируй", - msgtype="m.text", - replyto_event_id=None, - ) - - await bot.on_room_message(room, event) - - dispatched = runtime.dispatcher.dispatch.await_args.args[0] - assert isinstance(dispatched, IncomingMessage) - assert dispatched.text == "Проанализируй" - assert [a.workspace_path for a in dispatched.attachments] == [ - "surfaces/matrix/alice/r/inbox/report.pdf" - ] - assert await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") == [] - - -async def test_failed_commit_preserves_staged_attachments(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_room_meta( - runtime.store, - "!r:example.org", - { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "platform_chat_id": "41", - }, - ) - await add_staged_attachment( - runtime.store, - "!r:example.org", - "@alice:example.org", - { - "type": "document", - "filename": "report.pdf", - "workspace_path": "surfaces/matrix/alice/r/inbox/report.pdf", - }, - ) - client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) - bot = MatrixBot(client, runtime) - runtime.dispatcher.dispatch = AsyncMock(side_effect=PlatformError("boom")) - room = SimpleNamespace(room_id="!r:example.org") - event = SimpleNamespace( - sender="@alice:example.org", - body="Проанализируй", - msgtype="m.text", - replyto_event_id=None, - ) - - await bot.on_room_message(room, event) - - staged = await get_staged_attachments(runtime.store, "!r:example.org", "@alice:example.org") - assert [item["filename"] for item in staged] == ["report.pdf"] - - -async def test_bot_keeps_commands_on_local_chat_id(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_room_meta( - runtime.store, - "!chat1:example.org", - { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "platform_chat_id": "41", - }, - ) - client = SimpleNamespace(user_id="@bot:example.org") - bot = MatrixBot(client, runtime) - bot._send_all = AsyncMock() - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - room = SimpleNamespace(room_id="!chat1:example.org") - event = SimpleNamespace(sender="@alice:example.org", body="!rename New") - - await bot.on_room_message(room, event) - - dispatched = runtime.dispatcher.dispatch.await_args.args[0] - assert dispatched.chat_id == "C1" - assert dispatched.command == "rename" - - -async def test_bot_leaves_existing_platform_chat_id_unchanged(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_room_meta( - runtime.store, - "!chat1:example.org", - { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "platform_chat_id": "99", - }, - ) - client = SimpleNamespace(user_id="@bot:example.org") - bot = MatrixBot(client, runtime) - bot._send_all = AsyncMock() - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - room = SimpleNamespace(room_id="!chat1:example.org") - event = SimpleNamespace(sender="@alice:example.org", body="hello") - - await bot.on_room_message(room, event) - - assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "99" - runtime.dispatcher.dispatch.assert_awaited_once() - - -async def test_bot_assigns_platform_chat_id_before_load_selection(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_room_meta( - runtime.store, - "!chat1:example.org", - {"chat_id": "C1", "matrix_user_id": "@alice:example.org"}, - ) - client = SimpleNamespace( - user_id="@bot:example.org", - room_send=AsyncMock(), - ) - bot = MatrixBot(client, runtime) - await set_load_pending( - runtime.store, - "@alice:example.org", - "!chat1:example.org", - {"saves": [{"name": "session-a", "created_at": "2026-04-17T00:00:00+00:00"}]}, - ) - room = SimpleNamespace(room_id="!chat1:example.org") - event = SimpleNamespace(sender="@alice:example.org", body="0") - - await bot.on_room_message(room, event) - - assert await get_platform_chat_id(runtime.store, "!chat1:example.org") == "1" - client.room_send.assert_awaited_once_with( - "!chat1:example.org", - "m.room.message", - {"msgtype": "m.text", "body": "Отменено."}, - ) - - -async def test_unregistered_room_bootstraps_space_and_chat_on_first_message(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 1}) - space_resp = SimpleNamespace(room_id="!space:example.org") - chat_resp = SimpleNamespace(room_id="!chat1:example.org") - client = SimpleNamespace( - user_id="@bot:example.org", - room_create=AsyncMock(side_effect=[space_resp, chat_resp]), - room_put_state=AsyncMock(), - room_send=AsyncMock(), - ) - bot = MatrixBot(client, runtime) - room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry") - event = SimpleNamespace(sender="@alice:example.org", body="hello") - - await bot.on_room_message(room, event) - - assert client.room_create.await_count == 2 - first_call = client.room_create.call_args_list[0] - second_call = client.room_create.call_args_list[1] - assert first_call.kwargs.get("space") is True - assert first_call.kwargs.get("invite") == ["@alice:example.org"] - assert second_call.kwargs.get("name") == "Чат 1" - assert second_call.kwargs.get("invite") == ["@alice:example.org"] - client.room_put_state.assert_awaited_once() - room_meta = await get_room_meta(runtime.store, "!chat1:example.org") - assert room_meta is not None - assert room_meta["chat_id"] == "C1" - user_meta = await get_user_meta(runtime.store, "@alice:example.org") - assert user_meta is not None - assert user_meta["space_id"] == "!space:example.org" - room_send_calls = client.room_send.await_args_list - assert any(call.args[0] == "!chat1:example.org" for call in room_send_calls) - assert any(call.args[0] == "!entry:example.org" for call in room_send_calls) - entry_meta = await get_room_meta(runtime.store, "!entry:example.org") - assert entry_meta == { - "matrix_user_id": "@alice:example.org", - "redirect_room_id": "!chat1:example.org", - "redirect_chat_id": "C1", - } - - -async def test_unregistered_room_second_message_reuses_existing_bootstrap(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 1}) - space_resp = SimpleNamespace(room_id="!space:example.org") - chat_resp = SimpleNamespace(room_id="!chat1:example.org") - client = SimpleNamespace( - user_id="@bot:example.org", - room_create=AsyncMock(side_effect=[space_resp, chat_resp]), - room_put_state=AsyncMock(), - room_send=AsyncMock(), - ) - bot = MatrixBot(client, runtime) - room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry") - - await bot.on_room_message(room, SimpleNamespace(sender="@alice:example.org", body="hello")) - await bot.on_room_message( - room, SimpleNamespace(sender="@alice:example.org", body="hello again") - ) - - assert client.room_create.await_count == 2 - room_send_calls = client.room_send.await_args_list - assert any(call.args[0] == "!entry:example.org" for call in room_send_calls) - assert any( - call.args[0] == "!entry:example.org" - and "Рабочий чат уже создан: C1" in call.args[2]["body"] - for call in room_send_calls - ) - entry_meta = await get_room_meta(runtime.store, "!entry:example.org") - assert entry_meta is not None - assert "platform_chat_id" not in entry_meta - - -async def test_unregistered_room_welcome_send_failure_does_not_repeat_bootstrap(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 1}) - space_resp = SimpleNamespace(room_id="!space:example.org") - chat_resp = SimpleNamespace(room_id="!chat1:example.org") - client = SimpleNamespace( - user_id="@bot:example.org", - room_create=AsyncMock(side_effect=[space_resp, chat_resp]), - room_put_state=AsyncMock(), - room_send=AsyncMock(side_effect=[RuntimeError("welcome failed"), None]), - ) - bot = MatrixBot(client, runtime) - room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry") - - with pytest.raises(RuntimeError, match="welcome failed"): - await bot.on_room_message(room, SimpleNamespace(sender="@alice:example.org", body="hello")) - - entry_meta = await get_room_meta(runtime.store, "!entry:example.org") - assert entry_meta == { - "matrix_user_id": "@alice:example.org", - "redirect_room_id": "!chat1:example.org", - "redirect_chat_id": "C1", - } - - await bot.on_room_message( - room, SimpleNamespace(sender="@alice:example.org", body="hello again") - ) - - assert client.room_create.await_count == 2 - room_send_calls = client.room_send.await_args_list - assert any( - call.args[0] == "!entry:example.org" - and "Рабочий чат уже создан: C1" in call.args[2]["body"] - for call in room_send_calls - ) - - -async def test_unregistered_room_creates_new_chat_in_existing_space(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_user_meta( - runtime.store, - "@alice:example.org", - {"space_id": "!space:example.org", "next_chat_index": 4}, - ) - chat_resp = SimpleNamespace(room_id="!chat4:example.org") - client = SimpleNamespace( - user_id="@bot:example.org", - room_create=AsyncMock(return_value=chat_resp), - room_put_state=AsyncMock(), - room_send=AsyncMock(), - ) - bot = MatrixBot(client, runtime) - room = SimpleNamespace(room_id="!entry:example.org", display_name="Entry") - event = SimpleNamespace(sender="@alice:example.org", body="hello") - - await bot.on_room_message(room, event) - - client.room_create.assert_awaited_once_with( - name="Чат 4", - visibility=RoomVisibility.private, - is_direct=False, - invite=["@alice:example.org"], - ) - client.room_put_state.assert_awaited_once() - room_meta = await get_room_meta(runtime.store, "!chat4:example.org") - assert room_meta is not None - assert room_meta["chat_id"] == "C4" - - -async def test_mat11_settings_returns_mvp_unavailable_message(): - runtime = build_runtime(platform=MockPlatformClient()) - current_chat_id = "C9" - - start = IncomingCommand( - user_id="u1", platform="matrix", chat_id=current_chat_id, command="start" - ) - await runtime.dispatcher.dispatch(start) - - settings_cmd = IncomingCommand( - user_id="u1", platform="matrix", chat_id=current_chat_id, command="settings" - ) - result = await runtime.dispatcher.dispatch(settings_cmd) - - assert len(result) == 1 - text = result[0].text - assert "недоступна" in text.lower() - assert "mvp" in text.lower() - - -async def test_mat12_help_returns_command_reference(): - runtime = build_runtime(platform=MockPlatformClient()) - - result = await runtime.dispatcher.dispatch( - IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="help") - ) - - assert len(result) == 1 - text = result[0].text - assert "!new" in text - assert "!chats" in text - assert "!rename" in text - assert "!archive" in text - assert "!clear" in text - assert "!list" in text - assert "!yes" in text - assert "!context" not in text - assert "!save" not in text - assert "!load" not in text - assert "!agent" not in text - assert "!settings" not in text - assert "!skills" not in text - - -async def test_unknown_command_returns_helpful_message(): - runtime = build_runtime(platform=MockPlatformClient()) - - result = await runtime.dispatcher.dispatch( - IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="clear") - ) - - assert len(result) == 1 - assert "неизвестная команда" in result[0].text.lower() - - -async def test_prepare_live_sync_returns_next_batch_from_bootstrap_sync(): - client = SimpleNamespace( - sync=AsyncMock( - return_value=SyncResponse( - next_batch="s123", - rooms={}, - device_key_count={}, - device_list=SimpleNamespace(changed=[], left=[]), - to_device_events=[], - presence_events=[], - account_data_events=[], - ) - ) - ) - - since = await prepare_live_sync(client) - - client.sync.assert_awaited_once_with(timeout=0, full_state=True) - assert since == "s123" - - -async def test_build_runtime_uses_routed_platform_when_matrix_backend_is_real( - monkeypatch, tmp_path -): - registry_path = tmp_path / "agents.yaml" - registry_path.write_text( - "agents:\n - id: agent-1\n label: Analyst\n", encoding="utf-8" - ) - monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real") - monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example") - monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path)) - - runtime = build_runtime() - - assert isinstance(runtime.platform, RoutedPlatformClient) - - -async def test_matrix_main_closes_platform_without_connecting_root_agent(monkeypatch): - bot_module = importlib.import_module("adapter.matrix.bot") - - platform_close = AsyncMock() - runtime = SimpleNamespace(platform=SimpleNamespace(close=platform_close)) - - class FakeAsyncClient: - def __init__(self, *args, **kwargs): - self.access_token = None - self.callbacks = [] - self.sync_forever = AsyncMock() - self.close = AsyncMock() - - async def login(self, *args, **kwargs): - raise AssertionError("login should not be called when access token is provided") - - def add_event_callback(self, callback, event_type): - self.callbacks.append((callback, event_type)) - - monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") - monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org") - monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token") - monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient) - monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime) - monkeypatch.setattr(bot_module, "prepare_live_sync", AsyncMock(return_value="s123")) - - await bot_module.main() - - platform_close.assert_awaited_once() - - -async def test_matrix_main_registers_media_message_callbacks(monkeypatch): - bot_module = importlib.import_module("adapter.matrix.bot") - - runtime = SimpleNamespace(platform=SimpleNamespace(close=AsyncMock())) - created_clients = [] - - class FakeAsyncClient: - def __init__(self, *args, **kwargs): - self.access_token = None - self.callbacks = [] - self.sync_forever = AsyncMock() - self.close = AsyncMock() - created_clients.append(self) - - async def login(self, *args, **kwargs): - raise AssertionError("login should not be called when access token is provided") - - def add_event_callback(self, callback, event_type): - self.callbacks.append((callback, event_type)) - - monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") - monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org") - monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token") - monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient) - monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime) - monkeypatch.setattr(bot_module, "prepare_live_sync", AsyncMock(return_value="s123")) - - await bot_module.main() - - assert len(created_clients) == 1 - registered_types = [event_type for _, event_type in created_clients[0].callbacks] - assert ( - RoomMessageText, - RoomMessageFile, - RoomMessageImage, - RoomMessageVideo, - RoomMessageAudio, - ) in registered_types diff --git a/tests/adapter/matrix/test_files.py b/tests/adapter/matrix/test_files.py deleted file mode 100644 index a3a9146..0000000 --- a/tests/adapter/matrix/test_files.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from types import SimpleNamespace - -from adapter.matrix.files import ( - build_agent_workspace_path, - download_matrix_attachment, -) -from core.protocol import Attachment - - -async def test_download_matrix_attachment_persists_file_and_returns_workspace_path(tmp_path: Path): - async def download(url: str): - assert url == "mxc://server/id" - return SimpleNamespace(body=b"%PDF-1.7") - - client = SimpleNamespace(download=download) - attachment = Attachment( - type="document", - url="mxc://server/id", - filename="report.pdf", - mime_type="application/pdf", - ) - - saved = await download_matrix_attachment( - client=client, - workspace_root=tmp_path, - matrix_user_id="@alice:example.org", - room_id="!room:example.org", - attachment=attachment, - timestamp="20260420-153000", - ) - - assert saved.workspace_path == "report.pdf" - assert (tmp_path / "report.pdf").read_bytes() == b"%PDF-1.7" - - -def test_build_agent_workspace_path_uses_agent_workspace_volume(tmp_path: Path): - rel_path, abs_path = build_agent_workspace_path( - workspace_root=tmp_path / "agents" / "17", - filename="quarterly status.pdf", - ) - - assert rel_path == "quarterly status.pdf" - assert abs_path == tmp_path / "agents" / "17" / rel_path - - -def test_build_agent_workspace_path_uses_windows_style_copy_index(tmp_path: Path): - workspace_root = tmp_path / "agents" / "17" - workspace_root.mkdir(parents=True) - (workspace_root / "report.pdf").write_bytes(b"old") - (workspace_root / "report (1).pdf").write_bytes(b"older") - - rel_path, abs_path = build_agent_workspace_path( - workspace_root=workspace_root, - filename="report.pdf", - ) - - assert rel_path == "report (2).pdf" - assert abs_path == workspace_root / "report (2).pdf" - - -def test_build_agent_workspace_path_sanitizes_to_basename(tmp_path: Path): - rel_path, abs_path = build_agent_workspace_path( - workspace_root=tmp_path / "agents" / "17", - filename="../../quarterly: status?.pdf", - ) - - assert rel_path == "quarterly_ status_.pdf" - assert abs_path == tmp_path / "agents" / "17" / "quarterly_ status_.pdf" - - -async def test_download_matrix_attachment_uses_agent_workspace_root(tmp_path: Path): - async def download(url: str): - assert url == "mxc://server/id" - return SimpleNamespace(body=b"%PDF-1.7") - - saved = await download_matrix_attachment( - client=SimpleNamespace(download=download), - workspace_root=tmp_path / "agents" / "17", - matrix_user_id="@alice:example.org", - room_id="!room:example.org", - attachment=Attachment( - type="document", - url="mxc://server/id", - filename="report.pdf", - mime_type="application/pdf", - ), - timestamp="20260428-110000", - ) - - assert saved.workspace_path == "report.pdf" - assert (tmp_path / "agents" / "17" / saved.workspace_path).read_bytes() == b"%PDF-1.7" diff --git a/tests/adapter/matrix/test_invite_space.py b/tests/adapter/matrix/test_invite_space.py deleted file mode 100644 index 15ca57c..0000000 --- a/tests/adapter/matrix/test_invite_space.py +++ /dev/null @@ -1,174 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from nio.api import RoomVisibility - -from adapter.matrix.bot import build_runtime -from adapter.matrix.handlers.auth import handle_invite -from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta -from sdk.mock import MockPlatformClient - - -def _make_client(): - space_resp = SimpleNamespace(room_id="!space:example.org") - chat_resp = SimpleNamespace(room_id="!chat1:example.org") - return SimpleNamespace( - join=AsyncMock(), - room_create=AsyncMock(side_effect=[space_resp, chat_resp]), - room_put_state=AsyncMock(), - room_invite=AsyncMock(), - room_send=AsyncMock(), - ) - - -async def test_mat01_invite_creates_space_and_chat1(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 4}) - client = _make_client() - room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice") - event = SimpleNamespace(sender="@alice:example.org", membership="invite") - - await handle_invite( - client, - room, - event, - runtime.platform, - runtime.store, - runtime.auth_mgr, - runtime.chat_mgr, - ) - - first_call = client.room_create.call_args_list[0] - assert first_call.kwargs.get("space") is True - assert first_call.kwargs.get("visibility") is RoomVisibility.private - assert first_call.kwargs.get("invite") == ["@alice:example.org"] - second_call = client.room_create.call_args_list[1] - assert second_call.kwargs.get("visibility") is RoomVisibility.private - assert second_call.kwargs.get("invite") == ["@alice:example.org"] - assert client.room_create.await_count == 2 - client.room_invite.assert_not_awaited() - - client.room_put_state.assert_awaited_once() - kwargs = client.room_put_state.call_args.kwargs - assert kwargs.get("event_type") == "m.space.child" - assert kwargs.get("state_key") == "!chat1:example.org" - assert kwargs.get("room_id") == "!space:example.org" - - user_meta = await get_user_meta(runtime.store, "@alice:example.org") - assert user_meta is not None - assert user_meta["space_id"] == "!space:example.org" - - room_meta = await get_room_meta(runtime.store, "!chat1:example.org") - assert room_meta is not None - assert room_meta["chat_id"] == "C4" - assert room_meta["space_id"] == "!space:example.org" - assert room_meta["platform_chat_id"] == "1" - assert user_meta["next_chat_index"] == 5 - - chats = await runtime.chat_mgr.list_active("@alice:example.org") - assert [chat.chat_id for chat in chats] == ["C4"] - assert [chat.surface_ref for chat in chats] == ["!chat1:example.org"] - - -async def test_mat02_invite_idempotent(): - runtime = build_runtime(platform=MockPlatformClient()) - client = _make_client() - room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice") - event = SimpleNamespace(sender="@alice:example.org", membership="invite") - - await handle_invite( - client, - room, - event, - runtime.platform, - runtime.store, - runtime.auth_mgr, - runtime.chat_mgr, - ) - await handle_invite( - client, - room, - event, - runtime.platform, - runtime.store, - runtime.auth_mgr, - runtime.chat_mgr, - ) - - assert client.room_create.await_count == 2 - - -async def test_existing_user_invite_reinvites_space_and_active_chats(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_user_meta( - runtime.store, - "@alice:example.org", - {"space_id": "!space:example.org", "next_chat_index": 2}, - ) - await set_room_meta( - runtime.store, - "!chat1:example.org", - { - "room_type": "chat", - "chat_id": "C1", - "display_name": "Чат 1", - "matrix_user_id": "@alice:example.org", - "space_id": "!space:example.org", - "platform_chat_id": "1", - "agent_id": "agent-1", - }, - ) - await runtime.chat_mgr.get_or_create( - user_id="@alice:example.org", - chat_id="C1", - platform="matrix", - surface_ref="!chat1:example.org", - name="Чат 1", - ) - client = _make_client() - room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice") - event = SimpleNamespace(sender="@alice:example.org", membership="invite") - - await handle_invite( - client, - room, - event, - runtime.platform, - runtime.store, - runtime.auth_mgr, - runtime.chat_mgr, - ) - - client.room_create.assert_not_awaited() - client.room_invite.assert_any_await("!space:example.org", "@alice:example.org") - client.room_invite.assert_any_await("!chat1:example.org", "@alice:example.org") - client.room_send.assert_awaited() - - -async def test_mat03_no_hardcoded_c1(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 7}) - client = _make_client() - room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice") - event = SimpleNamespace(sender="@alice:example.org", membership="invite") - - await handle_invite( - client, - room, - event, - runtime.platform, - runtime.store, - runtime.auth_mgr, - runtime.chat_mgr, - ) - - room_meta = await get_room_meta(runtime.store, "!chat1:example.org") - assert room_meta is not None - assert room_meta["chat_id"] == "C7" - assert room_meta["platform_chat_id"] == "1" - - user_meta = await get_user_meta(runtime.store, "@alice:example.org") - assert user_meta is not None - assert user_meta["next_chat_index"] == 8 diff --git a/tests/adapter/matrix/test_reactions.py b/tests/adapter/matrix/test_reactions.py deleted file mode 100644 index 7974239..0000000 --- a/tests/adapter/matrix/test_reactions.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from adapter.matrix.reactions import ( - build_confirmation_text, - build_skills_text, -) -from sdk.interface import UserSettings - - -def test_build_skills_text(): - settings = UserSettings( - skills={"web-search": True, "fetch-url": False}, - connectors={}, - soul={}, - safety={}, - plan={}, - ) - text = build_skills_text(settings) - assert "web-search" in text - assert "fetch-url" in text - assert "!skill on/off" in text - assert "1️⃣" not in text - assert "2️⃣" not in text - assert "👍" not in text - assert "❌" not in text - - -def test_build_confirmation_text(): - text = build_confirmation_text("Отправить письмо?") - assert "Отправить письмо?" in text - assert "!yes" in text - assert "!no" in text diff --git a/tests/adapter/matrix/test_reconciliation.py b/tests/adapter/matrix/test_reconciliation.py deleted file mode 100644 index c44ffc0..0000000 --- a/tests/adapter/matrix/test_reconciliation.py +++ /dev/null @@ -1,253 +0,0 @@ -from __future__ import annotations - -import importlib -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry -from adapter.matrix.bot import MatrixBot, build_runtime -from adapter.matrix.reconciliation import reconcile_startup_state -from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta -from sdk.mock import MockPlatformClient - - -def _room( - room_id: str, - name: str, - members: list[str], - *, - parents: tuple[str, ...] = (), -): - return SimpleNamespace( - room_id=room_id, - name=name, - display_name=name, - users={user_id: SimpleNamespace(user_id=user_id) for user_id in members}, - space_parents=set(parents), - ) - - -async def test_reconcile_startup_state_restores_space_room_and_chat_bindings(): - runtime = build_runtime(platform=MockPlatformClient()) - client = SimpleNamespace( - user_id="@bot:example.org", - rooms={ - "!space:example.org": _room( - "!space:example.org", - "Lambda - Alice", - ["@bot:example.org", "@alice:example.org"], - ), - "!chat3:example.org": _room( - "!chat3:example.org", - "Чат 3", - ["@bot:example.org", "@alice:example.org"], - parents=("!space:example.org",), - ), - }, - ) - - await reconcile_startup_state(client, runtime) - - user_meta = await get_user_meta(runtime.store, "@alice:example.org") - assert user_meta is not None - assert user_meta["space_id"] == "!space:example.org" - assert user_meta["next_chat_index"] == 4 - - room_meta = await get_room_meta(runtime.store, "!chat3:example.org") - assert room_meta is not None - assert room_meta["room_type"] == "chat" - assert room_meta["chat_id"] == "C3" - assert room_meta["space_id"] == "!space:example.org" - assert room_meta["matrix_user_id"] == "@alice:example.org" - assert room_meta["platform_chat_id"] == "1" - - chats = await runtime.chat_mgr.list_active("@alice:example.org") - assert [chat.chat_id for chat in chats] == ["C3"] - assert [chat.surface_ref for chat in chats] == ["!chat3:example.org"] - - -async def test_reconcile_startup_state_is_idempotent_with_existing_local_state(): - runtime = build_runtime(platform=MockPlatformClient()) - client = SimpleNamespace( - user_id="@bot:example.org", - rooms={ - "!space:example.org": _room( - "!space:example.org", - "Lambda - Alice", - ["@bot:example.org", "@alice:example.org"], - ), - "!chat3:example.org": _room( - "!chat3:example.org", - "Чат 3", - ["@bot:example.org", "@alice:example.org"], - parents=("!space:example.org",), - ), - }, - ) - await set_user_meta( - runtime.store, - "@alice:example.org", - {"space_id": "!space:example.org", "next_chat_index": 8}, - ) - await set_room_meta( - runtime.store, - "!chat3:example.org", - { - "room_type": "chat", - "chat_id": "C3", - "display_name": "Existing name", - "matrix_user_id": "@alice:example.org", - "space_id": "!space:example.org", - "platform_chat_id": "42", - }, - ) - await runtime.chat_mgr.get_or_create( - user_id="@alice:example.org", - chat_id="C3", - platform="matrix", - surface_ref="!chat3:example.org", - name="Existing name", - ) - - await reconcile_startup_state(client, runtime) - await reconcile_startup_state(client, runtime) - - user_meta = await get_user_meta(runtime.store, "@alice:example.org") - assert user_meta == {"space_id": "!space:example.org", "next_chat_index": 8} - - room_meta = await get_room_meta(runtime.store, "!chat3:example.org") - assert room_meta is not None - assert room_meta["display_name"] == "Existing name" - assert room_meta["platform_chat_id"] == "42" - - chats = await runtime.chat_mgr.list_active("@alice:example.org") - assert len(chats) == 1 - assert chats[0].chat_id == "C3" - - -async def test_reconcile_updates_default_agent_assignment_after_user_is_configured(): - runtime = build_runtime(platform=MockPlatformClient()) - runtime.registry = AgentRegistry( - [ - AgentDefinition("agent-default", "Default"), - AgentDefinition("agent-alice", "Alice"), - ], - user_agents={"@alice:example.org": "agent-alice"}, - ) - client = SimpleNamespace( - user_id="@bot:example.org", - rooms={ - "!space:example.org": _room( - "!space:example.org", - "Lambda - Alice", - ["@bot:example.org", "@alice:example.org"], - ), - "!chat3:example.org": _room( - "!chat3:example.org", - "Чат 3", - ["@bot:example.org", "@alice:example.org"], - parents=("!space:example.org",), - ), - }, - ) - await set_room_meta( - runtime.store, - "!chat3:example.org", - { - "room_type": "chat", - "chat_id": "C3", - "display_name": "Чат 3", - "matrix_user_id": "@alice:example.org", - "space_id": "!space:example.org", - "platform_chat_id": "42", - "agent_id": "agent-default", - "agent_assignment": "default", - }, - ) - - await reconcile_startup_state(client, runtime) - - room_meta = await get_room_meta(runtime.store, "!chat3:example.org") - assert room_meta is not None - assert room_meta["agent_id"] == "agent-alice" - assert room_meta["agent_assignment"] == "configured" - assert room_meta["platform_chat_id"] == "42" - - -async def test_reconciliation_prevents_lazy_bootstrap_for_existing_room(): - runtime = build_runtime(platform=MockPlatformClient()) - client = SimpleNamespace( - user_id="@bot:example.org", - rooms={ - "!space:example.org": _room( - "!space:example.org", - "Lambda - Alice", - ["@bot:example.org", "@alice:example.org"], - ), - "!chat3:example.org": _room( - "!chat3:example.org", - "Чат 3", - ["@bot:example.org", "@alice:example.org"], - parents=("!space:example.org",), - ), - }, - room_send=AsyncMock(), - ) - bot = MatrixBot(client=client, runtime=runtime) - bot._bootstrap_unregistered_room = AsyncMock() - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - - await reconcile_startup_state(client, runtime) - await bot.on_room_message( - SimpleNamespace(room_id="!chat3:example.org"), - SimpleNamespace(sender="@alice:example.org", body="hello"), - ) - - bot._bootstrap_unregistered_room.assert_not_awaited() - runtime.dispatcher.dispatch.assert_awaited_once() - - -async def test_matrix_main_runs_reconciliation_before_sync_forever(monkeypatch): - bot_module = importlib.import_module("adapter.matrix.bot") - - runtime = SimpleNamespace(platform=SimpleNamespace(close=AsyncMock())) - call_order: list[str] = [] - - class FakeAsyncClient: - def __init__(self, *args, **kwargs): - self.access_token = None - self.callbacks = [] - self.close = AsyncMock() - self.sync_forever = AsyncMock(side_effect=self._sync_forever) - - async def _sync_forever(self, *args, **kwargs): - call_order.append("sync_forever") - - async def login(self, *args, **kwargs): - raise AssertionError("login should not be called when access token is provided") - - def add_event_callback(self, callback, event_type): - self.callbacks.append((callback, event_type)) - - async def fake_prepare_live_sync(client): - call_order.append("prepare_live_sync") - return "s123" - - async def fake_reconcile_startup_state(client, runtime): - call_order.append("reconcile_startup_state") - - monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") - monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org") - monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "token") - monkeypatch.setattr(bot_module, "AsyncClient", FakeAsyncClient) - monkeypatch.setattr(bot_module, "build_runtime", lambda **kwargs: runtime) - monkeypatch.setattr(bot_module, "prepare_live_sync", fake_prepare_live_sync) - monkeypatch.setattr(bot_module, "reconcile_startup_state", fake_reconcile_startup_state) - - await bot_module.main() - - assert call_order == [ - "prepare_live_sync", - "reconcile_startup_state", - "sync_forever", - ] diff --git a/tests/adapter/matrix/test_restart_persistence.py b/tests/adapter/matrix/test_restart_persistence.py deleted file mode 100644 index ac05423..0000000 --- a/tests/adapter/matrix/test_restart_persistence.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace - -from adapter.matrix.bot import build_runtime -from adapter.matrix.reconciliation import reconcile_startup_state -from adapter.matrix.store import ( - get_room_meta, - next_platform_chat_id, - set_room_meta, -) -from core.store import SQLiteStore -from sdk.mock import MockPlatformClient - - -async def test_room_agent_id_and_platform_chat_id_survive_restart(tmp_path): - db = str(tmp_path / "state.db") - store = SQLiteStore(db) - await set_room_meta(store, "!room:example.org", { - "room_type": "chat", - "agent_id": "agent-1", - "platform_chat_id": "42", - }) - - store2 = SQLiteStore(db) - meta = await get_room_meta(store2, "!room:example.org") - assert meta is not None - assert meta["agent_id"] == "agent-1" - assert meta["platform_chat_id"] == "42" - - -async def test_platform_chat_seq_survives_restart(tmp_path): - db = str(tmp_path / "state.db") - store = SQLiteStore(db) - assert await next_platform_chat_id(store) == "1" - assert await next_platform_chat_id(store) == "2" - assert await next_platform_chat_id(store) == "3" - - store2 = SQLiteStore(db) - assert await next_platform_chat_id(store2) == "4" - - -async def test_routing_state_survives_restart_and_routes_correctly(tmp_path): - db = str(tmp_path / "state.db") - store = SQLiteStore(db) - await set_room_meta(store, "!convo:example.org", { - "room_type": "chat", - "agent_id": "agent-1", - "platform_chat_id": "10", - }) - - store2 = SQLiteStore(db) - meta = await get_room_meta(store2, "!convo:example.org") - assert meta is not None - assert meta["agent_id"] == "agent-1" - assert meta["platform_chat_id"] == "10" - - -async def test_missing_durable_store_starts_clean(tmp_path): - db = str(tmp_path / "brand_new.db") - store = SQLiteStore(db) - assert await get_room_meta(store, "!nonexistent:example.org") is None - - -async def test_startup_reconciliation_backfills_legacy_platform_chat_id_before_restart_routes( - tmp_path, -): - db = str(tmp_path / "state.db") - store = SQLiteStore(db) - await set_room_meta( - store, - "!chat2:example.org", - { - "room_type": "chat", - "chat_id": "C2", - "display_name": "Чат 2", - "matrix_user_id": "@alice:example.org", - "space_id": "!space:example.org", - }, - ) - - runtime = build_runtime(platform=MockPlatformClient(), store=store) - client = SimpleNamespace( - user_id="@bot:example.org", - rooms={ - "!space:example.org": SimpleNamespace( - room_id="!space:example.org", - name="Lambda - Alice", - display_name="Lambda - Alice", - users={ - "@bot:example.org": SimpleNamespace(user_id="@bot:example.org"), - "@alice:example.org": SimpleNamespace(user_id="@alice:example.org"), - }, - space_parents=set(), - ), - "!chat2:example.org": SimpleNamespace( - room_id="!chat2:example.org", - name="Чат 2", - display_name="Чат 2", - users={ - "@bot:example.org": SimpleNamespace(user_id="@bot:example.org"), - "@alice:example.org": SimpleNamespace(user_id="@alice:example.org"), - }, - space_parents={"!space:example.org"}, - ), - }, - ) - - await reconcile_startup_state(client, runtime) - - store2 = SQLiteStore(db) - room_meta = await get_room_meta(store2, "!chat2:example.org") - assert room_meta is not None - assert room_meta["platform_chat_id"] == "1" diff --git a/tests/adapter/matrix/test_routed_platform.py b/tests/adapter/matrix/test_routed_platform.py deleted file mode 100644 index c3efca5..0000000 --- a/tests/adapter/matrix/test_routed_platform.py +++ /dev/null @@ -1,342 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncIterator -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest - -from adapter.matrix.bot import MatrixBot, build_runtime -from adapter.matrix.routed_platform import RoutedPlatformClient -from adapter.matrix.store import set_room_meta -from core.chat import ChatManager -from core.store import InMemoryStore -from sdk.interface import MessageChunk, MessageResponse, User, UserSettings -from sdk.mock import MockPlatformClient -from sdk.interface import PlatformError - - -class FakeDelegate: - def __init__(self, *, name: str) -> None: - self.name = name - self.send_calls: list[dict] = [] - self.stream_calls: list[dict] = [] - self.user_calls: list[dict] = [] - self.settings_calls: list[str] = [] - self.update_calls: list[tuple[str, object]] = [] - - async def get_or_create_user( - self, - external_id: str, - platform: str, - display_name: str | None = None, - ) -> User: - self.user_calls.append( - { - "external_id": external_id, - "platform": platform, - "display_name": display_name, - } - ) - return User( - user_id=f"user-{self.name}", - external_id=external_id, - platform=platform, - display_name=display_name, - created_at="2025-01-01T00:00:00Z", - is_new=False, - ) - - async def send_message( - self, - user_id: str, - chat_id: str, - text: str, - attachments=None, - ) -> MessageResponse: - self.send_calls.append( - { - "user_id": user_id, - "chat_id": chat_id, - "text": text, - "attachments": attachments, - } - ) - return MessageResponse( - message_id=f"msg-{self.name}", - response=f"reply-{self.name}", - tokens_used=0, - finished=True, - ) - - async def stream_message( - self, - user_id: str, - chat_id: str, - text: str, - attachments=None, - ) -> AsyncIterator[MessageChunk]: - self.stream_calls.append( - { - "user_id": user_id, - "chat_id": chat_id, - "text": text, - "attachments": attachments, - } - ) - yield MessageChunk( - message_id=f"stream-{self.name}", - delta=f"delta-{self.name}", - finished=True, - tokens_used=0, - ) - - async def get_settings(self, user_id: str) -> UserSettings: - self.settings_calls.append(user_id) - return UserSettings(skills={"files": True}) - - async def update_settings(self, user_id: str, action: object) -> None: - self.update_calls.append((user_id, action)) - - -@pytest.fixture(autouse=True) -def clear_matrix_registry_env(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False) - monkeypatch.delenv("MATRIX_PLATFORM_BACKEND", raising=False) - - -@pytest.mark.asyncio -async def test_send_message_routes_by_room_agent_and_platform_chat_id(): - store = InMemoryStore() - chat_mgr = ChatManager(None, store) - await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org") - await set_room_meta( - store, - "!room:example.org", - {"platform_chat_id": "41", "agent_id": "agent-2"}, - ) - delegates = { - "agent-1": FakeDelegate(name="agent-1"), - "agent-2": FakeDelegate(name="agent-2"), - } - platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates) - - response = await platform.send_message("u1", "C1", "hello", attachments=[]) - - assert response.response == "reply-agent-2" - assert delegates["agent-1"].send_calls == [] - assert delegates["agent-2"].send_calls == [ - { - "user_id": "u1", - "chat_id": "41", - "text": "hello", - "attachments": [], - } - ] - - -@pytest.mark.asyncio -async def test_stream_message_routes_by_room_agent_and_platform_chat_id(): - store = InMemoryStore() - chat_mgr = ChatManager(None, store) - await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org") - await set_room_meta( - store, - "!room:example.org", - {"platform_chat_id": "41", "agent_id": "agent-2"}, - ) - delegates = { - "agent-1": FakeDelegate(name="agent-1"), - "agent-2": FakeDelegate(name="agent-2"), - } - platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates) - - chunks = [chunk async for chunk in platform.stream_message("u1", "C1", "hello")] - - assert [chunk.delta for chunk in chunks] == ["delta-agent-2"] - assert delegates["agent-1"].stream_calls == [] - assert delegates["agent-2"].stream_calls == [ - { - "user_id": "u1", - "chat_id": "41", - "text": "hello", - "attachments": None, - } - ] - - -@pytest.mark.asyncio -async def test_send_message_fails_fast_when_platform_chat_id_is_missing(): - store = InMemoryStore() - chat_mgr = ChatManager(None, store) - await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org") - await set_room_meta( - store, - "!room:example.org", - {"agent_id": "agent-2"}, - ) - platform = RoutedPlatformClient( - chat_mgr=chat_mgr, - store=store, - delegates={"agent-2": FakeDelegate(name="agent-2")}, - ) - - with pytest.raises(PlatformError, match="routing is incomplete") as exc_info: - await platform.send_message("u1", "C1", "hello") - - assert exc_info.value.code == "MATRIX_ROUTE_INCOMPLETE" - - -@pytest.mark.asyncio -async def test_stream_message_fails_fast_when_agent_id_is_missing(): - store = InMemoryStore() - chat_mgr = ChatManager(None, store) - await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org") - await set_room_meta( - store, - "!room:example.org", - {"platform_chat_id": "41"}, - ) - platform = RoutedPlatformClient( - chat_mgr=chat_mgr, - store=store, - delegates={"agent-2": FakeDelegate(name="agent-2")}, - ) - - with pytest.raises(PlatformError, match="routing is incomplete") as exc_info: - await anext(platform.stream_message("u1", "C1", "hello")) - - assert exc_info.value.code == "MATRIX_ROUTE_INCOMPLETE" - - -@pytest.mark.asyncio -async def test_routing_uses_repaired_room_metadata_without_runtime_backfill(): - store = InMemoryStore() - chat_mgr = ChatManager(None, store) - await chat_mgr.get_or_create("u1", "C1", "matrix", "!room:example.org") - await set_room_meta( - store, - "!room:example.org", - {"platform_chat_id": "restored-41", "agent_id": "agent-2"}, - ) - delegate = FakeDelegate(name="agent-2") - platform = RoutedPlatformClient( - chat_mgr=chat_mgr, - store=store, - delegates={"agent-2": delegate}, - ) - - await platform.send_message("u1", "C1", "hello") - - assert delegate.send_calls == [ - { - "user_id": "u1", - "chat_id": "restored-41", - "text": "hello", - "attachments": None, - } - ] - - -@pytest.mark.asyncio -async def test_user_and_settings_delegate_to_default_client(): - store = InMemoryStore() - chat_mgr = ChatManager(None, store) - delegates = { - "agent-1": FakeDelegate(name="agent-1"), - "agent-2": FakeDelegate(name="agent-2"), - } - platform = RoutedPlatformClient(chat_mgr=chat_mgr, store=store, delegates=delegates) - - user = await platform.get_or_create_user("ext-1", "matrix", display_name="Alice") - settings = await platform.get_settings("u1") - await platform.update_settings("u1", {"action": "noop"}) - - assert user.user_id == "user-agent-1" - assert settings.skills == {"files": True} - assert delegates["agent-1"].user_calls == [ - { - "external_id": "ext-1", - "platform": "matrix", - "display_name": "Alice", - } - ] - assert delegates["agent-2"].user_calls == [] - assert delegates["agent-1"].settings_calls == ["u1"] - assert delegates["agent-1"].update_calls == [("u1", {"action": "noop"})] - - -@pytest.mark.asyncio -async def test_build_runtime_real_backend_uses_routed_platform_with_registry( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -): - registry_path = tmp_path / "matrix-agents.yaml" - registry_path.write_text( - "agents:\n" - " - id: agent-1\n" - " label: Analyst\n" - " - id: agent-2\n" - " label: Research\n", - encoding="utf-8", - ) - monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real") - monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path)) - monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example") - - runtime = build_runtime() - - assert isinstance(runtime.platform, RoutedPlatformClient) - assert set(runtime.platform._delegates) == {"agent-1", "agent-2"} - assert runtime.platform._delegates["agent-1"].agent_base_url == "http://agent.example" - assert runtime.platform._delegates["agent-1"].agent_id == "agent-1" - assert runtime.platform._delegates["agent-2"].agent_id == "agent-2" - - -def test_build_runtime_real_backend_requires_registry_path(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real") - monkeypatch.delenv("MATRIX_AGENT_REGISTRY_PATH", raising=False) - monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example") - - with pytest.raises(RuntimeError, match="MATRIX_AGENT_REGISTRY_PATH"): - build_runtime() - - -def test_build_runtime_real_backend_fails_explicitly_on_invalid_registry( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -): - registry_path = tmp_path / "missing.yaml" - monkeypatch.setenv("MATRIX_PLATFORM_BACKEND", "real") - monkeypatch.setenv("MATRIX_AGENT_REGISTRY_PATH", str(registry_path)) - monkeypatch.setenv("AGENT_BASE_URL", "http://agent.example") - - with pytest.raises(RuntimeError, match="failed to load matrix agent registry"): - build_runtime() - - -@pytest.mark.asyncio -async def test_bot_keeps_local_chat_id_for_plain_message_dispatch(): - runtime = build_runtime(platform=MockPlatformClient()) - await set_room_meta( - runtime.store, - "!chat1:example.org", - { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "platform_chat_id": "41", - "agent_id": "agent-2", - }, - ) - runtime.dispatcher.dispatch = AsyncMock(return_value=[]) - bot = MatrixBot(SimpleNamespace(user_id="@bot:example.org"), runtime) - - await bot.on_room_message( - SimpleNamespace(room_id="!chat1:example.org"), - SimpleNamespace(sender="@alice:example.org", body="hello"), - ) - - dispatched = runtime.dispatcher.dispatch.await_args.args[0] - assert dispatched.chat_id == "C1" - assert dispatched.text == "hello" diff --git a/tests/adapter/matrix/test_send_outgoing.py b/tests/adapter/matrix/test_send_outgoing.py deleted file mode 100644 index 72b9fa6..0000000 --- a/tests/adapter/matrix/test_send_outgoing.py +++ /dev/null @@ -1,194 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from adapter.matrix.bot import send_outgoing -from adapter.matrix.converter import from_room_event -from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_confirm -from adapter.matrix.store import get_pending_confirm, set_room_meta -from core.auth import AuthManager -from core.chat import ChatManager -from core.protocol import Attachment, OutgoingMessage, OutgoingUI, UIButton -from core.settings import SettingsManager -from core.store import InMemoryStore -from sdk.mock import MockPlatformClient - - -async def test_mat06_outgoing_ui_renders_text_with_yes_no(): - client = SimpleNamespace(room_send=AsyncMock()) - store = InMemoryStore() - await set_room_meta(store, "!confirm:example.org", {"matrix_user_id": "@alice:example.org"}) - event = OutgoingUI( - chat_id="C7", - text="Удалить файл?", - buttons=[UIButton(label="Подтвердить", action="confirm")], - ) - - await send_outgoing(client, "!confirm:example.org", event, store=store) - - client.room_send.assert_awaited_once() - body = client.room_send.call_args.args[2]["body"] - assert "Удалить файл?" in body - assert "!yes" in body - assert "!no" in body - assert "Подтвердить" in body - - -async def test_mat07_outgoing_ui_no_reaction_sent(): - client = SimpleNamespace(room_send=AsyncMock()) - store = InMemoryStore() - await set_room_meta(store, "!confirm:example.org", {"matrix_user_id": "@alice:example.org"}) - event = OutgoingUI( - chat_id="C7", - text="Confirm action?", - buttons=[UIButton(label="OK", action="confirm", payload={"id": 1})], - ) - - await send_outgoing(client, "!confirm:example.org", event, store=store) - - assert client.room_send.await_count == 1 - assert client.room_send.call_args.args[1] == "m.room.message" - for call in client.room_send.call_args_list: - assert call.args[1] != "m.reaction" - - pending = await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") - assert pending == { - "action_id": "confirm", - "description": "Confirm action?", - "payload": {"id": 1}, - } - - -async def test_outgoing_ui_yes_round_trip_uses_user_and_room_scope(): - client = SimpleNamespace(room_send=AsyncMock()) - store = InMemoryStore() - platform = MockPlatformClient() - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - await set_room_meta(store, "!confirm:example.org", {"matrix_user_id": "@alice:example.org"}) - await set_room_meta(store, "!other:example.org", {"matrix_user_id": "@bob:example.org"}) - - await send_outgoing( - client, - "!confirm:example.org", - OutgoingUI( - chat_id="C7", - text="Archive room", - buttons=[UIButton(label="Confirm", action="archive", payload={"id": 7})], - ), - store=store, - ) - await send_outgoing( - client, - "!other:example.org", - OutgoingUI( - chat_id="C8", - text="Keep other room", - buttons=[UIButton(label="Confirm", action="archive", payload={"id": 8})], - ), - store=store, - ) - - callback = from_room_event( - SimpleNamespace( - sender="@alice:example.org", - body="!yes", - event_id="$yes", - msgtype="m.text", - replyto_event_id=None, - ), - room_id="!confirm:example.org", - chat_id="C7", - ) - result = await make_handle_confirm(store)(callback, auth_mgr, platform, chat_mgr, settings_mgr) - - assert "Archive room" in result[0].text - assert await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") is None - assert await get_pending_confirm(store, "@bob:example.org", "!other:example.org") is not None - - -async def test_outgoing_ui_no_round_trip_uses_user_and_room_scope(): - client = SimpleNamespace(room_send=AsyncMock()) - store = InMemoryStore() - platform = MockPlatformClient() - chat_mgr = ChatManager(platform, store) - auth_mgr = AuthManager(platform, store) - settings_mgr = SettingsManager(platform, store) - await set_room_meta(store, "!confirm:example.org", {"matrix_user_id": "@alice:example.org"}) - await set_room_meta(store, "!other:example.org", {"matrix_user_id": "@bob:example.org"}) - - await send_outgoing( - client, - "!confirm:example.org", - OutgoingUI( - chat_id="C7", - text="Delete room", - buttons=[UIButton(label="Confirm", action="delete", payload={"id": 7})], - ), - store=store, - ) - await send_outgoing( - client, - "!other:example.org", - OutgoingUI( - chat_id="C8", - text="Keep other room", - buttons=[UIButton(label="Confirm", action="archive", payload={"id": 8})], - ), - store=store, - ) - - callback = from_room_event( - SimpleNamespace( - sender="@alice:example.org", - body="!no", - event_id="$no", - msgtype="m.text", - replyto_event_id=None, - ), - room_id="!confirm:example.org", - chat_id="C7", - ) - result = await make_handle_cancel(store)(callback, auth_mgr, platform, chat_mgr, settings_mgr) - - assert "отменено" in result[0].text.lower() - assert await get_pending_confirm(store, "@alice:example.org", "!confirm:example.org") is None - assert await get_pending_confirm(store, "@bob:example.org", "!other:example.org") is not None - - -async def test_send_outgoing_uploads_workspace_file_attachment(tmp_path, monkeypatch): - workspace_file = tmp_path / "surfaces" / "matrix" / "alice" / "room" / "inbox" / "result.txt" - workspace_file.parent.mkdir(parents=True, exist_ok=True) - workspace_file.write_text("ready") - monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path)) - - client = SimpleNamespace( - upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/file"), {})), - room_send=AsyncMock(), - ) - - await send_outgoing( - client, - "!room:example.org", - OutgoingMessage( - chat_id="!room:example.org", - text="Файл готов", - attachments=[ - Attachment( - type="document", - filename="result.txt", - mime_type="text/plain", - workspace_path="surfaces/matrix/alice/room/inbox/result.txt", - ) - ], - ), - ) - - client.upload.assert_awaited_once() - client.room_send.assert_awaited() - assert client.room_send.await_args_list[0].args[2]["body"] == "Файл готов" - file_call = client.room_send.await_args_list[1] - assert file_call.args[2]["msgtype"] == "m.file" - assert file_call.args[2]["url"] == "mxc://server/file" diff --git a/tests/adapter/matrix/test_store.py b/tests/adapter/matrix/test_store.py deleted file mode 100644 index 7c4a216..0000000 --- a/tests/adapter/matrix/test_store.py +++ /dev/null @@ -1,246 +0,0 @@ -from __future__ import annotations - -import pytest - -from adapter.matrix.store import ( - STAGED_ATTACHMENTS_PREFIX, - add_staged_attachment, - clear_pending_confirm, - clear_staged_attachments, - get_pending_confirm, - get_platform_chat_id, - get_room_meta, - get_room_state, - get_skills_message_id, - get_staged_attachments, - get_user_meta, - next_chat_id, - next_platform_chat_id, - remove_staged_attachment_at, - set_pending_confirm, - set_platform_chat_id, - set_room_meta, - set_room_state, - set_skills_message_id, - set_user_meta, -) -from core.store import InMemoryStore - - -@pytest.fixture -def store() -> InMemoryStore: - return InMemoryStore() - - -async def test_room_meta_roundtrip(store: InMemoryStore): - meta = { - "room_type": "chat", - "chat_id": "C1", - "display_name": "Чат 1", - "matrix_user_id": "@alice:m.org", - } - await set_room_meta(store, "!r:m.org", meta) - assert await get_room_meta(store, "!r:m.org") == meta - - -async def test_room_meta_roundtrip_with_platform_chat_id(store: InMemoryStore): - meta = { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "platform_chat_id": "chat-platform-1", - } - await set_room_meta(store, "!r:m.org", meta) - saved = await get_room_meta(store, "!r:m.org") - assert saved is not None - assert saved["platform_chat_id"] == "chat-platform-1" - - -async def test_platform_chat_id_helpers_roundtrip(store: InMemoryStore): - meta = { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "display_name": "Research", - } - await set_room_meta(store, "!r:m.org", meta) - await set_platform_chat_id(store, "!r:m.org", "chat-platform-1") - - assert await get_platform_chat_id(store, "!r:m.org") == "chat-platform-1" - assert await get_room_meta(store, "!r:m.org") == { - "chat_id": "C1", - "matrix_user_id": "@alice:example.org", - "display_name": "Research", - "platform_chat_id": "chat-platform-1", - } - - -async def test_room_meta_missing(store: InMemoryStore): - assert await get_room_meta(store, "!nonexistent:m.org") is None - - -async def test_user_meta_roundtrip(store: InMemoryStore): - meta = { - "platform_user_id": "usr-1", - "display_name": "Alice", - "space_id": None, - "settings_room_id": None, - "next_chat_index": 1, - } - await set_user_meta(store, "@alice:m.org", meta) - assert await get_user_meta(store, "@alice:m.org") == meta - - -async def test_room_state_roundtrip(store: InMemoryStore): - await set_room_state(store, "!r:m.org", "idle") - assert await get_room_state(store, "!r:m.org") == "idle" - await set_room_state(store, "!r:m.org", "waiting_response") - assert await get_room_state(store, "!r:m.org") == "waiting_response" - - -async def test_room_state_default_idle(store: InMemoryStore): - assert await get_room_state(store, "!unknown:m.org") == "idle" - - -async def test_next_chat_id_increments(store: InMemoryStore): - uid = "@alice:m.org" - await set_user_meta(store, uid, {"next_chat_index": 1}) - assert await next_chat_id(store, uid) == "C1" - assert await next_chat_id(store, uid) == "C2" - assert await next_chat_id(store, uid) == "C3" - - -async def test_next_platform_chat_id_increments(store: InMemoryStore): - assert await next_platform_chat_id(store) == "1" - assert await next_platform_chat_id(store) == "2" - assert await next_platform_chat_id(store) == "3" - - -async def test_skills_message_roundtrip(store: InMemoryStore): - await set_skills_message_id(store, "!room", "$event") - assert await get_skills_message_id(store, "!room") == "$event" - - -async def test_pending_confirm_roundtrip(store: InMemoryStore): - assert await get_pending_confirm(store, "!room:m.org") is None - - meta = {"action_id": "test", "description": "Do thing"} - await set_pending_confirm(store, "!room:m.org", meta) - assert await get_pending_confirm(store, "!room:m.org") == meta - - await clear_pending_confirm(store, "!room:m.org") - assert await get_pending_confirm(store, "!room:m.org") is None - - -async def test_staged_attachments_roundtrip(store: InMemoryStore): - room_id = "!room:m.org" - user_id = "@alice:m.org" - - assert await get_staged_attachments(store, room_id, user_id) == [] - - first = {"id": "att-1", "name": "screenshot.png"} - second = {"id": "att-2", "name": "invoice.pdf"} - - await add_staged_attachment(store, room_id, user_id, first) - await add_staged_attachment(store, room_id, user_id, second) - - assert await get_staged_attachments(store, room_id, user_id) == [ - first, - second, - ] - - -@pytest.mark.parametrize( - "stored_value", - [ - None, - "not-a-dict", - [], - 123, - ], -) -async def test_staged_attachments_invalid_container_state_returns_empty_list( - store: InMemoryStore, - stored_value, -): - room_id = "!room:m.org" - user_id = "@alice:m.org" - - await store.set(f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}", stored_value) - - assert await get_staged_attachments(store, room_id, user_id) == [] - - -async def test_staged_attachments_filters_invalid_entries(store: InMemoryStore): - room_id = "!room:m.org" - user_id = "@alice:m.org" - valid_one = {"id": "att-1", "name": "alpha.png"} - valid_two = {"id": "att-2", "name": "beta.pdf"} - - await store.set( - f"{STAGED_ATTACHMENTS_PREFIX}{room_id}:{user_id}", - { - "attachments": [ - valid_one, - "bad-entry", - None, - {"id": "ignored"}, - valid_two, - ] - }, - ) - - assert await get_staged_attachments(store, room_id, user_id) == [ - valid_one, - {"id": "ignored"}, - valid_two, - ] - - -async def test_staged_attachments_are_scoped_by_room_and_user(store: InMemoryStore): - room_a = "!room-a:m.org" - room_b = "!room-b:m.org" - user_a = "@alice:m.org" - user_b = "@bob:m.org" - - attachment_a = {"id": "att-a", "name": "alpha.png"} - attachment_b = {"id": "att-b", "name": "beta.png"} - attachment_c = {"id": "att-c", "name": "gamma.png"} - - await add_staged_attachment(store, room_a, user_a, attachment_a) - await add_staged_attachment(store, room_a, user_b, attachment_b) - await add_staged_attachment(store, room_b, user_a, attachment_c) - - assert await get_staged_attachments(store, room_a, user_a) == [attachment_a] - assert await get_staged_attachments(store, room_a, user_b) == [attachment_b] - assert await get_staged_attachments(store, room_b, user_a) == [attachment_c] - assert await get_staged_attachments(store, room_b, user_b) == [] - - -async def test_remove_staged_attachment_at_by_zero_based_index( - store: InMemoryStore, -): - room_id = "!room:m.org" - user_id = "@alice:m.org" - first = {"id": "att-1", "name": "first.png"} - second = {"id": "att-2", "name": "second.png"} - third = {"id": "att-3", "name": "third.png"} - - await add_staged_attachment(store, room_id, user_id, first) - await add_staged_attachment(store, room_id, user_id, second) - await add_staged_attachment(store, room_id, user_id, third) - - assert await remove_staged_attachment_at(store, room_id, user_id, 1) == second - assert await get_staged_attachments(store, room_id, user_id) == [first, third] - assert await remove_staged_attachment_at(store, room_id, user_id, 99) is None - assert await remove_staged_attachment_at(store, room_id, user_id, -1) is None - - -async def test_clear_staged_attachments(store: InMemoryStore): - room_id = "!room:m.org" - user_id = "@alice:m.org" - - await add_staged_attachment(store, room_id, user_id, {"id": "att-1"}) - await add_staged_attachment(store, room_id, user_id, {"id": "att-2"}) - - await clear_staged_attachments(store, room_id, user_id) - - assert await get_staged_attachments(store, room_id, user_id) == [] diff --git a/tests/adapter/telegram/__init__.py b/tests/adapter/telegram/__init__.py index e69de29..8b13789 100644 --- a/tests/adapter/telegram/__init__.py +++ b/tests/adapter/telegram/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/adapter/telegram/test_commands.py b/tests/adapter/telegram/test_commands.py deleted file mode 100644 index a9b6676..0000000 --- a/tests/adapter/telegram/test_commands.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -import importlib -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock - -import pytest -from aiogram.exceptions import TelegramBadRequest - - -@pytest.fixture(autouse=True) -def fresh_db(tmp_path, monkeypatch): - monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db")) - import adapter.telegram.db as db_mod - importlib.reload(db_mod) - db_mod.init_db() - return db_mod - - -def make_message(*, user_id=1, thread_id=42, chat_id=100, text="/new"): - m = SimpleNamespace() - m.from_user = SimpleNamespace(id=user_id, full_name="Alice") - m.message_thread_id = thread_id - m.chat = SimpleNamespace(id=chat_id) - m.text = text - m.answer = AsyncMock() - m.reply = AsyncMock() - m.bot = MagicMock() - m.bot.create_forum_topic = AsyncMock( - return_value=SimpleNamespace(message_thread_id=200) - ) - m.bot.close_forum_topic = AsyncMock() - m.bot.delete_forum_topic = AsyncMock() - m.bot.edit_forum_topic = AsyncMock() - m.bot.send_message = AsyncMock() - return m - - -async def test_cmd_new_creates_topic(fresh_db, monkeypatch): - import adapter.telegram.handlers.commands as mod - importlib.reload(mod) - fresh_db.create_chat(1, 42, "Чат #1") - msg = make_message(user_id=1, thread_id=42, chat_id=100) - await mod.cmd_new(msg) - msg.bot.create_forum_topic.assert_called_once() - call_kwargs = str(msg.bot.create_forum_topic.call_args) - assert "Чат #2" in call_kwargs - assert fresh_db.get_chat(1, 200) is not None - - -async def test_cmd_archive_deletes_topic_when_possible(fresh_db, monkeypatch): - """When delete_forum_topic succeeds (user-created topic), no answer is sent.""" - import adapter.telegram.handlers.commands as mod - importlib.reload(mod) - fresh_db.create_chat(1, 42, "Чат #1") - msg = make_message(user_id=1, thread_id=42, chat_id=100) - await mod.cmd_archive(msg) - msg.bot.delete_forum_topic.assert_called_once_with(chat_id=100, message_thread_id=42) - assert fresh_db.get_chat(1, 42)["archived_at"] is not None - msg.answer.assert_not_called() - - -async def test_cmd_archive_fallback_message_when_delete_fails(fresh_db, monkeypatch): - """When delete_forum_topic fails (bot-created topic), user gets explanation.""" - import adapter.telegram.handlers.commands as mod - importlib.reload(mod) - fresh_db.create_chat(1, 42, "Чат #1") - msg = make_message(user_id=1, thread_id=42, chat_id=100) - msg.bot.delete_forum_topic = AsyncMock( - side_effect=TelegramBadRequest(method=MagicMock(), message="not a supergroup forum") - ) - await mod.cmd_archive(msg) - assert fresh_db.get_chat(1, 42)["archived_at"] is not None - msg.answer.assert_called_once() - assert "архивирован" in msg.answer.call_args[0][0] - - -async def test_cmd_archive_unknown_topic_replies_error(fresh_db, monkeypatch): - import adapter.telegram.handlers.commands as mod - importlib.reload(mod) - msg = make_message(user_id=1, thread_id=999, chat_id=100) - await mod.cmd_archive(msg) - msg.answer.assert_called_once() - - -async def test_cmd_rename_updates_db_and_topic(fresh_db, monkeypatch): - import adapter.telegram.handlers.commands as mod - importlib.reload(mod) - fresh_db.create_chat(1, 42, "Чат #1") - msg = make_message(user_id=1, thread_id=42, chat_id=100, text="/rename Работа") - await mod.cmd_rename(msg) - msg.bot.edit_forum_topic.assert_called_once_with( - chat_id=100, message_thread_id=42, name="Работа" - ) - assert fresh_db.get_chat(1, 42)["chat_name"] == "Работа" - - -async def test_cmd_new_topics_limit(fresh_db): - """When Telegram returns topics limit error, user gets a friendly message.""" - import adapter.telegram.handlers.commands as mod - importlib.reload(mod) - msg = make_message(user_id=1, thread_id=42, chat_id=100) - msg.bot.create_forum_topic = AsyncMock( - side_effect=TelegramBadRequest(method=MagicMock(), message="topics limit exceeded") - ) - await mod.cmd_new(msg) - msg.answer.assert_called_once() - assert "лимит" in msg.answer.call_args[0][0] - # No chat should be created - assert fresh_db.count_active_chats(1) == 0 - - -async def test_cmd_archive_general_topic(fresh_db): - """/archive in General topic (thread_id=None) replies with 'not found'.""" - import adapter.telegram.handlers.commands as mod - importlib.reload(mod) - msg = make_message(user_id=1, thread_id=None, chat_id=100) - await mod.cmd_archive(msg) - msg.answer.assert_called_once() - msg.bot.close_forum_topic.assert_not_called() diff --git a/tests/adapter/telegram/test_converter.py b/tests/adapter/telegram/test_converter.py deleted file mode 100644 index 38fd70a..0000000 --- a/tests/adapter/telegram/test_converter.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace - -from adapter.telegram.converter import format_outgoing, from_message -from core.protocol import OutgoingMessage, OutgoingUI - - -def make_message(*, text="hello", thread_id=42, user_id=1): - m = SimpleNamespace() - m.text = text - m.caption = None - m.photo = None - m.document = None - m.voice = None - m.message_thread_id = thread_id - m.from_user = SimpleNamespace(id=user_id, full_name="Alice") - return m - - -def test_from_message_in_topic(): - msg = make_message(thread_id=42, user_id=7) - result = from_message(msg) - assert result is not None - assert result.user_id == "7" - assert result.chat_id == "42" - assert result.text == "hello" - assert result.platform == "telegram" - - -def test_from_message_in_general_returns_none(): - msg = make_message(thread_id=None) - assert from_message(msg) is None - - -def test_from_message_uses_caption_if_no_text(): - msg = make_message(text=None, thread_id=10) - msg.caption = "caption text" - result = from_message(msg) - assert result.text == "caption text" - - -def test_format_outgoing_message(): - event = OutgoingMessage(chat_id="42", text="response") - assert format_outgoing(event) == "response" - - -def test_format_outgoing_ui(): - event = OutgoingUI(chat_id="42", text="choose") - assert format_outgoing(event) == "choose" diff --git a/tests/adapter/telegram/test_forum.py b/tests/adapter/telegram/test_forum.py new file mode 100644 index 0000000..d0f7aaa --- /dev/null +++ b/tests/adapter/telegram/test_forum.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +from aiogram.fsm.context import FSMContext +from aiogram.types import Chat, MessageOriginChat + +from adapter.telegram.converter import is_forum_message, resolve_forum_chat_id +from adapter.telegram.handlers import chat as chat_handler +from adapter.telegram.handlers import confirm as confirm_handler +from adapter.telegram.handlers import forum as forum_handler +from adapter.telegram.states import ChatState, ForumSetupState +from core.protocol import OutgoingMessage + + +def make_message(*, text: str = "hello", thread_id: int | None = None): + message = SimpleNamespace() + message.text = text + message.caption = None + message.photo = None + message.document = None + message.voice = None + message.message_thread_id = thread_id + message.chat = SimpleNamespace(id=-100123) + message.from_user = SimpleNamespace(id=42, full_name="Alice", first_name="Alice") + message.answer = AsyncMock() + message.edit_text = AsyncMock() + message.edit_reply_markup = AsyncMock() + message.bot = SimpleNamespace( + send_message=AsyncMock(), + send_chat_action=AsyncMock(), + create_forum_topic=AsyncMock(), + get_me=AsyncMock(), + get_chat_member=AsyncMock(), + ) + message.chat_shared = None + return message + + +class FakeTask: + def cancel(self) -> None: + self.cancelled = True + + def __await__(self): + async def _done(): + return None + + return _done().__await__() + + +async def test_forum_helpers_detect_and_resolve(monkeypatch): + message = make_message(thread_id=77) + monkeypatch.setattr( + chat_handler.db, + "get_chat_by_thread", + lambda tg_user_id, thread_id: {"chat_id": "chat-77"} if thread_id == 77 else None, + ) + + assert is_forum_message(message) is True + assert resolve_forum_chat_id(message, 42) == "chat-77" + + +async def test_cmd_forum_enters_setup_state(): + message = make_message(text="/forum") + state = AsyncMock(spec=FSMContext) + + await forum_handler.cmd_forum(message, state) + + state.set_state.assert_awaited_once_with(ForumSetupState.waiting_for_group) + message.answer.assert_awaited_once() + assert message.answer.await_args.kwargs["reply_markup"] is not None + + +async def test_handle_group_forward_registers_group_and_topics(monkeypatch): + message = make_message(text="forwarded") + message.forward_from_chat = SimpleNamespace(id=-100200, type="supergroup", title="Lambda") + message.bot.get_me.return_value = SimpleNamespace(id=999) + message.bot.get_chat_member.return_value = SimpleNamespace( + status="administrator", + can_manage_topics=True, + ) + message.bot.create_forum_topic.side_effect = [ + SimpleNamespace(message_thread_id=11), + SimpleNamespace(message_thread_id=22), + ] + state = AsyncMock(spec=FSMContext) + + monkeypatch.setattr( + forum_handler.db, + "get_user_chats", + lambda tg_user_id: [ + {"chat_id": "chat-1", "name": "One", "forum_thread_id": None}, + {"chat_id": "chat-2", "name": "Two", "forum_thread_id": None}, + ], + ) + set_forum_group = Mock() + set_forum_thread = Mock() + monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group) + monkeypatch.setattr(forum_handler.db, "set_forum_thread", set_forum_thread) + + await forum_handler.handle_group_forward(message, state) + + set_forum_group.assert_called_once_with(42, -100200) + assert message.bot.create_forum_topic.await_count == 2 + set_forum_thread.assert_any_call("chat-1", 11) + set_forum_thread.assert_any_call("chat-2", 22) + state.set_state.assert_awaited_once_with(ChatState.idle) + assert "Группа подключена" in message.answer.await_args.args[0] + + +async def test_handle_group_forward_accepts_forward_origin_chat(monkeypatch): + message = make_message(text="forwarded") + message.forward_from_chat = None + message.forward_origin = MessageOriginChat( + date=datetime.now(), + sender_chat=Chat(id=-100200, type="supergroup", title="Lambda", is_forum=True), + ) + message.bot.get_me.return_value = SimpleNamespace(id=999) + message.bot.get_chat_member.return_value = SimpleNamespace( + status="administrator", + can_manage_topics=True, + ) + state = AsyncMock(spec=FSMContext) + + monkeypatch.setattr(forum_handler.db, "get_user_chats", lambda tg_user_id: []) + set_forum_group = Mock() + monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group) + + await forum_handler.handle_group_forward(message, state) + + set_forum_group.assert_called_once_with(42, -100200) + state.set_state.assert_awaited_once_with(ChatState.idle) + assert "Группа подключена" in message.answer.await_args.args[0] + + +async def test_handle_group_forward_accepts_chat_shared(monkeypatch): + message = make_message(text="selected") + message.chat_shared = SimpleNamespace(request_id=1, chat_id=-100200, title="Lambda") + message.bot.get_me.return_value = SimpleNamespace(id=999) + message.bot.get_chat_member.return_value = SimpleNamespace( + status="administrator", + can_manage_topics=True, + ) + state = AsyncMock(spec=FSMContext) + + monkeypatch.setattr(forum_handler.db, "get_user_chats", lambda tg_user_id: []) + set_forum_group = Mock() + monkeypatch.setattr(forum_handler.db, "set_forum_group", set_forum_group) + + await forum_handler.handle_group_forward(message, state) + + set_forum_group.assert_called_once_with(42, -100200) + state.set_state.assert_awaited_once_with(ChatState.idle) + assert "Группа подключена" in message.answer.await_args.args[0] + + +async def test_handle_group_forward_reports_missing_forward_metadata(): + message = make_message(text="not forwarded") + message.forward_from_chat = None + message.forward_origin = None + state = AsyncMock(spec=FSMContext) + + await forum_handler.handle_group_forward(message, state) + + message.answer.assert_awaited_once() + assert "данных о группе" in message.answer.await_args.args[0] + state.set_state.assert_not_awaited() + + +async def test_handle_group_forward_reports_non_forum_supergroup(): + message = make_message(text="forwarded") + message.forward_from_chat = SimpleNamespace( + id=-100200, + type="supergroup", + title="Lambda", + is_forum=False, + ) + state = AsyncMock(spec=FSMContext) + + await forum_handler.handle_group_forward(message, state) + + message.answer.assert_awaited_once() + assert "выключены Topics" in message.answer.await_args.args[0] + state.set_state.assert_not_awaited() + + +async def test_handle_message_routes_forum_thread(monkeypatch): + message = make_message(thread_id=77) + dispatcher = SimpleNamespace( + dispatch=AsyncMock( + return_value=[OutgoingMessage(chat_id="chat-77", text="ok")] + ) + ) + state = AsyncMock(spec=FSMContext) + state.get_data.return_value = {} + + monkeypatch.setattr( + chat_handler.db, + "get_or_create_tg_user", + lambda tg_user_id, platform_user_id, display_name: { + "platform_user_id": "usr-42", + "display_name": display_name, + }, + ) + monkeypatch.setattr( + chat_handler.db, + "get_chat_by_thread", + lambda tg_user_id, thread_id: {"chat_id": "chat-77", "name": "Forum chat"}, + ) + monkeypatch.setattr( + chat_handler.db, + "get_chat_by_id", + lambda chat_id: {"chat_id": chat_id, "name": "Forum chat"}, + ) + monkeypatch.setattr( + chat_handler.asyncio, + "create_task", + lambda coro: (coro.close(), FakeTask())[1], + ) + + await chat_handler.handle_message(message, state, dispatcher) + + incoming = dispatcher.dispatch.await_args.args[0] + assert incoming.chat_id == "chat-77" + assert incoming.user_id == "usr-42" + assert state.update_data.await_args.kwargs == { + "active_chat_id": "chat-77", + "active_chat_name": "Forum chat", + } + message.bot.send_message.assert_awaited_once() + assert message.bot.send_message.await_args.args[0] == -100123 + assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 77 + assert message.bot.send_message.await_args.args[1] == "ok" + + +async def test_cmd_new_chat_creates_forum_topic_for_dm(monkeypatch): + message = make_message(text="/new Analysis") + state = AsyncMock(spec=FSMContext) + message.bot.create_forum_topic.return_value = SimpleNamespace(message_thread_id=333) + + monkeypatch.setattr(chat_handler.db, "get_forum_group", lambda tg_user_id: -100200) + monkeypatch.setattr(chat_handler.db, "count_chats", lambda tg_user_id: 2) + create_chat = Mock(return_value="chat-3") + set_forum_thread = Mock() + monkeypatch.setattr(chat_handler.db, "create_chat", create_chat) + monkeypatch.setattr(chat_handler.db, "set_forum_thread", set_forum_thread) + + await chat_handler.cmd_new_chat(message, state) + + create_chat.assert_called_once_with(42, "Analysis") + message.bot.create_forum_topic.assert_awaited_once_with(chat_id=-100200, name="Analysis") + set_forum_thread.assert_called_once_with("chat-3", 333) + state.update_data.assert_awaited_once_with(active_chat_id="chat-3", active_chat_name="Analysis") + message.answer.assert_awaited_once() + assert "Форум-тема тоже создана" in message.answer.await_args.args[0] + + +async def test_cmd_new_chat_registers_topic(monkeypatch): + message = make_message(text="/new Research", thread_id=88) + state = AsyncMock(spec=FSMContext) + + monkeypatch.setattr( + chat_handler.db, + "get_chat_by_thread", + lambda tg_user_id, thread_id: None, + ) + monkeypatch.setattr(chat_handler.db, "count_chats", lambda tg_user_id: 4) + create_chat = Mock(return_value="chat-5") + set_forum_thread = Mock() + monkeypatch.setattr(chat_handler.db, "create_chat", create_chat) + monkeypatch.setattr(chat_handler.db, "set_forum_thread", set_forum_thread) + + await chat_handler.cmd_new_chat(message, state) + + create_chat.assert_called_once_with(42, "Research") + set_forum_thread.assert_called_once_with("chat-5", 88) + message.bot.send_message.assert_awaited_once() + assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 88 + state.update_data.assert_awaited_once_with(active_chat_id="chat-5", active_chat_name="Research") + + +async def test_cmd_list_chats_rejected_in_forum_topic(): + message = make_message(text="/chats", thread_id=88) + state = AsyncMock(spec=FSMContext) + + await chat_handler.cmd_list_chats(message, state) + + message.bot.send_message.assert_awaited_once() + assert message.bot.send_message.await_args.kwargs["message_thread_id"] == 88 + assert "отключено" in message.bot.send_message.await_args.args[1] + + +async def test_switch_chat_rejected_in_forum_topic(): + callback = SimpleNamespace( + data="switch:chat-9:Other", + from_user=SimpleNamespace(id=42, full_name="Alice"), + message=make_message(thread_id=88), + answer=AsyncMock(), + ) + state = AsyncMock(spec=FSMContext) + + await chat_handler.switch_chat(callback, state) + + state.update_data.assert_not_awaited() + callback.answer.assert_awaited_once_with( + "Переключение чатов доступно только в личке с ботом.", + show_alert=True, + ) + + +async def test_new_chat_callback_rejected_in_forum_topic(monkeypatch): + callback = SimpleNamespace( + data="new_chat", + from_user=SimpleNamespace(id=42, full_name="Alice"), + message=make_message(thread_id=88), + answer=AsyncMock(), + ) + state = AsyncMock(spec=FSMContext) + create_chat = Mock() + monkeypatch.setattr(chat_handler.db, "create_chat", create_chat) + + await chat_handler.cb_new_chat(callback, state) + + create_chat.assert_not_called() + state.update_data.assert_not_awaited() + callback.answer.assert_awaited_once_with( + "Создание нового чата из списка доступно только в личке с ботом.", + show_alert=True, + ) + + +async def test_confirm_callback_routes_back_to_forum_thread(monkeypatch): + message = make_message(thread_id=77) + callback = SimpleNamespace( + data="confirm:yes:action-1", + from_user=message.from_user, + message=message, + answer=AsyncMock(), + ) + dispatcher = SimpleNamespace( + dispatch=AsyncMock( + return_value=[OutgoingMessage(chat_id="chat-77", text="done")] + ) + ) + state = AsyncMock(spec=FSMContext) + state.get_data.return_value = {} + + monkeypatch.setattr( + confirm_handler.db, + "get_or_create_tg_user", + lambda tg_user_id, platform_user_id, display_name: { + "platform_user_id": "usr-42", + "display_name": display_name, + }, + ) + monkeypatch.setattr( + confirm_handler.db, + "get_chat_by_thread", + lambda tg_user_id, thread_id: {"chat_id": "chat-77"}, + ) + monkeypatch.setattr( + confirm_handler.db, + "get_chat_by_id", + lambda chat_id: {"chat_id": chat_id, "name": "Forum chat"}, + ) + + await confirm_handler.handle_confirm(callback, state, dispatcher) + + assert dispatcher.dispatch.await_args.args[0].chat_id == "chat-77" + assert callback.message.bot.send_message.await_count == 1 + assert callback.message.bot.send_message.await_args.args[1] == "done" + assert callback.message.bot.send_message.await_args.kwargs["message_thread_id"] == 77 diff --git a/tests/adapter/telegram/test_message.py b/tests/adapter/telegram/test_message.py deleted file mode 100644 index 69aab1e..0000000 --- a/tests/adapter/telegram/test_message.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import importlib -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - - -@pytest.fixture(autouse=True) -def fresh_db(tmp_path, monkeypatch): - monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db")) - import adapter.telegram.db as db_mod - importlib.reload(db_mod) - db_mod.init_db() - return db_mod - - -def make_message(*, user_id=1, thread_id=42, chat_id=100): - m = SimpleNamespace() - m.from_user = SimpleNamespace(id=user_id, full_name="Alice") - m.message_thread_id = thread_id - m.chat = SimpleNamespace(id=chat_id) - m.text = "Hello" - m.photo = None - m.document = None - m.voice = None - m.video = None - m.sticker = None - m.answer = AsyncMock() - placeholder = MagicMock() - placeholder.edit_text = AsyncMock() - m.reply = AsyncMock(return_value=placeholder) - m.bot = MagicMock() - return m, placeholder - - -def make_dispatcher(chunks=None, raise_exc=None): - """Build a mock EventDispatcher with configurable stream_message behaviour.""" - async def _stream(*args, **kwargs): - if raise_exc is not None: - raise raise_exc - for chunk in (chunks or []): - yield chunk - - platform = MagicMock() - platform.get_or_create_user = AsyncMock( - return_value=SimpleNamespace(user_id="uid-1") - ) - platform.stream_message = _stream - - dispatcher = MagicMock() - dispatcher._platform = platform - return dispatcher - - -async def test_stream_exception_shows_error(fresh_db): - """When stream_message raises, the placeholder is updated with an error message.""" - fresh_db.create_chat(1, 42, "Чат #1") - import adapter.telegram.handlers.message as mod - importlib.reload(mod) - - msg, placeholder = make_message() - dispatcher = make_dispatcher(raise_exc=RuntimeError("boom")) - - await mod.handle_topic_message(msg, dispatcher) - - placeholder.edit_text.assert_called() - last_call_text = placeholder.edit_text.call_args[0][0] - assert "недоступен" in last_call_text or "ошибка" in last_call_text.lower() - - -async def test_stream_success_edits_placeholder(fresh_db): - """When stream_message succeeds, the placeholder is updated with the response.""" - fresh_db.create_chat(1, 42, "Чат #1") - import adapter.telegram.handlers.message as mod - importlib.reload(mod) - - chunks = [SimpleNamespace(delta="Hello "), SimpleNamespace(delta="world")] - msg, placeholder = make_message() - dispatcher = make_dispatcher(chunks=chunks) - - await mod.handle_topic_message(msg, dispatcher) - - placeholder.edit_text.assert_called() - last_call_text = placeholder.edit_text.call_args[0][0] - assert "Hello world" in last_call_text diff --git a/tests/adapter/telegram/test_topic_events.py b/tests/adapter/telegram/test_topic_events.py deleted file mode 100644 index fb490af..0000000 --- a/tests/adapter/telegram/test_topic_events.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -import importlib -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest - - -@pytest.fixture(autouse=True) -def fresh_db(tmp_path, monkeypatch): - monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db")) - import adapter.telegram.db as db_mod - importlib.reload(db_mod) - db_mod.init_db() - return db_mod - - -BOT_ID = 9999 # distinct from any test user_id - - -def make_service_message(*, user_id=1, thread_id=42, topic_name="Мой чат"): - m = SimpleNamespace() - m.message_thread_id = thread_id - m.from_user = SimpleNamespace(id=user_id, full_name="Alice") - m.chat = SimpleNamespace(id=user_id) - m.forum_topic_created = SimpleNamespace(name=topic_name) - m.forum_topic_edited = SimpleNamespace(name="Новое имя") - m.forum_topic_closed = SimpleNamespace() - m.answer = AsyncMock() - m.bot = SimpleNamespace(id=BOT_ID) - return m - - -async def test_on_topic_created_registers_chat(fresh_db, monkeypatch): - import adapter.telegram.handlers.topic_events as mod - importlib.reload(mod) - msg = make_service_message(user_id=5, thread_id=99, topic_name="Мой чат") - await mod.on_topic_created(msg) - chat = fresh_db.get_chat(5, 99) - assert chat is not None - assert chat["chat_name"] == "Мой чат" - - -async def test_on_topic_edited_renames_chat(fresh_db, monkeypatch): - import adapter.telegram.handlers.topic_events as mod - importlib.reload(mod) - fresh_db.create_chat(5, 99, "Старое имя") - msg = make_service_message(user_id=5, thread_id=99) - await mod.on_topic_edited(msg) - assert fresh_db.get_chat(5, 99)["chat_name"] == "Новое имя" - - -async def test_on_topic_edited_unknown_chat_is_noop(fresh_db): - import adapter.telegram.handlers.topic_events as mod - importlib.reload(mod) - msg = make_service_message(user_id=5, thread_id=999) - await mod.on_topic_edited(msg) # should not raise - - -async def test_on_topic_closed_archives_chat(fresh_db): - import adapter.telegram.handlers.topic_events as mod - importlib.reload(mod) - fresh_db.create_chat(5, 99, "Чат #1") - msg = make_service_message(user_id=5, thread_id=99) - await mod.on_topic_closed(msg) - assert fresh_db.get_chat(5, 99)["archived_at"] is not None - - -async def test_on_topic_closed_unknown_chat_is_noop(fresh_db): - import adapter.telegram.handlers.topic_events as mod - importlib.reload(mod) - msg = make_service_message(user_id=5, thread_id=999) - await mod.on_topic_closed(msg) # should not raise diff --git a/tests/adapter/test_forum_db.py b/tests/adapter/test_forum_db.py deleted file mode 100644 index e69adc4..0000000 --- a/tests/adapter/test_forum_db.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -import importlib -import pytest - - -@pytest.fixture(autouse=True) -def fresh_db(tmp_path, monkeypatch): - monkeypatch.setenv("DB_PATH", str(tmp_path / "test.db")) - import adapter.telegram.db as db_mod - importlib.reload(db_mod) - db_mod.init_db() - return db_mod - - -def test_create_and_get_chat(fresh_db): - db = fresh_db - db.create_chat(user_id=1, thread_id=100, chat_name="Чат #1") - chat = db.get_chat(user_id=1, thread_id=100) - assert chat is not None - assert chat["chat_name"] == "Чат #1" - assert chat["archived_at"] is None - - -def test_get_chat_missing(fresh_db): - assert fresh_db.get_chat(user_id=1, thread_id=999) is None - - -def test_archive_chat(fresh_db): - db = fresh_db - db.create_chat(1, 100, "Чат #1") - db.archive_chat(1, 100) - chat = db.get_chat(1, 100) - assert chat["archived_at"] is not None - - -def test_rename_chat(fresh_db): - db = fresh_db - db.create_chat(1, 100, "Чат #1") - db.rename_chat(1, 100, "Новое имя") - assert db.get_chat(1, 100)["chat_name"] == "Новое имя" - - -def test_get_active_chats(fresh_db): - db = fresh_db - db.create_chat(1, 100, "Чат #1") - db.create_chat(1, 200, "Чат #2") - db.archive_chat(1, 100) - chats = db.get_active_chats(1) - assert len(chats) == 1 - assert chats[0]["thread_id"] == 200 - - -def test_display_number(fresh_db): - db = fresh_db - db.create_chat(1, 100, "Чат #1") - db.create_chat(1, 200, "Чат #2") - db.create_chat(1, 300, "Чат #3") - assert db.get_display_number(1, 100) == 1 - assert db.get_display_number(1, 200) == 2 - assert db.get_display_number(1, 300) == 3 - - -def test_count_active_chats(fresh_db): - db = fresh_db - db.create_chat(1, 100, "Чат #1") - db.create_chat(1, 200, "Чат #2") - db.archive_chat(1, 100) - assert db.count_active_chats(1) == 1 - - -def test_different_users_isolated(fresh_db): - db = fresh_db - db.create_chat(1, 100, "Чат #1") - db.create_chat(2, 100, "Чат #1") # same thread_id, different user - assert db.get_chat(1, 100)["chat_name"] == "Чат #1" - assert db.get_chat(2, 100)["chat_name"] == "Чат #1" - db.archive_chat(1, 100) - assert db.get_chat(1, 100)["archived_at"] is not None - assert db.get_chat(2, 100)["archived_at"] is None diff --git a/tests/core/test_dispatcher.py b/tests/core/test_dispatcher.py index fad2a4f..eb437d2 100644 --- a/tests/core/test_dispatcher.py +++ b/tests/core/test_dispatcher.py @@ -75,27 +75,6 @@ async def test_dispatch_routes_audio_before_catchall(dispatcher): assert (await dispatcher.dispatch(text_msg))[0].text == "text" -async def test_dispatch_routes_document_before_catchall(dispatcher): - async def document_handler(event, **kwargs): - return [OutgoingMessage(chat_id=event.chat_id, text="document")] - - async def catch_all(event, **kwargs): - return [OutgoingMessage(chat_id=event.chat_id, text="text")] - - dispatcher.register(IncomingMessage, "document", document_handler) - dispatcher.register(IncomingMessage, "*", catch_all) - - document_msg = IncomingMessage( - user_id="u1", - platform="matrix", - chat_id="C1", - text="", - attachments=[Attachment(type="document", workspace_path="surfaces/matrix/u1/file.pdf")], - ) - - assert (await dispatcher.dispatch(document_msg))[0].text == "document" - - async def test_dispatch_callback_by_action(dispatcher): async def confirm_handler(event, **kwargs): return [OutgoingMessage(chat_id=event.chat_id, text="confirmed")] diff --git a/tests/core/test_integration.py b/tests/core/test_integration.py index 9260ec8..207a0ba 100644 --- a/tests/core/test_integration.py +++ b/tests/core/test_integration.py @@ -4,57 +4,18 @@ Smoke test: полный цикл через dispatcher + реальные manag Имитирует что делает адаптер (Telegram или Matrix) при получении события. """ import pytest - -from core.auth import AuthManager +from sdk.mock import MockPlatformClient +from core.store import InMemoryStore from core.chat import ChatManager +from core.auth import AuthManager +from core.settings import SettingsManager from core.handler import EventDispatcher from core.handlers import register_all from core.protocol import ( - Attachment, - IncomingCallback, - IncomingCommand, - IncomingMessage, - OutgoingMessage, - OutgoingUI, + IncomingCommand, IncomingMessage, IncomingCallback, + OutgoingMessage, OutgoingUI, + Attachment, SettingsAction, ) -from core.settings import SettingsManager -from core.store import InMemoryStore -from sdk.mock import MockPlatformClient -from sdk.prototype_state import PrototypeStateStore -from sdk.real import RealPlatformClient -from sdk.upstream_agent_api import MsgEventTextChunk - - -class FakeAgentApi: - def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None: - self.agent_id = agent_id - self.base_url = base_url - self.chat_id = chat_id - self.calls: list[tuple[str, list[str]]] = [] - self.connect_calls = 0 - self.close_calls = 0 - - async def connect(self) -> None: - self.connect_calls += 1 - - async def close(self) -> None: - self.close_calls += 1 - - async def send_message(self, text: str, attachments: list[str] | None = None): - self.calls.append((text, attachments or [])) - yield MsgEventTextChunk(text=f"[REAL] {text}") - - -class FakeAgentApiFactory: - def __init__(self) -> None: - self.created_chat_ids: list[str] = [] - self.instances: dict[str, list[FakeAgentApi]] = {} - - def __call__(self, agent_id: str, base_url: str, chat_id: str) -> FakeAgentApi: - chat_api = FakeAgentApi(agent_id, base_url, chat_id) - self.created_chat_ids.append(chat_id) - self.instances.setdefault(chat_id, []).append(chat_api) - return chat_api @pytest.fixture @@ -71,27 +32,6 @@ def dispatcher(): return d -@pytest.fixture -def real_dispatcher(): - agent_api = FakeAgentApiFactory() - platform = RealPlatformClient( - agent_id="matrix-bot", - agent_base_url="http://platform-agent:8000", - agent_api_cls=agent_api, - prototype_state=PrototypeStateStore(), - platform="matrix", - ) - store = InMemoryStore() - d = EventDispatcher( - platform=platform, - chat_mgr=ChatManager(platform, store), - auth_mgr=AuthManager(platform, store), - settings_mgr=SettingsManager(platform, store), - ) - register_all(d) - return d, agent_api - - async def test_full_flow_start_then_message(dispatcher): start = IncomingCommand(user_id="tg_123", platform="telegram", chat_id="C1", command="start") result = await dispatcher.dispatch(start) @@ -107,13 +47,7 @@ async def test_new_chat_command(dispatcher): start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start") await dispatcher.dispatch(start) - new = IncomingCommand( - user_id="u1", - platform="matrix", - chat_id="C2", - command="new", - args=["Анализ"], - ) + new = IncomingCommand(user_id="u1", platform="matrix", chat_id="C2", command="new", args=["Анализ"]) result = await dispatcher.dispatch(new) assert any("Анализ" in r.text for r in result if isinstance(r, OutgoingMessage)) @@ -149,46 +83,3 @@ async def test_toggle_skill_callback(dispatcher): ) result = await dispatcher.dispatch(cb) assert any("browser" in r.text for r in result if isinstance(r, OutgoingMessage)) - - -async def test_full_flow_with_real_platform_uses_direct_agent_api(real_dispatcher): - dispatcher, agent_api = real_dispatcher - - start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start") - result = await dispatcher.dispatch(start) - assert any(isinstance(r, OutgoingMessage) for r in result) - - msg = IncomingMessage(user_id="u1", platform="matrix", chat_id="C1", text="Привет!") - result = await dispatcher.dispatch(msg) - texts = [r.text for r in result if isinstance(r, OutgoingMessage)] - - assert texts == ["[REAL] Привет!"] - assert agent_api.created_chat_ids == ["C1"] - assert [instance.calls for instance in agent_api.instances["C1"]] == [[("Привет!", [])]] - - -async def test_full_flow_with_real_platform_forwards_workspace_attachment(real_dispatcher): - dispatcher, agent_api = real_dispatcher - - start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start") - await dispatcher.dispatch(start) - - msg = IncomingMessage( - user_id="u1", - platform="matrix", - chat_id="C1", - text="Посмотри файл", - attachments=[ - Attachment( - type="document", - filename="report.pdf", - mime_type="application/pdf", - workspace_path="surfaces/matrix/u1/room/inbox/report.pdf", - ) - ], - ) - await dispatcher.dispatch(msg) - - assert [instance.calls for instance in agent_api.instances["C1"]] == [ - [("Посмотри файл", ["surfaces/matrix/u1/room/inbox/report.pdf"])] - ] diff --git a/tests/platform/test_agent_session.py b/tests/platform/test_agent_session.py deleted file mode 100644 index c398e8c..0000000 --- a/tests/platform/test_agent_session.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Compatibility tests after the Phase 4 migration.""" - -from pathlib import Path - - -def test_lambda_agent_api_module_is_importable(): - from sdk.upstream_agent_api import AgentApi - - assert AgentApi is not None - - -def test_lambda_agent_api_preserves_base_url_path_suffix(): - from sdk.upstream_agent_api import AgentApi - - api = AgentApi( - agent_id="matrix-bot", - base_url="http://platform-agent:8000/proxy/", - chat_id="chat-7", - ) - - assert api.url == "http://platform-agent:8000/proxy/v1/agent_ws/chat-7/" - - -def test_agent_session_module_is_intentionally_stubbed(): - contents = Path(__file__).resolve().parents[2] / "sdk" / "agent_session.py" - - assert "replaced by direct AgentApi usage" in contents.read_text() diff --git a/tests/platform/test_mock.py b/tests/platform/test_mock.py index 18003d2..86e4afe 100644 --- a/tests/platform/test_mock.py +++ b/tests/platform/test_mock.py @@ -43,19 +43,3 @@ async def test_update_settings_toggle_skill(): await client.update_settings("usr-1", action) settings = await client.get_settings("usr-1") assert settings.skills.get("browser") is True - - -async def test_update_settings_toggle_skill_preserves_other_skills(): - client = MockPlatformClient() - - initial = await client.get_settings("usr-1") - initial_skill_names = set(initial.skills) - - action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}) - await client.update_settings("usr-1", action) - - settings = await client.get_settings("usr-1") - - assert set(settings.skills) == initial_skill_names - assert settings.skills["browser"] is True - assert settings.skills["web-search"] is True diff --git a/tests/platform/test_prototype_state.py b/tests/platform/test_prototype_state.py deleted file mode 100644 index 376c0c4..0000000 --- a/tests/platform/test_prototype_state.py +++ /dev/null @@ -1,184 +0,0 @@ -import pytest - -from core.protocol import SettingsAction -from sdk.interface import UserSettings -from sdk.prototype_state import PrototypeStateStore - - -@pytest.mark.asyncio -async def test_get_or_create_user_is_stable_per_surface_identity(): - store = PrototypeStateStore() - - first = await store.get_or_create_user("@alice:example.org", "matrix", "Alice") - second = await store.get_or_create_user("@alice:example.org", "matrix") - - assert first.user_id == "usr-matrix-@alice:example.org" - assert first.is_new is True - assert store._users["matrix:@alice:example.org"].is_new is False - - first.display_name = "Mallory" - first.is_new = False - - assert second.user_id == first.user_id - assert second.is_new is False - assert second.display_name == "Alice" - assert store._users["matrix:@alice:example.org"].display_name == "Alice" - assert store._users["matrix:@alice:example.org"].is_new is False - - -@pytest.mark.asyncio -async def test_settings_defaults_match_existing_mock_shape(): - store = PrototypeStateStore() - - settings = await store.get_settings("usr-matrix-@alice:example.org") - - assert isinstance(settings, UserSettings) - assert settings.skills == { - "web-search": True, - "fetch-url": True, - "email": False, - "browser": False, - "image-gen": False, - "files": True, - } - assert settings.safety == { - "email-send": True, - "file-delete": True, - "social-post": True, - } - assert settings.soul == {"name": "Лямбда", "instructions": ""} - assert settings.plan == {"name": "Beta", "tokens_used": 0, "tokens_limit": 1000} - - -@pytest.mark.asyncio -async def test_get_settings_returns_connectors_copy(): - store = PrototypeStateStore() - store._settings["usr-matrix-@alice:example.org"] = { - "connectors": {"github": {"enabled": True}}, - } - - settings = await store.get_settings("usr-matrix-@alice:example.org") - settings.connectors["github"]["enabled"] = False - settings.connectors["slack"] = {"enabled": True} - - assert store._settings["usr-matrix-@alice:example.org"]["connectors"] == { - "github": {"enabled": True}, - } - - -@pytest.mark.asyncio -async def test_update_settings_supports_toggle_skill_and_setters(): - store = PrototypeStateStore() - - await store.update_settings( - "usr-matrix-@alice:example.org", - SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}), - ) - await store.update_settings( - "usr-matrix-@alice:example.org", - SettingsAction(action="set_soul", payload={"field": "instructions", "value": "Be concise"}), - ) - await store.update_settings( - "usr-matrix-@alice:example.org", - SettingsAction(action="set_safety", payload={"trigger": "social-post", "enabled": False}), - ) - - settings = await store.get_settings("usr-matrix-@alice:example.org") - - assert settings.skills["browser"] is True - assert settings.skills["web-search"] is True - assert settings.soul["instructions"] == "Be concise" - assert settings.safety["social-post"] is False - - -@pytest.mark.asyncio -async def test_add_saved_session_appends_named_entries(): - store = PrototypeStateStore() - - await store.add_saved_session( - "usr-matrix-@alice:example.org", - "alpha", - source_context_id="ctx-room-1", - ) - await store.add_saved_session("usr-matrix-@alice:example.org", "beta") - - sessions = await store.list_saved_sessions("usr-matrix-@alice:example.org") - - assert [session["name"] for session in sessions] == ["alpha", "beta"] - assert all("created_at" in session for session in sessions) - assert sessions[0]["source_context_id"] == "ctx-room-1" - - -@pytest.mark.asyncio -async def test_list_saved_sessions_returns_copy(): - store = PrototypeStateStore() - - await store.add_saved_session("usr-matrix-@alice:example.org", "alpha") - - sessions = await store.list_saved_sessions("usr-matrix-@alice:example.org") - sessions.append({"name": "tampered", "created_at": "never"}) - - stored = await store.list_saved_sessions("usr-matrix-@alice:example.org") - - assert [session["name"] for session in stored] == ["alpha"] - - -@pytest.mark.asyncio -async def test_get_last_tokens_used_defaults_to_zero(): - store = PrototypeStateStore() - - assert await store.get_last_tokens_used_for_context("ctx-room-1") == 0 - - -@pytest.mark.asyncio -async def test_live_tokens_used_are_scoped_per_context(): - store = PrototypeStateStore() - - await store.set_last_tokens_used_for_context("ctx-room-1", 321) - await store.set_last_tokens_used_for_context("ctx-room-2", 654) - - assert await store.get_last_tokens_used_for_context("ctx-room-1") == 321 - assert await store.get_last_tokens_used_for_context("ctx-room-2") == 654 - - -@pytest.mark.asyncio -async def test_current_session_roundtrip_is_scoped_per_context(): - store = PrototypeStateStore() - - assert await store.get_current_session_for_context("ctx-room-1") is None - assert await store.get_current_session_for_context("ctx-room-2") is None - - await store.set_current_session_for_context("ctx-room-1", "session-1") - await store.set_current_session_for_context("ctx-room-2", "session-2") - - assert await store.get_current_session_for_context("ctx-room-1") == "session-1" - assert await store.get_current_session_for_context("ctx-room-2") == "session-2" - - -@pytest.mark.asyncio -async def test_clear_current_session_removes_only_target_context(): - store = PrototypeStateStore() - - await store.set_current_session_for_context("ctx-room-1", "session-1") - await store.set_current_session_for_context("ctx-room-2", "session-2") - - await store.clear_current_session_for_context("ctx-room-1") - - assert await store.get_current_session_for_context("ctx-room-1") is None - assert await store.get_current_session_for_context("ctx-room-2") == "session-2" - - -@pytest.mark.asyncio -async def test_saved_sessions_remain_user_scoped_separate_from_live_context_state(): - store = PrototypeStateStore() - - await store.set_current_session_for_context("ctx-room-1", "room-session") - await store.set_last_tokens_used_for_context("ctx-room-1", 77) - await store.add_saved_session("usr-matrix-@alice:example.org", "alpha") - - sessions = await store.list_saved_sessions("usr-matrix-@alice:example.org") - - assert [session["name"] for session in sessions] == ["alpha"] - assert all(isinstance(session["created_at"], str) for session in sessions) - assert await store.get_current_session_for_context("ctx-room-1") == "room-session" - assert await store.get_last_tokens_used_for_context("ctx-room-1") == 77 diff --git a/tests/platform/test_real.py b/tests/platform/test_real.py deleted file mode 100644 index 8bce30b..0000000 --- a/tests/platform/test_real.py +++ /dev/null @@ -1,465 +0,0 @@ -import asyncio - -import pytest -from pydantic import Field - -from core.protocol import SettingsAction -from sdk.interface import Attachment, MessageChunk, MessageResponse, PlatformError, UserSettings -from sdk.prototype_state import PrototypeStateStore -from sdk.real import RealPlatformClient -from sdk.upstream_agent_api import MsgEventSendFile, MsgEventTextChunk - - -class FakeChatAgentApi: - def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None: - self.agent_id = agent_id - self.base_url = base_url - self.chat_id = str(chat_id) - self.calls: list[str] = [] - self.connect_calls = 0 - self.close_calls = 0 - - async def connect(self) -> None: - self.connect_calls += 1 - - async def close(self) -> None: - self.close_calls += 1 - - async def send_message(self, text: str, attachments: list[str] | None = None): - self.calls.append(text) - midpoint = len(text) // 2 - yield MsgEventTextChunk(text=text[:midpoint]) - yield MsgEventTextChunk(text=text[midpoint:]) - - -class FakeAgentApiFactory: - def __init__(self, chat_api_cls=FakeChatAgentApi) -> None: - self.chat_api_cls = chat_api_cls - self.created_calls: list[tuple[str, str, str]] = [] - self.instances_by_chat: dict[str, list[FakeChatAgentApi]] = {} - - def __call__(self, agent_id: str, base_url: str, chat_id: str): - chat_key = str(chat_id) - chat_api = self.chat_api_cls(agent_id, base_url, chat_key) - self.created_calls.append((agent_id, base_url, chat_key)) - self.instances_by_chat.setdefault(chat_key, []).append(chat_api) - return chat_api - - def latest(self, chat_id: str): - return self.instances_by_chat[str(chat_id)][-1] - - -class BlockingTracker: - def __init__(self) -> None: - self.active_calls = 0 - self.max_active_calls = 0 - self.started = asyncio.Event() - self.release = asyncio.Event() - - -class BlockingChatAgentApi(FakeChatAgentApi): - def __init__( - self, - agent_id: str, - base_url: str, - chat_id: str, - *, - tracker: BlockingTracker, - ) -> None: - super().__init__(agent_id, base_url, chat_id) - self._tracker = tracker - - async def send_message(self, text: str, attachments: list[str] | None = None): - self.calls.append(text) - self._tracker.active_calls += 1 - self._tracker.max_active_calls = max( - self._tracker.max_active_calls, - self._tracker.active_calls, - ) - self._tracker.started.set() - await self._tracker.release.wait() - self._tracker.active_calls -= 1 - yield MsgEventTextChunk(text=text) - - -class BlockingAgentApiFactory(FakeAgentApiFactory): - def __init__(self) -> None: - super().__init__() - self.tracker = BlockingTracker() - - def __call__(self, agent_id: str, base_url: str, chat_id: str): - chat_key = str(chat_id) - chat_api = BlockingChatAgentApi( - agent_id, - base_url, - chat_key, - tracker=self.tracker, - ) - self.created_calls.append((agent_id, base_url, chat_key)) - self.instances_by_chat.setdefault(chat_key, []).append(chat_api) - return chat_api - - -class AttachmentTrackingChatAgentApi(FakeChatAgentApi): - def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None: - super().__init__(agent_id, base_url, chat_id) - self.calls: list[tuple[str, list[str] | None]] = [] - - async def send_message(self, text: str, attachments: list[str] | None = None): - self.calls.append((text, attachments)) - yield MsgEventTextChunk(text=text) - - -class FlakyChatAgentApi(FakeChatAgentApi): - async def send_message(self, text: str, attachments: list[str] | None = None): - raise ConnectionError("Connection closed") - yield - - -class ReuseSensitiveChatAgentApi(FakeChatAgentApi): - def __init__(self, agent_id: str, base_url: str, chat_id: str) -> None: - super().__init__(agent_id, base_url, chat_id) - self._send_calls = 0 - - async def send_message(self, text: str, attachments: list[str] | None = None): - self.calls.append(text) - self._send_calls += 1 - if text == "first": - yield MsgEventTextChunk(text="tool ok") - return - if text == "second" and self._send_calls == 1: - yield MsgEventTextChunk(text="Missing") - - -class MessageResponseWithAttachments(MessageResponse): - attachments: list[Attachment] = Field(default_factory=list) - - -def make_real_platform_client( - agent_api_cls, - *, - prototype_state: PrototypeStateStore | None = None, -) -> RealPlatformClient: - return RealPlatformClient( - agent_id="matrix-bot", - agent_base_url="http://platform-agent:8000", - agent_api_cls=agent_api_cls, - prototype_state=prototype_state or PrototypeStateStore(), - platform="matrix", - ) - - -@pytest.mark.asyncio -async def test_real_platform_client_get_or_create_user_uses_local_state(): - client = make_real_platform_client(FakeAgentApiFactory()) - - first = await client.get_or_create_user("u1", "matrix", "Alice") - second = await client.get_or_create_user("u1", "matrix") - - assert first.user_id == "usr-matrix-u1" - assert first.is_new is True - assert second.user_id == first.user_id - assert second.is_new is False - assert second.display_name == "Alice" - - -@pytest.mark.asyncio -async def test_real_platform_client_send_message_uses_direct_agent_api_per_chat(): - agent_api = FakeAgentApiFactory() - prototype_state = PrototypeStateStore() - client = make_real_platform_client(agent_api, prototype_state=prototype_state) - - result = await client.send_message("@alice:example.org", "chat-7", "hello") - - assert result == MessageResponse( - message_id="@alice:example.org", - response="hello", - tokens_used=0, - finished=True, - ) - assert agent_api.created_calls == [("matrix-bot", "http://platform-agent:8000", "chat-7")] - assert agent_api.latest("chat-7").chat_id == "chat-7" - assert agent_api.latest("chat-7").calls == ["hello"] - assert agent_api.latest("chat-7").connect_calls == 1 - assert agent_api.latest("chat-7").close_calls == 1 - assert await prototype_state.get_last_tokens_used_for_context("chat-7") == 0 - - -@pytest.mark.asyncio -async def test_real_platform_client_preserves_path_base_url_without_trailing_slash(): - agent_api = FakeAgentApiFactory() - client = RealPlatformClient( - agent_id="agent-17", - agent_base_url="http://lambda.coredump.ru:7000/agent_17", - agent_api_cls=agent_api, - prototype_state=PrototypeStateStore(), - platform="matrix", - ) - - await client.send_message("@alice:example.org", "41", "hello") - - assert agent_api.created_calls == [ - ("agent-17", "http://lambda.coredump.ru:7000/agent_17/", "41") - ] - - -@pytest.mark.asyncio -async def test_real_platform_client_forwards_attachments_to_chat_api(): - agent_api = FakeAgentApiFactory(chat_api_cls=AttachmentTrackingChatAgentApi) - client = make_real_platform_client(agent_api) - attachment = Attachment( - url="/agents/7/surfaces/matrix/alice/room/inbox/report.pdf", - workspace_path="surfaces/matrix/alice/room/inbox/report.pdf", - mime_type="application/pdf", - filename="report.pdf", - size=123, - ) - - result = await client.send_message( - "@alice:example.org", - "chat-7", - "hello", - attachments=[attachment], - ) - - assert agent_api.latest("chat-7").calls == [ - ("hello", ["surfaces/matrix/alice/room/inbox/report.pdf"]) - ] - assert result.response == "hello" - assert result.tokens_used == 0 - - -def test_attachment_paths_normalize_workspace_roots_to_relative_paths(): - attachments = [ - Attachment(workspace_path="/workspace/report.pdf"), - Attachment(workspace_path="/agents/7/report.csv"), - Attachment(workspace_path="note.txt"), - ] - - assert RealPlatformClient._attachment_paths(attachments) == [ - "report.pdf", - "report.csv", - "note.txt", - ] - - -@pytest.mark.asyncio -async def test_real_platform_client_preserves_send_file_events_in_sync_result(monkeypatch): - class FileEventAgentApi(AttachmentTrackingChatAgentApi): - async def send_message(self, text: str, attachments: list[str] | None = None): - self.calls.append((text, attachments)) - yield MsgEventTextChunk(text="he") - yield MsgEventSendFile(path="report.pdf") - yield MsgEventTextChunk(text="llo") - - agent_api = FakeAgentApiFactory(chat_api_cls=FileEventAgentApi) - client = make_real_platform_client(agent_api) - - monkeypatch.setattr("sdk.real.MessageResponse", MessageResponseWithAttachments) - - result = await client.send_message("@alice:example.org", "chat-7", "hello") - - assert result.response == "hello" - assert result.tokens_used == 0 - assert result.attachments == [ - Attachment( - url="report.pdf", - mime_type="application/octet-stream", - filename="report.pdf", - size=None, - workspace_path="report.pdf", - ) - ] - - -@pytest.mark.parametrize( - ("location", "expected_workspace_path"), - [ - ("/workspace/report.pdf", "report.pdf"), - ("/agents/7/report.pdf", "report.pdf"), - ( - "surfaces/matrix/alice/room/inbox/report.pdf", - "surfaces/matrix/alice/room/inbox/report.pdf", - ), - ], -) -def test_attachment_from_send_file_event_normalizes_shared_volume_paths( - location: str, expected_workspace_path: str -): - attachment = RealPlatformClient._attachment_from_send_file_event( - MsgEventSendFile(path=location) - ) - - assert attachment.url == location - assert attachment.workspace_path == expected_workspace_path - assert attachment.filename == "report.pdf" - - -@pytest.mark.asyncio -async def test_real_platform_client_uses_fresh_agent_connection_per_request(): - agent_api = FakeAgentApiFactory() - client = make_real_platform_client(agent_api) - - await client.send_message("@alice:example.org", "chat-1", "hello") - await client.send_message("@alice:example.org", "chat-1", "again") - - assert agent_api.created_calls == [ - ("matrix-bot", "http://platform-agent:8000", "chat-1"), - ("matrix-bot", "http://platform-agent:8000", "chat-1"), - ] - assert [instance.calls for instance in agent_api.instances_by_chat["chat-1"]] == [ - ["hello"], - ["again"], - ] - assert all(instance.connect_calls == 1 for instance in agent_api.instances_by_chat["chat-1"]) - assert all(instance.close_calls == 1 for instance in agent_api.instances_by_chat["chat-1"]) - - -@pytest.mark.asyncio -async def test_real_platform_client_avoids_reuse_sensitive_second_message_loss(): - agent_api = FakeAgentApiFactory(chat_api_cls=ReuseSensitiveChatAgentApi) - client = make_real_platform_client(agent_api) - - first = await client.send_message("@alice:example.org", "chat-1", "first") - second = await client.send_message("@alice:example.org", "chat-1", "second") - - assert first.response == "tool ok" - assert second.response == "Missing" - assert len(agent_api.instances_by_chat["chat-1"]) == 2 - - -@pytest.mark.asyncio -async def test_real_platform_client_wraps_connection_closed_as_platform_error(): - agent_api = FakeAgentApiFactory(chat_api_cls=FlakyChatAgentApi) - client = make_real_platform_client(agent_api) - - with pytest.raises(PlatformError, match="Connection closed") as exc_info: - await client.send_message("@alice:example.org", "chat-1", "hello") - - assert exc_info.value.code == "PLATFORM_CONNECTION_ERROR" - assert agent_api.latest("chat-1").close_calls == 1 - - -@pytest.mark.asyncio -async def test_real_platform_client_uses_fresh_connection_after_failure(): - class SometimesFlakyAgentApi(FakeChatAgentApi): - async def send_message(self, text: str, attachments: list[str] | None = None): - if text == "hello": - raise ConnectionError("Connection closed") - self.calls.append(text) - yield MsgEventTextChunk(text=text) - - agent_api = FakeAgentApiFactory(chat_api_cls=SometimesFlakyAgentApi) - client = make_real_platform_client(agent_api) - - with pytest.raises(PlatformError, match="Connection closed"): - await client.send_message("@alice:example.org", "chat-1", "hello") - - result = await client.send_message("@alice:example.org", "chat-1", "again") - - assert result.response == "again" - assert agent_api.created_calls == [ - ("matrix-bot", "http://platform-agent:8000", "chat-1"), - ("matrix-bot", "http://platform-agent:8000", "chat-1"), - ] - assert agent_api.latest("chat-1").calls == ["again"] - - -@pytest.mark.asyncio -async def test_real_platform_client_serializes_same_chat_streams_across_send_paths(): - agent_api = BlockingAgentApiFactory() - client = make_real_platform_client(agent_api) - - async def consume_stream(): - chunks = [] - async for chunk in client.stream_message("@alice:example.org", "chat-1", "hello"): - chunks.append(chunk) - return chunks - - stream_task = asyncio.create_task(consume_stream()) - await asyncio.wait_for(agent_api.tracker.started.wait(), timeout=1) - - send_task = asyncio.create_task(client.send_message("@alice:example.org", "chat-1", "again")) - await asyncio.sleep(0) - - assert len(agent_api.instances_by_chat["chat-1"]) == 1 - assert agent_api.instances_by_chat["chat-1"][0].calls == ["hello"] - assert agent_api.tracker.max_active_calls == 1 - - agent_api.tracker.release.set() - stream_chunks = await stream_task - send_result = await send_task - - assert [chunk.delta for chunk in stream_chunks] == ["hello", ""] - assert send_result.response == "again" - assert [instance.calls for instance in agent_api.instances_by_chat["chat-1"]] == [ - ["hello"], - ["again"], - ] - assert agent_api.tracker.max_active_calls == 1 - - -@pytest.mark.asyncio -async def test_real_platform_client_creates_distinct_connections_per_chat(): - agent_api = FakeAgentApiFactory() - client = make_real_platform_client(agent_api) - - await client.send_message("@alice:example.org", "chat-1", "hello") - await client.send_message("@alice:example.org", "chat-2", "world") - - assert agent_api.created_calls == [ - ("matrix-bot", "http://platform-agent:8000", "chat-1"), - ("matrix-bot", "http://platform-agent:8000", "chat-2"), - ] - assert agent_api.latest("chat-1").calls == ["hello"] - assert agent_api.latest("chat-2").calls == ["world"] - - -@pytest.mark.asyncio -async def test_real_platform_client_stream_message_emits_final_tokens_chunk(): - agent_api = FakeAgentApiFactory() - client = make_real_platform_client(agent_api) - - chunks = [] - async for chunk in client.stream_message("@alice:example.org", "chat-1", "hello"): - chunks.append(chunk) - - assert chunks == [ - MessageChunk( - message_id="@alice:example.org", - delta="he", - finished=False, - tokens_used=0, - ), - MessageChunk( - message_id="@alice:example.org", - delta="llo", - finished=False, - tokens_used=0, - ), - MessageChunk( - message_id="@alice:example.org", - delta="", - finished=True, - tokens_used=0, - ), - ] - assert agent_api.created_calls == [("matrix-bot", "http://platform-agent:8000", "chat-1")] - assert agent_api.latest("chat-1").calls == ["hello"] - assert agent_api.latest("chat-1").close_calls == 1 - - -@pytest.mark.asyncio -async def test_real_platform_client_settings_are_local(): - client = make_real_platform_client(FakeAgentApiFactory()) - - await client.update_settings( - "usr-matrix-u1", - SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}), - ) - - settings = await client.get_settings("usr-matrix-u1") - - assert isinstance(settings, UserSettings) - assert settings.skills["browser"] is True - assert settings.skills["web-search"] is True diff --git a/tests/test_check_matrix_agents.py b/tests/test_check_matrix_agents.py deleted file mode 100644 index 25f63bd..0000000 --- a/tests/test_check_matrix_agents.py +++ /dev/null @@ -1,22 +0,0 @@ -from tools.check_matrix_agents import build_agent_ws_url - - -def test_build_agent_ws_url_preserves_path_prefix_without_trailing_slash(): - assert ( - build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17", "41") - == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/" - ) - - -def test_build_agent_ws_url_preserves_path_prefix_with_trailing_slash(): - assert ( - build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17/", "41") - == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/" - ) - - -def test_build_agent_ws_url_accepts_existing_agent_ws_url(): - assert ( - build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/0/", "41") - == "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/" - ) diff --git a/tests/test_deploy_handoff.py b/tests/test_deploy_handoff.py deleted file mode 100644 index 0cf2057..0000000 --- a/tests/test_deploy_handoff.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import yaml - -ROOT = Path(__file__).resolve().parents[1] - - -def _compose(path: str) -> dict: - return yaml.safe_load((ROOT / path).read_text(encoding="utf-8")) - - -def test_prod_compose_uses_registry_image_not_local_build(): - prod = _compose("docker-compose.prod.yml") - service = prod["services"]["matrix-bot"] - - assert "image" in service - assert "build" not in service - assert service["image"].startswith("${SURFACES_BOT_IMAGE:?") - - -def test_fullstack_compose_keeps_local_dev_build_with_agent_api_context(): - fullstack = _compose("docker-compose.fullstack.yml") - service = fullstack["services"]["matrix-bot"] - - assert service["build"]["target"] == "development" - assert service["build"]["additional_contexts"]["agent_api"] == "./external/platform-agent_api" - assert service["extends"]["file"] == "docker-compose.prod.yml" - - -def test_dockerfile_production_build_does_not_require_local_external_tree(): - dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") - - assert "/app/external/platform-agent_api" not in dockerfile - assert "external/platform-agent_api" not in dockerfile - assert "git+https://git.lambda.coredump.ru/platform/agent_api.git" in dockerfile - assert "python -m pip install --no-cache-dir --ignore-requires-python" in dockerfile - assert "uv pip install --system --ignore-requires-python" not in dockerfile - - -def test_dockerfile_installs_agent_api_after_final_uv_sync(): - dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") - development = dockerfile.split("FROM base AS development", maxsplit=1)[1].split( - "FROM base AS production", maxsplit=1 - )[0] - production = dockerfile.split("FROM base AS production", maxsplit=1)[1] - - assert development.index("RUN uv sync --no-dev --frozen") < development.index( - "pip install --no-cache-dir --ignore-requires-python -e /agent_api/" - ) - assert production.index("RUN uv sync --no-dev --frozen") < production.index( - "git+https://git.lambda.coredump.ru/platform/agent_api.git" - ) - - -def test_dockerignore_excludes_local_only_and_runtime_artifacts(): - dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8") - - assert "external/" in dockerignore - assert ".planning/" in dockerignore - assert "config/matrix-agents.yaml" in dockerignore - assert ".env" in dockerignore - - -def test_agent_registry_example_documents_multi_agent_volume_contract(): - registry = yaml.safe_load( - (ROOT / "config" / "matrix-agents.example.yaml").read_text(encoding="utf-8") - ) - agents = registry["agents"] - - assert len(agents) >= 3 - assert len({agent["id"] for agent in agents}) == len(agents) - assert len({agent["workspace_path"] for agent in agents}) == len(agents) - for index, agent in enumerate(agents): - assert agent["base_url"].endswith(f"/agent_{index}/") - assert agent["workspace_path"] == f"/agents/{index}" - - -def test_smoke_compose_models_deploy_like_proxy_and_surface_checker(): - smoke = _compose("docker-compose.smoke.yml") - - assert set(smoke["services"]) >= {"surface-smoke", "agent-proxy", "agent-0", "agent-1"} - assert "tools.check_matrix_agents" in smoke["services"]["surface-smoke"]["command"] - assert smoke["services"]["agent-proxy"]["ports"] == ["${SMOKE_PROXY_PORT:-7000}:7000"] - - -def test_smoke_timeout_override_routes_one_agent_to_no_status_stub(): - smoke_timeout = _compose("docker-compose.smoke.timeout.yml") - - assert set(smoke_timeout["services"]) >= {"agent-proxy", "agent-no-status"} - - -def test_smoke_registry_targets_local_proxy_routes(): - registry = yaml.safe_load( - (ROOT / "config" / "matrix-agents.smoke.yaml").read_text(encoding="utf-8") - ) - - assert [agent["base_url"] for agent in registry["agents"]] == [ - "http://agent-proxy:7000/agent_0/", - "http://agent-proxy:7000/agent_1/", - ] diff --git a/tools/__init__.py b/tools/__init__.py deleted file mode 100644 index a1d9c25..0000000 --- a/tools/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Operational tools for surfaces-bot.""" diff --git a/tools/check_matrix_agents.py b/tools/check_matrix_agents.py deleted file mode 100644 index d6035aa..0000000 --- a/tools/check_matrix_agents.py +++ /dev/null @@ -1,197 +0,0 @@ -from __future__ import annotations - -import argparse -import asyncio -import json -import os -import time -from dataclasses import asdict, dataclass -from pathlib import Path -from urllib.parse import urljoin - -import aiohttp - -from adapter.matrix.agent_registry import AgentDefinition, load_agent_registry -from sdk.real import RealPlatformClient - - -@dataclass -class AgentCheckResult: - agent_id: str - label: str - chat_id: str - base_url: str - ws_url: str - ok: bool - stage: str - latency_ms: int - error: str = "" - response_type: str = "" - - -def build_agent_ws_url(base_url: str, chat_id: str) -> str: - normalized = RealPlatformClient._normalize_agent_base_url(base_url) - return urljoin(normalized, f"v1/agent_ws/{chat_id}/") - - -def _message_type(payload: str) -> str: - try: - data = json.loads(payload) - except json.JSONDecodeError: - return "" - value = data.get("type") - return value if isinstance(value, str) else "" - - -async def _receive_text(ws: aiohttp.ClientWebSocketResponse, timeout: float) -> str: - msg = await asyncio.wait_for(ws.receive(), timeout=timeout) - if msg.type == aiohttp.WSMsgType.TEXT: - return str(msg.data) - if msg.type == aiohttp.WSMsgType.ERROR: - raise RuntimeError(f"websocket error: {ws.exception()}") - raise RuntimeError(f"unexpected websocket message type: {msg.type.name}") - - -async def check_agent( - agent: AgentDefinition, - *, - fallback_base_url: str, - chat_id: str, - timeout: float, - message: str | None, -) -> AgentCheckResult: - base_url = agent.base_url or fallback_base_url - ws_url = build_agent_ws_url(base_url, chat_id) if base_url else "" - started = time.perf_counter() - - def result(ok: bool, stage: str, error: str = "", response_type: str = "") -> AgentCheckResult: - return AgentCheckResult( - agent_id=agent.agent_id, - label=agent.label, - chat_id=chat_id, - base_url=base_url, - ws_url=ws_url, - ok=ok, - stage=stage, - latency_ms=int((time.perf_counter() - started) * 1000), - error=error, - response_type=response_type, - ) - - if not base_url: - return result(False, "config", "missing base_url and AGENT_BASE_URL") - - try: - client_timeout = aiohttp.ClientTimeout( - total=timeout, - connect=timeout, - sock_connect=timeout, - sock_read=timeout, - ) - async with aiohttp.ClientSession(timeout=client_timeout) as session: - async with session.ws_connect(ws_url, heartbeat=30) as ws: - raw_status = await _receive_text(ws, timeout) - status_type = _message_type(raw_status) - if status_type != "STATUS": - return result( - False, - "status", - f"expected STATUS, got {raw_status[:200]}", - status_type, - ) - - if not message: - return result(True, "status", response_type=status_type) - - payload = { - "type": "USER_MESSAGE", - "text": message, - "attachments": [], - } - await ws.send_str(json.dumps(payload)) - - while True: - raw_event = await _receive_text(ws, timeout) - event_type = _message_type(raw_event) - if event_type == "ERROR": - return result(False, "message", raw_event[:200], event_type) - if event_type == "AGENT_EVENT_END": - return result(True, "message", response_type=event_type) - if not event_type: - return result(False, "message", f"invalid JSON event: {raw_event[:200]}") - except TimeoutError: - return result(False, "timeout", f"no response within {timeout:g}s") - except Exception as exc: - return result(False, "connect", str(exc)) - - -def _select_agents( - agents: tuple[AgentDefinition, ...], - selected: set[str], -) -> list[AgentDefinition]: - if not selected: - return list(agents) - return [agent for agent in agents if agent.agent_id in selected] - - -async def run_checks(args: argparse.Namespace) -> list[AgentCheckResult]: - registry = load_agent_registry(args.config) - selected = _select_agents(registry.agents, set(args.agent)) - if not selected: - raise SystemExit("no matching agents selected") - - fallback_base_url = args.base_url or os.environ.get("AGENT_BASE_URL", "") - semaphore = asyncio.Semaphore(args.concurrency) - - async def run_one(index: int, agent: AgentDefinition) -> AgentCheckResult: - chat_id = str(args.chat_id if args.chat_id is not None else args.chat_id_base + index) - async with semaphore: - return await check_agent( - agent, - fallback_base_url=fallback_base_url, - chat_id=chat_id, - timeout=args.timeout, - message=args.message, - ) - - return await asyncio.gather(*(run_one(index, agent) for index, agent in enumerate(selected))) - - -def print_table(results: list[AgentCheckResult]) -> None: - for item in results: - status = "OK" if item.ok else "FAIL" - detail = item.response_type or item.error - print( - f"{status:4} {item.agent_id:20} {item.stage:8} " - f"{item.latency_ms:5}ms chat={item.chat_id} url={item.ws_url} {detail}" - ) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Smoke-check Matrix agent WebSocket endpoints from matrix-agents.yaml." - ) - parser.add_argument("--config", type=Path, default=Path("config/matrix-agents.yaml")) - parser.add_argument("--agent", action="append", default=[], help="Agent id to check") - parser.add_argument("--base-url", default="", help="Fallback base URL when an agent has none") - parser.add_argument("--timeout", type=float, default=10.0) - parser.add_argument("--concurrency", type=int, default=5) - parser.add_argument("--chat-id", type=int, default=None, help="Use one explicit chat id") - parser.add_argument("--chat-id-base", type=int, default=900000) - parser.add_argument("--message", default=None, help="Optional test message after STATUS") - parser.add_argument("--json", action="store_true", help="Print machine-readable JSON") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - results = asyncio.run(run_checks(args)) - if args.json: - print(json.dumps([asdict(result) for result in results], ensure_ascii=False, indent=2)) - else: - print_table(results) - return 0 if all(result.ok for result in results) else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/no_status_agent.py b/tools/no_status_agent.py deleted file mode 100644 index adb563a..0000000 --- a/tools/no_status_agent.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -import argparse -import asyncio - -from aiohttp import web - - -async def websocket_handler(request: web.Request) -> web.WebSocketResponse: - ws = web.WebSocketResponse() - await ws.prepare(request) - await asyncio.sleep(3600) - return ws - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="WebSocket stub that accepts connections but sends no STATUS." - ) - parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--port", type=int, default=8000) - return parser.parse_args() - - -def main() -> None: - args = parse_args() - app = web.Application() - app.router.add_get("/v1/agent_ws/{chat_id}/", websocket_handler) - web.run_app(app, host=args.host, port=args.port) - - -if __name__ == "__main__": - main() diff --git a/uv.lock b/uv.lock index 76a9426..600768a 100644 --- a/uv.lock +++ b/uv.lock @@ -39,7 +39,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -50,93 +50,93 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, - { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, - { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, - { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, - { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" }, + { url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" }, + { url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" }, + { url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" }, + { url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" }, + { url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" }, + { url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" }, + { url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" }, + { url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" }, + { url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" }, + { url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" }, + { url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" }, + { url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" }, + { url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" }, + { url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" }, + { url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" }, + { url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" }, + { url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" }, + { url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" }, + { url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" }, + { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, + { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, + { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, + { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, + { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, + { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, + { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, + { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, + { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, + { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, ] [[package]] @@ -756,7 +756,7 @@ wheels = [ [[package]] name = "mypy" -version = "1.19.1" +version = "1.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, @@ -764,33 +764,38 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, - { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, + { url = "https://files.pythonhosted.org/packages/6e/1c/74cb1d9993236910286865679d1c616b136b2eae468493aa939431eda410/mypy-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4525e7010b1b38334516181c5b81e16180b8e149e6684cee5a727c78186b4e3b", size = 14343972, upload-time = "2026-03-31T16:49:04.887Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/01399515eca280386e308cf57901e68d3a52af18691941b773b3380c1df8/mypy-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a17c5d0bdcca61ce24a35beb828a2d0d323d3fcf387d7512206888c900193367", size = 13225007, upload-time = "2026-03-31T16:50:08.151Z" }, + { url = "https://files.pythonhosted.org/packages/56/ac/b4ba5094fb2d7fe9d2037cd8d18bbe02bcf68fd22ab9ff013f55e57ba095/mypy-1.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75ff57defcd0f1d6e006d721ccdec6c88d4f6a7816eb92f1c4890d979d9ee62", size = 13663752, upload-time = "2026-03-31T16:49:26.064Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/460678d3cf7da252d2288dad0c602294b6ec22a91932ec368cc11e44bb6e/mypy-1.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b503ab55a836136b619b5fc21c8803d810c5b87551af8600b72eecafb0059cb0", size = 14532265, upload-time = "2026-03-31T16:53:55.077Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/051cca8166cf0438ae3ea80e0e7c030d7a8ab98dffc93f80a1aa3f23c1a2/mypy-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1973868d2adbb4584a3835780b27436f06d1dc606af5be09f187aaa25be1070f", size = 14768476, upload-time = "2026-03-31T16:50:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/be/66/8e02ec184f852ed5c4abb805583305db475930854e09964b55e107cdcbc4/mypy-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:2fcedb16d456106e545b2bfd7ef9d24e70b38ec252d2a629823a4d07ebcdb69e", size = 10818226, upload-time = "2026-03-31T16:53:15.624Z" }, + { url = "https://files.pythonhosted.org/packages/13/4b/383ad1924b28f41e4879a74151e7a5451123330d45652da359f9183bcd45/mypy-1.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:379edf079ce44ac8d2805bcf9b3dd7340d4f97aad3a5e0ebabbf9d125b84b442", size = 9750091, upload-time = "2026-03-31T16:54:12.162Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/227b516ab8cad9f2a13c5e7a98d28cd6aa75e9c83e82776ae6c1c4c046c7/mypy-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9336b5e6712f4adaf5afc3203a99a40b379049104349d747eb3e5a3aa23ac2e", size = 13326469, upload-time = "2026-03-31T16:51:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/57/d4/1ddb799860c1b5ac6117ec307b965f65deeb47044395ff01ab793248a591/mypy-1.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f13b3e41bce9d257eded794c0f12878af3129d80aacd8a3ee0dee51f3a978651", size = 13705953, upload-time = "2026-03-31T16:48:55.69Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b7/54a720f565a87b893182a2a393370289ae7149e4715859e10e1c05e49154/mypy-1.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9804c3ad27f78e54e58b32e7cb532d128b43dbfb9f3f9f06262b821a0f6bd3f5", size = 14710363, upload-time = "2026-03-31T16:53:26.948Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2a/74810274848d061f8a8ea4ac23aaad43bd3d8c1882457999c2e568341c57/mypy-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:697f102c5c1d526bdd761a69f17c6070f9892eebcb94b1a5963d679288c09e78", size = 14947005, upload-time = "2026-03-31T16:50:17.591Z" }, + { url = "https://files.pythonhosted.org/packages/77/91/21b8ba75f958bcda75690951ce6fa6b7138b03471618959529d74b8544e2/mypy-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ecd63f75fdd30327e4ad8b5704bd6d91fc6c1b2e029f8ee14705e1207212489", size = 10880616, upload-time = "2026-03-31T16:52:19.986Z" }, + { url = "https://files.pythonhosted.org/packages/8a/15/3d8198ef97c1ca03aea010cce4f1d4f3bc5d9849e8c0140111ca2ead9fdd/mypy-1.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:f194db59657c58593a3c47c6dfd7bad4ef4ac12dbc94d01b3a95521f78177e33", size = 9813091, upload-time = "2026-03-31T16:53:44.385Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f64ea7bd592fa431cb597418b6dec4a47f7d0c36325fec7ac67bc8402b94/mypy-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b20c8b0fd5877abdf402e79a3af987053de07e6fb208c18df6659f708b535134", size = 14485344, upload-time = "2026-03-31T16:49:16.78Z" }, + { url = "https://files.pythonhosted.org/packages/bb/72/8927d84cfc90c6abea6e96663576e2e417589347eb538749a464c4c218a0/mypy-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:367e5c993ba34d5054d11937d0485ad6dfc60ba760fa326c01090fc256adf15c", size = 13327400, upload-time = "2026-03-31T16:53:08.02Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4a/11ab99f9afa41aa350178d24a7d2da17043228ea10f6456523f64b5a6cf6/mypy-1.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f799d9db89fc00446f03281f84a221e50018fc40113a3ba9864b132895619ebe", size = 13706384, upload-time = "2026-03-31T16:52:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/42/79/694ca73979cfb3535ebfe78733844cd5aff2e63304f59bf90585110d975a/mypy-1.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555658c611099455b2da507582ea20d2043dfdfe7f5ad0add472b1c6238b433f", size = 14700378, upload-time = "2026-03-31T16:48:45.527Z" }, + { url = "https://files.pythonhosted.org/packages/84/24/a022ccab3a46e3d2cdf2e0e260648633640eb396c7e75d5a42818a8d3971/mypy-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:efe8d70949c3023698c3fca1e94527e7e790a361ab8116f90d11221421cd8726", size = 14932170, upload-time = "2026-03-31T16:49:36.038Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9b/549228d88f574d04117e736f55958bd4908f980f9f5700a07aeb85df005b/mypy-1.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:f49590891d2c2f8a9de15614e32e459a794bcba84693c2394291a2038bbaaa69", size = 10888526, upload-time = "2026-03-31T16:50:59.827Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/15095c0e54a8bc04d22d4ff06b2139d5f142c2e87520b4e39010c4862771/mypy-1.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:76a70bf840495729be47510856b978f1b0ec7d08f257ca38c9d932720bf6b43e", size = 9816456, upload-time = "2026-03-31T16:49:59.537Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/6ca4a84cbed9e62384bc0b2974c90395ece5ed672393e553996501625fc5/mypy-1.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0f42dfaab7ec1baff3b383ad7af562ab0de573c5f6edb44b2dab016082b89948", size = 14483331, upload-time = "2026-03-31T16:52:57.999Z" }, + { url = "https://files.pythonhosted.org/packages/4c/33/e18bcfa338ca4e6b2771c85d4c5203e627d0c69d9de5c1a2cf2ba13320ba/mypy-1.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d11c6f573a5a08f77fad13faff2139f6d0730ebed2cfa9b3d2702671dd7188", size = 13719585, upload-time = "2026-03-31T16:51:53.89Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/93491ff7b79419edc7eabf95cb3b3f7490e2e574b2855c7c7e7394ff933f/mypy-1.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d3243c406773185144527f83be0e0aefc7bf4601b0b2b956665608bf7c98a83", size = 14685075, upload-time = "2026-03-31T16:54:04.464Z" }, + { url = "https://files.pythonhosted.org/packages/59/98/1da9977016678c0b99d43afe52ed00bb3c1a0c4c995d3e6acca1a6ebb9b4/mypy-1.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:00e047c74d3ec6e71a2eb88e9ea551a2edb90c21f993aefa9e0d2a898e0bb732", size = 11050925, upload-time = "2026-03-31T16:51:30.758Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e3/ba0b7a3143e49a9c4f5967dde6ea4bf8e0b10ecbbcca69af84027160ee89/mypy-1.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:931a7630bba591593dcf6e97224a21ff80fb357e7982628d25e3c618e7f598ef", size = 10001089, upload-time = "2026-03-31T16:49:43.632Z" }, + { url = "https://files.pythonhosted.org/packages/12/28/e617e67b3be9d213cda7277913269c874eb26472489f95d09d89765ce2d8/mypy-1.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:26c8b52627b6552f47ff11adb4e1509605f094e29815323e487fc0053ebe93d1", size = 15534710, upload-time = "2026-03-31T16:52:12.506Z" }, + { url = "https://files.pythonhosted.org/packages/42/37/a946bb416e37a57fa752b3100fd5ede0e28df94f92366d1716555d47c454/mypy-1.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:555493c44a4f5a1b58d611a43333e71a9981c6dbe26270377b6f8174126a0526", size = 15858565, upload-time = "2026-03-31T16:53:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/2f/99/7690b5b5b552db1bd4ff362e4c0eb3107b98d680835e65823fbe888c8b78/mypy-1.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2721f0ce49cb74a38f00c50da67cb7d36317b5eda38877a49614dc018e91c787", size = 16087874, upload-time = "2026-03-31T16:52:48.313Z" }, + { url = "https://files.pythonhosted.org/packages/aa/76/53e893a498138066acd28192b77495c9357e5a58cc4be753182846b43315/mypy-1.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47781555a7aa5fedcc2d16bcd72e0dc83eb272c10dd657f9fb3f9cc08e2e6abb", size = 12572380, upload-time = "2026-03-31T16:49:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/6dbdae21f01b7aacddc2c0bbf3c5557aa547827fdf271770fe1e521e7093/mypy-1.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c70380fe5d64010f79fb863b9081c7004dd65225d2277333c219d93a10dad4dd", size = 10381174, upload-time = "2026-03-31T16:51:20.179Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4d734961ce167f0fd8380769b3b7c06dbdd6ff54c2190f3f2ecd22528158/mypy-1.20.0-py3-none-any.whl", hash = "sha256:a6e0641147cbfa7e4e94efdb95c2dab1aff8cfc159ded13e07f308ddccc8c48e", size = 2636365, upload-time = "2026-03-31T16:51:44.911Z" }, ] [[package]] @@ -1072,11 +1077,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -1095,20 +1100,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] -[[package]] -name = "pytest-aiohttp" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" }, -] - [[package]] name = "pytest-asyncio" version = "1.3.0" @@ -1154,61 +1145,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087, upload-time = "2026-02-16T05:23:59.147Z" }, ] -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - [[package]] name = "referencing" version = "0.37.0" @@ -1371,12 +1307,10 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "aiogram" }, - { name = "aiohttp" }, { name = "httpx" }, { name = "matrix-nio" }, { name = "pydantic" }, { name = "python-dotenv" }, - { name = "pyyaml" }, { name = "structlog" }, ] @@ -1384,7 +1318,6 @@ dependencies = [ dev = [ { name = "mypy" }, { name = "pytest" }, - { name = "pytest-aiohttp" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -1393,17 +1326,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiogram", specifier = ">=3.4,<4" }, - { name = "aiohttp", specifier = ">=3.9" }, { name = "httpx", specifier = ">=0.27" }, { name = "matrix-nio", specifier = ">=0.21" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8" }, { name = "pydantic", specifier = ">=2.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, - { name = "pytest-aiohttp", marker = "extra == 'dev'", specifier = ">=1.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" }, { name = "python-dotenv", specifier = ">=1.0" }, - { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3" }, { name = "structlog", specifier = ">=24.1" }, ]