ForcePilot/backend/package/yuxi/channel/extensions/matrix/gateway.py
Kris 4e9c6dd8ab feat(channel): 添加 Matrix 渠道扩展
新增 Matrix 渠道扩展,支持在 Yuxi 平台中集成 Matrix 去中心化通讯协议。

包含以下功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- crypto: 端到端加密
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- room_resolver: 房间解析
- dm_tracker: 私聊追踪
- rate_limiter: 速率限制
- actions: 动作处理
- constants: 常量定义
- utils: 工具函数
- types: 类型定义
2026-05-21 11:18:13 +08:00

240 lines
7.4 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from .config import _apply_env_overrides, _dict_to_account
from .constants import SYNC_LOOP_SLEEP_MS
from .dedupe import DedupeCache
from .dm_tracker import DmTracker
from .monitor import normalize_matrix_event
from .rate_limiter import MatrixRateLimiter
from .utils import build_dedupe_key, get_nio
logger = logging.getLogger(__name__)
SYNC_FILTER = {
"room": {
"timeline": {
"types": [
"m.room.message",
"m.room.encrypted",
"m.reaction",
"m.room.member",
"m.room.name",
"m.room.topic",
],
"limit": 50,
},
"ephemeral": {"types": ["m.typing", "m.receipt"]},
"account_data": {"types": ["m.direct"]},
},
}
async def start(ctx) -> object:
nio = get_nio()
queue = ctx.queue
cancel_event = ctx.cancel_event
account_data = ctx.config.get("accounts", {}).get(ctx.account_id, {})
account = _dict_to_account(account_data)
account = _apply_env_overrides(account)
store_path = account_data.get("store_path") or f"data/matrix_store/{account.account_id}"
client_config = nio.AsyncClientConfig(
store_sync_tokens=True,
encryption_enabled=account.encryption,
)
client = nio.AsyncClient(
homeserver=account.homeserver,
user=account.user_id,
device_id=account.device_id,
store_path=store_path,
config=client_config,
)
client.access_token = account.access_token
resp = await client.whoami()
logger.info(
"Matrix whoami: user=%s device=%s",
resp.user_id,
resp.device_id,
)
account.device_id = account.device_id or resp.device_id
ctx._matrix_client = client
_dedupe = DedupeCache()
_rate_limiter = MatrixRateLimiter()
ctx._matrix_dedupe = _dedupe
ctx._matrix_rate_limiter = _rate_limiter
dm_tracker = DmTracker()
sync_filter = dict(SYNC_FILTER)
sync_filter["room"]["timeline"]["limit"] = account.initial_sync_limit
sync_token = account_data.get("sync_token")
try:
async for response in client.sync_forever(
since=sync_token,
sync_filter=sync_filter,
loop_sleep_time=SYNC_LOOP_SLEEP_MS,
):
if cancel_event.is_set():
logger.info("Matrix sync cancelled for account %s", account.account_id)
break
if isinstance(response, nio.SyncResponse):
sync_token = response.next_batch
_persist_sync_token(ctx, sync_token)
if isinstance(response, nio.InviteEvent):
_handle_invite(response, dm_tracker, ctx)
events = _extract_sync_events(response)
for event in events:
dedupe_key = build_dedupe_key(account.account_id, event.event_id)
if dedupe_key in _dedupe:
continue
_dedupe.add(dedupe_key)
msg = normalize_matrix_event(
event=event,
own_user_id=account.user_id,
account_id=account.account_id,
dm_tracker=dm_tracker,
)
if msg is not None:
await queue.put(msg)
except Exception:
logger.exception("Matrix sync loop error for account %s", account.account_id)
finally:
ctx._matrix_client = None
await client.close()
return client
def _handle_invite(response, dm_tracker: DmTracker, ctx) -> None:
event = response
room_id = getattr(event, "room_id", None)
sender = getattr(getattr(event, "sender", None), "user_id", getattr(event, "sender", None))
if room_id and sender:
dm_tracker.mark_candidate_dm(room_id, sender)
logger.info("Received invite to room %s from %s", room_id, sender)
if not room_id:
return
accounts = ctx.config.get("accounts", {})
acct_data = accounts.get(ctx.account_id, {})
auto_join = acct_data.get("auto_join", "off")
if auto_join == "off":
return
if auto_join == "all" or (auto_join == "allowlist" and sender in acct_data.get("auto_join_allowlist", [])):
client = getattr(ctx, "_matrix_client", None)
if client:
asyncio.ensure_future(client.join(room_id))
logger.info("Auto-joining room %s (auto_join=%s)", room_id, auto_join)
def _extract_sync_events(response) -> list:
if hasattr(response, "rooms") and response.rooms:
events = []
join = getattr(response.rooms, "join", {}) or {}
for room_id, room_info in join.items():
timeline = getattr(room_info, "timeline", None)
if timeline and hasattr(timeline, "events"):
events.extend(timeline.events)
return events
if hasattr(response, "source"):
source = response.source
rooms_data = source.get("rooms", {}).get("join", {})
events = []
for room_id, room_data in rooms_data.items():
timeline_events = room_data.get("timeline", {}).get("events", [])
for e in timeline_events:
events.append(_wrap_raw_event(e, room_id))
return events
return []
def _wrap_raw_event(raw: dict, room_id: str):
class _WrappedEvent:
pass
event = _WrappedEvent()
event.source = raw
event.room_id = room_id
event.event_id = raw.get("event_id", "")
event.sender = raw.get("sender", "")
event.type = raw.get("type", "")
return event
async def stop(ctx) -> None:
client = getattr(ctx, "_matrix_client", None)
if client:
try:
await client.close()
except Exception:
logger.debug("Matrix client close error", exc_info=True)
def _persist_sync_token(ctx, sync_token: str) -> None:
try:
config_path = getattr(ctx, "config_path", None)
if not config_path:
return
raw = ctx.config
accounts = raw.get("accounts", {})
acct = accounts.setdefault(ctx.account_id, {})
acct["sync_token"] = sync_token
Path(config_path).parent.mkdir(parents=True, exist_ok=True)
Path(config_path).write_text(json.dumps(raw, indent=2, ensure_ascii=False), encoding="utf-8")
except Exception:
logger.debug("Failed to persist sync_token", exc_info=True)
def resolve_gateway_auth_bypass_paths(config: dict) -> list[str]:
return []
async def login_with_qr_start(account_id: str | None = None, *, force=False, timeout_ms=None) -> dict:
raise NotImplementedError("Matrix does not support QR login")
async def login_with_qr_wait(account_id=None, *, timeout_ms=None, current_qr_data_url=None) -> dict:
raise NotImplementedError("Matrix does not support QR login")
async def logout_account(ctx) -> dict:
nio = get_nio()
account_data = ctx.config.get("accounts", {}).get(ctx.account_id, {})
account = _dict_to_account(account_data)
account = _apply_env_overrides(account)
client = nio.AsyncClient(
homeserver=account.homeserver,
user=account.user_id,
)
client.access_token = account.access_token
try:
await client.logout()
return {"ok": True}
except Exception as e:
logger.warning("Matrix logout failed: %s", e)
return {"ok": False, "error": str(e)}
finally:
await client.close()