ForcePilot/backend/package/yuxi/channel/extensions/matrix/outbound.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

1022 lines
29 KiB
Python

from __future__ import annotations
import logging
from enum import StrEnum
from .config import _apply_env_overrides, _dict_to_account
from .errors import classify_error
from .utils import chunk_text, get_nio, sanitize_matrix_text
logger = logging.getLogger(__name__)
class OutboundDeliveryMode(StrEnum):
DIRECT = "direct"
GATEWAY = "gateway"
HYBRID = "hybrid"
delivery_mode = OutboundDeliveryMode.DIRECT
chunker_mode = None
text_chunk_limit = 4000
presentation_capabilities = None
delivery_capabilities = None
def chunker(text: str, limit: int, ctx=None) -> list[str]:
return chunk_text(text, max(limit, 100))
def sanitize_text(text: str, payload=None) -> str:
return sanitize_matrix_text(text)
async def send_text(
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
body = {
"msgtype": "m.text",
"body": content,
"format": "org.matrix.custom.html",
"formatted_body": content,
}
if reply_to_id:
body["m.relates_to"] = {
"m.in_reply_to": {"event_id": reply_to_id},
}
if thread_id:
relates = body.setdefault("m.relates_to", {})
relates["rel_type"] = "m.thread"
relates["event_id"] = thread_id
resp = await client.room_send(target_id, "m.room.message", body)
return {"event_id": resp.event_id, "room_id": target_id}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix send_text failed: severity=%s retry_after=%dms error=%s",
matrix_err.severity,
matrix_err.retry_after_ms,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def send_media(
target_id: str,
media_url: str,
media_type: str = "file",
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
msgtype_map = {
"image": "m.image",
"file": "m.file",
"audio": "m.audio",
"voice": "m.audio",
"video": "m.video",
}
content_type_map = {
"image": "image/png",
"file": "application/octet-stream",
"audio": "audio/ogg",
"voice": "audio/ogg",
"video": "video/mp4",
}
try:
mime_type = content_type_map.get(media_type, "application/octet-stream")
mxc_uri, _ = await client.upload(media_url, content_type=mime_type)
body = {
"msgtype": msgtype_map.get(media_type, "m.file"),
"body": media_url.split("/")[-1] if "/" in media_url else "file",
"url": mxc_uri,
}
if reply_to_id:
body["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_id}}
if thread_id:
relates = body.setdefault("m.relates_to", {})
relates["rel_type"] = "m.thread"
relates["event_id"] = thread_id
resp = await client.room_send(target_id, "m.room.message", body)
return {"event_id": resp.event_id, "room_id": target_id, "mxc_uri": mxc_uri}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix send_media failed: severity=%s retry_after=%dms error=%s",
matrix_err.severity,
matrix_err.retry_after_ms,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def edit_message(
target_id: str,
message_id: str,
content: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
body = {
"msgtype": "m.text",
"body": content,
"m.new_content": {
"msgtype": "m.text",
"body": content,
},
"m.relates_to": {
"rel_type": "m.replace",
"event_id": message_id,
},
}
resp = await client.room_send(target_id, "m.room.message", body)
return {"event_id": resp.event_id, "room_id": target_id}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix edit_message failed: severity=%s retry_after=%dms error=%s",
matrix_err.severity,
matrix_err.retry_after_ms,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def unsend_message(
target_id: str,
message_id: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
resp = await client.room_redact(target_id, message_id)
return {"event_id": resp.event_id, "room_id": target_id}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix unsend_message failed: severity=%s retry_after=%dms error=%s",
matrix_err.severity,
matrix_err.retry_after_ms,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def send_reaction(
target_id: str,
message_id: str,
emoji: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
body = {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": message_id,
"key": emoji,
}
}
resp = await client.room_send(target_id, "m.reaction", body)
return {"event_id": resp.event_id, "room_id": target_id}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix send_reaction failed: severity=%s retry_after=%dms error=%s",
matrix_err.severity,
matrix_err.retry_after_ms,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def send_typing(
target_id: str, typing_state: bool, *, account_id: str | None = None, config: dict | None = None, client: object = None
) -> None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
await client.room_typing(target_id, typing_state=typing_state)
except Exception:
logger.debug("Matrix send_typing failed for room %s", target_id)
finally:
if _should_close:
await client.close()
async def send_read_receipt(
target_id: str,
event_id: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
await client.room_read_markers(target_id, fully_read_event=event_id, read_event=event_id)
except Exception:
logger.debug("Matrix send_read_receipt failed for room %s", target_id)
finally:
if _should_close:
await client.close()
async def send_poll(
target_id: str,
question: str,
options: list[str],
*,
is_anonymous: bool = True,
allows_multiple_answers: bool = False,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
kind = "org.matrix.msc3381.poll.disclosed"
poll_content = {
"org.matrix.msc3381.poll.start": {
"kind": kind,
"max_selections": len(options) if allows_multiple_answers else 1,
"question": {
"org.matrix.msc1767.text": question,
"body": question,
},
"answers": [
{
"id": f"opt_{i}",
"org.matrix.msc1767.text": opt,
}
for i, opt in enumerate(options)
],
},
"org.matrix.msc1767.text": question,
}
resp = await client.room_send(target_id, "m.poll.start", poll_content)
return {"event_id": resp.event_id, "room_id": target_id}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix send_poll failed: severity=%s retry_after=%dms error=%s",
matrix_err.severity,
matrix_err.retry_after_ms,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
poll_max_options = 10
supports_poll_duration_seconds = False
supports_anonymous_polls = True
def _resolve_account(config: dict | None, account_id: str | None):
if config is None:
config = {}
aid = account_id or "default"
account_data = config.get("accounts", {}).get(aid, {})
account = _dict_to_account(account_data)
return _apply_env_overrides(account)
def _create_client(account, nio):
client = nio.AsyncClient(
homeserver=account.homeserver,
user=account.user_id,
device_id=account.device_id,
)
client.access_token = account.access_token
return client
async def _with_client(
config: dict | None,
account_id: str | None,
client: object | None,
operation_name: str,
operation_fn,
):
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
return await operation_fn(nio, client)
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix %s failed: severity=%s retry_after=%dms error=%s",
operation_name,
matrix_err.severity,
matrix_err.retry_after_ms,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def send_notice(
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
async def _op(nio, client):
body = {"msgtype": "m.notice", "body": content}
if reply_to_id:
body["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_id}}
if thread_id:
relates = body.setdefault("m.relates_to", {})
relates["rel_type"] = "m.thread"
relates["event_id"] = thread_id
resp = await client.room_send(target_id, "m.room.message", body)
return {"event_id": resp.event_id, "room_id": target_id}
return await _with_client(config, account_id, client, "send_notice", _op)
async def create_room(
name: str | None = None,
topic: str | None = None,
invite_users: list[str] | None = None,
*,
is_direct: bool = False,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
async def _op(nio, client):
resp = await client.room_create(
name=name, topic=topic, invite=invite_users or [], is_direct=is_direct
)
return {"room_id": resp.room_id}
return await _with_client(config, account_id, client, "create_room", _op)
async def join_room(
room_id_or_alias: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
async def _op(nio, client):
resp = await client.join(room_id_or_alias)
return {"room_id": resp.room_id}
return await _with_client(config, account_id, client, "join_room", _op)
async def leave_room(
room_id: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
await client.room_leave(room_id)
return {"ok": True}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix leave_room failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def invite_user(
room_id: str,
user_id: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
await client.room_invite(room_id, user_id)
return {"ok": True}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix invite_user failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def kick_user(
room_id: str,
user_id: str,
reason: str = "",
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
await client.room_kick(room_id, user_id, reason=reason)
return {"ok": True}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix kick_user failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def ban_user(
room_id: str,
user_id: str,
reason: str = "",
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
await client.room_ban(room_id, user_id, reason=reason)
return {"ok": True}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix ban_user failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def set_display_name(
display_name: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
await client.set_displayname(display_name)
return {"ok": True}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix set_display_name failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def set_avatar(
avatar_url: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
await client.set_avatar(avatar_url)
return {"ok": True}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix set_avatar failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def send_emote(
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
body = {"msgtype": "m.emote", "body": content}
if reply_to_id:
body["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_id}}
resp = await client.room_send(target_id, "m.room.message", body)
return {"event_id": resp.event_id, "room_id": target_id}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix send_emote failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def set_room_name(
room_id: str,
name: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
await client.room_put_state(room_id, "m.room.name", {"name": name})
return {"ok": True}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix set_room_name failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def set_room_topic(
room_id: str,
topic: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
await client.room_put_state(room_id, "m.room.topic", {"topic": topic})
return {"ok": True}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix set_room_topic failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def get_room_members(
room_id: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> list[dict] | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
resp = await client.joined_members(room_id)
members_data = resp.members if hasattr(resp, "members") else {}
return [
{"user_id": uid, "display_name": info.display_name, "avatar_url": info.avatar_url}
for uid, info in (members_data.items() if isinstance(members_data, dict) else [])
]
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix get_room_members failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return None
finally:
if _should_close:
await client.close()
async def update_dm_mapping(
user_id: str,
room_id: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
resp = await client.get_account_data("m.direct")
dm_map = resp.content if hasattr(resp, "content") else {}
dm_map = dict(dm_map) if isinstance(dm_map, dict) else {}
existing = dm_map.get(user_id, [])
if room_id not in existing:
dm_map[user_id] = existing + [room_id]
await client.set_account_data("m.direct", dm_map)
return {"ok": True}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix update_dm_mapping failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def send_location(
location_url: str,
body: str = "",
geo_uri: str = "",
*,
target_id: str = "",
reply_to_id: str | None = None,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
location_content = {"body": body or "Location", "geo_uri": geo_uri, "msgtype": "m.location"}
if reply_to_id:
location_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_id}}
resp = await client.room_send(target_id, "m.room.message", location_content)
return {"event_id": resp.event_id, "room_id": target_id}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix send_location failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def send_sticker(
target_id: str,
sticker_url: str,
*,
body: str = "Sticker",
reply_to_id: str | None = None,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
sticker_content = {"body": body, "url": sticker_url, "msgtype": "m.sticker"}
if reply_to_id:
sticker_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_id}}
resp = await client.room_send(target_id, "m.sticker", sticker_content)
return {"event_id": resp.event_id, "room_id": target_id}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix send_sticker failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def send_voice(
target_id: str,
audio_url: str,
*,
body: str = "Voice message",
reply_to_id: str | None = None,
duration_ms: int = 0,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
voice_content = {
"body": body,
"msgtype": "m.voice",
"url": audio_url,
"org.matrix.msc3245.voice": {},
}
if duration_ms > 0:
voice_content["org.matrix.msc3245.voice"]["duration"] = duration_ms
if reply_to_id:
voice_content["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to_id}}
resp = await client.room_send(target_id, "m.room.message", voice_content)
return {"event_id": resp.event_id, "room_id": target_id}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix send_voice failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def set_room_avatar(
room_id: str,
avatar_url: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
await client.room_put_state(room_id, "m.room.avatar", {"url": avatar_url})
return {"ok": True}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix set_room_avatar failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()
async def get_user_profile(
user_id: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
resp = await client.get_profile(user_id)
return {
"user_id": user_id,
"displayname": getattr(resp, "displayname", None),
"avatar_url": getattr(resp, "avatar_url", None),
}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix get_user_profile failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return None
finally:
if _should_close:
await client.close()
async def get_room_tags(
room_id: str,
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
resp = await client.get_account_data(f"m.tag/{room_id}")
return getattr(resp, "content", {}).get("tags", {}) if hasattr(resp, "content") else {}
except Exception as e:
matrix_err = classify_error(e)
if "M_NOT_FOUND" not in str(e):
logger.error(
"Matrix get_room_tags failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {}
finally:
if _should_close:
await client.close()
async def set_room_tags(
room_id: str,
tags: dict[str, dict | None],
*,
account_id: str | None = None,
config: dict | None = None,
client: object = None,
) -> dict | None:
nio = get_nio()
account = _resolve_account(config, account_id)
_should_close = client is None
if client is None:
client = _create_client(account, nio)
try:
await client.set_account_data(f"m.tag/{room_id}", {"tags": tags})
return {"ok": True}
except Exception as e:
matrix_err = classify_error(e)
logger.error(
"Matrix set_room_tags failed: severity=%s error=%s",
matrix_err.severity,
e,
)
return {"error": str(e)}
finally:
if _should_close:
await client.close()