feat(matrix): add adapter baseline and platform-aware command hints
This commit is contained in:
parent
bcdaea5143
commit
82eb711844
20 changed files with 1127 additions and 3 deletions
215
adapter/matrix/bot.py
Normal file
215
adapter/matrix/bot.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from nio import (
|
||||
AsyncClient,
|
||||
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)
|
||||
return dispatcher
|
||||
|
||||
|
||||
def build_runtime(
|
||||
platform: MockPlatformClient | None = None, store: StateStore | 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)
|
||||
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")
|
||||
if not homeserver or not user_id:
|
||||
raise RuntimeError("MATRIX_HOMESERVER and MATRIX_USER_ID are required")
|
||||
|
||||
runtime = build_runtime(store=SQLiteStore(db_path))
|
||||
client = AsyncClient(
|
||||
homeserver,
|
||||
user=user_id,
|
||||
device_id=device_id,
|
||||
store_path=os.environ.get("MATRIX_STORE_PATH"),
|
||||
)
|
||||
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")
|
||||
try:
|
||||
await client.sync_forever(timeout=30000)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue