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/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
# Visual brainstorming sessions
|
||||||
|
.superpowers/
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.coverage
|
.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,
|
OutgoingEvent,
|
||||||
)
|
)
|
||||||
from core.settings import SettingsManager
|
from core.settings import SettingsManager
|
||||||
from platform.interface import PlatformClient
|
from sdk.interface import PlatformClient
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,19 @@ from __future__ import annotations
|
||||||
from core.protocol import IncomingCommand, OutgoingMessage
|
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:
|
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):
|
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
|
name = " ".join(event.args) if event.args else None
|
||||||
ctx = await chat_mgr.get_or_create(
|
ctx = await chat_mgr.get_or_create(
|
||||||
user_id=event.user_id,
|
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:
|
async def handle_rename(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
|
||||||
if not event.args:
|
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))
|
ctx = await chat_mgr.rename(event.chat_id, " ".join(event.args))
|
||||||
return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")]
|
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
|
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:
|
async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
|
||||||
if not await auth_mgr.is_authenticated(event.user_id):
|
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
|
# Voice slot fallback: audio attachment without registered voice_handler
|
||||||
if event.attachments and event.attachments[0].type == "audio":
|
if event.attachments and event.attachments[0].type == "audio":
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import structlog
|
||||||
|
|
||||||
from core.protocol import SettingsAction
|
from core.protocol import SettingsAction
|
||||||
from core.store import StateStore
|
from core.store import StateStore
|
||||||
from platform.interface import PlatformClient, UserSettings
|
from sdk.interface import PlatformClient, UserSettings
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
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
|
import structlog
|
||||||
|
|
||||||
from platform.interface import (
|
from sdk.interface import (
|
||||||
AgentEvent,
|
AgentEvent,
|
||||||
Attachment,
|
Attachment,
|
||||||
MessageChunk,
|
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
|
import pytest
|
||||||
from core.auth import AuthManager
|
from core.auth import AuthManager
|
||||||
from core.store import InMemoryStore
|
from core.store import InMemoryStore
|
||||||
from platform.mock import MockPlatformClient
|
from sdk.mock import MockPlatformClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import pytest
|
import pytest
|
||||||
from core.chat import ChatManager
|
from core.chat import ChatManager
|
||||||
from core.store import InMemoryStore
|
from core.store import InMemoryStore
|
||||||
from platform.mock import MockPlatformClient
|
from sdk.mock import MockPlatformClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from core.chat import ChatManager
|
||||||
from core.auth import AuthManager
|
from core.auth import AuthManager
|
||||||
from core.settings import SettingsManager
|
from core.settings import SettingsManager
|
||||||
from core.store import InMemoryStore
|
from core.store import InMemoryStore
|
||||||
from platform.mock import MockPlatformClient
|
from sdk.mock import MockPlatformClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ Smoke test: полный цикл через dispatcher + реальные manag
|
||||||
Имитирует что делает адаптер (Telegram или Matrix) при получении события.
|
Имитирует что делает адаптер (Telegram или Matrix) при получении события.
|
||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
from platform.mock import MockPlatformClient
|
from sdk.mock import MockPlatformClient
|
||||||
from core.store import InMemoryStore
|
from core.store import InMemoryStore
|
||||||
from core.chat import ChatManager
|
from core.chat import ChatManager
|
||||||
from core.auth import AuthManager
|
from core.auth import AuthManager
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import pytest
|
||||||
from core.settings import SettingsManager
|
from core.settings import SettingsManager
|
||||||
from core.store import InMemoryStore
|
from core.store import InMemoryStore
|
||||||
from core.protocol import SettingsAction
|
from core.protocol import SettingsAction
|
||||||
from platform.mock import MockPlatformClient
|
from sdk.mock import MockPlatformClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from core.store import InMemoryStore
|
||||||
from core.auth import AuthManager
|
from core.auth import AuthManager
|
||||||
from core.chat import ChatManager
|
from core.chat import ChatManager
|
||||||
from core.settings import SettingsManager
|
from core.settings import SettingsManager
|
||||||
from platform.mock import MockPlatformClient
|
from sdk.mock import MockPlatformClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# tests/platform/test_mock.py
|
# tests/platform/test_mock.py
|
||||||
from platform.mock import MockPlatformClient
|
from sdk.mock import MockPlatformClient
|
||||||
from platform.interface import User, MessageResponse, UserSettings
|
from sdk.interface import User, MessageResponse, UserSettings
|
||||||
from core.protocol import SettingsAction
|
from core.protocol import SettingsAction
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue