ForcePilot/backend/package/yuxi/channels/adapters/nextcloudtalk/normalizer.py
Kris b018ad25da feat(backend): add Nextcloud Talk channel adapter implementation
实现了完整的Nextcloud Talk聊天适配器,包含以下核心功能:
1. 基础客户端通信与认证
2. 会话路由与聊天类型解析
3. 消息分块与格式转换
4. 用户配对与权限校验
5. 轮询式消息监听
6. 配置验证与诊断工具
7. 安全策略与去重缓存
8. 指标统计与状态快照

新增配套的配对用户缓存文件与模块导出入口。
2026-05-12 00:47:06 +08:00

222 lines
7.7 KiB
Python

from __future__ import annotations
from datetime import datetime, UTC
from typing import Any
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelType,
ChatType,
EventType,
MentionsInfo,
MessageType,
)
_AS2_TYPE_MAP: dict[str, EventType] = {
"Create": EventType.MESSAGE_RECEIVED,
"Announce": EventType.MESSAGE_RECEIVED,
"Update": EventType.MESSAGE_UPDATED,
"Delete": EventType.MESSAGE_DELETED,
"Like": EventType.SYSTEM_EVENT,
"Dislike": EventType.SYSTEM_EVENT,
"Add": EventType.MEMBER_JOINED,
"Remove": EventType.MEMBER_LEFT,
"Join": EventType.MEMBER_JOINED,
"Leave": EventType.MEMBER_LEFT,
}
def _parse_as2_activity(raw: dict[str, Any]) -> dict[str, Any] | None:
if "@context" not in raw:
return None
as2_type = raw.get("type", "")
obj = raw.get("object", {})
if isinstance(obj, dict):
content = obj.get("content", "")
obj_id = obj.get("id", "")
message_type = obj.get("messageType", raw.get("messageType", "comment"))
else:
content = raw.get("message", "")
obj_id = ""
message_type = raw.get("messageType", "comment")
actor = raw.get("actor", {})
if isinstance(actor, dict):
actor_id = actor.get("id", "")
actor_display_name = actor.get("name", "")
else:
actor_id = raw.get("actorId", "")
actor_display_name = raw.get("actorDisplayName", "")
target = raw.get("target", {})
if isinstance(target, dict):
token = target.get("id", "")
else:
token = raw.get("token", "")
return {
"id": obj_id or raw.get("id", ""),
"token": token or raw.get("token", ""),
"actorId": actor_id or raw.get("actorId", ""),
"actorDisplayName": actor_display_name or raw.get("actorDisplayName", ""),
"timestamp": raw.get("published", raw.get("timestamp", 0)),
"message": content or raw.get("message", ""),
"messageType": message_type,
"_as2_type": as2_type,
}
def normalize_inbound(raw: dict[str, Any], channel_id: str, room_type: int | None = None) -> ChannelMessage:
as2_parsed = _parse_as2_activity(raw)
as2_type = ""
if as2_parsed:
raw = {**raw, **{k: v for k, v in as2_parsed.items() if not k.startswith("_")}}
as2_type = as2_parsed["_as2_type"]
msg_type = raw.get("messageType", raw.get("message_type", "comment"))
identity = ChannelIdentity(
channel_id=channel_id,
channel_type=ChannelType.NEXTCLOUDTALK,
channel_user_id=raw.get("actorId", raw.get("actor_id", "")),
channel_chat_id=raw.get("token", ""),
channel_message_id=str(raw.get("id", "")),
)
if msg_type == "system":
return _build_system_event(raw, identity, channel_id, as2_parsed)
message_type = MessageType.COMMAND if msg_type == "command" else MessageType.TEXT
content = raw.get("message", "")
attachments = []
params = raw.get("messageParameters", {})
for param_data in params.values():
ptype = param_data.get("type", "")
if ptype in ("file", "media"):
attachments.append(
Attachment(
type="file",
url=param_data.get("link", ""),
filename=param_data.get("name", ""),
mime_type=param_data.get("mimetype", ""),
size_bytes=param_data.get("size", 0),
)
)
elif ptype == "talk-poll":
message_type = MessageType.POLL
reply_to = None
parent_data = raw.get("parent")
if parent_data:
reply_to = str(parent_data.get("id", ""))
timestamp = datetime.fromtimestamp(raw.get("timestamp", 0), tz=UTC)
metadata = {
"nc_message_type": msg_type,
"nc_actor_type": raw.get("actorType", raw.get("actor_type", "")),
"nc_actor_display_name": raw.get("actorDisplayName", raw.get("actor_display_name", "")),
"reference_id": raw.get("referenceId", raw.get("reference_id", "")),
"nc_message_parameters": params,
}
if as2_parsed:
metadata["as2_type"] = as2_parsed["_as2_type"]
was_mentioned = False
sender_name = raw.get("actorDisplayName", raw.get("actor_display_name", ""))
for key, param_data in params.items():
if key.startswith("mention"):
param_type = param_data.get("type", "")
if param_type in ("user", "guest"):
was_mentioned = True
elif param_data.get("type") == "user" and param_data.get("id") == identity.channel_user_id:
pass
is_forwarded = msg_type == "comment" and "{mention-forwarded}" in content
if is_forwarded:
forward_actor = raw.get("forwardActorDisplayName", raw.get("forward_actor_display_name", ""))
forward_actor_id = raw.get("forwardActorId", raw.get("forward_actor_id", ""))
metadata["nc_forwarded"] = True
metadata["nc_forward_actor_display_name"] = forward_actor
metadata["nc_forward_actor_id"] = forward_actor_id
mentions_info = MentionsInfo(
mentioned_user_ids=[],
is_bot_mentioned=was_mentioned,
raw_text=content,
)
chat_type = ChatType.DIRECT
if room_type is not None and room_type in (2, 3):
chat_type = ChatType.GROUP
return ChannelMessage(
identity=identity,
event_type=EventType.MESSAGE_RECEIVED,
message_type=message_type,
chat_type=chat_type,
content=content,
attachments=attachments,
mentions=mentions_info,
reply_to_message_id=reply_to,
metadata=metadata,
timestamp=timestamp,
)
def _build_system_event(
raw: dict[str, Any], identity: ChannelIdentity, channel_id: str, as2_parsed: dict[str, Any] | None = None
) -> ChannelMessage:
system_msg = raw.get("systemMessage", raw.get("system_message", ""))
event_map = {
"user_added": EventType.MEMBER_JOINED,
"user_removed": EventType.MEMBER_LEFT,
"conversation_created": EventType.SYSTEM_EVENT,
"conversation_renamed": EventType.SYSTEM_EVENT,
"conversation_description_changed": EventType.SYSTEM_EVENT,
"call_started": EventType.SYSTEM_EVENT,
"call_ended": EventType.SYSTEM_EVENT,
"call_missed": EventType.SYSTEM_EVENT,
"message_deleted": EventType.MESSAGE_DELETED,
"reaction": EventType.SYSTEM_EVENT,
"reaction_revoked": EventType.SYSTEM_EVENT,
"reaction_added": EventType.SYSTEM_EVENT,
"reaction_removed": EventType.SYSTEM_EVENT,
"poll_created": EventType.SYSTEM_EVENT,
"poll_closed": EventType.SYSTEM_EVENT,
"poll_voted": EventType.SYSTEM_EVENT,
"conversation_password_changed": EventType.SYSTEM_EVENT,
"lobby_timer_reached": EventType.SYSTEM_EVENT,
"lobby_none": EventType.SYSTEM_EVENT,
"breakout_rooms_started": EventType.SYSTEM_EVENT,
"breakout_rooms_stopped": EventType.SYSTEM_EVENT,
"recording_started": EventType.SYSTEM_EVENT,
"recording_stopped": EventType.SYSTEM_EVENT,
"avatar_updated": EventType.SYSTEM_EVENT,
"message_edited": EventType.MESSAGE_UPDATED,
"message_pinned": EventType.SYSTEM_EVENT,
"message_unpinned": EventType.SYSTEM_EVENT,
}
event_type = event_map.get(system_msg, EventType.SYSTEM_EVENT)
description = system_msg if system_msg else "system"
metadata: dict[str, Any] = {"nc_system_message": system_msg}
if as2_parsed:
metadata["as2_type"] = as2_parsed["_as2_type"]
return ChannelMessage(
identity=identity,
event_type=event_type,
message_type=MessageType.TEXT,
chat_type=ChatType.GROUP,
content=description,
metadata=metadata,
)