新增腾讯 IM(Tencent IM)渠道扩展,支持在 Yuxi 平台中集成腾讯即时通讯 IM 渠道。 包含以下功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - pairing: 用户配对与绑定 - security: 安全校验 - signature: 请求签名验证 - usersig: UserSig 生成 - dedupe: 消息去重 - status: 会话状态管理 - group: 群组管理 - types: 类型定义
140 lines
4.6 KiB
Python
140 lines
4.6 KiB
Python
import json
|
|
import logging
|
|
import time
|
|
from collections import defaultdict
|
|
from datetime import UTC, datetime
|
|
|
|
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
|
|
from yuxi.channel.routing.models import PeerKind
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RateLimiter:
|
|
def __init__(self, max_requests: int = 10, window: int = 10):
|
|
self._max = max_requests
|
|
self._window = window
|
|
self._buckets: dict[str, list[float]] = defaultdict(list)
|
|
|
|
def allow(self, key: str) -> bool:
|
|
now = time.time()
|
|
bucket = self._buckets[key]
|
|
bucket[:] = [t for t in bucket if now - t < self._window]
|
|
if len(bucket) >= self._max:
|
|
return False
|
|
bucket.append(now)
|
|
return True
|
|
|
|
|
|
def _convert_to_unified_event(
|
|
event: dict,
|
|
account,
|
|
) -> UnifiedMessage | None:
|
|
cmd = event.get("CallbackCommand", "")
|
|
|
|
msg_body = event.get("MsgBody", [])
|
|
if not msg_body:
|
|
return None
|
|
|
|
first_msg = msg_body[0]
|
|
msg_type = first_msg.get("MsgType", "")
|
|
msg_content = first_msg.get("MsgContent", {})
|
|
|
|
content, unified_type = _parse_msg_content(msg_type, msg_content)
|
|
if not content:
|
|
return None
|
|
|
|
is_group = "Group" in cmd
|
|
from_account = event.get("From_Account", "")
|
|
to_account = event.get("To_Account", "")
|
|
group_id = event.get("GroupId", "")
|
|
|
|
media_urls = _extract_media_urls(msg_type, msg_content)
|
|
|
|
return UnifiedMessage(
|
|
msg_id=f"{from_account}_{event.get('MsgSeq', '')}_{event.get('MsgRandom', '')}",
|
|
channel_type="tencent-im",
|
|
account_id=account.account_id,
|
|
content=content,
|
|
message_type=unified_type,
|
|
sender=PeerInfo(
|
|
id=from_account,
|
|
kind=PeerKind.GROUP if is_group else PeerKind.DIRECT,
|
|
display_name=from_account,
|
|
),
|
|
timestamp=datetime.fromtimestamp(event.get("MsgTime", 0), tz=UTC),
|
|
raw_payload=event,
|
|
body_for_agent=content,
|
|
media_urls=media_urls,
|
|
metadata={
|
|
"group_id": group_id if is_group else "",
|
|
"callback_command": cmd,
|
|
"msg_type": msg_type,
|
|
"to_account": to_account,
|
|
},
|
|
)
|
|
|
|
|
|
def _parse_msg_content(msg_type: str, msg_content: dict) -> tuple[str, str]:
|
|
if msg_type == "TIMTextElem":
|
|
return msg_content.get("Text", ""), MessageType.TEXT
|
|
if msg_type == "TIMCustomElem":
|
|
data = msg_content.get("Data", "")
|
|
content = data if isinstance(data, str) else json.dumps(data)
|
|
return content, MessageType.TEXT
|
|
if msg_type == "TIMImageElem":
|
|
return "[图片]", MessageType.IMAGE
|
|
if msg_type == "TIMSoundElem":
|
|
return "[语音]", MessageType.VOICE
|
|
if msg_type == "TIMVideoFileElem":
|
|
return "[视频]", MessageType.VIDEO
|
|
if msg_type == "TIMFileElem":
|
|
return f"[文件: {msg_content.get('FileName', 'unknown')}]", MessageType.FILE
|
|
if msg_type == "TIMFaceElem":
|
|
return f"[表情: {msg_content.get('Index', 0)}]", MessageType.TEXT
|
|
if msg_type == "TIMLocationElem":
|
|
lat = msg_content.get("Latitude", 0)
|
|
lng = msg_content.get("Longitude", 0)
|
|
desc = msg_content.get("Desc", "")
|
|
return f"[位置: {desc} ({lat}, {lng})]", MessageType.TEXT
|
|
if msg_type == "TIMRelayElem":
|
|
title = msg_content.get("Title", "合并转发消息")
|
|
relay_count = len(msg_content.get("MsgList", []))
|
|
return f"[合并转发: {title} ({relay_count}条)]", MessageType.TEXT
|
|
return f"[{msg_type}]", MessageType.TEXT
|
|
|
|
|
|
def _extract_media_urls(msg_type: str, msg_content: dict) -> list[str]:
|
|
urls = []
|
|
if msg_type == "TIMImageElem":
|
|
for img_info in msg_content.get("ImageInfoArray", []):
|
|
if url := img_info.get("URL"):
|
|
urls.append(url)
|
|
elif msg_type == "TIMSoundElem":
|
|
if url := msg_content.get("Url"):
|
|
urls.append(url)
|
|
elif msg_type == "TIMVideoFileElem":
|
|
if url := msg_content.get("VideoUrl"):
|
|
urls.append(url)
|
|
if thumb_url := msg_content.get("ThumbUrl"):
|
|
urls.append(thumb_url)
|
|
elif msg_type == "TIMFileElem":
|
|
if url := msg_content.get("Url"):
|
|
urls.append(url)
|
|
return urls
|
|
|
|
|
|
def resolve_event_type(raw_event: dict) -> str:
|
|
cmd = raw_event.get("CallbackCommand", "")
|
|
if "SendMsg" in cmd:
|
|
return "message"
|
|
if "WithDraw" in cmd:
|
|
return "withdraw"
|
|
if "StateChange" in cmd:
|
|
return "state"
|
|
if "Report" in cmd:
|
|
return "read_report"
|
|
if "Group" in cmd:
|
|
return "group_event"
|
|
return "unknown"
|