实现了完整的Nextcloud Talk聊天适配器,包含以下核心功能: 1. 基础客户端通信与认证 2. 会话路由与聊天类型解析 3. 消息分块与格式转换 4. 用户配对与权限校验 5. 轮询式消息监听 6. 配置验证与诊断工具 7. 安全策略与去重缓存 8. 指标统计与状态快照 新增配套的配对用户缓存文件与模块导出入口。
222 lines
7.7 KiB
Python
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,
|
|
)
|