Compare commits
8 commits
a3449fc864
...
6a843e8036
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a843e8036 | |||
| 14c091b5f5 | |||
| 82eb711844 | |||
| bcdaea5143 | |||
| a8885aeaa1 | |||
| 41660fe84a | |||
| c979f96c3c | |||
| 09919b2463 |
38 changed files with 4118 additions and 14 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -21,6 +21,9 @@ build/
|
|||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Visual brainstorming sessions
|
||||
.superpowers/
|
||||
|
||||
# Tests
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
|
|
|
|||
2
adapter/__init__.py
Normal file
2
adapter/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from __future__ import annotations
|
||||
|
||||
1
adapter/matrix/__init__.py
Normal file
1
adapter/matrix/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
233
adapter/matrix/bot.py
Normal file
233
adapter/matrix/bot.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from nio import (
|
||||
AsyncClient,
|
||||
AsyncClientConfig,
|
||||
InviteMemberEvent,
|
||||
MatrixRoom,
|
||||
ReactionEvent,
|
||||
RoomMemberEvent,
|
||||
RoomMessageText,
|
||||
)
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from adapter.matrix.converter import from_reaction, from_room_event
|
||||
from adapter.matrix.handlers import register_matrix_handlers
|
||||
from adapter.matrix.handlers.auth import handle_invite
|
||||
from adapter.matrix.room_router import resolve_chat_id
|
||||
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 (
|
||||
OutgoingEvent,
|
||||
OutgoingMessage,
|
||||
OutgoingNotification,
|
||||
OutgoingTyping,
|
||||
OutgoingUI,
|
||||
)
|
||||
from core.settings import SettingsManager
|
||||
from core.store import InMemoryStore, SQLiteStore, StateStore
|
||||
from sdk.mock import MockPlatformClient
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
load_dotenv(Path(__file__).resolve().parents[2] / ".env")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatrixRuntime:
|
||||
platform: MockPlatformClient
|
||||
store: StateStore
|
||||
chat_mgr: ChatManager
|
||||
auth_mgr: AuthManager
|
||||
settings_mgr: SettingsManager
|
||||
dispatcher: EventDispatcher
|
||||
|
||||
|
||||
def build_event_dispatcher(platform: MockPlatformClient, store: StateStore) -> EventDispatcher:
|
||||
chat_mgr = ChatManager(platform, store)
|
||||
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
|
||||
)
|
||||
register_all(dispatcher)
|
||||
register_matrix_handlers(dispatcher, store=store)
|
||||
return dispatcher
|
||||
|
||||
|
||||
def build_runtime(
|
||||
platform: MockPlatformClient | None = None,
|
||||
store: StateStore | None = None,
|
||||
client: AsyncClient | None = None,
|
||||
) -> MatrixRuntime:
|
||||
platform = platform or MockPlatformClient()
|
||||
store = store or InMemoryStore()
|
||||
chat_mgr = ChatManager(platform, store)
|
||||
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
|
||||
)
|
||||
register_all(dispatcher)
|
||||
register_matrix_handlers(dispatcher, client=client, store=store)
|
||||
return MatrixRuntime(
|
||||
platform=platform,
|
||||
store=store,
|
||||
chat_mgr=chat_mgr,
|
||||
auth_mgr=auth_mgr,
|
||||
settings_mgr=settings_mgr,
|
||||
dispatcher=dispatcher,
|
||||
)
|
||||
|
||||
|
||||
class MatrixBot:
|
||||
def __init__(self, client: AsyncClient, runtime: MatrixRuntime) -> None:
|
||||
self.client = client
|
||||
self.runtime = runtime
|
||||
|
||||
async def on_room_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
|
||||
if getattr(event, "sender", None) == self.client.user_id:
|
||||
return
|
||||
chat_id = await resolve_chat_id(self.runtime.store, room.room_id, event.sender)
|
||||
incoming = from_room_event(event, room_id=room.room_id, chat_id=chat_id)
|
||||
if incoming is None:
|
||||
return
|
||||
outgoing = await self.runtime.dispatcher.dispatch(incoming)
|
||||
await self._send_all(room.room_id, outgoing)
|
||||
|
||||
async def on_reaction(self, room: MatrixRoom, event: ReactionEvent) -> None:
|
||||
if getattr(event, "sender", None) == self.client.user_id:
|
||||
return
|
||||
chat_id = await resolve_chat_id(self.runtime.store, room.room_id, event.sender)
|
||||
incoming = from_reaction(event, sender=event.sender, chat_id=chat_id)
|
||||
if incoming is None:
|
||||
return
|
||||
outgoing = await self.runtime.dispatcher.dispatch(incoming)
|
||||
await self._send_all(room.room_id, outgoing)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
async def _send_all(self, room_id: str, outgoing: list[OutgoingEvent]) -> None:
|
||||
for event in outgoing:
|
||||
await send_outgoing(self.client, room_id, event)
|
||||
|
||||
|
||||
def _button_action_to_reaction(action: str) -> str | None:
|
||||
if action in {"confirm", "ok", "accept"}:
|
||||
return "👍"
|
||||
if action in {"cancel", "reject", "deny"}:
|
||||
return "❌"
|
||||
return None
|
||||
|
||||
|
||||
async def send_outgoing(client: AsyncClient, room_id: str, event: OutgoingEvent) -> 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):
|
||||
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text})
|
||||
return
|
||||
if isinstance(event, OutgoingUI):
|
||||
body = event.text
|
||||
buttons = []
|
||||
for button in event.buttons:
|
||||
buttons.append(f"• {button.label}")
|
||||
if buttons:
|
||||
body = "\n".join([body, "", *buttons])
|
||||
resp = await client.room_send(
|
||||
room_id, "m.room.message", {"msgtype": "m.text", "body": body}
|
||||
)
|
||||
event_id = getattr(resp, "event_id", None)
|
||||
if event_id:
|
||||
for button in event.buttons:
|
||||
reaction = _button_action_to_reaction(button.action)
|
||||
if reaction:
|
||||
await client.room_send(
|
||||
room_id,
|
||||
"m.reaction",
|
||||
{
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.annotation",
|
||||
"event_id": event_id,
|
||||
"key": reaction,
|
||||
}
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
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")
|
||||
|
||||
bot = MatrixBot(client, runtime)
|
||||
client.add_event_callback(bot.on_room_message, RoomMessageText)
|
||||
client.add_event_callback(bot.on_reaction, ReactionEvent)
|
||||
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,
|
||||
)
|
||||
try:
|
||||
await client.sync_forever(timeout=30000)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
143
adapter/matrix/converter.py
Normal file
143
adapter/matrix/converter.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from adapter.matrix.reactions import CANCEL_REACTION, CONFIRM_REACTION, reaction_to_skill_index
|
||||
from core.protocol import (
|
||||
Attachment,
|
||||
IncomingCallback,
|
||||
IncomingCommand,
|
||||
IncomingEvent,
|
||||
IncomingMessage,
|
||||
)
|
||||
|
||||
PLATFORM = "matrix"
|
||||
|
||||
|
||||
def extract_attachments(event: Any) -> list[Attachment]:
|
||||
msgtype = getattr(event, "msgtype", None)
|
||||
if msgtype is None:
|
||||
content = getattr(event, "content", {}) or {}
|
||||
msgtype = content.get("msgtype")
|
||||
|
||||
if msgtype == "m.image":
|
||||
return [
|
||||
Attachment(
|
||||
type="image",
|
||||
url=getattr(event, "url", None),
|
||||
mime_type=getattr(event, "mimetype", None),
|
||||
)
|
||||
]
|
||||
if msgtype == "m.file":
|
||||
return [
|
||||
Attachment(
|
||||
type="document",
|
||||
url=getattr(event, "url", None),
|
||||
filename=getattr(event, "body", None),
|
||||
mime_type=getattr(event, "mimetype", None),
|
||||
)
|
||||
]
|
||||
if msgtype == "m.audio":
|
||||
return [
|
||||
Attachment(
|
||||
type="audio",
|
||||
url=getattr(event, "url", None),
|
||||
mime_type=getattr(event, "mimetype", None),
|
||||
)
|
||||
]
|
||||
if msgtype == "m.video":
|
||||
return [
|
||||
Attachment(
|
||||
type="video",
|
||||
url=getattr(event, "url", None),
|
||||
mime_type=getattr(event, "mimetype", None),
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def from_command(body: str, sender: str, chat_id: str) -> 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},
|
||||
)
|
||||
|
||||
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_reaction(event: Any, sender: str, chat_id: str) -> IncomingCallback | None:
|
||||
content = getattr(event, "content", {}) or {}
|
||||
relates_to = content.get("m.relates_to", {})
|
||||
key = getattr(event, "key", None) or relates_to.get("key")
|
||||
event_id = getattr(event, "event_id", None) or relates_to.get("event_id")
|
||||
if not key:
|
||||
return None
|
||||
|
||||
if key == CONFIRM_REACTION:
|
||||
return IncomingCallback(
|
||||
user_id=sender,
|
||||
platform=PLATFORM,
|
||||
chat_id=chat_id,
|
||||
action="confirm",
|
||||
payload={"event_id": event_id, "reaction": key},
|
||||
)
|
||||
if key == CANCEL_REACTION:
|
||||
return IncomingCallback(
|
||||
user_id=sender,
|
||||
platform=PLATFORM,
|
||||
chat_id=chat_id,
|
||||
action="cancel",
|
||||
payload={"event_id": event_id, "reaction": key},
|
||||
)
|
||||
|
||||
skill_index = reaction_to_skill_index(key)
|
||||
if skill_index is not None:
|
||||
return IncomingCallback(
|
||||
user_id=sender,
|
||||
platform=PLATFORM,
|
||||
chat_id=chat_id,
|
||||
action="toggle_skill",
|
||||
payload={"event_id": event_id, "reaction": key, "skill_index": skill_index},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
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)
|
||||
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),
|
||||
)
|
||||
41
adapter/matrix/handlers/__init__.py
Normal file
41
adapter/matrix/handlers/__init__.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from adapter.matrix.handlers.chat import (
|
||||
handle_archive,
|
||||
handle_list_chats,
|
||||
make_handle_new_chat,
|
||||
handle_rename,
|
||||
)
|
||||
from adapter.matrix.handlers.confirm import handle_cancel, handle_confirm
|
||||
from adapter.matrix.handlers.settings import (
|
||||
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,
|
||||
)
|
||||
from core.handler import EventDispatcher
|
||||
from core.protocol import IncomingCallback, IncomingCommand
|
||||
|
||||
|
||||
def register_matrix_handlers(dispatcher: EventDispatcher, client=None, store=None) -> None:
|
||||
dispatcher.register(IncomingCommand, "new", make_handle_new_chat(client, store))
|
||||
dispatcher.register(IncomingCommand, "chats", handle_list_chats)
|
||||
dispatcher.register(IncomingCommand, "rename", handle_rename)
|
||||
dispatcher.register(IncomingCommand, "archive", handle_archive)
|
||||
dispatcher.register(IncomingCommand, "settings", 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", handle_confirm)
|
||||
dispatcher.register(IncomingCallback, "cancel", handle_cancel)
|
||||
dispatcher.register(IncomingCallback, "toggle_skill", handle_toggle_skill)
|
||||
34
adapter/matrix/handlers/auth.py
Normal file
34
adapter/matrix/handlers/auth.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from adapter.matrix.store import get_room_meta, set_room_meta
|
||||
|
||||
|
||||
async def handle_invite(client: Any, room: Any, event: Any, platform, store, auth_mgr) -> None:
|
||||
existing = await get_room_meta(store, room.room_id)
|
||||
if existing is not None:
|
||||
return
|
||||
|
||||
user = await platform.get_or_create_user(
|
||||
external_id=getattr(event, "sender", ""),
|
||||
platform="matrix",
|
||||
display_name=getattr(room, "display_name", None),
|
||||
)
|
||||
await auth_mgr.confirm(getattr(event, "sender", ""))
|
||||
await client.join(room.room_id)
|
||||
await set_room_meta(
|
||||
store,
|
||||
room.room_id,
|
||||
{
|
||||
"room_type": "chat",
|
||||
"chat_id": "C1",
|
||||
"display_name": getattr(room, "display_name", room.room_id),
|
||||
"matrix_user_id": getattr(event, "sender", user.external_id),
|
||||
},
|
||||
)
|
||||
message = (
|
||||
f"Привет, {user.display_name or user.external_id}! Пиши — я здесь.\n\n"
|
||||
f"Команды: !new · !chats · !rename · !archive · !skills"
|
||||
)
|
||||
await client.room_send(room.room_id, "m.room.message", {"msgtype": "m.text", "body": message})
|
||||
107
adapter/matrix/handlers/chat.py
Normal file
107
adapter/matrix/handlers/chat.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from adapter.matrix.store import set_room_meta
|
||||
from core.protocol import IncomingCommand, OutgoingMessage
|
||||
|
||||
|
||||
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,
|
||||
) -> 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="Введите !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}"
|
||||
room_name = name or f"Чат {chat_id}"
|
||||
|
||||
response = await client.room_create(
|
||||
name=room_name,
|
||||
invite=[event.user_id],
|
||||
is_direct=False,
|
||||
)
|
||||
room_id = getattr(response, "room_id", None)
|
||||
if not room_id:
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text="Не удалось создать комнату.")]
|
||||
|
||||
await set_room_meta(
|
||||
store,
|
||||
room_id,
|
||||
{
|
||||
"room_type": "chat",
|
||||
"chat_id": chat_id,
|
||||
"display_name": room_name,
|
||||
"matrix_user_id": event.user_id,
|
||||
},
|
||||
)
|
||||
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})\nКомната: {room_id}",
|
||||
)
|
||||
]
|
||||
|
||||
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))]
|
||||
|
||||
|
||||
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 Название")]
|
||||
ctx = await chat_mgr.rename(event.chat_id, " ".join(event.args), user_id=event.user_id)
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")]
|
||||
|
||||
|
||||
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="Чат архивирован.")]
|
||||
19
adapter/matrix/handlers/confirm.py
Normal file
19
adapter/matrix/handlers/confirm.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from core.protocol import IncomingCallback, OutgoingMessage
|
||||
|
||||
|
||||
async def handle_confirm(
|
||||
event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr
|
||||
) -> list:
|
||||
action_id = event.payload.get("action_id", "unknown")
|
||||
return [
|
||||
OutgoingMessage(chat_id=event.chat_id, text=f"Действие подтверждено (id: {action_id}).")
|
||||
]
|
||||
|
||||
|
||||
async def handle_cancel(
|
||||
event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr
|
||||
) -> list:
|
||||
action_id = event.payload.get("action_id", "unknown")
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text=f"Действие отменено (id: {action_id}).")]
|
||||
145
adapter/matrix/handlers/settings.py
Normal file
145
adapter/matrix/handlers/settings.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from adapter.matrix.reactions import build_skills_text
|
||||
from core.protocol import IncomingCommand, OutgoingMessage, SettingsAction
|
||||
|
||||
|
||||
def _render_mapping(title: str, data: dict | None) -> str:
|
||||
data = data or {}
|
||||
lines = [title]
|
||||
if not data:
|
||||
lines.append("Нет данных.")
|
||||
else:
|
||||
for key, value in data.items():
|
||||
lines.append(f"• {key}: {value}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_bool(value: str) -> bool:
|
||||
return value.lower() in {"1", "true", "yes", "on", "enable", "enabled"}
|
||||
|
||||
|
||||
async def handle_settings(
|
||||
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
|
||||
) -> list:
|
||||
return [
|
||||
OutgoingMessage(
|
||||
chat_id=event.chat_id,
|
||||
text=(
|
||||
"⚙️ Настройки Matrix\n"
|
||||
"!skills\n"
|
||||
"!connectors\n"
|
||||
"!soul [field value]\n"
|
||||
"!safety [trigger on|off]\n"
|
||||
"!plan\n"
|
||||
"!status\n"
|
||||
"!whoami"
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def handle_settings_skills(
|
||||
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
|
||||
) -> list:
|
||||
settings = await settings_mgr.get(event.user_id)
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text=build_skills_text(settings))]
|
||||
|
||||
|
||||
async def handle_settings_connectors(
|
||||
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
|
||||
) -> list:
|
||||
settings = await settings_mgr.get(event.user_id)
|
||||
return [
|
||||
OutgoingMessage(
|
||||
chat_id=event.chat_id, text=_render_mapping("🔗 Коннекторы", settings.connectors)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def handle_settings_soul(
|
||||
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
|
||||
) -> list:
|
||||
if len(event.args) >= 2:
|
||||
field = event.args[0]
|
||||
value = " ".join(event.args[1:])
|
||||
await settings_mgr.apply(
|
||||
event.user_id,
|
||||
SettingsAction(action="set_soul", payload={"field": field, "value": value}),
|
||||
)
|
||||
return [
|
||||
OutgoingMessage(chat_id=event.chat_id, text=f"Личность обновлена: {field} = {value}")
|
||||
]
|
||||
settings = await settings_mgr.get(event.user_id)
|
||||
return [
|
||||
OutgoingMessage(chat_id=event.chat_id, text=_render_mapping("🧠 Личность", settings.soul))
|
||||
]
|
||||
|
||||
|
||||
async def handle_settings_safety(
|
||||
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
|
||||
) -> list:
|
||||
if len(event.args) >= 2:
|
||||
trigger = event.args[0]
|
||||
enabled = _parse_bool(event.args[1])
|
||||
await settings_mgr.apply(
|
||||
event.user_id,
|
||||
SettingsAction(action="set_safety", payload={"trigger": trigger, "enabled": enabled}),
|
||||
)
|
||||
state = "включена" if enabled else "выключена"
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text=f"Безопасность {trigger} {state}")]
|
||||
settings = await settings_mgr.get(event.user_id)
|
||||
return [
|
||||
OutgoingMessage(
|
||||
chat_id=event.chat_id, text=_render_mapping("🔒 Безопасность", settings.safety)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def handle_settings_plan(
|
||||
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
|
||||
) -> list:
|
||||
settings = await settings_mgr.get(event.user_id)
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text=_render_mapping("💳 План", settings.plan))]
|
||||
|
||||
|
||||
async def handle_settings_status(
|
||||
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
|
||||
) -> list:
|
||||
chats = await chat_mgr.list_active(event.user_id)
|
||||
settings = await settings_mgr.get(event.user_id)
|
||||
text = "\n".join(
|
||||
[
|
||||
"📊 Статус",
|
||||
f"Активных чатов: {len(chats)}",
|
||||
f"Скиллов: {len(settings.skills)}",
|
||||
f"Коннекторов: {len(settings.connectors)}",
|
||||
]
|
||||
)
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text=text)]
|
||||
|
||||
|
||||
async def handle_settings_whoami(
|
||||
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
|
||||
) -> list:
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text=f"👤 {event.platform}:{event.user_id}")]
|
||||
|
||||
|
||||
async def handle_toggle_skill(event, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
|
||||
settings = await settings_mgr.get(event.user_id)
|
||||
keys = list(settings.skills.keys())
|
||||
skill = event.payload.get("skill")
|
||||
if not skill:
|
||||
idx = event.payload.get("skill_index")
|
||||
if isinstance(idx, int) and 1 <= idx <= len(keys):
|
||||
skill = keys[idx - 1]
|
||||
if not skill:
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text="Ошибка: не удалось определить навык.")]
|
||||
|
||||
enabled = not bool(settings.skills.get(skill, False))
|
||||
await settings_mgr.apply(
|
||||
event.user_id,
|
||||
SettingsAction(action="toggle_skill", payload={"skill": skill, "enabled": enabled}),
|
||||
)
|
||||
state = "включён" if enabled else "выключен"
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text=f"Навык {skill} {state}.")]
|
||||
68
adapter/matrix/reactions.py
Normal file
68
adapter/matrix/reactions.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from nio import AsyncClient
|
||||
|
||||
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 = "✅" if enabled else "❌"
|
||||
emoji = SKILL_REACTIONS[idx - 1] if idx - 1 < len(SKILL_REACTIONS) else f"{idx}."
|
||||
lines.append(f"{state} {emoji} {name}")
|
||||
lines.append("")
|
||||
lines.append("Реакции 1️⃣-9️⃣ переключают навыки.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_confirmation_text(description: str) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"🤖 Lambda",
|
||||
description,
|
||||
"",
|
||||
f"{CONFIRM_REACTION} подтвердить · {CANCEL_REACTION} отменить",
|
||||
"!yes — подтвердить · !no — отменить",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def reaction_to_skill_index(key: str) -> int | None:
|
||||
return REACTION_TO_INDEX.get(key)
|
||||
|
||||
|
||||
async def add_reaction(client: AsyncClient, room_id: str, event_id: str, key: str) -> Any:
|
||||
return await client.room_send(
|
||||
room_id,
|
||||
"m.reaction",
|
||||
{
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.annotation",
|
||||
"event_id": event_id,
|
||||
"key": key,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def remove_reaction(client: AsyncClient, room_id: str, event_id: str, key: str) -> Any:
|
||||
return await client.room_send(
|
||||
room_id,
|
||||
"m.reaction",
|
||||
{
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.annotation",
|
||||
"event_id": event_id,
|
||||
"key": key,
|
||||
},
|
||||
"undo": True,
|
||||
},
|
||||
)
|
||||
23
adapter/matrix/room_router.py
Normal file
23
adapter/matrix/room_router.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from adapter.matrix.store import get_room_meta, next_chat_id, set_room_meta
|
||||
from core.store import StateStore
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
chat_id = await next_chat_id(store, matrix_user_id)
|
||||
await set_room_meta(
|
||||
store,
|
||||
room_id,
|
||||
{
|
||||
"room_type": "chat",
|
||||
"chat_id": chat_id,
|
||||
"display_name": f"Чат {chat_id}",
|
||||
"matrix_user_id": matrix_user_id,
|
||||
},
|
||||
)
|
||||
return chat_id
|
||||
50
adapter/matrix/store.py
Normal file
50
adapter/matrix/store.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from __future__ import annotations
|
||||
|
||||
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:"
|
||||
|
||||
|
||||
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_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 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}"
|
||||
|
|
@ -15,7 +15,7 @@ from core.protocol import (
|
|||
OutgoingEvent,
|
||||
)
|
||||
from core.settings import SettingsManager
|
||||
from platform.interface import PlatformClient
|
||||
from sdk.interface import PlatformClient
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,19 @@ 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="Введите /start чтобы начать.")]
|
||||
return [
|
||||
OutgoingMessage(
|
||||
chat_id=event.chat_id,
|
||||
text=f"Введите {_command(event.platform, 'start')} чтобы начать.",
|
||||
)
|
||||
]
|
||||
name = " ".join(event.args) if event.args else None
|
||||
ctx = await chat_mgr.get_or_create(
|
||||
user_id=event.user_id,
|
||||
|
|
@ -20,7 +30,12 @@ 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="Укажите название: /rename Название")]
|
||||
return [
|
||||
OutgoingMessage(
|
||||
chat_id=event.chat_id,
|
||||
text=f"Укажите название: {_command(event.platform, 'rename')} Название",
|
||||
)
|
||||
]
|
||||
ctx = await chat_mgr.rename(event.chat_id, " ".join(event.args))
|
||||
return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")]
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,18 @@ from __future__ import annotations
|
|||
from core.protocol import IncomingMessage, OutgoingMessage, OutgoingTyping
|
||||
|
||||
|
||||
def _start_command(platform: str) -> str:
|
||||
return "!start" if platform == "matrix" else "/start"
|
||||
|
||||
|
||||
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="Введите /start чтобы начать.")]
|
||||
return [
|
||||
OutgoingMessage(
|
||||
chat_id=event.chat_id,
|
||||
text=f"Введите {_start_command(event.platform)} чтобы начать.",
|
||||
)
|
||||
]
|
||||
|
||||
# Voice slot fallback: audio attachment without registered voice_handler
|
||||
if event.attachments and event.attachments[0].type == "audio":
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import structlog
|
|||
|
||||
from core.protocol import SettingsAction
|
||||
from core.store import StateStore
|
||||
from platform.interface import PlatformClient, UserSettings
|
||||
from sdk.interface import PlatformClient, UserSettings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
|
|
|||
172
docs/research/aiogram-architecture-review.md
Normal file
172
docs/research/aiogram-architecture-review.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Ресёрч: 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-фильтры) небольшие и органично вписываются в текущую структуру.
|
||||
704
docs/superpowers/plans/2026-03-31-forum-topics.md
Normal file
704
docs/superpowers/plans/2026-03-31-forum-topics.md
Normal file
|
|
@ -0,0 +1,704 @@
|
|||
# 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"
|
||||
```
|
||||
180
docs/superpowers/specs/2026-03-31-forum-topics-design.md
Normal file
180
docs/superpowers/specs/2026-03-31-forum-topics-design.md
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
# 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`
|
||||
283
docs/superpowers/specs/2026-03-31-matrix-adapter-design.md
Normal file
283
docs/superpowers/specs/2026-03-31-matrix-adapter-design.md
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
# 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 <name>` | Переименовать текущую комнату |
|
||||
| `!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 + остальные команды
|
||||
|
|
@ -9,7 +9,7 @@ from typing import Any, AsyncIterator, Literal
|
|||
|
||||
import structlog
|
||||
|
||||
from platform.interface import (
|
||||
from sdk.interface import (
|
||||
AgentEvent,
|
||||
Attachment,
|
||||
MessageChunk,
|
||||
0
tests/adapter/__init__.py
Normal file
0
tests/adapter/__init__.py
Normal file
1
tests/adapter/matrix/__init__.py
Normal file
1
tests/adapter/matrix/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from __future__ import annotations
|
||||
109
tests/adapter/matrix/test_converter.py
Normal file
109
tests/adapter/matrix/test_converter.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from adapter.matrix.converter import from_command, from_reaction, 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 reaction_event(key: str, relates_to: str = "$orig"):
|
||||
return SimpleNamespace(
|
||||
sender="@a:m.org",
|
||||
event_id="$r1",
|
||||
key=key,
|
||||
content={"m.relates_to": {"key": key, "event_id": relates_to}},
|
||||
)
|
||||
|
||||
|
||||
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_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"
|
||||
|
||||
|
||||
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_reaction_confirm():
|
||||
result = from_reaction(reaction_event("👍"), sender="@a:m.org", chat_id="C1")
|
||||
assert isinstance(result, IncomingCallback)
|
||||
assert result.action == "confirm"
|
||||
|
||||
|
||||
async def test_reaction_toggle_skill():
|
||||
result = from_reaction(reaction_event("2️⃣"), sender="@a:m.org", chat_id="C1")
|
||||
assert isinstance(result, IncomingCallback)
|
||||
assert result.action == "toggle_skill"
|
||||
assert result.payload["skill_index"] == 2
|
||||
116
tests/adapter/matrix/test_dispatcher.py
Normal file
116
tests/adapter/matrix/test_dispatcher.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from adapter.matrix.bot import MatrixBot, build_runtime
|
||||
from adapter.matrix.handlers.auth import handle_invite
|
||||
from adapter.matrix.store import get_room_meta
|
||||
from core.protocol import IncomingCallback, IncomingCommand, OutgoingMessage
|
||||
from sdk.mock import MockPlatformClient
|
||||
|
||||
|
||||
async def test_matrix_dispatcher_registers_custom_handlers():
|
||||
runtime = build_runtime(platform=MockPlatformClient())
|
||||
|
||||
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)
|
||||
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"]
|
||||
|
||||
new2 = IncomingCommand(
|
||||
user_id="u1", platform="matrix", chat_id="C1", 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="C1", command="settings_skills"
|
||||
)
|
||||
result = await runtime.dispatcher.dispatch(skills)
|
||||
assert any(isinstance(r, OutgoingMessage) and "Реакции 1️⃣-9️⃣" in r.text 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 "fetch-url" in r.text 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")))
|
||||
runtime = build_runtime(platform=MockPlatformClient(), client=client)
|
||||
|
||||
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", invite=["u1"], is_direct=False)
|
||||
chats = await runtime.chat_mgr.list_active("u1")
|
||||
assert [c.surface_ref for c in chats] == ["!r2:example"]
|
||||
assert any(isinstance(r, OutgoingMessage) and "!r2:example" in r.text for r in result)
|
||||
|
||||
|
||||
async def test_invite_event_creates_dm_room_and_sends_welcome():
|
||||
runtime = build_runtime(platform=MockPlatformClient())
|
||||
client = SimpleNamespace(join=AsyncMock(), room_send=AsyncMock())
|
||||
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice DM")
|
||||
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
|
||||
|
||||
await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
|
||||
|
||||
client.join.assert_awaited_once_with("!dm:example.org")
|
||||
client.room_send.assert_awaited_once()
|
||||
meta = await get_room_meta(runtime.store, "!dm:example.org")
|
||||
assert meta is not None
|
||||
assert meta["chat_id"] == "C1"
|
||||
assert meta["matrix_user_id"] == "@alice:example.org"
|
||||
assert await runtime.auth_mgr.is_authenticated("@alice:example.org") is True
|
||||
|
||||
|
||||
async def test_invite_event_is_idempotent_per_room():
|
||||
runtime = build_runtime(platform=MockPlatformClient())
|
||||
client = SimpleNamespace(join=AsyncMock(), room_send=AsyncMock())
|
||||
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice DM")
|
||||
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
|
||||
|
||||
await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
|
||||
await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
|
||||
|
||||
client.join.assert_awaited_once_with("!dm:example.org")
|
||||
client.room_send.assert_awaited_once()
|
||||
|
||||
|
||||
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()
|
||||
33
tests/adapter/matrix/test_reactions.py
Normal file
33
tests/adapter/matrix/test_reactions.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from adapter.matrix.reactions import (
|
||||
build_confirmation_text,
|
||||
build_skills_text,
|
||||
reaction_to_skill_index,
|
||||
)
|
||||
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 "Реакции 1️⃣-9️⃣" in text
|
||||
|
||||
|
||||
def test_build_confirmation_text():
|
||||
text = build_confirmation_text("Отправить письмо?")
|
||||
assert "Отправить письмо?" in text
|
||||
assert "подтвердить" in text
|
||||
|
||||
|
||||
def test_reaction_to_skill_index():
|
||||
assert reaction_to_skill_index("1️⃣") == 1
|
||||
assert reaction_to_skill_index("👍") is None
|
||||
72
tests/adapter/matrix/test_store.py
Normal file
72
tests/adapter/matrix/test_store.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from adapter.matrix.store import (
|
||||
get_room_meta,
|
||||
get_room_state,
|
||||
get_skills_message_id,
|
||||
get_user_meta,
|
||||
next_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_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_skills_message_roundtrip(store: InMemoryStore):
|
||||
await set_skills_message_id(store, "!room", "$event")
|
||||
assert await get_skills_message_id(store, "!room") == "$event"
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
import pytest
|
||||
from core.auth import AuthManager
|
||||
from core.store import InMemoryStore
|
||||
from platform.mock import MockPlatformClient
|
||||
from sdk.mock import MockPlatformClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import pytest
|
||||
from core.chat import ChatManager
|
||||
from core.store import InMemoryStore
|
||||
from platform.mock import MockPlatformClient
|
||||
from sdk.mock import MockPlatformClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from core.chat import ChatManager
|
|||
from core.auth import AuthManager
|
||||
from core.settings import SettingsManager
|
||||
from core.store import InMemoryStore
|
||||
from platform.mock import MockPlatformClient
|
||||
from sdk.mock import MockPlatformClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Smoke test: полный цикл через dispatcher + реальные manag
|
|||
Имитирует что делает адаптер (Telegram или Matrix) при получении события.
|
||||
"""
|
||||
import pytest
|
||||
from platform.mock import MockPlatformClient
|
||||
from sdk.mock import MockPlatformClient
|
||||
from core.store import InMemoryStore
|
||||
from core.chat import ChatManager
|
||||
from core.auth import AuthManager
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import pytest
|
|||
from core.settings import SettingsManager
|
||||
from core.store import InMemoryStore
|
||||
from core.protocol import SettingsAction
|
||||
from platform.mock import MockPlatformClient
|
||||
from sdk.mock import MockPlatformClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from core.store import InMemoryStore
|
|||
from core.auth import AuthManager
|
||||
from core.chat import ChatManager
|
||||
from core.settings import SettingsManager
|
||||
from platform.mock import MockPlatformClient
|
||||
from sdk.mock import MockPlatformClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# tests/platform/test_mock.py
|
||||
from platform.mock import MockPlatformClient
|
||||
from platform.interface import User, MessageResponse, UserSettings
|
||||
from sdk.mock import MockPlatformClient
|
||||
from sdk.interface import User, MessageResponse, UserSettings
|
||||
from core.protocol import SettingsAction
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue