ForcePilot/backend/package/yuxi/channel/extensions/telegram/monitor.py
Kris 8a3601250e feat(channel): 添加 Telegram 渠道扩展
新增 Telegram 渠道扩展,支持在 Yuxi 平台中集成 Telegram 即时通讯渠道。

包含以下功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- polling: 长轮询模式
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- actions: 动作处理
- inline_keyboard: 内联键盘
- native_commands: 原生指令
- chat: 聊天管理
- delivery: 消息送达确认
- media: 媒体资源处理
- profile: 用户资料
- reactions: 表情反应
- sticker: 贴纸处理
- types: 类型定义
2026-05-21 11:48:09 +08:00

411 lines
15 KiB
Python

from __future__ import annotations
import logging
from datetime import datetime, UTC
logger = logging.getLogger(__name__)
MESSAGE_TYPE_MAP = {
"text": "TEXT",
"photo": "IMAGE",
"video": "FILE",
"voice": "VOICE",
"audio": "VOICE",
"document": "FILE",
"sticker": "IMAGE",
"location": "TEXT",
"contact": "TEXT",
"poll": "EVENT",
"callback_query": "EVENT",
}
def convert_update_to_unified(update: dict, account_id: str) -> dict | None:
if "message" in update:
return _convert_message(update["message"], update.get("update_id", 0), account_id)
if "callback_query" in update:
return _convert_callback_query(update["callback_query"], update.get("update_id", 0), account_id)
if "edited_message" in update:
return _convert_edited_message(update["edited_message"], update.get("update_id", 0), account_id)
if "my_chat_member" in update:
return _convert_my_chat_member(update["my_chat_member"], update.get("update_id", 0), account_id)
if "poll" in update:
return _convert_poll_update(update["poll"], update.get("update_id", 0), account_id)
if "poll_answer" in update:
return _convert_poll_answer(update["poll_answer"], update.get("update_id", 0), account_id)
if "message_reaction" in update:
return _convert_message_reaction(update["message_reaction"], update.get("update_id", 0), account_id)
if "channel_post" in update:
return _convert_message(update["channel_post"], update.get("update_id", 0), account_id)
if "chat_member" in update:
return _convert_chat_member(update["chat_member"], update.get("update_id", 0), account_id)
if "chat_join_request" in update:
return _convert_chat_join_request(update["chat_join_request"], update.get("update_id", 0), account_id)
return None
def convert_fake_message_to_unified(fake_msg: object, account_id: str) -> dict | None:
from yuxi.channel.extensions.telegram.types import FakeUpdateMessage
msg = fake_msg if isinstance(fake_msg, FakeUpdateMessage) else FakeUpdateMessage(**fake_msg)
sender_kind = "DIRECT" if msg.chat_type == "private" else "GROUP"
return {
"msg_id": f"tg:{msg.msg_id}",
"channel_type": "telegram",
"account_id": account_id,
"content": msg.text or msg.caption or "",
"message_type": MESSAGE_TYPE_MAP.get(msg.media_type or "text", "TEXT"),
"media_urls": [msg.file_id] if msg.file_id else [],
"sender": {
"id": str(msg.from_id),
"username": msg.from_username,
"display_name": msg.from_first_name + (f" {msg.from_last_name}" if msg.from_last_name else ""),
"is_bot": msg.from_is_bot,
"kind": sender_kind,
},
"group": {
"id": str(msg.chat_id), "title": msg.chat_title, "kind": msg.chat_type,
} if msg.chat_type != "private" else None,
"timestamp": datetime.fromtimestamp(msg.date, tz=UTC) if msg.date else None,
"reply_to_id": str(msg.reply_to_message_id) if msg.reply_to_message_id else None,
"thread_id": str(msg.message_thread_id) if msg.message_thread_id else None,
"is_topic_message": msg.is_topic_message,
"forwarded_from": {
"id": str(msg.forward_from_id),
"name": msg.forward_from_name,
} if msg.forward_from_id else None,
"raw_payload": {},
}
def _convert_message(message: dict, update_id: int, account_id: str) -> dict | None:
msg_id = f"tg:{message.get('message_id', update_id)}"
chat = message.get("chat", {})
from_user = message.get("from", {})
chat_type = chat.get("type", "private")
msg_type = _detect_message_type(message)
text = message.get("text", "") or message.get("caption", "")
media_urls: list[str] = []
file_id = None
if "photo" in message:
photos = message["photo"]
if photos:
file_id = photos[-1].get("file_id", "")
media_urls.append(file_id)
elif "video" in message:
file_id = message["video"].get("file_id", "")
media_urls.append(file_id)
elif "document" in message:
file_id = message["document"].get("file_id", "")
media_urls.append(file_id)
elif "voice" in message:
file_id = message["voice"].get("file_id", "")
media_urls.append(file_id)
elif "audio" in message:
file_id = message["audio"].get("file_id", "")
media_urls.append(file_id)
reply_to = message.get("reply_to_message", {})
reply_to_id = str(reply_to.get("message_id")) if reply_to else None
is_topic = bool(message.get("is_topic_message", False))
thread_id = str(message.get("message_thread_id")) if message.get("message_thread_id") else None
sender_kind = "GROUP" if chat_type in ("group", "supergroup") else "DIRECT"
forward_origin = message.get("forward_origin", {})
forwarded_from = None
if forward_origin:
forwarded_from = {
"type": forward_origin.get("type", ""),
"sender_user_id": (
str(forward_origin.get("sender_user", {}).get("id", ""))
if forward_origin.get("sender_user") else None
),
"sender_user_name": forward_origin.get("sender_user_name", ""),
"chat_id": str(forward_origin.get("chat", {}).get("id", "")) if forward_origin.get("chat") else None,
"chat_title": forward_origin.get("chat", {}).get("title", "") if forward_origin.get("chat") else "",
}
return {
"msg_id": msg_id,
"channel_type": "telegram",
"account_id": account_id,
"content": text,
"message_type": msg_type,
"media_urls": media_urls,
"sender": {
"id": str(from_user.get("id", "")),
"username": from_user.get("username", ""),
"display_name": _build_display_name(from_user),
"is_bot": from_user.get("is_bot", False),
"kind": sender_kind,
},
"group": {
"id": str(chat.get("id", "")),
"title": chat.get("title", chat.get("username", "")),
"kind": chat_type,
} if chat_type != "private" else None,
"timestamp": datetime.fromtimestamp(message.get("date", 0), tz=UTC) if message.get("date") else None,
"reply_to_id": reply_to_id,
"thread_id": thread_id,
"is_topic_message": is_topic,
"forwarded_from": forwarded_from,
"raw_payload": message,
}
def _convert_callback_query(callback: dict, update_id: int, account_id: str) -> dict | None:
from_user = callback.get("from", {})
message = callback.get("message", {})
chat = message.get("chat", {})
data = callback.get("data", "")
return {
"msg_id": f"tg:cb:{callback.get('id', update_id)}",
"channel_type": "telegram",
"account_id": account_id,
"content": data,
"message_type": "EVENT",
"media_urls": [],
"sender": {
"id": str(from_user.get("id", "")),
"username": from_user.get("username", ""),
"display_name": _build_display_name(from_user),
"is_bot": from_user.get("is_bot", False),
"kind": "DIRECT" if chat.get("type") == "private" else "GROUP",
},
"group": {
"id": str(chat.get("id", "")),
"title": chat.get("title", ""),
"kind": chat.get("type", "private"),
} if chat.get("type") != "private" else None,
"timestamp": None,
"reply_to_id": str(message.get("message_id")) if message else None,
"thread_id": str(message.get("message_thread_id")) if message.get("message_thread_id") else None,
"raw_payload": callback,
}
def _detect_message_type(message: dict) -> str:
if "text" in message:
return "TEXT"
if "photo" in message:
return "IMAGE"
if "video" in message:
return "FILE"
if "voice" in message or "audio" in message:
return "VOICE"
if "document" in message:
return "FILE"
if "sticker" in message:
return "IMAGE"
if "location" in message:
return "TEXT"
if "contact" in message:
return "TEXT"
if "poll" in message:
return "EVENT"
return "TEXT"
def _build_display_name(from_user: dict) -> str:
first = from_user.get("first_name", "")
last = from_user.get("last_name", "")
if first and last:
return f"{first} {last}"
if first:
return first
return from_user.get("username", str(from_user.get("id", "")))
def _convert_edited_message(message: dict, update_id: int, account_id: str) -> dict | None:
unified = _convert_message(message, update_id, account_id)
if unified:
unified["message_type"] = "EDITED"
return unified
def _convert_my_chat_member(chat_member: dict, update_id: int, account_id: str) -> dict | None:
chat = chat_member.get("chat", {})
new_status = chat_member.get("new_chat_member", {}).get("status", "")
old_status = chat_member.get("old_chat_member", {}).get("status", "")
from_user = chat_member.get("from", {})
return {
"msg_id": f"tg:member:{update_id}",
"channel_type": "telegram",
"account_id": account_id,
"content": f"bot_status_change: {old_status}{new_status}",
"message_type": "SYSTEM",
"media_urls": [],
"sender": {
"id": str(from_user.get("id", "")),
"username": from_user.get("username", ""),
"display_name": _build_display_name(from_user),
"is_bot": from_user.get("is_bot", False),
"kind": "GROUP",
},
"group": {
"id": str(chat.get("id", "")),
"title": chat.get("title", ""),
"kind": chat.get("type", ""),
} if chat.get("type") != "private" else None,
"timestamp": (
datetime.fromtimestamp(chat_member.get("date", 0), tz=UTC)
if chat_member.get("date") else None
),
"reply_to_id": None,
"thread_id": None,
"raw_payload": chat_member,
}
def _convert_poll_update(poll: dict, update_id: int, account_id: str) -> dict | None:
return {
"msg_id": f"tg:poll:{poll.get('id', update_id)}",
"channel_type": "telegram",
"account_id": account_id,
"content": poll.get("question", ""),
"message_type": "EVENT",
"media_urls": [],
"sender": {"id": "", "username": "", "display_name": "", "is_bot": False, "kind": "GROUP"},
"group": None,
"timestamp": (
datetime.fromtimestamp(poll.get("close_date", 0), tz=UTC)
if poll.get("close_date") else None
),
"reply_to_id": None,
"thread_id": None,
"raw_payload": poll,
}
def _convert_poll_answer(poll_answer: dict, update_id: int, account_id: str) -> dict | None:
from_user = poll_answer.get("user", {})
return {
"msg_id": f"tg:poll_answer:{poll_answer.get('poll_id', update_id)}",
"channel_type": "telegram",
"account_id": account_id,
"content": "poll_answer",
"message_type": "EVENT",
"media_urls": [],
"sender": {
"id": str(from_user.get("id", "")),
"username": from_user.get("username", ""),
"display_name": _build_display_name(from_user),
"is_bot": from_user.get("is_bot", False),
"kind": "GROUP",
},
"group": None,
"timestamp": None,
"reply_to_id": None,
"thread_id": None,
"raw_payload": poll_answer,
}
def _convert_message_reaction(reaction: dict, update_id: int, account_id: str) -> dict | None:
chat = reaction.get("chat", {})
from_user = reaction.get("user", {})
return {
"msg_id": f"tg:reaction:{reaction.get('message_id', update_id)}",
"channel_type": "telegram",
"account_id": account_id,
"content": "message_reaction",
"message_type": "EVENT",
"media_urls": [],
"sender": {
"id": str(from_user.get("id", "")),
"username": from_user.get("username", ""),
"display_name": _build_display_name(from_user),
"is_bot": from_user.get("is_bot", False),
"kind": "GROUP" if chat.get("type") in ("group", "supergroup") else "DIRECT",
},
"group": {
"id": str(chat.get("id", "")),
"title": chat.get("title", ""),
"kind": chat.get("type", ""),
} if chat.get("type") != "private" else None,
"timestamp": (
datetime.fromtimestamp(reaction.get("date", 0), tz=UTC)
if reaction.get("date") else None
),
"reply_to_id": None,
"thread_id": None,
"raw_payload": reaction,
}
def _convert_chat_member(member_update: dict, update_id: int, account_id: str) -> dict | None:
chat = member_update.get("chat", {})
from_user = member_update.get("from", {})
return {
"msg_id": f"tg:cm:{update_id}",
"channel_type": "telegram",
"account_id": account_id,
"content": "chat_member_update",
"message_type": "SYSTEM",
"media_urls": [],
"sender": {
"id": str(from_user.get("id", "")),
"username": from_user.get("username", ""),
"display_name": _build_display_name(from_user),
"is_bot": from_user.get("is_bot", False),
"kind": "GROUP",
},
"group": {
"id": str(chat.get("id", "")),
"title": chat.get("title", ""),
"kind": chat.get("type", ""),
} if chat.get("type") != "private" else None,
"timestamp": (
datetime.fromtimestamp(member_update.get("date", 0), tz=UTC)
if member_update.get("date") else None
),
"reply_to_id": None,
"thread_id": None,
"raw_payload": member_update,
}
def _convert_chat_join_request(join_request: dict, update_id: int, account_id: str) -> dict | None:
chat = join_request.get("chat", {})
from_user = join_request.get("from", {})
return {
"msg_id": f"tg:cjr:{update_id}",
"channel_type": "telegram",
"account_id": account_id,
"content": "chat_join_request",
"message_type": "SYSTEM",
"media_urls": [],
"sender": {
"id": str(from_user.get("id", "")),
"username": from_user.get("username", ""),
"display_name": _build_display_name(from_user),
"is_bot": from_user.get("is_bot", False),
"kind": "GROUP",
},
"group": {
"id": str(chat.get("id", "")),
"title": chat.get("title", ""),
"kind": chat.get("type", ""),
} if chat.get("type") != "private" else None,
"timestamp": (
datetime.fromtimestamp(join_request.get("date", 0), tz=UTC)
if join_request.get("date") else None
),
"reply_to_id": None,
"thread_id": None,
"raw_payload": join_request,
}