mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(wecom_bot): add callback (webhook) mode alongside long connection
Support receiving WeCom smart-bot messages via encrypted HTTP callback in addition to the existing WebSocket long connection. Disabled by default (wecom_bot_callback=false). - Add wecom_bot_callback / wecom_bot_token / wecom_bot_encoding_aes_key / wecom_bot_port config keys - Add WXBizJsonMsgCrypt-based crypto module for URL verification, callback decryption and passive-reply encryption (receive_id empty for internal bots) - Reply asynchronously via the official stream-refresh polling: register a stream id on first reply, accumulate agent output into per-stream state, and serve the latest content (text + image) on each poll until finish - Fall back to EncodingAESKey for media decryption when callback bodies carry no per-message aeskey - Degrade unsupported passive replies (file/voice/video) to a text notice - Expose the new fields in the Web console channel config Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3016,6 +3016,10 @@ class ChannelsHandler:
|
|||||||
"fields": [
|
"fields": [
|
||||||
{"key": "wecom_bot_id", "label": "Bot ID", "type": "text"},
|
{"key": "wecom_bot_id", "label": "Bot ID", "type": "text"},
|
||||||
{"key": "wecom_bot_secret", "label": "Secret", "type": "secret"},
|
{"key": "wecom_bot_secret", "label": "Secret", "type": "secret"},
|
||||||
|
{"key": "wecom_bot_callback", "label": "Callback Mode", "type": "bool", "default": False},
|
||||||
|
{"key": "wecom_bot_token", "label": "Token", "type": "secret"},
|
||||||
|
{"key": "wecom_bot_encoding_aes_key", "label": "EncodingAESKey", "type": "secret"},
|
||||||
|
{"key": "wecom_bot_port", "label": "Port", "type": "number", "default": 9892},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
("qq", {
|
("qq", {
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import web
|
||||||
import websocket
|
import websocket
|
||||||
|
|
||||||
from bridge.context import Context, ContextType
|
from bridge.context import Context, ContextType
|
||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
from channel.chat_channel import ChatChannel, check_prefix
|
from channel.chat_channel import ChatChannel, check_prefix
|
||||||
|
from channel.wecom_bot.wecom_bot_crypt import WecomBotCrypt
|
||||||
from channel.wecom_bot.wecom_bot_message import WecomBotMessage
|
from channel.wecom_bot.wecom_bot_message import WecomBotMessage
|
||||||
from common.expired_dict import ExpiredDict
|
from common.expired_dict import ExpiredDict
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
@@ -97,6 +99,14 @@ class WecomBotChannel(ChatChannel):
|
|||||||
self._pending_lock = threading.Lock()
|
self._pending_lock = threading.Lock()
|
||||||
self._stream_states = {} # req_id -> {"stream_id": str, "content": str}
|
self._stream_states = {} # req_id -> {"stream_id": str, "content": str}
|
||||||
|
|
||||||
|
# Callback (webhook) mode state
|
||||||
|
self.callback_mode = False
|
||||||
|
self._crypt = None
|
||||||
|
self._http_server = None
|
||||||
|
# stream_id -> {"committed", "current", "finished", "images", "last_access"}
|
||||||
|
self._callback_streams = ExpiredDict(60 * 10) # auto-expire after 10min (max poll window is 6min)
|
||||||
|
self._callback_lock = threading.Lock()
|
||||||
|
|
||||||
conf()["group_name_white_list"] = ["ALL_GROUP"]
|
conf()["group_name_white_list"] = ["ALL_GROUP"]
|
||||||
conf()["single_chat_prefix"] = [""]
|
conf()["single_chat_prefix"] = [""]
|
||||||
|
|
||||||
@@ -105,6 +115,11 @@ class WecomBotChannel(ChatChannel):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def startup(self):
|
def startup(self):
|
||||||
|
self.callback_mode = bool(conf().get("wecom_bot_callback", False))
|
||||||
|
if self.callback_mode:
|
||||||
|
self._startup_callback()
|
||||||
|
return
|
||||||
|
|
||||||
self.bot_id = conf().get("wecom_bot_id", "")
|
self.bot_id = conf().get("wecom_bot_id", "")
|
||||||
self.bot_secret = conf().get("wecom_bot_secret", "")
|
self.bot_secret = conf().get("wecom_bot_secret", "")
|
||||||
|
|
||||||
@@ -127,6 +142,13 @@ class WecomBotChannel(ChatChannel):
|
|||||||
pass
|
pass
|
||||||
self._ws = None
|
self._ws = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
if self._http_server:
|
||||||
|
try:
|
||||||
|
self._http_server.stop()
|
||||||
|
logger.info("[WecomBot] Callback HTTP server stopped")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[WecomBot] Error stopping HTTP server: {e}")
|
||||||
|
self._http_server = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# WebSocket connection
|
# WebSocket connection
|
||||||
@@ -183,6 +205,158 @@ class WecomBotChannel(ChatChannel):
|
|||||||
def _gen_req_id(self) -> str:
|
def _gen_req_id(self) -> str:
|
||||||
return uuid.uuid4().hex[:16]
|
return uuid.uuid4().hex[:16]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Callback (webhook) mode
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _startup_callback(self):
|
||||||
|
"""Start an HTTP server that receives encrypted callbacks (webhook mode).
|
||||||
|
|
||||||
|
The bot's "接收消息" URL in the WeCom admin console should point at this
|
||||||
|
server (any path is accepted). Verification (GET) and message delivery
|
||||||
|
(POST) are both handled by ``WecomBotCallbackController``.
|
||||||
|
"""
|
||||||
|
token = conf().get("wecom_bot_token", "")
|
||||||
|
aes_key = conf().get("wecom_bot_encoding_aes_key", "")
|
||||||
|
if not token or not aes_key:
|
||||||
|
err = "[WecomBot] callback mode requires wecom_bot_token and wecom_bot_encoding_aes_key"
|
||||||
|
logger.error(err)
|
||||||
|
self.report_startup_error(err)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Enterprise-internal smart bot: receive_id is an empty string.
|
||||||
|
self._crypt = WecomBotCrypt(token, aes_key, "")
|
||||||
|
except Exception as e:
|
||||||
|
err = f"[WecomBot] invalid callback credentials: {e}"
|
||||||
|
logger.error(err)
|
||||||
|
self.report_startup_error(err)
|
||||||
|
return
|
||||||
|
|
||||||
|
port = int(conf().get("wecom_bot_port", 9892))
|
||||||
|
logger.info(f"[WecomBot] Starting callback (webhook) server on port {port}...")
|
||||||
|
urls = ("/.*", "channel.wecom_bot.wecom_bot_channel.WecomBotCallbackController")
|
||||||
|
app = web.application(urls, globals(), autoreload=False)
|
||||||
|
func = web.httpserver.StaticMiddleware(app.wsgifunc())
|
||||||
|
func = web.httpserver.LogMiddleware(func)
|
||||||
|
server = web.httpserver.WSGIServer(("0.0.0.0", port), func)
|
||||||
|
self._http_server = server
|
||||||
|
self.report_startup_success()
|
||||||
|
try:
|
||||||
|
server.start()
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
server.stop()
|
||||||
|
|
||||||
|
def _new_callback_stream(self) -> str:
|
||||||
|
"""Create a new stream state and return its id."""
|
||||||
|
stream_id = uuid.uuid4().hex[:16]
|
||||||
|
with self._callback_lock:
|
||||||
|
self._callback_streams[stream_id] = {
|
||||||
|
"committed": "",
|
||||||
|
"current": "",
|
||||||
|
"finished": False,
|
||||||
|
"images": [], # list of (base64_str, md5_str), flushed only at finish
|
||||||
|
"last_access": time.time(),
|
||||||
|
}
|
||||||
|
return stream_id
|
||||||
|
|
||||||
|
def _callback_handle_message(self, data: dict) -> dict:
|
||||||
|
"""Handle a freshly-received user message in callback mode.
|
||||||
|
|
||||||
|
Produces the context for async processing and returns the initial passive
|
||||||
|
reply (a stream packet with finish=false) so WeCom starts polling for the
|
||||||
|
agent's streamed answer. Returns ``None`` when there's nothing to reply
|
||||||
|
(e.g. an image/file silently cached for the next query).
|
||||||
|
"""
|
||||||
|
msg_id = data.get("msgid", "")
|
||||||
|
if msg_id and self.received_msgs.get(msg_id):
|
||||||
|
logger.debug(f"[WecomBot] Duplicate msg filtered: {msg_id}")
|
||||||
|
return None
|
||||||
|
if msg_id:
|
||||||
|
self.received_msgs[msg_id] = True
|
||||||
|
|
||||||
|
chattype = data.get("chattype", "single")
|
||||||
|
is_group = chattype == "group"
|
||||||
|
|
||||||
|
default_aeskey = conf().get("wecom_bot_encoding_aes_key", "")
|
||||||
|
result = self._build_context(data, is_group, default_aeskey=default_aeskey)
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
context, wecom_msg = result
|
||||||
|
|
||||||
|
stream_id = self._new_callback_stream()
|
||||||
|
wecom_msg.stream_id = stream_id
|
||||||
|
context["wecom_stream_id"] = stream_id
|
||||||
|
context["on_event"] = self._make_callback_stream_callback(stream_id)
|
||||||
|
self.produce(context)
|
||||||
|
|
||||||
|
# First passive reply: register the stream id, WeCom will poll for updates.
|
||||||
|
return {
|
||||||
|
"msgtype": "stream",
|
||||||
|
"stream": {"id": stream_id, "finish": False, "content": ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _callback_handle_stream_poll(self, data: dict) -> dict:
|
||||||
|
"""Handle a "流式消息刷新" poll: return the latest accumulated content."""
|
||||||
|
stream_id = data.get("stream", {}).get("id", "")
|
||||||
|
with self._callback_lock:
|
||||||
|
state = self._callback_streams.get(stream_id)
|
||||||
|
if state is None:
|
||||||
|
# Unknown / expired stream: tell WeCom we're done to stop polling.
|
||||||
|
return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
|
||||||
|
state["last_access"] = time.time()
|
||||||
|
content = state["committed"] + state["current"]
|
||||||
|
finished = state["finished"]
|
||||||
|
images = state["images"] if finished else []
|
||||||
|
|
||||||
|
stream = {"id": stream_id, "finish": finished, "content": content}
|
||||||
|
if images:
|
||||||
|
stream["msg_item"] = [
|
||||||
|
{"msgtype": "image", "image": {"base64": b64, "md5": md5}}
|
||||||
|
for (b64, md5) in images
|
||||||
|
]
|
||||||
|
return {"msgtype": "stream", "stream": stream}
|
||||||
|
|
||||||
|
def _make_callback_stream_callback(self, stream_id: str):
|
||||||
|
"""Build an on_event callback that accumulates agent output into stream state.
|
||||||
|
|
||||||
|
Mirrors the websocket streaming behaviour: intermediate turns (text before
|
||||||
|
a tool call) are committed with a '---' separator; WeCom reads the full
|
||||||
|
accumulated content on each poll.
|
||||||
|
"""
|
||||||
|
def on_event(event: dict):
|
||||||
|
event_type = event.get("type")
|
||||||
|
edata = event.get("data", {})
|
||||||
|
with self._callback_lock:
|
||||||
|
state = self._callback_streams.get(stream_id)
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "turn_start":
|
||||||
|
state["current"] = ""
|
||||||
|
elif event_type == "message_update":
|
||||||
|
delta = edata.get("delta", "")
|
||||||
|
if delta:
|
||||||
|
state["current"] += delta
|
||||||
|
elif event_type == "message_end":
|
||||||
|
tool_calls = edata.get("tool_calls", [])
|
||||||
|
if tool_calls:
|
||||||
|
if state["current"].strip():
|
||||||
|
state["committed"] += state["current"].strip() + "\n\n---\n\n"
|
||||||
|
state["current"] = ""
|
||||||
|
else:
|
||||||
|
state["committed"] += state["current"]
|
||||||
|
state["current"] = ""
|
||||||
|
elif event_type == "agent_cancelled":
|
||||||
|
if state["current"]:
|
||||||
|
state["committed"] += state["current"]
|
||||||
|
state["current"] = ""
|
||||||
|
state["committed"] = state["committed"].rstrip()
|
||||||
|
if state["committed"].endswith("---"):
|
||||||
|
state["committed"] = state["committed"][:-3].rstrip()
|
||||||
|
|
||||||
|
return on_event
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Subscribe & heartbeat
|
# Subscribe & heartbeat
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -287,16 +461,31 @@ class WecomBotChannel(ChatChannel):
|
|||||||
chattype = body.get("chattype", "single")
|
chattype = body.get("chattype", "single")
|
||||||
is_group = chattype == "group"
|
is_group = chattype == "group"
|
||||||
|
|
||||||
|
result = self._build_context(body, is_group)
|
||||||
|
if not result:
|
||||||
|
return
|
||||||
|
context, wecom_msg = result
|
||||||
|
wecom_msg.req_id = req_id
|
||||||
|
if req_id:
|
||||||
|
context["on_event"] = self._make_stream_callback(req_id)
|
||||||
|
self.produce(context)
|
||||||
|
|
||||||
|
def _build_context(self, body: dict, is_group: bool, default_aeskey: str = ""):
|
||||||
|
"""Parse a wecom message body into a Context, applying file-cache logic.
|
||||||
|
|
||||||
|
Shared by both the websocket (long-connection) and callback (webhook)
|
||||||
|
receive paths. Returns ``(context, wecom_msg)`` when the message should be
|
||||||
|
handed to the agent, or ``None`` when it was consumed (cached image/file,
|
||||||
|
parse failure, etc.).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
wecom_msg = WecomBotMessage(body, is_group=is_group)
|
wecom_msg = WecomBotMessage(body, is_group=is_group, default_aeskey=default_aeskey)
|
||||||
except NotImplementedError as e:
|
except NotImplementedError as e:
|
||||||
logger.warning(f"[WecomBot] {e}")
|
logger.warning(f"[WecomBot] {e}")
|
||||||
return
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True)
|
logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True)
|
||||||
return
|
return None
|
||||||
|
|
||||||
wecom_msg.req_id = req_id
|
|
||||||
|
|
||||||
# File cache logic (same pattern as feishu)
|
# File cache logic (same pattern as feishu)
|
||||||
from channel.file_cache import get_file_cache
|
from channel.file_cache import get_file_cache
|
||||||
@@ -314,13 +503,13 @@ class WecomBotChannel(ChatChannel):
|
|||||||
if hasattr(wecom_msg, "image_path") and wecom_msg.image_path:
|
if hasattr(wecom_msg, "image_path") and wecom_msg.image_path:
|
||||||
file_cache.add(session_id, wecom_msg.image_path, file_type="image")
|
file_cache.add(session_id, wecom_msg.image_path, file_type="image")
|
||||||
logger.info(f"[WecomBot] Image cached for session {session_id}")
|
logger.info(f"[WecomBot] Image cached for session {session_id}")
|
||||||
return
|
return None
|
||||||
|
|
||||||
if wecom_msg.ctype == ContextType.FILE:
|
if wecom_msg.ctype == ContextType.FILE:
|
||||||
wecom_msg.prepare()
|
wecom_msg.prepare()
|
||||||
file_cache.add(session_id, wecom_msg.content, file_type="file")
|
file_cache.add(session_id, wecom_msg.content, file_type="file")
|
||||||
logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}")
|
logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}")
|
||||||
return
|
return None
|
||||||
|
|
||||||
if wecom_msg.ctype == ContextType.TEXT:
|
if wecom_msg.ctype == ContextType.TEXT:
|
||||||
cached_files = file_cache.get(session_id)
|
cached_files = file_cache.get(session_id)
|
||||||
@@ -346,10 +535,9 @@ class WecomBotChannel(ChatChannel):
|
|||||||
msg=wecom_msg,
|
msg=wecom_msg,
|
||||||
no_need_at=True,
|
no_need_at=True,
|
||||||
)
|
)
|
||||||
if context:
|
if not context:
|
||||||
if req_id:
|
return None
|
||||||
context["on_event"] = self._make_stream_callback(req_id)
|
return context, wecom_msg
|
||||||
self.produce(context)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Event callback
|
# Event callback
|
||||||
@@ -490,11 +678,121 @@ class WecomBotChannel(ChatChannel):
|
|||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Callback (webhook) send: write the final reply into the stream state
|
||||||
|
# so the next "流式消息刷新" poll returns it with finish=true.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _callback_send(self, reply: Reply, context: Context):
|
||||||
|
msg = context.get("msg")
|
||||||
|
stream_id = getattr(msg, "stream_id", None) if msg else None
|
||||||
|
if not stream_id:
|
||||||
|
stream_id = context.get("wecom_stream_id")
|
||||||
|
if not stream_id:
|
||||||
|
logger.warning("[WecomBot] callback send without stream_id, dropping reply")
|
||||||
|
return
|
||||||
|
|
||||||
|
if reply.type == ReplyType.TEXT:
|
||||||
|
self._callback_finalize_text(stream_id, reply.content)
|
||||||
|
elif reply.type in (ReplyType.IMAGE_URL, ReplyType.IMAGE):
|
||||||
|
self._callback_finalize_image(stream_id, reply.content)
|
||||||
|
elif reply.type == ReplyType.FILE:
|
||||||
|
# Passive callback replies only support text + image (base64); files
|
||||||
|
# are not supported by the protocol, so degrade to a text notice.
|
||||||
|
text = getattr(reply, "text_content", "") or ""
|
||||||
|
note = (text + "\n" if text else "") + "[文件回复在回调模式下暂不支持]"
|
||||||
|
self._callback_finalize_text(stream_id, note)
|
||||||
|
elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL, ReplyType.VOICE):
|
||||||
|
logger.warning(f"[WecomBot] reply type {reply.type} not supported in callback mode")
|
||||||
|
self._callback_finalize_text(stream_id, "[该消息类型在回调模式下暂不支持]")
|
||||||
|
else:
|
||||||
|
self._callback_finalize_text(stream_id, str(reply.content))
|
||||||
|
|
||||||
|
def _callback_get_or_create_state(self, stream_id: str) -> dict:
|
||||||
|
state = self._callback_streams.get(stream_id)
|
||||||
|
if state is None:
|
||||||
|
state = {
|
||||||
|
"committed": "",
|
||||||
|
"current": "",
|
||||||
|
"finished": False,
|
||||||
|
"images": [],
|
||||||
|
"last_access": time.time(),
|
||||||
|
}
|
||||||
|
self._callback_streams[stream_id] = state
|
||||||
|
return state
|
||||||
|
|
||||||
|
def _callback_finalize_text(self, stream_id: str, content: str):
|
||||||
|
with self._callback_lock:
|
||||||
|
state = self._callback_get_or_create_state(stream_id)
|
||||||
|
accumulated = (state["committed"] + state["current"]).strip()
|
||||||
|
state["committed"] = accumulated if accumulated else (content or "")
|
||||||
|
state["current"] = ""
|
||||||
|
state["finished"] = True
|
||||||
|
state["last_access"] = time.time()
|
||||||
|
|
||||||
|
def _callback_finalize_image(self, stream_id: str, img_path_or_url: str):
|
||||||
|
b64md5 = self._load_image_base64(img_path_or_url)
|
||||||
|
with self._callback_lock:
|
||||||
|
state = self._callback_get_or_create_state(stream_id)
|
||||||
|
accumulated = (state["committed"] + state["current"]).strip()
|
||||||
|
state["current"] = ""
|
||||||
|
if b64md5:
|
||||||
|
state["images"].append(b64md5)
|
||||||
|
state["committed"] = accumulated
|
||||||
|
else:
|
||||||
|
state["committed"] = accumulated or "[图片发送失败]"
|
||||||
|
state["finished"] = True
|
||||||
|
state["last_access"] = time.time()
|
||||||
|
|
||||||
|
def _load_image_base64(self, img_path_or_url: str):
|
||||||
|
"""Load a local/remote image, ensure JPG/PNG within 10MB, return (base64, md5)."""
|
||||||
|
local_path = img_path_or_url
|
||||||
|
if local_path.startswith("file://"):
|
||||||
|
local_path = local_path[7:]
|
||||||
|
|
||||||
|
if local_path.startswith(("http://", "https://")):
|
||||||
|
try:
|
||||||
|
resp = requests.get(local_path, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
tmp_path = f"/tmp/wecom_cb_img_{uuid.uuid4().hex[:8]}"
|
||||||
|
with open(tmp_path, "wb") as f:
|
||||||
|
f.write(resp.content)
|
||||||
|
local_path = tmp_path
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] Failed to download image for callback reply: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not os.path.exists(local_path):
|
||||||
|
logger.error(f"[WecomBot] Image file not found: {local_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
local_path = self._ensure_image_format(local_path)
|
||||||
|
if not local_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
max_image_size = 10 * 1024 * 1024 # callback image base64 input max is 10MB
|
||||||
|
if os.path.getsize(local_path) > max_image_size:
|
||||||
|
local_path = self._compress_image(local_path, max_image_size)
|
||||||
|
if not local_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(local_path, "rb") as f:
|
||||||
|
raw = f.read()
|
||||||
|
return base64.b64encode(raw).decode("utf-8"), hashlib.md5(raw).hexdigest()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] Failed to read image for callback reply: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Send reply
|
# Send reply
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def send(self, reply: Reply, context: Context):
|
def send(self, reply: Reply, context: Context):
|
||||||
|
if self.callback_mode:
|
||||||
|
self._callback_send(reply, context)
|
||||||
|
return
|
||||||
|
|
||||||
msg = context.get("msg")
|
msg = context.get("msg")
|
||||||
is_group = context.get("isgroup", False)
|
is_group = context.get("isgroup", False)
|
||||||
receiver = context.get("receiver", "")
|
receiver = context.get("receiver", "")
|
||||||
@@ -906,3 +1204,82 @@ class WecomBotChannel(ChatChannel):
|
|||||||
else:
|
else:
|
||||||
logger.error("[WecomBot] Failed to get media_id from finish response")
|
logger.error("[WecomBot] Failed to get media_id from finish response")
|
||||||
return media_id
|
return media_id
|
||||||
|
|
||||||
|
|
||||||
|
class WecomBotCallbackController:
|
||||||
|
"""HTTP controller for wecom bot callback (webhook) mode.
|
||||||
|
|
||||||
|
- GET : URL verification (echo the decrypted echostr).
|
||||||
|
- POST : encrypted message / stream-refresh / event callbacks; returns an
|
||||||
|
encrypted passive reply (or "success" for an empty reply).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _channel() -> "WecomBotChannel":
|
||||||
|
return WecomBotChannel()
|
||||||
|
|
||||||
|
def GET(self):
|
||||||
|
channel = self._channel()
|
||||||
|
params = web.input(msg_signature="", timestamp="", nonce="", echostr="")
|
||||||
|
if not channel._crypt:
|
||||||
|
return "wecom bot callback not ready"
|
||||||
|
ret, echo = channel._crypt.verify_url(
|
||||||
|
params.msg_signature, params.timestamp, params.nonce, params.echostr
|
||||||
|
)
|
||||||
|
if ret != 0:
|
||||||
|
logger.error(f"[WecomBot] URL verify failed: ret={ret}")
|
||||||
|
return "verify fail"
|
||||||
|
if isinstance(echo, bytes):
|
||||||
|
echo = echo.decode("utf-8")
|
||||||
|
return echo
|
||||||
|
|
||||||
|
def POST(self):
|
||||||
|
channel = self._channel()
|
||||||
|
if not channel._crypt:
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
params = web.input(msg_signature="", timestamp="", nonce="")
|
||||||
|
body = web.data()
|
||||||
|
ret, plain = channel._crypt.decrypt_msg(
|
||||||
|
body, params.msg_signature, params.timestamp, params.nonce
|
||||||
|
)
|
||||||
|
if ret != 0:
|
||||||
|
logger.error(f"[WecomBot] callback decrypt failed: ret={ret}")
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(plain)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] callback json parse failed: {e}")
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
msgtype = data.get("msgtype", "")
|
||||||
|
logger.debug(f"[WecomBot] callback received msgtype={msgtype}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if msgtype == "stream":
|
||||||
|
reply = channel._callback_handle_stream_poll(data)
|
||||||
|
elif msgtype == "event":
|
||||||
|
event_type = data.get("event", {}).get("eventtype", "")
|
||||||
|
logger.info(f"[WecomBot] callback event: {event_type}")
|
||||||
|
reply = None
|
||||||
|
elif msgtype in ("text", "image", "voice", "file", "video", "mixed"):
|
||||||
|
reply = channel._callback_handle_message(data)
|
||||||
|
else:
|
||||||
|
logger.warning(f"[WecomBot] unsupported callback msgtype: {msgtype}")
|
||||||
|
reply = None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] callback handling error: {e}", exc_info=True)
|
||||||
|
reply = None
|
||||||
|
|
||||||
|
if not reply:
|
||||||
|
# Empty reply package is acceptable.
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
plain_reply = json.dumps(reply, ensure_ascii=False)
|
||||||
|
ret, enc = channel._crypt.encrypt_msg(plain_reply, params.nonce, params.timestamp)
|
||||||
|
if ret != 0:
|
||||||
|
logger.error(f"[WecomBot] callback encrypt failed: ret={ret}")
|
||||||
|
return "success"
|
||||||
|
web.header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
return json.dumps(enc, ensure_ascii=False)
|
||||||
|
|||||||
203
channel/wecom_bot/wecom_bot_crypt.py
Normal file
203
channel/wecom_bot/wecom_bot_crypt.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
WeCom (企业微信) smart-bot callback message encryption/decryption.
|
||||||
|
|
||||||
|
Adapted from the official `WXBizJsonMsgCrypt` sample (JSON variant) used by the
|
||||||
|
AI bot callback (webhook) mode. The bot's receive-message callback delivers
|
||||||
|
AES-256-CBC encrypted JSON payloads, and passive replies must be encrypted the
|
||||||
|
same way before being returned in the HTTP response.
|
||||||
|
|
||||||
|
For an enterprise-internal smart bot, ``receive_id`` is always an empty string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
# Error codes (mirrors the official ierror.py)
|
||||||
|
WXBizMsgCrypt_OK = 0
|
||||||
|
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||||
|
WXBizMsgCrypt_ParseJson_Error = -40002
|
||||||
|
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||||
|
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||||
|
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||||
|
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||||
|
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||||
|
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||||
|
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||||
|
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||||
|
WXBizMsgCrypt_GenReturnJson_Error = -40011
|
||||||
|
|
||||||
|
|
||||||
|
class FormatException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_sha1(token, timestamp, nonce, encrypt):
|
||||||
|
"""Compute the WeCom message signature with SHA1 over the sorted parts."""
|
||||||
|
try:
|
||||||
|
if isinstance(encrypt, bytes):
|
||||||
|
encrypt = encrypt.decode("utf-8")
|
||||||
|
sortlist = [str(token), str(timestamp), str(nonce), str(encrypt)]
|
||||||
|
sortlist.sort()
|
||||||
|
sha = hashlib.sha1()
|
||||||
|
sha.update("".join(sortlist).encode("utf-8"))
|
||||||
|
return WXBizMsgCrypt_OK, sha.hexdigest()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] compute signature error: {e}")
|
||||||
|
return WXBizMsgCrypt_ComputeSignature_Error, None
|
||||||
|
|
||||||
|
|
||||||
|
class _PKCS7Encoder:
|
||||||
|
"""PKCS#7 padding with a 32-byte block size (AES-256)."""
|
||||||
|
|
||||||
|
block_size = 32
|
||||||
|
|
||||||
|
def encode(self, text: bytes) -> bytes:
|
||||||
|
text_length = len(text)
|
||||||
|
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||||
|
if amount_to_pad == 0:
|
||||||
|
amount_to_pad = self.block_size
|
||||||
|
pad = bytes([amount_to_pad])
|
||||||
|
return text + pad * amount_to_pad
|
||||||
|
|
||||||
|
def decode(self, decrypted: bytes) -> bytes:
|
||||||
|
pad = decrypted[-1]
|
||||||
|
if pad < 1 or pad > 32:
|
||||||
|
pad = 0
|
||||||
|
return decrypted[:-pad] if pad else decrypted
|
||||||
|
|
||||||
|
|
||||||
|
class _Prpcrypt:
|
||||||
|
"""AES-256-CBC encrypt/decrypt for WeCom callback messages."""
|
||||||
|
|
||||||
|
def __init__(self, key: bytes):
|
||||||
|
self.key = key
|
||||||
|
self.mode = AES.MODE_CBC
|
||||||
|
|
||||||
|
def encrypt(self, text: str, receive_id: str):
|
||||||
|
text_bytes = text.encode()
|
||||||
|
# 16-byte random prefix + network-order length + body + receive_id
|
||||||
|
text_bytes = (
|
||||||
|
self._get_random_str()
|
||||||
|
+ struct.pack("I", socket.htonl(len(text_bytes)))
|
||||||
|
+ text_bytes
|
||||||
|
+ receive_id.encode()
|
||||||
|
)
|
||||||
|
text_bytes = _PKCS7Encoder().encode(text_bytes)
|
||||||
|
try:
|
||||||
|
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||||
|
ciphertext = cryptor.encrypt(text_bytes)
|
||||||
|
return WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] AES encrypt error: {e}")
|
||||||
|
return WXBizMsgCrypt_EncryptAES_Error, None
|
||||||
|
|
||||||
|
def decrypt(self, text, receive_id: str):
|
||||||
|
try:
|
||||||
|
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||||
|
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] AES decrypt error: {e}")
|
||||||
|
return WXBizMsgCrypt_DecryptAES_Error, None
|
||||||
|
try:
|
||||||
|
pad = plain_text[-1]
|
||||||
|
content = plain_text[16:-pad]
|
||||||
|
json_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
||||||
|
json_content = content[4 : json_len + 4].decode("utf-8")
|
||||||
|
from_receive_id = content[json_len + 4 :].decode("utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WecomBot] illegal buffer when decrypting: {e}")
|
||||||
|
return WXBizMsgCrypt_IllegalBuffer, None
|
||||||
|
if from_receive_id != receive_id:
|
||||||
|
logger.error(
|
||||||
|
f"[WecomBot] receive_id not match: expect={receive_id}, got={from_receive_id}"
|
||||||
|
)
|
||||||
|
return WXBizMsgCrypt_ValidateCorpid_Error, None
|
||||||
|
return WXBizMsgCrypt_OK, json_content
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_random_str() -> bytes:
|
||||||
|
return str(random.randint(1000000000000000, 9999999999999999)).encode()
|
||||||
|
|
||||||
|
|
||||||
|
class WecomBotCrypt:
|
||||||
|
"""High-level helper for verifying URLs and (de)crypting callback messages."""
|
||||||
|
|
||||||
|
def __init__(self, token: str, encoding_aes_key: str, receive_id: str = ""):
|
||||||
|
try:
|
||||||
|
self.key = base64.b64decode(encoding_aes_key + "=")
|
||||||
|
assert len(self.key) == 32
|
||||||
|
except Exception:
|
||||||
|
raise FormatException("[WecomBot] invalid EncodingAESKey")
|
||||||
|
self.token = token
|
||||||
|
self.receive_id = receive_id
|
||||||
|
|
||||||
|
def verify_url(self, msg_signature, timestamp, nonce, echostr):
|
||||||
|
ret, signature = _gen_sha1(self.token, timestamp, nonce, echostr)
|
||||||
|
if ret != 0:
|
||||||
|
return ret, None
|
||||||
|
if signature != msg_signature:
|
||||||
|
return WXBizMsgCrypt_ValidateSignature_Error, None
|
||||||
|
pc = _Prpcrypt(self.key)
|
||||||
|
return pc.decrypt(echostr, self.receive_id)
|
||||||
|
|
||||||
|
def encrypt_msg(self, reply_msg: str, nonce: str, timestamp: str = None):
|
||||||
|
"""Encrypt a passive-reply JSON string and return the full response JSON.
|
||||||
|
|
||||||
|
Returns (ret, response_dict). On success ret==0 and response_dict is a
|
||||||
|
dict with encrypt/msgsignature/timestamp/nonce fields.
|
||||||
|
"""
|
||||||
|
pc = _Prpcrypt(self.key)
|
||||||
|
ret, encrypt = pc.encrypt(reply_msg, self.receive_id)
|
||||||
|
if ret != 0:
|
||||||
|
return ret, None
|
||||||
|
encrypt = encrypt.decode("utf-8")
|
||||||
|
if timestamp is None:
|
||||||
|
timestamp = str(int(time.time()))
|
||||||
|
ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt)
|
||||||
|
if ret != 0:
|
||||||
|
return ret, None
|
||||||
|
return WXBizMsgCrypt_OK, {
|
||||||
|
"encrypt": encrypt,
|
||||||
|
"msgsignature": signature,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"nonce": nonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
def decrypt_msg(self, post_data, msg_signature, timestamp, nonce):
|
||||||
|
"""Verify signature and decrypt the encrypted callback payload.
|
||||||
|
|
||||||
|
``post_data`` may be the raw request body (bytes/str) containing
|
||||||
|
``{"encrypt": "..."}`` or the already-extracted encrypt string.
|
||||||
|
Returns (ret, plaintext_json_str).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
encrypt = None
|
||||||
|
if isinstance(post_data, (bytes, bytearray)):
|
||||||
|
post_data = post_data.decode("utf-8")
|
||||||
|
if isinstance(post_data, str):
|
||||||
|
try:
|
||||||
|
encrypt = json.loads(post_data).get("encrypt")
|
||||||
|
except Exception:
|
||||||
|
encrypt = post_data
|
||||||
|
elif isinstance(post_data, dict):
|
||||||
|
encrypt = post_data.get("encrypt")
|
||||||
|
if not encrypt:
|
||||||
|
return WXBizMsgCrypt_ParseJson_Error, None
|
||||||
|
|
||||||
|
ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt)
|
||||||
|
if ret != 0:
|
||||||
|
return ret, None
|
||||||
|
if signature != msg_signature:
|
||||||
|
logger.error("[WecomBot] callback signature not match")
|
||||||
|
return WXBizMsgCrypt_ValidateSignature_Error, None
|
||||||
|
pc = _Prpcrypt(self.key)
|
||||||
|
return pc.decrypt(encrypt, self.receive_id)
|
||||||
@@ -87,11 +87,14 @@ def _get_tmp_dir() -> str:
|
|||||||
class WecomBotMessage(ChatMessage):
|
class WecomBotMessage(ChatMessage):
|
||||||
"""Message wrapper for wecom bot (websocket long-connection mode)."""
|
"""Message wrapper for wecom bot (websocket long-connection mode)."""
|
||||||
|
|
||||||
def __init__(self, msg_body: dict, is_group: bool = False):
|
def __init__(self, msg_body: dict, is_group: bool = False, default_aeskey: str = ""):
|
||||||
super().__init__(msg_body)
|
super().__init__(msg_body)
|
||||||
self.msg_id = msg_body.get("msgid")
|
self.msg_id = msg_body.get("msgid")
|
||||||
self.create_time = msg_body.get("create_time")
|
self.create_time = msg_body.get("create_time")
|
||||||
self.is_group = is_group
|
self.is_group = is_group
|
||||||
|
# In callback (webhook) mode the media bodies carry no per-message aeskey;
|
||||||
|
# the download url is encrypted with the bot's EncodingAESKey instead.
|
||||||
|
self._default_aeskey = default_aeskey
|
||||||
|
|
||||||
msg_type = msg_body.get("msgtype")
|
msg_type = msg_body.get("msgtype")
|
||||||
from_userid = msg_body.get("from", {}).get("userid", "")
|
from_userid = msg_body.get("from", {}).get("userid", "")
|
||||||
@@ -113,7 +116,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
self.ctype = ContextType.IMAGE
|
self.ctype = ContextType.IMAGE
|
||||||
image_info = msg_body.get("image", {})
|
image_info = msg_body.get("image", {})
|
||||||
image_url = image_info.get("url", "")
|
image_url = image_info.get("url", "")
|
||||||
aeskey = image_info.get("aeskey", "")
|
aeskey = image_info.get("aeskey", "") or self._default_aeskey
|
||||||
tmp_dir = _get_tmp_dir()
|
tmp_dir = _get_tmp_dir()
|
||||||
image_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}.png")
|
image_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}.png")
|
||||||
|
|
||||||
@@ -147,7 +150,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
elif item_type == "image":
|
elif item_type == "image":
|
||||||
img_info = item.get("image", {})
|
img_info = item.get("image", {})
|
||||||
img_url = img_info.get("url", "")
|
img_url = img_info.get("url", "")
|
||||||
img_aeskey = img_info.get("aeskey", "")
|
img_aeskey = img_info.get("aeskey", "") or self._default_aeskey
|
||||||
img_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}_{idx}.png")
|
img_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}_{idx}.png")
|
||||||
try:
|
try:
|
||||||
img_data = _decrypt_media(img_url, img_aeskey)
|
img_data = _decrypt_media(img_url, img_aeskey)
|
||||||
@@ -166,7 +169,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
self.ctype = ContextType.FILE
|
self.ctype = ContextType.FILE
|
||||||
file_info = msg_body.get("file", {})
|
file_info = msg_body.get("file", {})
|
||||||
file_url = file_info.get("url", "")
|
file_url = file_info.get("url", "")
|
||||||
aeskey = file_info.get("aeskey", "")
|
aeskey = file_info.get("aeskey", "") or self._default_aeskey
|
||||||
tmp_dir = _get_tmp_dir()
|
tmp_dir = _get_tmp_dir()
|
||||||
base_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}")
|
base_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}")
|
||||||
self.content = base_path
|
self.content = base_path
|
||||||
@@ -188,7 +191,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
self.ctype = ContextType.FILE
|
self.ctype = ContextType.FILE
|
||||||
video_info = msg_body.get("video", {})
|
video_info = msg_body.get("video", {})
|
||||||
video_url = video_info.get("url", "")
|
video_url = video_info.get("url", "")
|
||||||
aeskey = video_info.get("aeskey", "")
|
aeskey = video_info.get("aeskey", "") or self._default_aeskey
|
||||||
tmp_dir = _get_tmp_dir()
|
tmp_dir = _get_tmp_dir()
|
||||||
self.content = os.path.join(tmp_dir, f"wecom_{self.msg_id}.mp4")
|
self.content = os.path.join(tmp_dir, f"wecom_{self.msg_id}.mp4")
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,11 @@ available_setting = {
|
|||||||
# WeCom smart bot config (long connection mode)
|
# WeCom smart bot config (long connection mode)
|
||||||
"wecom_bot_id": "", # WeCom smart bot BotID
|
"wecom_bot_id": "", # WeCom smart bot BotID
|
||||||
"wecom_bot_secret": "", # WeCom smart bot long-connection secret
|
"wecom_bot_secret": "", # WeCom smart bot long-connection secret
|
||||||
|
# WeCom smart bot callback (webhook) mode; default off, keep using the long connection
|
||||||
|
"wecom_bot_callback": False, # whether to receive messages via HTTP callback (webhook) instead of the long connection
|
||||||
|
"wecom_bot_token": "", # callback mode: Token configured on the bot's receive-message URL
|
||||||
|
"wecom_bot_encoding_aes_key": "", # callback mode: EncodingAESKey configured on the bot's receive-message URL
|
||||||
|
"wecom_bot_port": 9892, # callback mode: local HTTP server port for the receive-message URL
|
||||||
# Telegram config
|
# Telegram config
|
||||||
"telegram_token": "", # Bot token from @BotFather
|
"telegram_token": "", # Bot token from @BotFather
|
||||||
"telegram_proxy": "", # Optional HTTP/SOCKS5 proxy, e.g. http://127.0.0.1:7890 or socks5://127.0.0.1:1080 (empty falls back to env vars)
|
"telegram_proxy": "", # Optional HTTP/SOCKS5 proxy, e.g. http://127.0.0.1:7890 or socks5://127.0.0.1:1080 (empty falls back to env vars)
|
||||||
|
|||||||
Reference in New Issue
Block a user