diff --git a/libs/wecom_customer_service_api/api.py b/libs/wecom_customer_service_api/api.py index f912326e..3c4e0fab 100644 --- a/libs/wecom_customer_service_api/api.py +++ b/libs/wecom_customer_service_api/api.py @@ -13,7 +13,7 @@ import aiofiles class WecomCSClient: - def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None): + def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None, unified_mode: bool = False): self.corpid = corpid self.secret = secret self.access_token_for_contacts = '' @@ -22,10 +22,15 @@ class WecomCSClient: self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin' self.access_token = '' self.logger = logger + self.unified_mode = unified_mode self.app = Quart(__name__) - self.app.add_url_rule( - '/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'] - ) + + # 只有在非统一模式下才注册独立路由 + if not self.unified_mode: + self.app.add_url_rule( + '/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'] + ) + self._message_handlers = { 'example': [], } @@ -192,27 +197,45 @@ class WecomCSClient: return data async def handle_callback_request(self): + """处理回调请求(独立端口模式,使用全局 request)。""" + return await self._handle_callback_internal(request) + + async def handle_unified_webhook(self, req): + """处理回调请求(统一 webhook 模式,显式传递 request)。 + + Args: + req: Quart Request 对象 + + Returns: + 响应数据 """ - 处理回调请求,包括 GET 验证和 POST 消息接收。 + return await self._handle_callback_internal(req) + + async def _handle_callback_internal(self, req): + """ + 处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 + + Args: + req: Quart Request 对象 """ try: - msg_signature = request.args.get('msg_signature') - timestamp = request.args.get('timestamp') - nonce = request.args.get('nonce') + msg_signature = req.args.get('msg_signature') + timestamp = req.args.get('timestamp') + nonce = req.args.get('nonce') try: wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid) except Exception as e: raise Exception(f'初始化失败,错误码: {e}') - if request.method == 'GET': - echostr = request.args.get('echostr') + if req.method == 'GET': + echostr = req.args.get('echostr') ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) if ret != 0: raise Exception(f'验证失败,错误码: {ret}') return reply_echo_str - elif request.method == 'POST': - encrypt_msg = await request.data + elif req.method == 'POST': + encrypt_msg = await req.data ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce) if ret != 0: raise Exception(f'消息解密失败,错误码: {ret}') diff --git a/pkg/api/http/controller/groups/webhooks.py b/pkg/api/http/controller/groups/webhooks.py index dc4f3e96..0964076f 100644 --- a/pkg/api/http/controller/groups/webhooks.py +++ b/pkg/api/http/controller/groups/webhooks.py @@ -1,15 +1,3 @@ -"""统一 Webhook 路由组 - -处理所有外部平台的回调请求,统一在一个端口上通过 bot_uuid 路由到对应的适配器。 - -路由格式: -- /bots/{bot_uuid} - 处理 bot 的 webhook 回调 -- /bots/{bot_uuid}/{path} - 处理带子路径的 webhook 回调 - -Example: - http://your-server.com:5300/bots/550e8400-e29b-41d4-a716-446655440000 -""" - from __future__ import annotations import quart @@ -42,7 +30,7 @@ class WebhookRouterGroup(group.RouterGroup): 适配器返回的响应 """ try: - # 通过 UUID 获取运行时 bot + runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) if not runtime_bot: @@ -51,11 +39,11 @@ class WebhookRouterGroup(group.RouterGroup): if not runtime_bot.enable: return quart.jsonify({'error': 'Bot is disabled'}), 403 - # 检查 adapter 是否支持统一 webhook + if not hasattr(runtime_bot.adapter, 'handle_unified_webhook'): return quart.jsonify({'error': 'Adapter does not support unified webhook'}), 501 - # 调用 adapter 的 handle_unified_webhook 方法,显式传递 request 对象 + response = await runtime_bot.adapter.handle_unified_webhook( bot_uuid=bot_uuid, path=path, diff --git a/pkg/api/http/service/bot.py b/pkg/api/http/service/bot.py index 497ef4ad..f171e35d 100644 --- a/pkg/api/http/service/bot.py +++ b/pkg/api/http/service/bot.py @@ -58,9 +58,7 @@ class BotService: if runtime_bot is not None: adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id - # 为支持统一 webhook 的适配器生成 webhook URL - # 支持:wecom、wecombot、officialaccount、qqofficial、slack - if persistence_bot['adapter'] in ['wecom', 'wecombot', 'officialaccount', 'qqofficial', 'slack']: + if persistence_bot['adapter'] in ['wecom', 'wecombot', 'officialaccount', 'qqofficial', 'slack','wecomcs']: api_port = self.ap.instance_config.data['api']['port'] webhook_url = f"/bots/{bot_uuid}" adapter_runtime_values['webhook_url'] = webhook_url diff --git a/pkg/platform/sources/wecom.py b/pkg/platform/sources/wecom.py index dbb09b01..1bf2108d 100644 --- a/pkg/platform/sources/wecom.py +++ b/pkg/platform/sources/wecom.py @@ -189,9 +189,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id']) async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): - """企业微信目前只有发送给个人的方法, - 构造target_id的方式为前半部分为账户id,后半部分为agent_id,中间使用“|”符号隔开。 - """ + content_list = await WecomMessageConverter.yiri2target(message, self.bot) parts = target_id.split('|') user_id = parts[0] @@ -237,10 +235,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): return await self.bot.handle_unified_webhook(request) async def run_async(self): - # 统一 webhook 模式下,不启动独立的 Quart 应用 - # 保持运行但不启动独立端口 - # 打印 webhook 回调地址 if self.bot_uuid and hasattr(self.logger, 'ap'): try: api_port = self.logger.ap.instance_config.data['api']['port'] diff --git a/pkg/platform/sources/wecomcs.py b/pkg/platform/sources/wecomcs.py index 7ce3a064..7c82bfea 100644 --- a/pkg/platform/sources/wecomcs.py +++ b/pkg/platform/sources/wecomcs.py @@ -121,6 +121,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: WecomCSClient = pydantic.Field(exclude=True) message_converter: WecomMessageConverter = WecomMessageConverter() event_converter: WecomEventConverter = WecomEventConverter() + bot_uuid: str = None def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger): required_keys = [ @@ -139,6 +140,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): token=config['token'], EncodingAESKey=config['EncodingAESKey'], logger=logger, + unified_mode=True, ) super().__init__( @@ -170,6 +172,10 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): pass + def set_bot_uuid(self, bot_uuid: str): + """设置 bot UUID(用于生成 webhook URL)""" + self.bot_uuid = bot_uuid + def register_listener( self, event_type: typing.Type[platform_events.Event], @@ -190,16 +196,41 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): elif event_type == platform_events.GroupMessage: pass + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + """处理统一 webhook 请求。 + + Args: + bot_uuid: Bot 的 UUID + path: 子路径(如果有的话) + request: Quart Request 对象 + + Returns: + 响应数据 + """ + return await self.bot.handle_unified_webhook(request) + async def run_async(self): - async def shutdown_trigger_placeholder(): + # 统一 webhook 模式下,不启动独立的 Quart 应用 + # 保持运行但不启动独立端口 + + # 打印 webhook 回调地址 + if self.bot_uuid and hasattr(self.logger, 'ap'): + try: + api_port = self.logger.ap.instance_config.data['api']['port'] + webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}" + webhook_url_public = f"http://:{api_port}/bots/{self.bot_uuid}" + + await self.logger.info(f"企业微信客服 Webhook 回调地址:") + await self.logger.info(f" 本地地址: {webhook_url}") + await self.logger.info(f" 公网地址: {webhook_url_public}") + await self.logger.info(f"请在企业微信后台配置此回调地址") + except Exception as e: + await self.logger.warning(f"无法生成 webhook URL: {e}") + + async def keep_alive(): while True: await asyncio.sleep(1) - - await self.bot.run_task( - host='0.0.0.0', - port=self.config['port'], - shutdown_trigger=shutdown_trigger_placeholder, - ) + await keep_alive() async def kill(self) -> bool: return False