新增 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: 类型定义
240 lines
7.4 KiB
Python
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()
|