初始提交: Qihu-AIFlow v2.0.1 完整项目
- 前端 React+TypeScript (源码+构建产物) - 后端 Python FastAPI - 数据库迁移 001-015 - Docker部署配置 - 微信ClawBot API频道绑定 - 项目文档 README.md
This commit is contained in:
commit
78ff261b5a
199 files changed
+69126
No files matched your search
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
平台适配器初始化 - 导入所有适配器触发注册
|
||||
"""
|
||||
from .wecom_adapter import WecomPlatformAdapter
|
||||
from .feishu_adapter import FeishuPlatformAdapter
|
||||
from .dingtalk_adapter import DingtalkPlatformAdapter
|
||||
from .telegram_adapter import TelegramPlatformAdapter
|
||||
from .slack_adapter import SlackPlatformAdapter
|
||||
from .whatsapp_adapter import WhatsAppPlatformAdapter
|
||||
|
||||
__all__ = [
|
||||
"WecomPlatformAdapter",
|
||||
"FeishuPlatformAdapter",
|
||||
"DingtalkPlatformAdapter",
|
||||
"TelegramPlatformAdapter",
|
||||
"SlackPlatformAdapter",
|
||||
"WhatsAppPlatformAdapter",
|
||||
]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
钉钉平台适配器
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..platform_adapter import register_platform_adapter, BasePlatformAdapter, PlatformMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
name="dingtalk",
|
||||
display_name="钉钉",
|
||||
description="钉钉开放平台消息适配器,支持文本、图片、文件等消息类型",
|
||||
support_streaming=False,
|
||||
support_image=True,
|
||||
support_file=True,
|
||||
support_voice=False,
|
||||
)
|
||||
class DingtalkPlatformAdapter(BasePlatformAdapter):
|
||||
"""钉钉平台适配器"""
|
||||
|
||||
def get_metadata(self) -> PlatformMetadata:
|
||||
from ..platform_adapter import platform_registry
|
||||
return platform_registry["dingtalk"]
|
||||
|
||||
def verify_signature(self, payload: dict, signature: str, timestamp: str) -> bool:
|
||||
"""钉钉签名验证: HMAC-SHA256 Base64"""
|
||||
if not signature or not timestamp:
|
||||
return True # 无签名默认通过
|
||||
|
||||
app_secret = self.config.get("app_secret", "")
|
||||
if not app_secret:
|
||||
logger.warning("[DingTalk] 未配置app_secret,跳过签名验证")
|
||||
return True
|
||||
|
||||
try:
|
||||
# 钉钉签名格式: timestamp + "\n" + appSecret
|
||||
sign_str = f"{timestamp}\n{app_secret}"
|
||||
hmac_code = hmac.new(
|
||||
app_secret.encode("utf-8"),
|
||||
sign_str.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
expected = base64.b64encode(hmac_code).decode("utf-8")
|
||||
return signature == expected
|
||||
except Exception as e:
|
||||
logger.error(f"[DingTalk] 签名验证异常: {e}")
|
||||
return False
|
||||
|
||||
def parse_webhook(self, payload: dict) -> dict:
|
||||
"""解析钉钉webhook payload为统一消息格式"""
|
||||
msg_type = payload.get("msgtype", "text")
|
||||
content = ""
|
||||
content_type = "text"
|
||||
sender_id = ""
|
||||
sender_name = ""
|
||||
message_id = ""
|
||||
|
||||
if msg_type == "text":
|
||||
text_obj = payload.get("text", {})
|
||||
content = text_obj.get("content", "")
|
||||
content_type = "text"
|
||||
elif msg_type == "image":
|
||||
image_obj = payload.get("image", {})
|
||||
content = f"[图片] media_id={image_obj.get('mediaId', '')}"
|
||||
content_type = "image"
|
||||
elif msg_type == "file":
|
||||
file_obj = payload.get("file", {})
|
||||
content = f"[文件] {file_obj.get('fileName', 'unknown')} media_id={file_obj.get('mediaId', '')}"
|
||||
content_type = "file"
|
||||
elif msg_type == "link":
|
||||
link_obj = payload.get("link", {})
|
||||
content = f"[链接] {link_obj.get('title', '')} url={link_obj.get('messageUrl', '')}"
|
||||
content_type = "link"
|
||||
elif msg_type == "markdown":
|
||||
md_obj = payload.get("markdown", {})
|
||||
content = md_obj.get("title", "")
|
||||
content_type = "markdown"
|
||||
elif msg_type == "actionCard":
|
||||
card = payload.get("actionCard", {})
|
||||
content = f"[卡片] {card.get('title', '')}"
|
||||
content_type = "actionCard"
|
||||
else:
|
||||
content = json.dumps(payload, ensure_ascii=False)
|
||||
content_type = msg_type
|
||||
|
||||
# 提取发送者信息
|
||||
sender_id = payload.get("senderId", payload.get("senderStaffId", ""))
|
||||
sender_name = payload.get("senderNick", payload.get("senderCorpId", ""))
|
||||
message_id = str(payload.get("msgId", ""))
|
||||
|
||||
# 群聊信息
|
||||
group_id = payload.get("conversationId", payload.get("chatId", None))
|
||||
group_name = payload.get("conversationTitle", None)
|
||||
|
||||
return {
|
||||
"content_type": content_type,
|
||||
"content": content,
|
||||
"sender_id": str(sender_id),
|
||||
"sender_name": str(sender_name),
|
||||
"message_id": message_id,
|
||||
"timestamp": int(payload.get("createAt", time.time() * 1000)) // 1000,
|
||||
"group_id": group_id,
|
||||
"group_name": group_name,
|
||||
"raw": payload,
|
||||
}
|
||||
|
||||
def build_reply(self, channel_config: dict, content: str, reply_to: str = None) -> dict:
|
||||
"""构建钉钉回复消息"""
|
||||
webhook_url = channel_config.get("webhook_url", "")
|
||||
if not webhook_url:
|
||||
logger.warning("[DingTalk] 未配置webhook_url,无法构建回复")
|
||||
return {}
|
||||
return {
|
||||
"api_url": webhook_url,
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"body": {"msgtype": "text", "text": {"content": content}},
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
飞书平台适配器
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ..platform_adapter import register_platform_adapter, BasePlatformAdapter, PlatformMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
name="feishu",
|
||||
display_name="飞书",
|
||||
description="飞书开放平台消息适配器,支持文本、图片、文件、富文本消息",
|
||||
support_streaming=True,
|
||||
support_image=True,
|
||||
support_file=True,
|
||||
support_voice=False,
|
||||
)
|
||||
class FeishuPlatformAdapter(BasePlatformAdapter):
|
||||
"""飞书平台适配器"""
|
||||
|
||||
def get_metadata(self) -> PlatformMetadata:
|
||||
from ..platform_adapter import platform_registry
|
||||
return platform_registry["feishu"]
|
||||
|
||||
def verify_signature(self, payload: dict, signature: str, timestamp: str) -> bool:
|
||||
"""飞书签名验证: HMAC-SHA256"""
|
||||
if not signature or not timestamp:
|
||||
# 飞书首次URL验证(challenge)不需要签名验证
|
||||
if payload.get("challenge"):
|
||||
return True
|
||||
return True # 宽松模式
|
||||
|
||||
app_secret = self.config.get("app_secret", "")
|
||||
if not app_secret:
|
||||
logger.warning("[Feishu] 未配置app_secret,跳过签名验证")
|
||||
return True
|
||||
|
||||
# 飞书签名格式: timestamp + nonce + body
|
||||
try:
|
||||
body_str = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
nonce = timestamp # 飞书用timestamp作为nonce的一部分
|
||||
sign_str = f"{timestamp}\n{nonce}\n{body_str}"
|
||||
expected = hmac.new(
|
||||
app_secret.encode("utf-8"),
|
||||
sign_str.encode("utf-8"),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(signature, expected)
|
||||
except Exception as e:
|
||||
logger.error(f"[Feishu] 签名验证异常: {e}")
|
||||
return False
|
||||
|
||||
def parse_webhook(self, payload: dict) -> dict:
|
||||
"""解析飞书webhook payload为统一消息格式"""
|
||||
# 飞书URL验证challenge已在外部处理,这里处理正常消息
|
||||
|
||||
event = payload.get("event", {})
|
||||
message = event.get("message", {})
|
||||
msg_type = message.get("message_type", "text")
|
||||
raw_content = message.get("content", "{}")
|
||||
|
||||
content = ""
|
||||
content_type = "text"
|
||||
sender_id = event.get("sender", {}).get("sender_id", {}).get("open_id", "")
|
||||
sender_name = event.get("sender", {}).get("sender_id", {}).get("open_id", "")
|
||||
message_id = event.get("header", {}).get("message_id", "")
|
||||
|
||||
try:
|
||||
if isinstance(raw_content, str):
|
||||
content_obj = json.loads(raw_content)
|
||||
else:
|
||||
content_obj = raw_content if isinstance(raw_content, dict) else {}
|
||||
|
||||
if msg_type == "text":
|
||||
content = content_obj.get("text", "")
|
||||
content_type = "text"
|
||||
elif msg_type == "image":
|
||||
image_key = message.get("image_key", "")
|
||||
content = f"[图片] image_key={image_key}"
|
||||
content_type = "image"
|
||||
elif msg_type == "file":
|
||||
file_key = message.get("file_key", "")
|
||||
file_name = content_obj.get("file_name", "unknown")
|
||||
content = f"[文件] {file_name} file_key={file_key}"
|
||||
content_type = "file"
|
||||
elif msg_type == "post":
|
||||
# 富文本消息,提取text内容
|
||||
post_content = content_obj.get("content", [])
|
||||
text_parts = []
|
||||
for paragraph in post_content:
|
||||
if isinstance(paragraph, list):
|
||||
for element in paragraph:
|
||||
if isinstance(element, dict) and element.get("tag") == "text":
|
||||
text_parts.append(element.get("text", ""))
|
||||
content = "".join(text_parts)
|
||||
content_type = "post"
|
||||
else:
|
||||
content = str(content_obj)
|
||||
content_type = msg_type
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.warning(f"[Feishu] 解析content失败: {e}, raw={str(raw_content)[:200]}")
|
||||
content = str(raw_content)
|
||||
|
||||
return {
|
||||
"content_type": content_type,
|
||||
"content": content,
|
||||
"sender_id": sender_id,
|
||||
"sender_name": sender_name,
|
||||
"message_id": message_id,
|
||||
"timestamp": event.get("header", {}).get("create_time", 0),
|
||||
"group_id": event.get("chat_id", None),
|
||||
"group_name": None,
|
||||
"raw": payload,
|
||||
}
|
||||
|
||||
def build_reply(self, channel_config: dict, content: str, reply_to: str = None) -> dict:
|
||||
"""构建飞书回复消息"""
|
||||
app_id = channel_config.get("app_id", "")
|
||||
app_secret = channel_config.get("app_secret", "")
|
||||
return {
|
||||
"api_url": "https://open.feishu.cn/open-apis/im/v1/messages",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {channel_config.get('tenant_access_token', '')}",
|
||||
},
|
||||
"body": {
|
||||
"msg_type": "text",
|
||||
"content": json.dumps({"text": content}),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Slack平台适配器
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..platform_adapter import register_platform_adapter, BasePlatformAdapter, PlatformMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
name="slack",
|
||||
display_name="Slack",
|
||||
description="Slack Events API消息适配器,支持文本消息和URL验证",
|
||||
support_streaming=True,
|
||||
support_image=True,
|
||||
support_file=True,
|
||||
support_voice=False,
|
||||
)
|
||||
class SlackPlatformAdapter(BasePlatformAdapter):
|
||||
"""Slack平台适配器 - 签名验证 + challenge响应"""
|
||||
|
||||
def get_metadata(self) -> PlatformMetadata:
|
||||
from ..platform_adapter import platform_registry
|
||||
return platform_registry["slack"]
|
||||
|
||||
def verify_signature(self, payload: dict, signature: str, timestamp: str) -> bool:
|
||||
"""Slack签名验证: v0=HMAC-SHA256(SigningSecret, 'v0:timestamp:body')"""
|
||||
if not signature or not timestamp:
|
||||
return True # 无签名默认通过
|
||||
|
||||
signing_secret = self.config.get("signing_secret", "")
|
||||
if not signing_secret:
|
||||
logger.warning("[Slack] 未配置signing_secret,跳过签名验证")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Slack签名格式: v0=XXXX
|
||||
# 签名基础字符串: v0:timestamp:body
|
||||
body_str = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
sig_basestring = f"v0:{timestamp}:{body_str}"
|
||||
|
||||
computed_sig = "v0=" + hmac.new(
|
||||
signing_secret.encode("utf-8"),
|
||||
sig_basestring.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(computed_sig, signature)
|
||||
except Exception as e:
|
||||
logger.error(f"[Slack] 签名验证异常: {e}")
|
||||
return False
|
||||
|
||||
def parse_webhook(self, payload: dict) -> dict:
|
||||
"""解析Slack webhook payload为统一消息格式"""
|
||||
# Slack URL验证 challenge 在外部处理
|
||||
|
||||
event = payload.get("event", {})
|
||||
event_type = event.get("type", "message")
|
||||
subtype = event.get("subtype", "")
|
||||
|
||||
content = ""
|
||||
content_type = "text"
|
||||
sender_id = ""
|
||||
sender_name = ""
|
||||
message_id = ""
|
||||
group_id = None
|
||||
group_name = None
|
||||
|
||||
if event_type == "message":
|
||||
text = event.get("text", "")
|
||||
user = event.get("user", "")
|
||||
channel = event.get("channel", "")
|
||||
ts = event.get("ts", "")
|
||||
|
||||
content = text
|
||||
content_type = "text"
|
||||
sender_id = user
|
||||
sender_name = event.get("user_profile", {}).get("display_name", user)
|
||||
message_id = ts
|
||||
|
||||
# 区分私聊/群聊
|
||||
channel_type = event.get("channel_type", "")
|
||||
if channel or channel_type == "channel":
|
||||
group_id = channel
|
||||
elif channel:
|
||||
group_id = channel
|
||||
|
||||
elif event_type == "app_mention":
|
||||
text = event.get("text", "")
|
||||
user = event.get("user", "")
|
||||
channel = event.get("channel", "")
|
||||
ts = event.get("ts", "")
|
||||
|
||||
# 去除@bot标记
|
||||
content = text
|
||||
content_type = "text"
|
||||
sender_id = user
|
||||
sender_name = user
|
||||
message_id = ts
|
||||
group_id = channel
|
||||
|
||||
else:
|
||||
content = json.dumps(event, ensure_ascii=False)
|
||||
content_type = event_type
|
||||
|
||||
return {
|
||||
"content_type": content_type,
|
||||
"content": content,
|
||||
"sender_id": sender_id,
|
||||
"sender_name": sender_name,
|
||||
"message_id": message_id,
|
||||
"timestamp": int(float(message_id.split(".")[0]) if message_id else time.time()),
|
||||
"group_id": group_id,
|
||||
"group_name": group_name,
|
||||
"raw": payload,
|
||||
}
|
||||
|
||||
def build_reply(self, channel_config: dict, content: str, reply_to: str = None) -> dict:
|
||||
"""构建Slack回复消息"""
|
||||
bot_token = channel_config.get("bot_token", "")
|
||||
channel = channel_config.get("channel", "")
|
||||
if not bot_token:
|
||||
logger.warning("[Slack] 未配置bot_token,无法构建回复")
|
||||
return {}
|
||||
|
||||
body = {
|
||||
"channel": channel,
|
||||
"text": content,
|
||||
}
|
||||
if reply_to:
|
||||
body["thread_ts"] = reply_to
|
||||
|
||||
return {
|
||||
"api_url": "https://slack.com/api/chat.postMessage",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {bot_token}",
|
||||
},
|
||||
"body": body,
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Telegram平台适配器
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ..platform_adapter import register_platform_adapter, BasePlatformAdapter, PlatformMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
name="telegram",
|
||||
display_name="Telegram",
|
||||
description="Telegram Bot API消息适配器,支持文本、图片、文件、语音、视频、贴纸等消息类型",
|
||||
support_streaming=True,
|
||||
support_image=True,
|
||||
support_file=True,
|
||||
support_voice=True,
|
||||
)
|
||||
class TelegramPlatformAdapter(BasePlatformAdapter):
|
||||
"""Telegram平台适配器"""
|
||||
|
||||
def get_metadata(self) -> PlatformMetadata:
|
||||
from ..platform_adapter import platform_registry
|
||||
return platform_registry["telegram"]
|
||||
|
||||
def verify_signature(self, payload: dict, signature: str, timestamp: str) -> bool:
|
||||
"""Telegram签名验证: HMAC-SHA256(BotToken, 可选)"""
|
||||
if not signature:
|
||||
return True # Telegram不使用签名头验证,通过setWebhook时设置secret_token
|
||||
|
||||
bot_token = self.config.get("bot_token", "")
|
||||
if not bot_token:
|
||||
logger.warning("[Telegram] 未配置bot_token,跳过签名验证")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Telegram支持通过 X-Telegram-Bot-Api-Secret-Token 头验证
|
||||
# 新式验证:secret_token = SHA256(bot_token)
|
||||
expected = hashlib.sha256(bot_token.encode("utf-8")).hexdigest()
|
||||
return signature == expected
|
||||
except Exception as e:
|
||||
logger.error(f"[Telegram] 签名验证异常: {e}")
|
||||
return False
|
||||
|
||||
def parse_webhook(self, payload: dict) -> dict:
|
||||
"""解析Telegram webhook payload为统一消息格式"""
|
||||
message = payload.get("message", payload.get("edited_message", {}))
|
||||
if not message:
|
||||
# 可能是callback_query等其他更新
|
||||
callback = payload.get("callback_query", {})
|
||||
if callback:
|
||||
message = callback.get("message", {})
|
||||
message["_callback_data"] = callback.get("data", "")
|
||||
|
||||
chat = message.get("chat", {})
|
||||
from_user = message.get("from", {})
|
||||
chat_id = str(chat.get("id", ""))
|
||||
chat_type = chat.get("type", "private")
|
||||
chat_title = chat.get("title", "")
|
||||
|
||||
sender_id = str(from_user.get("id", ""))
|
||||
sender_name = from_user.get("first_name", "") or from_user.get("username", "")
|
||||
|
||||
content = ""
|
||||
content_type = "text"
|
||||
|
||||
if "text" in message:
|
||||
content = message.get("text", "")
|
||||
content_type = "text"
|
||||
elif "photo" in message:
|
||||
photos = message.get("photo", [])
|
||||
largest = photos[-1] if photos else {}
|
||||
content = f"[图片] file_id={largest.get('file_id', '')}"
|
||||
content_type = "image"
|
||||
elif "document" in message:
|
||||
doc = message.get("document", {})
|
||||
content = f"[文件] {doc.get('file_name', 'unknown')} file_id={doc.get('file_id', '')}"
|
||||
content_type = "file"
|
||||
elif "voice" in message:
|
||||
voice = message.get("voice", {})
|
||||
content = f"[语音] file_id={voice.get('file_id', '')} duration={voice.get('duration', 0)}s"
|
||||
content_type = "voice"
|
||||
elif "video" in message:
|
||||
video = message.get("video", {})
|
||||
content = f"[视频] file_id={video.get('file_id', '')}"
|
||||
content_type = "video"
|
||||
elif "sticker" in message:
|
||||
sticker = message.get("sticker", {})
|
||||
content = f"[贴纸] emoji={sticker.get('emoji', '')} set={sticker.get('set_name', '')}"
|
||||
content_type = "sticker"
|
||||
elif "location" in message:
|
||||
loc = message.get("location", {})
|
||||
content = f"[位置] lat={loc.get('latitude')} lng={loc.get('longitude')}"
|
||||
content_type = "location"
|
||||
elif "contact" in message:
|
||||
contact = message.get("contact", {})
|
||||
content = f"[联系人] {contact.get('first_name', '')} phone={contact.get('phone_number', '')}"
|
||||
content_type = "contact"
|
||||
elif "animation" in message:
|
||||
anim = message.get("animation", {})
|
||||
content = f"[动画] file_id={anim.get('file_id', '')}"
|
||||
content_type = "animation"
|
||||
else:
|
||||
content = json.dumps(message, ensure_ascii=False)
|
||||
content_type = "unknown"
|
||||
|
||||
# 群聊/频道标识
|
||||
group_id = None
|
||||
group_name = None
|
||||
if chat_type in ("group", "supergroup"):
|
||||
group_id = chat_id
|
||||
group_name = chat_title
|
||||
elif chat_type == "channel":
|
||||
group_id = chat_id
|
||||
group_name = chat_title
|
||||
|
||||
return {
|
||||
"content_type": content_type,
|
||||
"content": content,
|
||||
"sender_id": sender_id,
|
||||
"sender_name": sender_name,
|
||||
"message_id": str(message.get("message_id", "")),
|
||||
"timestamp": message.get("date", 0),
|
||||
"group_id": group_id,
|
||||
"group_name": group_name,
|
||||
"raw": payload,
|
||||
}
|
||||
|
||||
def build_reply(self, channel_config: dict, content: str, reply_to: str = None) -> dict:
|
||||
"""构建Telegram回复消息"""
|
||||
bot_token = channel_config.get("bot_token", "")
|
||||
chat_id = channel_config.get("chat_id", "")
|
||||
if not bot_token:
|
||||
logger.warning("[Telegram] 未配置bot_token,无法构建回复")
|
||||
return {}
|
||||
|
||||
body = {
|
||||
"chat_id": chat_id,
|
||||
"text": content,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
if reply_to:
|
||||
body["reply_to_message_id"] = reply_to
|
||||
|
||||
return {
|
||||
"api_url": f"https://api.telegram.org/bot{bot_token}/sendMessage",
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"body": body,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
企业微信平台适配器
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ..platform_adapter import register_platform_adapter, BasePlatformAdapter, PlatformMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
name="wecom",
|
||||
display_name="企业微信",
|
||||
description="企业微信消息平台适配器,支持文本、图片、文件、语音消息",
|
||||
support_streaming=False,
|
||||
support_image=True,
|
||||
support_file=True,
|
||||
support_voice=True,
|
||||
)
|
||||
class WecomPlatformAdapter(BasePlatformAdapter):
|
||||
"""企业微信平台适配器"""
|
||||
|
||||
def get_metadata(self) -> PlatformMetadata:
|
||||
from ..platform_adapter import platform_registry
|
||||
return platform_registry["wecom"]
|
||||
|
||||
def verify_signature(self, payload: dict, signature: str, timestamp: str) -> bool:
|
||||
"""企业微信签名验证: SHA1(token + timestamp + nonce)"""
|
||||
if not signature or not timestamp:
|
||||
return True # 无签名要求默认通过
|
||||
token = self.config.get("token", "")
|
||||
encoding_aes_key = self.config.get("encoding_aes_key", "")
|
||||
nonce = (encoding_aes_key or token or "defaultnonce")[:16]
|
||||
tmp = "".join(sorted([token, timestamp, nonce]))
|
||||
expected = hashlib.sha1(tmp.encode()).hexdigest()
|
||||
return signature == expected
|
||||
|
||||
def parse_webhook(self, payload: dict) -> dict:
|
||||
"""解析企业微信webhook payload为统一消息格式"""
|
||||
msg_type = payload.get("MsgType", "text")
|
||||
content = ""
|
||||
content_type = "text"
|
||||
|
||||
if msg_type == "text":
|
||||
content = payload.get("Text", {}).get("Content", "")
|
||||
content_type = "text"
|
||||
elif msg_type == "image":
|
||||
content = payload.get("Image", {}).get("MediaId", "")
|
||||
content_type = "image"
|
||||
elif msg_type == "voice":
|
||||
content = payload.get("Voice", {}).get("MediaId", "")
|
||||
content_type = "voice"
|
||||
elif msg_type == "video":
|
||||
content = payload.get("Video", {}).get("MediaId", "")
|
||||
content_type = "video"
|
||||
elif msg_type == "file":
|
||||
content = payload.get("FileName", "")
|
||||
content_type = "file"
|
||||
elif msg_type == "location":
|
||||
loc = payload.get("Location", {})
|
||||
content = json.dumps(loc, ensure_ascii=False)
|
||||
content_type = "location"
|
||||
elif msg_type == "event":
|
||||
content = json.dumps({"event": payload.get("Event", ""), "event_key": payload.get("EventKey", "")})
|
||||
content_type = "event"
|
||||
else:
|
||||
content = json.dumps(payload, ensure_ascii=False)
|
||||
content_type = msg_type
|
||||
|
||||
return {
|
||||
"content_type": content_type,
|
||||
"content": content,
|
||||
"sender_id": payload.get("FromUserName", ""),
|
||||
"sender_name": payload.get("FromUserName", ""),
|
||||
"message_id": str(payload.get("MsgId", "")),
|
||||
"timestamp": int(payload.get("CreateTime", 0)),
|
||||
"group_id": None,
|
||||
"group_name": None,
|
||||
"raw": payload,
|
||||
}
|
||||
|
||||
def build_reply(self, channel_config: dict, content: str, reply_to: str = None) -> dict:
|
||||
"""构建企业微信回复消息"""
|
||||
webhook_key = channel_config.get("webhook_key", "")
|
||||
return {
|
||||
"api_url": f"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={webhook_key}",
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"body": {"msgtype": "text", "text": {"content": content}},
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
WhatsApp平台适配器
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from ..platform_adapter import register_platform_adapter, BasePlatformAdapter, PlatformMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
name="whatsapp",
|
||||
display_name="WhatsApp",
|
||||
description="WhatsApp Cloud API消息适配器,支持文本、图片、文件、音频、视频、位置等消息类型",
|
||||
support_streaming=False,
|
||||
support_image=True,
|
||||
support_file=True,
|
||||
support_voice=True,
|
||||
)
|
||||
class WhatsAppPlatformAdapter(BasePlatformAdapter):
|
||||
"""WhatsApp平台适配器"""
|
||||
|
||||
def get_metadata(self) -> PlatformMetadata:
|
||||
from ..platform_adapter import platform_registry
|
||||
return platform_registry["whatsapp"]
|
||||
|
||||
def verify_signature(self, payload: dict, signature: str, timestamp: str) -> bool:
|
||||
"""WhatsApp签名验证: HMAC-SHA256(AppSecret, payload)"""
|
||||
if not signature:
|
||||
# WhatsApp可使用 verify_token 进行webhook验证
|
||||
app_secret = self.config.get("app_secret", "")
|
||||
if app_secret:
|
||||
return False
|
||||
return True # 无签名默认通过
|
||||
|
||||
app_secret = self.config.get("app_secret", "")
|
||||
if not app_secret:
|
||||
logger.warning("[WhatsApp] 未配置app_secret,跳过签名验证")
|
||||
return True
|
||||
|
||||
try:
|
||||
# WhatsApp使用 SHA256 HMAC 验证payload
|
||||
body_str = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
expected = "sha256=" + hmac.new(
|
||||
app_secret.encode("utf-8"),
|
||||
body_str.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(expected, signature)
|
||||
except Exception as e:
|
||||
logger.error(f"[WhatsApp] 签名验证异常: {e}")
|
||||
return False
|
||||
|
||||
def parse_webhook(self, payload: dict) -> dict:
|
||||
"""解析WhatsApp Cloud API webhook payload为统一消息格式
|
||||
|
||||
WhatsApp Cloud API格式:
|
||||
{
|
||||
"entry": [{
|
||||
"changes": [{
|
||||
"value": {
|
||||
"messaging_product": "whatsapp",
|
||||
"metadata": {"phone_number_id": "..."},
|
||||
"messages": [{"from": "...", "type": "text", "text": {"body": "..."}}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
"""
|
||||
entries = payload.get("entry", [])
|
||||
|
||||
content = ""
|
||||
content_type = "text"
|
||||
sender_id = ""
|
||||
sender_name = ""
|
||||
message_id = ""
|
||||
timestamp = int(time.time())
|
||||
|
||||
for entry in entries:
|
||||
changes = entry.get("changes", [])
|
||||
for change in changes:
|
||||
value = change.get("value", {})
|
||||
messages = value.get("messages", [])
|
||||
metadata = value.get("metadata", {})
|
||||
phone_number_id = metadata.get("phone_number_id", "")
|
||||
|
||||
for msg in messages:
|
||||
msg_from = msg.get("from", "")
|
||||
msg_type = msg.get("type", "text")
|
||||
msg_id = msg.get("id", "")
|
||||
|
||||
sender_id = msg_from
|
||||
sender_name = msg_from
|
||||
message_id = msg_id
|
||||
timestamp = int(msg.get("timestamp", time.time()))
|
||||
|
||||
if msg_type == "text":
|
||||
body = msg.get("text", {}).get("body", "")
|
||||
content = body
|
||||
content_type = "text"
|
||||
elif msg_type == "image":
|
||||
image = msg.get("image", {})
|
||||
content = f"[图片] id={image.get('id', '')} caption={image.get('caption', '')}"
|
||||
content_type = "image"
|
||||
elif msg_type == "document":
|
||||
doc = msg.get("document", {})
|
||||
content = f"[文件] {doc.get('filename', 'unknown')} id={doc.get('id', '')}"
|
||||
content_type = "file"
|
||||
elif msg_type == "audio":
|
||||
audio = msg.get("audio", {})
|
||||
content = f"[语音] id={audio.get('id', '')}"
|
||||
content_type = "voice"
|
||||
elif msg_type == "video":
|
||||
video = msg.get("video", {})
|
||||
content = f"[视频] id={video.get('id', '')}"
|
||||
content_type = "video"
|
||||
elif msg_type == "location":
|
||||
loc = msg.get("location", {})
|
||||
content = f"[位置] lat={loc.get('latitude')} lng={loc.get('longitude')}"
|
||||
content_type = "location"
|
||||
elif msg_type == "contacts":
|
||||
contacts = msg.get("contacts", [])
|
||||
names = [c.get("name", {}).get("formatted_name", "") for c in contacts]
|
||||
content = f"[联系人] {', '.join(names)}"
|
||||
content_type = "contacts"
|
||||
elif msg_type == "button":
|
||||
button = msg.get("button", {})
|
||||
content = f"[按钮] text={button.get('text', '')} payload={button.get('payload', '')}"
|
||||
content_type = "button"
|
||||
elif msg_type == "interactive":
|
||||
interactive = msg.get("interactive", {})
|
||||
list_reply = interactive.get("list_reply", {})
|
||||
button_reply = interactive.get("button_reply", {})
|
||||
if list_reply:
|
||||
content = f"[列表回复] {list_reply.get('title', '')}: {list_reply.get('id', '')}"
|
||||
elif button_reply:
|
||||
content = f"[按钮回复] {button_reply.get('title', '')}: {button_reply.get('id', '')}"
|
||||
else:
|
||||
content = f"[交互] {json.dumps(interactive)}"
|
||||
content_type = "interactive"
|
||||
elif msg_type == "sticker":
|
||||
sticker = msg.get("sticker", {})
|
||||
content = f"[贴纸] id={sticker.get('id', '')}"
|
||||
content_type = "sticker"
|
||||
elif msg_type == "reaction":
|
||||
reaction = msg.get("reaction", {})
|
||||
content = f"[反应] emoji={reaction.get('emoji', '')}"
|
||||
content_type = "reaction"
|
||||
else:
|
||||
content = json.dumps(msg, ensure_ascii=False)
|
||||
content_type = msg_type
|
||||
|
||||
if not content:
|
||||
# 可能是状态更新(无消息内容)
|
||||
content = json.dumps(payload, ensure_ascii=False)
|
||||
content_type = "status"
|
||||
|
||||
return {
|
||||
"content_type": content_type,
|
||||
"content": content,
|
||||
"sender_id": sender_id,
|
||||
"sender_name": sender_name,
|
||||
"message_id": message_id,
|
||||
"timestamp": timestamp,
|
||||
"group_id": None, # WhatsApp主要是私聊
|
||||
"group_name": None,
|
||||
"raw": payload,
|
||||
}
|
||||
|
||||
def build_reply(self, channel_config: dict, content: str, reply_to: str = None) -> dict:
|
||||
"""构建WhatsApp回复消息"""
|
||||
phone_number_id = channel_config.get("phone_number_id", "")
|
||||
access_token = channel_config.get("access_token", "")
|
||||
recipient = channel_config.get("recipient_id", "")
|
||||
|
||||
if not phone_number_id or not access_token:
|
||||
logger.warning("[WhatsApp] 未配置phone_number_id或access_token,无法构建回复")
|
||||
return {}
|
||||
|
||||
body = {
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": recipient,
|
||||
"type": "text",
|
||||
"text": {"preview_url": False, "body": content},
|
||||
}
|
||||
|
||||
return {
|
||||
"api_url": f"https://graph.facebook.com/v18.0/{phone_number_id}/messages",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
},
|
||||
"body": body,
|
||||
}
|
||||
Reference in new issue
Block a user