- harden Matrix onboarding/chat lifecycle after manual QA - refresh README and Matrix docs to match current behavior - add local ignores for runtime artifacts and include current planning/report docs Closes #7 Closes #9 Closes #14
229 lines
7.6 KiB
Python
229 lines
7.6 KiB
Python
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,
|
|
RoomMemberEvent,
|
|
RoomMessageText,
|
|
)
|
|
from nio.responses import SyncResponse
|
|
from dotenv import load_dotenv
|
|
|
|
from adapter.matrix.converter import 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 adapter.matrix.store import get_room_meta, set_pending_confirm
|
|
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_member(self, room: MatrixRoom, event: RoomMemberEvent) -> None:
|
|
if getattr(event, "sender", None) == self.client.user_id:
|
|
return
|
|
membership = getattr(event, "membership", None)
|
|
if membership == "invite":
|
|
await handle_invite(
|
|
self.client,
|
|
room,
|
|
event,
|
|
self.runtime.platform,
|
|
self.runtime.store,
|
|
self.runtime.auth_mgr,
|
|
self.runtime.chat_mgr,
|
|
)
|
|
|
|
async def _send_all(self, room_id: str, outgoing: list[OutgoingEvent]) -> None:
|
|
for event in outgoing:
|
|
await send_outgoing(self.client, room_id, event, store=self.runtime.store)
|
|
|
|
|
|
async def prepare_live_sync(client: AsyncClient) -> str | None:
|
|
response = await client.sync(timeout=0, full_state=True)
|
|
if isinstance(response, SyncResponse):
|
|
return response.next_batch
|
|
return None
|
|
|
|
async def send_outgoing(
|
|
client: AsyncClient,
|
|
room_id: str,
|
|
event: OutgoingEvent,
|
|
store: StateStore | None = None,
|
|
) -> None:
|
|
if isinstance(event, OutgoingTyping):
|
|
await client.room_typing(room_id, event.is_typing, timeout=25000)
|
|
return
|
|
if isinstance(event, OutgoingNotification):
|
|
body = f"[{event.level.upper()}] {event.text}"
|
|
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
|
|
return
|
|
if isinstance(event, OutgoingMessage):
|
|
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": event.text})
|
|
return
|
|
if isinstance(event, OutgoingUI):
|
|
lines = [event.text]
|
|
if event.buttons:
|
|
lines.append("")
|
|
for button in event.buttons:
|
|
lines.append(f" {button.label}")
|
|
lines.append("")
|
|
lines.append("Ответьте !yes для подтверждения или !no для отмены.")
|
|
body = "\n".join(lines)
|
|
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
|
|
if event.buttons and store is not None:
|
|
action_id = event.buttons[0].action
|
|
payload = event.buttons[0].payload
|
|
room_meta = await get_room_meta(store, room_id)
|
|
matrix_user_id = room_meta.get("matrix_user_id") if room_meta else None
|
|
if matrix_user_id:
|
|
await set_pending_confirm(
|
|
store,
|
|
matrix_user_id,
|
|
room_id,
|
|
{
|
|
"action_id": action_id,
|
|
"description": event.text,
|
|
"payload": payload,
|
|
},
|
|
)
|
|
return
|
|
|
|
|
|
async def main() -> None:
|
|
homeserver = os.environ.get("MATRIX_HOMESERVER")
|
|
user_id = os.environ.get("MATRIX_USER_ID")
|
|
device_id = os.environ.get("MATRIX_DEVICE_ID", "")
|
|
password = os.environ.get("MATRIX_PASSWORD")
|
|
token = os.environ.get("MATRIX_ACCESS_TOKEN")
|
|
db_path = os.environ.get("MATRIX_DB_PATH", "lambda_matrix.db")
|
|
store_path = os.environ.get("MATRIX_STORE_PATH", "matrix_store")
|
|
if not homeserver or not user_id:
|
|
raise RuntimeError("MATRIX_HOMESERVER and MATRIX_USER_ID are required")
|
|
|
|
client_config = AsyncClientConfig(
|
|
request_timeout=120,
|
|
max_timeouts=12,
|
|
max_limit_exceeded=20,
|
|
backoff_factor=0.5,
|
|
max_timeout_retry_wait_time=15,
|
|
)
|
|
client = AsyncClient(
|
|
homeserver,
|
|
user=user_id,
|
|
device_id=device_id,
|
|
store_path=store_path,
|
|
config=client_config,
|
|
)
|
|
runtime = build_runtime(store=SQLiteStore(db_path), client=client)
|
|
if token:
|
|
client.access_token = token
|
|
elif password:
|
|
await client.login(password=password, device_name="surfaces-bot")
|
|
|
|
since_token = await prepare_live_sync(client)
|
|
|
|
bot = MatrixBot(client, runtime)
|
|
client.add_event_callback(bot.on_room_message, RoomMessageText)
|
|
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, since=since_token)
|
|
finally:
|
|
await client.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|