feat(matrix): land QA follow-ups and refresh docs

- harden Matrix onboarding/chat lifecycle after manual QA
- refresh README and Matrix docs to match current behavior
- add local ignores for runtime artifacts and include current planning/report docs

Closes #7
Closes #9
Closes #14
This commit is contained in:
Mikhail Putilovskij 2026-04-05 19:08:58 +03:00
parent 7fce4c9b3e
commit 6ced154124
35 changed files with 8380 additions and 67 deletions

View file

@ -14,6 +14,7 @@ from nio import (
RoomMemberEvent,
RoomMessageText,
)
from nio.responses import SyncResponse
from dotenv import load_dotenv
from adapter.matrix.converter import from_room_event
@ -115,12 +116,20 @@ class MatrixBot:
self.runtime.platform,
self.runtime.store,
self.runtime.auth_mgr,
self.runtime.chat_mgr,
)
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)
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,
@ -197,6 +206,8 @@ async def main() -> None:
elif password:
await client.login(password=password, device_name="surfaces-bot")
since_token = await prepare_live_sync(client)
bot = MatrixBot(client, runtime)
client.add_event_callback(bot.on_room_message, RoomMessageText)
client.add_event_callback(bot.on_member, (InviteMemberEvent, RoomMemberEvent))
@ -209,7 +220,7 @@ async def main() -> None:
request_timeout=client_config.request_timeout,
)
try:
await client.sync_forever(timeout=30000)
await client.sync_forever(timeout=30000, since=since_token)
finally:
await client.close()

View file

@ -8,6 +8,7 @@ from adapter.matrix.handlers.chat import (
)
from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_confirm
from adapter.matrix.handlers.settings import (
handle_help,
handle_settings,
handle_settings_connectors,
handle_settings_plan,
@ -27,6 +28,7 @@ def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=Non
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)
dispatcher.register(IncomingCommand, "settings_skills", handle_settings_skills)
dispatcher.register(IncomingCommand, "settings_connectors", handle_settings_connectors)

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import structlog
from typing import Any
from nio.api import RoomVisibility
from nio.responses import RoomCreateError
from adapter.matrix.store import (
@ -15,7 +16,7 @@ from adapter.matrix.store import (
logger = structlog.get_logger(__name__)
async def handle_invite(client: Any, room: Any, event: Any, platform, store, auth_mgr) -> None:
async def handle_invite(client: Any, room: Any, event: Any, platform, store, auth_mgr, chat_mgr) -> None:
matrix_user_id = getattr(event, "sender", "")
display_name = getattr(room, "display_name", None) or matrix_user_id
@ -37,7 +38,8 @@ async def handle_invite(client: Any, room: Any, event: Any, platform, store, aut
space_resp = await client.room_create(
name=f"Lambda — {display_name}",
space=True,
visibility="private",
visibility=RoomVisibility.private,
invite=[matrix_user_id],
)
if isinstance(space_resp, RoomCreateError):
logger.error(
@ -50,8 +52,9 @@ async def handle_invite(client: Any, room: Any, event: Any, platform, store, aut
chat_resp = await client.room_create(
name="Чат 1",
visibility="private",
visibility=RoomVisibility.private,
is_direct=False,
invite=[matrix_user_id],
)
if isinstance(chat_resp, RoomCreateError):
logger.error(
@ -69,9 +72,6 @@ async def handle_invite(client: Any, room: Any, event: Any, platform, store, aut
state_key=chat_room_id,
)
await client.room_invite(space_id, matrix_user_id)
await client.room_invite(chat_room_id, matrix_user_id)
chat_id = await next_chat_id(store, matrix_user_id)
user_meta = await get_user_meta(store, matrix_user_id) or {}
@ -89,6 +89,13 @@ async def handle_invite(client: Any, room: Any, event: Any, platform, store, aut
"space_id": space_id,
},
)
await chat_mgr.get_or_create(
user_id=matrix_user_id,
chat_id=chat_id,
platform="matrix",
surface_ref=chat_room_id,
name="Чат 1",
)
welcome = (
f"Привет, {user.display_name or matrix_user_id}! Пиши — я здесь.\n\n"

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any, Awaitable, Callable
import structlog
from nio.api import RoomVisibility
from nio.responses import RoomCreateError
from adapter.matrix.store import get_user_meta, next_chat_id, set_room_meta
@ -11,6 +12,10 @@ 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:
@ -68,8 +73,9 @@ def make_handle_new_chat(
response = await client.room_create(
name=room_name,
visibility="private",
visibility=RoomVisibility.private,
is_direct=False,
invite=[event.user_id],
)
if isinstance(response, RoomCreateError):
logger.error(
@ -90,7 +96,6 @@ def make_handle_new_chat(
content={"via": [homeserver]},
state_key=room_id,
)
await client.room_invite(room_id, event.user_id)
await set_room_meta(
store,
@ -141,11 +146,23 @@ def make_handle_rename(
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_set_name(ctx.surface_ref, new_name)
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}")]
@ -159,7 +176,19 @@ def make_handle_archive(
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

View file

@ -4,6 +4,25 @@ from adapter.matrix.reactions import build_skills_text
from core.protocol import IncomingCommand, OutgoingMessage, SettingsAction
HELP_TEXT = "\n".join(
[
"Команды",
"",
"!new [название] создать новый чат",
"!chats список активных чатов",
"!rename <название> переименовать текущий чат",
"!archive архивировать текущий чат",
"!settings общий обзор настроек",
"!skills список навыков",
"!soul [поле значение] показать или изменить личность",
"!safety [триггер on/off] показать или изменить безопасность",
"!status краткий статус",
"!whoami показать ваш id",
"!yes / !no подтвердить или отменить действие",
]
)
def _render_mapping(title: str, data: dict | None) -> str:
data = data or {}
lines = [title]
@ -66,6 +85,12 @@ async def handle_settings(
return [OutgoingMessage(chat_id=event.chat_id, text=dashboard)]
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:

View file

@ -4,6 +4,9 @@ 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
@ -41,9 +44,9 @@ def build_event_dispatcher() -> EventDispatcher:
async def main() -> None:
token = os.environ.get("BOT_TOKEN")
token = os.environ.get("BOT_TOKEN") or os.environ.get("TELEGRAM_BOT_TOKEN")
if not token:
raise RuntimeError("BOT_TOKEN env variable is not set")
raise RuntimeError("BOT_TOKEN (or TELEGRAM_BOT_TOKEN) env variable is not set")
db.init_db()