diff --git a/src/langbot/libs/official_account_api/api.py b/src/langbot/libs/official_account_api/api.py index 57d41c49..671f49a4 100644 --- a/src/langbot/libs/official_account_api/api.py +++ b/src/langbot/libs/official_account_api/api.py @@ -23,20 +23,25 @@ xml_template = """ class OAClient: - def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None): + def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None, unified_mode: bool = False): self.token = token self.aes = EncodingAESKey self.appid = AppID self.appsecret = Appsecret self.base_url = 'https://api.weixin.qq.com' self.access_token = '' + 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': [], } @@ -46,19 +51,39 @@ class OAClient: self.logger = logger 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: + 响应数据 + """ + return await self._handle_callback_internal(req) + + async def _handle_callback_internal(self, req): + """处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 + + Args: + req: Quart Request 对象 + """ try: # 每隔100毫秒查询是否生成ai回答 start_time = time.time() - signature = request.args.get('signature', '') - timestamp = request.args.get('timestamp', '') - nonce = request.args.get('nonce', '') - echostr = request.args.get('echostr', '') - msg_signature = request.args.get('msg_signature', '') + signature = req.args.get('signature', '') + timestamp = req.args.get('timestamp', '') + nonce = req.args.get('nonce', '') + echostr = req.args.get('echostr', '') + msg_signature = req.args.get('msg_signature', '') if msg_signature is None: await self.logger.error('msg_signature不在请求体中') raise Exception('msg_signature不在请求体中') - if request.method == 'GET': + if req.method == 'GET': # 校验签名 check_str = ''.join(sorted([self.token, timestamp, nonce])) check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest() @@ -68,8 +93,8 @@ class OAClient: else: await self.logger.error('拒绝请求') raise Exception('拒绝请求') - elif request.method == 'POST': - encryt_msg = await request.data + elif req.method == 'POST': + encryt_msg = await req.data wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid) ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce) xml_msg = xml_msg.decode('utf-8') @@ -182,6 +207,7 @@ class OAClientForLongerResponse: Appsecret: str, LoadingMessage: str, logger: None, + unified_mode: bool = False, ): self.token = token self.aes = EncodingAESKey @@ -189,13 +215,18 @@ class OAClientForLongerResponse: self.appsecret = Appsecret self.base_url = 'https://api.weixin.qq.com' self.access_token = '' + 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': [], } @@ -206,24 +237,44 @@ class OAClientForLongerResponse: self.logger = logger 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: + 响应数据 + """ + return await self._handle_callback_internal(req) + + async def _handle_callback_internal(self, req): + """处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 + + Args: + req: Quart Request 对象 + """ try: - signature = request.args.get('signature', '') - timestamp = request.args.get('timestamp', '') - nonce = request.args.get('nonce', '') - echostr = request.args.get('echostr', '') - msg_signature = request.args.get('msg_signature', '') + signature = req.args.get('signature', '') + timestamp = req.args.get('timestamp', '') + nonce = req.args.get('nonce', '') + echostr = req.args.get('echostr', '') + msg_signature = req.args.get('msg_signature', '') if msg_signature is None: await self.logger.error('msg_signature不在请求体中') raise Exception('msg_signature不在请求体中') - if request.method == 'GET': + if req.method == 'GET': check_str = ''.join(sorted([self.token, timestamp, nonce])) check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest() return echostr if check_signature == signature else '拒绝请求' - elif request.method == 'POST': - encryt_msg = await request.data + elif req.method == 'POST': + encryt_msg = await req.data wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid) ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce) xml_msg = xml_msg.decode('utf-8') diff --git a/src/langbot/libs/qq_official_api/api.py b/src/langbot/libs/qq_official_api/api.py index c5728437..e4d4e468 100644 --- a/src/langbot/libs/qq_official_api/api.py +++ b/src/langbot/libs/qq_official_api/api.py @@ -10,38 +10,20 @@ import traceback from cryptography.hazmat.primitives.asymmetric import ed25519 -def handle_validation(body: dict, bot_secret: str): - # bot正确的secert是32位的,此处仅为了适配演示demo - while len(bot_secret) < 32: - bot_secret = bot_secret * 2 - bot_secret = bot_secret[:32] - # 实际使用场景中以上三行内容可清除 - - seed_bytes = bot_secret.encode() - - signing_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed_bytes) - - msg = body['d']['event_ts'] + body['d']['plain_token'] - msg_bytes = msg.encode() - - signature = signing_key.sign(msg_bytes) - - signature_hex = signature.hex() - - response = {'plain_token': body['d']['plain_token'], 'signature': signature_hex} - - return response - - class QQOfficialClient: - def __init__(self, secret: str, token: str, app_id: str, logger: None): + def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False): + 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.secret = secret self.token = token self.app_id = app_id @@ -82,18 +64,45 @@ class QQOfficialClient: raise Exception(f'获取access_token失败: {e}') 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: + 响应数据 + """ + return await self._handle_callback_internal(req) + + async def _handle_callback_internal(self, req): + """处理回调请求的内部实现。 + + Args: + req: Quart Request 对象 + """ try: - # 读取请求数据 - body = await request.get_data() + + body = await req.get_data() + + print(f'[QQ Official] Received request, body length: {len(body)}') + + if not body or len(body) == 0: + print('[QQ Official] Received empty body, might be health check or GET request') + return {'code': 0, 'message': 'ok'}, 200 + payload = json.loads(body) - # 验证是否为回调验证请求 + if payload.get('op') == 13: - # 生成签名 - response = handle_validation(payload, self.secret) - - return response + validation_data = payload.get('d') + if not validation_data: + return {'error': "missing 'd' field"}, 400 + response = await self.verify(validation_data) + return response, 200 if payload.get('op') == 0: message_data = await self.get_message(payload) @@ -104,6 +113,7 @@ class QQOfficialClient: return {'code': 0, 'message': 'success'} except Exception as e: + print(f'[QQ Official] ERROR: {traceback.format_exc()}') await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}') return {'error': str(e)}, 400 @@ -261,3 +271,26 @@ class QQOfficialClient: if self.access_token_expiry_time is None: return True return time.time() > self.access_token_expiry_time + + async def repeat_seed(self, bot_secret: str, target_size: int = 32) -> bytes: + seed = bot_secret + while len(seed) < target_size: + seed *= 2 + return seed[:target_size].encode("utf-8") + + async def verify(self, validation_payload: dict): + seed = await self.repeat_seed(self.secret) + private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed) + + event_ts = validation_payload.get("event_ts", "") + plain_token = validation_payload.get("plain_token", "") + msg = event_ts + plain_token + + # sign + signature = private_key.sign(msg.encode()).hex() + + response = { + "plain_token": plain_token, + "signature": signature, + } + return response diff --git a/src/langbot/libs/slack_api/api.py b/src/langbot/libs/slack_api/api.py index 241a42cf..6f869b93 100644 --- a/src/langbot/libs/slack_api/api.py +++ b/src/langbot/libs/slack_api/api.py @@ -8,14 +8,19 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events class SlackClient: - def __init__(self, bot_token: str, signing_secret: str, logger: None): + def __init__(self, bot_token: str, signing_secret: str, logger: None, unified_mode: bool = False): self.bot_token = bot_token self.signing_secret = signing_secret + self.unified_mode = unified_mode self.app = Quart(__name__) self.client = AsyncWebClient(self.bot_token) - 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': [], } @@ -23,8 +28,28 @@ class SlackClient: self.logger = logger 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: + 响应数据 + """ + return await self._handle_callback_internal(req) + + async def _handle_callback_internal(self, req): + """处理回调请求的内部实现。 + + Args: + req: Quart Request 对象 + """ try: - body = await request.get_data() + body = await req.get_data() data = json.loads(body) if 'type' in data: if data['type'] == 'url_verification': diff --git a/src/langbot/libs/wecom_ai_bot_api/api.py b/src/langbot/libs/wecom_ai_bot_api/api.py index 0620791b..bed94d02 100644 --- a/src/langbot/libs/wecom_ai_bot_api/api.py +++ b/src/langbot/libs/wecom_ai_bot_api/api.py @@ -200,7 +200,7 @@ class StreamSessionManager: class WecomBotClient: - def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger): + def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False): """企业微信智能机器人客户端。 Args: @@ -208,6 +208,7 @@ class WecomBotClient: EnCodingAESKey: 企业微信消息加解密密钥。 Corpid: 企业 ID。 logger: 日志记录器。 + unified_mode: 是否使用统一 webhook 模式(默认 False)。 Example: >>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger) @@ -217,10 +218,15 @@ class WecomBotClient: self.EnCodingAESKey = EnCodingAESKey self.Corpid = Corpid self.ReceiveId = '' + self.unified_mode = unified_mode self.app = Quart(__name__) - self.app.add_url_rule( - '/callback/command', 'handle_callback', self.handle_callback_request, methods=['POST', 'GET'] - ) + + # 只有在非统一模式下才注册独立路由 + if not self.unified_mode: + self.app.add_url_rule( + '/callback/command', 'handle_callback', self.handle_callback_request, methods=['POST', 'GET'] + ) + self._message_handlers = { 'example': [], } @@ -359,7 +365,7 @@ class WecomBotClient: return await self._encrypt_and_reply(payload, nonce) async def handle_callback_request(self): - """企业微信回调入口。 + """企业微信回调入口(独立端口模式,使用全局 request)。 Returns: Quart Response: 根据请求类型返回验证、首包或刷新结果。 @@ -367,15 +373,34 @@ class WecomBotClient: Example: 作为 Quart 路由处理函数直接注册并使用。 """ + return await self._handle_callback_internal(request) + + async def handle_unified_webhook(self, req): + """处理回调请求(统一 webhook 模式,显式传递 request)。 + + Args: + req: Quart Request 对象 + + Returns: + 响应数据 + """ + return await self._handle_callback_internal(req) + + async def _handle_callback_internal(self, req): + """处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。 + + Args: + req: Quart Request 对象 + """ try: self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '') - await self.logger.info(f'{request.method} {request.url} {str(request.args)}') + await self.logger.info(f'{req.method} {req.url} {str(req.args)}') - if request.method == 'GET': - return await self._handle_get_callback() + if req.method == 'GET': + return await self._handle_get_callback(req) - if request.method == 'POST': - return await self._handle_post_callback() + if req.method == 'POST': + return await self._handle_post_callback(req) return Response('', status=405) @@ -383,13 +408,13 @@ class WecomBotClient: await self.logger.error(traceback.format_exc()) return Response('Internal Server Error', status=500) - async def _handle_get_callback(self) -> tuple[Response, int] | Response: + async def _handle_get_callback(self, req) -> tuple[Response, int] | Response: """处理企业微信的 GET 验证请求。""" - msg_signature = unquote(request.args.get('msg_signature', '')) - timestamp = unquote(request.args.get('timestamp', '')) - nonce = unquote(request.args.get('nonce', '')) - echostr = unquote(request.args.get('echostr', '')) + msg_signature = unquote(req.args.get('msg_signature', '')) + timestamp = unquote(req.args.get('timestamp', '')) + nonce = unquote(req.args.get('nonce', '')) + echostr = unquote(req.args.get('echostr', '')) if not all([msg_signature, timestamp, nonce, echostr]): await self.logger.error('请求参数缺失') @@ -402,16 +427,16 @@ class WecomBotClient: return Response(decrypted_str, mimetype='text/plain') - async def _handle_post_callback(self) -> tuple[Response, int] | Response: + async def _handle_post_callback(self, req) -> tuple[Response, int] | Response: """处理企业微信的 POST 回调请求。""" self.stream_sessions.cleanup() - msg_signature = unquote(request.args.get('msg_signature', '')) - timestamp = unquote(request.args.get('timestamp', '')) - nonce = unquote(request.args.get('nonce', '')) + msg_signature = unquote(req.args.get('msg_signature', '')) + timestamp = unquote(req.args.get('timestamp', '')) + nonce = unquote(req.args.get('nonce', '')) - encrypted_json = await request.get_json() + encrypted_json = await req.get_json() encrypted_msg = (encrypted_json or {}).get('encrypt', '') if not encrypted_msg: await self.logger.error("请求体中缺少 'encrypt' 字段") diff --git a/src/langbot/libs/wecom_ai_bot_api/wecombotevent.py b/src/langbot/libs/wecom_ai_bot_api/wecombotevent.py index b2f02831..099c58bc 100644 --- a/src/langbot/libs/wecom_ai_bot_api/wecombotevent.py +++ b/src/langbot/libs/wecom_ai_bot_api/wecombotevent.py @@ -29,12 +29,7 @@ class WecomBotEvent(dict): """ 用户名称 """ - return ( - self.get('username', '') - or self.get('from', {}).get('alias', '') - or self.get('from', {}).get('name', '') - or self.userid - ) + return self.get('username', '') or self.get('from', {}).get('alias', '') or self.get('from', {}).get('name', '') or self.userid @property def chatname(self) -> str: @@ -70,7 +65,7 @@ class WecomBotEvent(dict): 消息id """ return self.get('msgid', '') - + @property def ai_bot_id(self) -> str: """ diff --git a/src/langbot/libs/wecom_api/api.py b/src/langbot/libs/wecom_api/api.py index ee073211..7a6e1c69 100644 --- a/src/langbot/libs/wecom_api/api.py +++ b/src/langbot/libs/wecom_api/api.py @@ -21,6 +21,7 @@ class WecomClient: EncodingAESKey: str, contacts_secret: str, logger: None, + unified_mode: bool = False, ): self.corpid = corpid self.secret = secret @@ -31,13 +32,18 @@ class WecomClient: self.access_token = '' self.secret_for_contacts = contacts_secret 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': [], } @@ -161,25 +167,43 @@ class WecomClient: raise Exception('Failed to send message: ' + str(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') wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid) - 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: await self.logger.error('验证失败') 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: await self.logger.error('消息解密失败') diff --git a/src/langbot/libs/wecom_customer_service_api/api.py b/src/langbot/libs/wecom_customer_service_api/api.py index f912326e..3c4e0fab 100644 --- a/src/langbot/libs/wecom_customer_service_api/api.py +++ b/src/langbot/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/src/langbot/pkg/api/http/controller/groups/platform/bots.py b/src/langbot/pkg/api/http/controller/groups/platform/bots.py index a261eacb..ac580b1a 100644 --- a/src/langbot/pkg/api/http/controller/groups/platform/bots.py +++ b/src/langbot/pkg/api/http/controller/groups/platform/bots.py @@ -18,7 +18,8 @@ class BotsRouterGroup(group.RouterGroup): @self.route('/', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _(bot_uuid: str) -> str: if quart.request.method == 'GET': - bot = await self.ap.bot_service.get_bot(bot_uuid) + # 返回运行时信息,包括webhook地址等 + bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid) if bot is None: return self.http_status(404, -1, 'bot not found') return self.success(data={'bot': bot}) diff --git a/src/langbot/pkg/api/http/controller/groups/webhooks.py b/src/langbot/pkg/api/http/controller/groups/webhooks.py index 1149f89b..0964076f 100644 --- a/src/langbot/pkg/api/http/controller/groups/webhooks.py +++ b/src/langbot/pkg/api/http/controller/groups/webhooks.py @@ -1,49 +1,57 @@ +from __future__ import annotations + import quart +import traceback from .. import group -@group.group_class('webhooks', '/api/v1/webhooks') -class WebhooksRouterGroup(group.RouterGroup): +@group.group_class('webhooks', '/bots') +class WebhookRouterGroup(group.RouterGroup): async def initialize(self) -> None: - @self.route('', methods=['GET', 'POST']) - async def _() -> str: - if quart.request.method == 'GET': - webhooks = await self.ap.webhook_service.get_webhooks() - return self.success(data={'webhooks': webhooks}) - elif quart.request.method == 'POST': - json_data = await quart.request.json - name = json_data.get('name', '') - url = json_data.get('url', '') - description = json_data.get('description', '') - enabled = json_data.get('enabled', True) + @self.route('/', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) + async def handle_webhook(bot_uuid: str): + """处理 bot webhook 回调(无子路径)""" + return await self._dispatch_webhook(bot_uuid, '') - if not name: - return self.http_status(400, -1, 'Name is required') - if not url: - return self.http_status(400, -1, 'URL is required') + @self.route('//', methods=['GET', 'POST'], auth_type=group.AuthType.NONE) + async def handle_webhook_with_path(bot_uuid: str, path: str): + """处理 bot webhook 回调(带子路径)""" + return await self._dispatch_webhook(bot_uuid, path) - webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled) - return self.success(data={'webhook': webhook}) + async def _dispatch_webhook(self, bot_uuid: str, path: str): + """分发 webhook 请求到对应的 bot adapter - @self.route('/', methods=['GET', 'PUT', 'DELETE']) - async def _(webhook_id: int) -> str: - if quart.request.method == 'GET': - webhook = await self.ap.webhook_service.get_webhook(webhook_id) - if webhook is None: - return self.http_status(404, -1, 'Webhook not found') - return self.success(data={'webhook': webhook}) + Args: + bot_uuid: Bot 的 UUID + path: 子路径(如果有的话) - elif quart.request.method == 'PUT': - json_data = await quart.request.json - name = json_data.get('name') - url = json_data.get('url') - description = json_data.get('description') - enabled = json_data.get('enabled') + Returns: + 适配器返回的响应 + """ + try: + + runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) - await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled) - return self.success() + if not runtime_bot: + return quart.jsonify({'error': 'Bot not found'}), 404 - elif quart.request.method == 'DELETE': - await self.ap.webhook_service.delete_webhook(webhook_id) - return self.success() + if not runtime_bot.enable: + return quart.jsonify({'error': 'Bot is disabled'}), 403 + + + if not hasattr(runtime_bot.adapter, 'handle_unified_webhook'): + return quart.jsonify({'error': 'Adapter does not support unified webhook'}), 501 + + + response = await runtime_bot.adapter.handle_unified_webhook( + bot_uuid=bot_uuid, + path=path, + request=quart.request, + ) + + return response + + except Exception as e: + self.ap.logger.error(f'Webhook dispatch error for bot {bot_uuid}: {traceback.format_exc()}') + return quart.jsonify({'error': str(e)}), 500 diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 1a65fa87..7db32fbe 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -58,6 +58,16 @@ class BotService: if runtime_bot is not None: adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id + # Webhook URL for unified webhook adapters (independent of bot running state) + if persistence_bot['adapter'] in ['wecom', 'wecombot', 'officialaccount', 'qqofficial', 'slack', 'wecomcs', 'LINE']: + webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300') + webhook_url = f'/bots/{bot_uuid}' + adapter_runtime_values['webhook_url'] = webhook_url + adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}' + else: + adapter_runtime_values['webhook_url'] = None + adapter_runtime_values['webhook_full_url'] = None + persistence_bot['adapter_runtime_values'] = adapter_runtime_values return persistence_bot diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 44305c92..6e2f20b0 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -245,6 +245,10 @@ class PlatformManager: logger, ) + # 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook) + if hasattr(adapter_inst, 'set_bot_uuid'): + adapter_inst.set_bot_uuid(bot_entity.uuid) + runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger) await runtime_bot.initialize() diff --git a/src/langbot/pkg/platform/sources/line.py b/src/langbot/pkg/platform/sources/line.py index 413105d6..c36b4f05 100644 --- a/src/langbot/pkg/platform/sources/line.py +++ b/src/langbot/pkg/platform/sources/line.py @@ -121,6 +121,7 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter): class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: MessagingApi api_client: ApiClient + parser: WebhookParser bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识 message_converter: LINEMessageConverter @@ -132,7 +133,7 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ] config: dict - quart_app: quart.Quart + bot_uuid: str = None card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片 @@ -149,7 +150,6 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): super().__init__( config=config, logger=logger, - quart_app=quart.Quart(__name__), listeners={}, card_id_dict={}, seq=1, @@ -163,29 +163,6 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot_account_id=bot_account_id, ) - @self.quart_app.route('/line/callback', methods=['POST']) - async def line_callback(): - try: - signature = quart.request.headers.get('X-Line-Signature') - body = await quart.request.get_data(as_text=True) - events = parser.parse(body, signature) # 解密解析消息 - - try: - # print(events) - lb_event = await self.event_converter.target2yiri(events[0], self.api_client) - if lb_event.__class__ in self.listeners: - await self.listeners[lb_event.__class__](lb_event, self) - except InvalidSignatureError: - self.logger.info( - f'Invalid signature. Please check your channel access token/channel secret.{traceback.format_exc()}' - ) - return quart.Response('Invalid signature', status=400) - - return {'code': 200, 'message': 'ok'} - except Exception: - await self.logger.error(f'Error in LINE callback: {traceback.format_exc()}') - return {'code': 500, 'message': 'error'} - async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): pass @@ -236,18 +213,73 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): ): self.listeners.pop(event_type) - async def run_async(self): - port = self.config['port'] + def set_bot_uuid(self, bot_uuid: str): + """设置 bot UUID(用于生成 webhook URL)""" + self.bot_uuid = bot_uuid - async def shutdown_trigger_placeholder(): + async def handle_unified_webhook(self, bot_uuid: str, path: str, request): + """处理统一 webhook 请求。 + + Args: + bot_uuid: Bot 的 UUID + path: 子路径(如果有的话) + request: Quart Request 对象 + + Returns: + 响应数据 + """ + try: + signature = request.headers.get('X-Line-Signature') + body = await request.get_data(as_text=True) + + # Check if signature header exists + if not signature: + await self.logger.warning('Missing X-Line-Signature header') + return quart.Response('Missing X-Line-Signature header', status=400) + + try: + events = self.parser.parse(body, signature) # 解密解析消息 + except InvalidSignatureError: + await self.logger.info( + f'Invalid signature. Please check your channel access token/channel secret.{traceback.format_exc()}' + ) + return quart.Response('Invalid signature', status=400) + + # 处理事件 + if events and len(events) > 0: + lb_event = await self.event_converter.target2yiri(events[0], self.api_client) + if lb_event.__class__ in self.listeners: + await self.listeners[lb_event.__class__](lb_event, self) + + return {'code': 200, 'message': 'ok'} + except Exception: + await self.logger.error(f'Error in LINE callback: {traceback.format_exc()}') + print(traceback.format_exc()) + return {'code': 500, 'message': 'error'} + + 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'] + 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('LINE Webhook 回调地址:') + await self.logger.info(f' 本地地址: {webhook_url}') + await self.logger.info(f' 公网地址: {webhook_url_public}') + await self.logger.info('请在 LINE 后台配置此回调地址') + except Exception as e: + await self.logger.warning(f'无法生成 webhook URL: {e}') + + async def keep_alive(): while True: await asyncio.sleep(1) - await self.quart_app.run_task( - host='0.0.0.0', - port=port, - shutdown_trigger=shutdown_trigger_placeholder, - ) + await keep_alive() async def kill(self) -> bool: pass diff --git a/src/langbot/pkg/platform/sources/line.yaml b/src/langbot/pkg/platform/sources/line.yaml index c0237dd9..5b399337 100644 --- a/src/langbot/pkg/platform/sources/line.yaml +++ b/src/langbot/pkg/platform/sources/line.yaml @@ -22,18 +22,6 @@ spec: type: string required: true default: "" - - name: port - label: - en_US: Webhook Port - zh_Hans: Webhook端口 - description: - en_US: Only valid when webhook mode is enabled, please fill in the webhook port - zh_Hans: 请填写 Webhook 端口 - ja_JP: Webhookポートを入力してください - zh_Hant: 請填寫 Webhook 端口 - type: integer - required: true - default: 2287 - name: channel_secret label: en_US: Channel secret diff --git a/src/langbot/pkg/platform/sources/officialaccount.py b/src/langbot/pkg/platform/sources/officialaccount.py index 7b7f38d9..5f6f0e41 100644 --- a/src/langbot/pkg/platform/sources/officialaccount.py +++ b/src/langbot/pkg/platform/sources/officialaccount.py @@ -11,7 +11,7 @@ from langbot.libs.official_account_api.api import OAClientForLongerResponse import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events -from langbot.pkg.platform.logger import EventLogger +from ..logger import EventLogger class OAMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @@ -58,13 +58,16 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd message_converter: OAMessageConverter = OAMessageConverter() event_converter: OAEventConverter = OAEventConverter() bot: typing.Union[OAClient, OAClientForLongerResponse] = pydantic.Field(exclude=True) + bot_uuid: str = None def __init__(self, config: dict, logger: EventLogger): + # 校验必填项 required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode'] missing_keys = [k for k in required_keys if k not in config] if missing_keys: raise Exception(f'OfficialAccount 缺少配置项: {missing_keys}') + # 创建运行时 bot 对象,始终使用统一 webhook 模式 if config['Mode'] == 'drop': bot = OAClient( token=config['token'], @@ -72,6 +75,7 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd Appsecret=config['AppSecret'], AppID=config['AppID'], logger=logger, + unified_mode=True, ) elif config['Mode'] == 'passive': bot = OAClientForLongerResponse( @@ -81,6 +85,7 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd AppID=config['AppID'], LoadingMessage=config.get('LoadingMessage', ''), logger=logger, + unified_mode=True, ) else: raise KeyError('请设置微信公众号通信模式') @@ -129,16 +134,46 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd elif event_type == platform_events.GroupMessage: pass + def set_bot_uuid(self, bot_uuid: str): + """设置 bot UUID(用于生成 webhook URL)""" + self.bot_uuid = bot_uuid + + 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('微信公众号 Webhook 回调地址:') + await self.logger.info(f' 本地地址: {webhook_url}') + await self.logger.info(f' 公网地址: {webhook_url_public}') + await self.logger.info('请在微信公众号后台配置此回调地址') + 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=self.config['host'], - port=self.config['port'], - shutdown_trigger=shutdown_trigger_placeholder, - ) + await keep_alive() async def kill(self) -> bool: return False diff --git a/src/langbot/pkg/platform/sources/officialaccount.yaml b/src/langbot/pkg/platform/sources/officialaccount.yaml index 1a93c0bc..53345413 100644 --- a/src/langbot/pkg/platform/sources/officialaccount.yaml +++ b/src/langbot/pkg/platform/sources/officialaccount.yaml @@ -53,23 +53,6 @@ spec: type: string required: true default: "AI正在思考中,请发送任意内容获取回复。" - - name: host - label: - en_US: Host - zh_Hans: 监听主机 - description: - en_US: The host that Official Account listens on for Webhook connections. - zh_Hans: 微信公众号监听的主机,除非你知道自己在做什么,否则请写 0.0.0.0 - type: string - required: true - default: 0.0.0.0 - - name: port - label: - en_US: Port - zh_Hans: 监听端口 - type: integer - required: true - default: 2287 execution: python: path: ./officialaccount.py diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py index c0375e16..a3288793 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.py +++ b/src/langbot/pkg/platform/sources/qqofficial.py @@ -11,8 +11,8 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities from langbot.libs.qq_official_api.api import QQOfficialClient from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent -from langbot.pkg.utils import image -from langbot.pkg.platform.logger import EventLogger +from ...utils import image +from ..logger import EventLogger class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @@ -134,11 +134,14 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter bot: QQOfficialClient config: dict bot_account_id: str + bot_uuid: str = None message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter() event_converter: QQOfficialEventConverter = QQOfficialEventConverter() def __init__(self, config: dict, logger: EventLogger): - bot = QQOfficialClient(app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger) + bot = QQOfficialClient( + app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True + ) super().__init__( config=config, @@ -223,16 +226,46 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter self.bot.on_message('GROUP_AT_MESSAGE_CREATE')(on_message) self.bot.on_message('AT_MESSAGE_CREATE')(on_message) + def set_bot_uuid(self, bot_uuid: str): + """设置 bot UUID(用于生成 webhook URL)""" + self.bot_uuid = bot_uuid + + 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('QQ 官方机器人 Webhook 回调地址:') + await self.logger.info(f' 本地地址: {webhook_url}') + await self.logger.info(f' 公网地址: {webhook_url_public}') + await self.logger.info('请在 QQ 官方机器人后台配置此回调地址') + 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 diff --git a/src/langbot/pkg/platform/sources/qqofficial.yaml b/src/langbot/pkg/platform/sources/qqofficial.yaml index e3f39ce1..54d800bb 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.yaml +++ b/src/langbot/pkg/platform/sources/qqofficial.yaml @@ -25,13 +25,6 @@ spec: type: string required: true default: "" - - name: port - label: - en_US: Port - zh_Hans: 监听端口 - type: integer - required: true - default: 2284 - name: token label: en_US: Token diff --git a/src/langbot/pkg/platform/sources/slack.py b/src/langbot/pkg/platform/sources/slack.py index ec6fbae8..1f2a70b7 100644 --- a/src/langbot/pkg/platform/sources/slack.py +++ b/src/langbot/pkg/platform/sources/slack.py @@ -97,13 +97,12 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter): class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot: SlackClient bot_account_id: str + bot_uuid: str = None message_converter: SlackMessageConverter = SlackMessageConverter() event_converter: SlackEventConverter = SlackEventConverter() config: dict def __init__(self, config: dict, logger: EventLogger): - self.config = config - self.logger = logger required_keys = [ 'bot_token', 'signing_secret', @@ -112,8 +111,18 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): if missing_keys: raise command_errors.ParamNotEnoughError('Slack机器人缺少相关配置项,请查看文档或联系管理员') - self.bot = SlackClient( - bot_token=self.config['bot_token'], signing_secret=self.config['signing_secret'], logger=self.logger + bot = SlackClient( + bot_token=config['bot_token'], + signing_secret=config['signing_secret'], + logger=logger, + unified_mode=True + ) + + super().__init__( + config=config, + logger=logger, + bot=bot, + bot_account_id=config['bot_token'], ) async def reply_message( @@ -165,16 +174,45 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): elif event_type == platform_events.GroupMessage: self.bot.on_message('channel')(on_message) + def set_bot_uuid(self, bot_uuid: str): + """设置 bot UUID(用于生成 webhook URL)""" + self.bot_uuid = bot_uuid + + 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"Slack 机器人 Webhook 回调地址:") + await self.logger.info(f" 本地地址: {webhook_url}") + await self.logger.info(f" 公网地址: {webhook_url_public}") + await self.logger.info(f"请在 Slack 后台配置此回调地址") + 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 diff --git a/src/langbot/pkg/platform/sources/slack.yaml b/src/langbot/pkg/platform/sources/slack.yaml index 69d96187..13d303bb 100644 --- a/src/langbot/pkg/platform/sources/slack.yaml +++ b/src/langbot/pkg/platform/sources/slack.yaml @@ -25,13 +25,6 @@ spec: type: string required: true default: "" - - name: port - label: - en_US: Port - zh_Hans: 监听端口 - type: int - required: true - default: 2288 execution: python: path: ./slack.py diff --git a/src/langbot/pkg/platform/sources/wecom.py b/src/langbot/pkg/platform/sources/wecom.py index a009fbd8..21daad67 100644 --- a/src/langbot/pkg/platform/sources/wecom.py +++ b/src/langbot/pkg/platform/sources/wecom.py @@ -8,8 +8,8 @@ import datetime from langbot.libs.wecom_api.api import WecomClient import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter from langbot.libs.wecom_api.wecomevent import WecomEvent -from langbot.pkg.utils import image -from langbot.pkg.platform.logger import EventLogger +from ...utils import image +from ..logger import EventLogger import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities @@ -131,6 +131,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): message_converter: WecomMessageConverter = WecomMessageConverter() event_converter: WecomEventConverter = WecomEventConverter() config: dict + bot_uuid: str = None def __init__(self, config: dict, logger: EventLogger): # 校验必填项 @@ -141,11 +142,12 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'EncodingAESKey', 'contacts_secret', ] + missing_keys = [key for key in required_keys if key not in config] if missing_keys: raise Exception(f'Wecom 缺少配置项: {missing_keys}') - # 创建运行时 bot 对象 + # 创建运行时 bot 对象,始终使用统一 webhook 模式 bot = WecomClient( corpid=config['corpid'], secret=config['secret'], @@ -153,6 +155,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): EncodingAESKey=config['EncodingAESKey'], contacts_secret=config['contacts_secret'], logger=logger, + unified_mode=True, ) super().__init__( @@ -162,6 +165,10 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): bot_account_id='', ) + def set_bot_uuid(self, bot_uuid: str): + """设置 bot UUID(用于生成 webhook URL)""" + self.bot_uuid = bot_uuid + async def reply_message( self, message_source: platform_events.MessageEvent, @@ -180,9 +187,6 @@ 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] @@ -214,16 +218,38 @@ class WecomAdapter(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(): + 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('企业微信 Webhook 回调地址:') + await self.logger.info(f' 本地地址: {webhook_url}') + await self.logger.info(f' 公网地址: {webhook_url_public}') + await self.logger.info('请在企业微信后台配置此回调地址') + 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=self.config['host'], - port=self.config['port'], - shutdown_trigger=shutdown_trigger_placeholder, - ) + await keep_alive() async def kill(self) -> bool: return False diff --git a/src/langbot/pkg/platform/sources/wecom.yaml b/src/langbot/pkg/platform/sources/wecom.yaml index f1015518..8720bbdf 100644 --- a/src/langbot/pkg/platform/sources/wecom.yaml +++ b/src/langbot/pkg/platform/sources/wecom.yaml @@ -11,23 +11,6 @@ metadata: icon: wecom.png spec: config: - - name: host - label: - en_US: Host - zh_Hans: 监听主机 - description: - en_US: Webhook host, unless you know what you're doing, please write 0.0.0.0 - zh_Hans: Webhook 监听主机,除非你知道自己在做什么,否则请写 0.0.0.0 - type: string - required: true - default: "0.0.0.0" - - name: port - label: - en_US: Port - zh_Hans: 监听端口 - type: integer - required: true - default: 2290 - name: corpid label: en_US: Corpid diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index dca8f2c3..5fd66aca 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -8,7 +8,7 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.entities as platform_entities -from langbot.pkg.platform.logger import EventLogger +from ..logger import EventLogger from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent from langbot.libs.wecom_ai_bot_api.api import WecomBotClient @@ -88,19 +88,20 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): message_converter: WecomBotMessageConverter = WecomBotMessageConverter() event_converter: WecomBotEventConverter = WecomBotEventConverter() config: dict + bot_uuid: str = None def __init__(self, config: dict, logger: EventLogger): - required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId', 'port'] + required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId'] missing_keys = [key for key in required_keys if key not in config] if missing_keys: raise Exception(f'WecomBot 缺少配置项: {missing_keys}') - # 创建运行时 bot 对象 bot = WecomBotClient( Token=config['Token'], EnCodingAESKey=config['EncodingAESKey'], Corpid=config['Corpid'], logger=logger, + unified_mode=True, ) bot_account_id = config['BotId'] @@ -189,16 +190,46 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): except Exception: print(traceback.format_exc()) + def set_bot_uuid(self, bot_uuid: str): + """设置 bot UUID(用于生成 webhook URL)""" + self.bot_uuid = bot_uuid + + 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('企业微信机器人 Webhook 回调地址:') + await self.logger.info(f' 本地地址: {webhook_url}') + await self.logger.info(f' 公网地址: {webhook_url_public}') + await self.logger.info('请在企业微信后台配置此回调地址') + 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 diff --git a/src/langbot/pkg/platform/sources/wecombot.yaml b/src/langbot/pkg/platform/sources/wecombot.yaml index 487c2df6..ceb97b7b 100644 --- a/src/langbot/pkg/platform/sources/wecombot.yaml +++ b/src/langbot/pkg/platform/sources/wecombot.yaml @@ -11,13 +11,6 @@ metadata: icon: wecombot.png spec: config: - - name: port - label: - en_US: Port - zh_Hans: 监听端口 - type: integer - required: true - default: 2291 - name: Corpid label: en_US: Corpid diff --git a/src/langbot/pkg/platform/sources/wecomcs.py b/src/langbot/pkg/platform/sources/wecomcs.py index 756daec0..7d3a6ff7 100644 --- a/src/langbot/pkg/platform/sources/wecomcs.py +++ b/src/langbot/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 diff --git a/src/langbot/pkg/platform/sources/wecomcs.yaml b/src/langbot/pkg/platform/sources/wecomcs.yaml index 5b12150b..7d4f398d 100644 --- a/src/langbot/pkg/platform/sources/wecomcs.yaml +++ b/src/langbot/pkg/platform/sources/wecomcs.yaml @@ -11,13 +11,6 @@ metadata: icon: wecom.png spec: config: - - name: port - label: - en_US: Port - zh_Hans: 监听端口 - type: int - required: true - default: 2289 - name: corpid label: en_US: Corpid diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index 2df38662..449b245d 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -1,6 +1,7 @@ admins: [] api: port: 5300 + webhook_prefix: 'http://127.0.0.1:5300' command: enable: true prefix: diff --git a/tests/unit_tests/config/test_webhook_display_prefix.py b/tests/unit_tests/config/test_webhook_display_prefix.py new file mode 100644 index 00000000..8331befc --- /dev/null +++ b/tests/unit_tests/config/test_webhook_display_prefix.py @@ -0,0 +1,143 @@ +""" +Tests for webhook_prefix configuration +""" + +import os +import pytest +from typing import Any + + +def _apply_env_overrides_to_config(cfg: dict) -> dict: + """Apply environment variable overrides to data/config.yaml + + Environment variables should be uppercase and use __ (double underscore) + to represent nested keys. For example: + - CONCURRENCY__PIPELINE overrides concurrency.pipeline + - PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url + + Arrays and dict types are ignored. + + Args: + cfg: Configuration dictionary + + Returns: + Updated configuration dictionary + """ + + def convert_value(value: str, original_value: Any) -> Any: + """Convert string value to appropriate type based on original value + + Args: + value: String value from environment variable + original_value: Original value to infer type from + + Returns: + Converted value (falls back to string if conversion fails) + """ + if isinstance(original_value, bool): + return value.lower() in ('true', '1', 'yes', 'on') + elif isinstance(original_value, int): + try: + return int(value) + except ValueError: + # If conversion fails, keep as string (user error, but non-breaking) + return value + elif isinstance(original_value, float): + try: + return float(value) + except ValueError: + # If conversion fails, keep as string (user error, but non-breaking) + return value + else: + return value + + # Process environment variables + for env_key, env_value in os.environ.items(): + # Check if the environment variable is uppercase and contains __ + if not env_key.isupper(): + continue + if '__' not in env_key: + continue + + # Convert environment variable name to config path + # e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline'] + keys = [key.lower() for key in env_key.split('__')] + + # Navigate to the target value and validate the path + current = cfg + + for i, key in enumerate(keys): + if not isinstance(current, dict) or key not in current: + break + + if i == len(keys) - 1: + # At the final key - check if it's a scalar value + if isinstance(current[key], (dict, list)): + # Skip dict and list types + pass + else: + # Valid scalar value - convert and set it + converted_value = convert_value(env_value, current[key]) + current[key] = converted_value + else: + # Navigate deeper + current = current[key] + + return cfg + + +class TestWebhookDisplayPrefix: + """Test webhook_prefix configuration functionality""" + + def test_default_webhook_prefix(self): + """Test that the default webhook display prefix is correctly set""" + cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}} + + # Should have the default value + assert cfg['api']['webhook_prefix'] == 'http://127.0.0.1:5300' + + def test_webhook_prefix_env_override(self): + """Test overriding webhook_prefix via environment variable""" + cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}} + + # Set environment variable + os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com:8080' + + result = _apply_env_overrides_to_config(cfg) + + assert result['api']['webhook_prefix'] == 'https://example.com:8080' + + # Cleanup + del os.environ['API__WEBHOOK_PREFIX'] + + def test_webhook_prefix_with_custom_domain(self): + """Test webhook_prefix with custom domain""" + cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}} + + # Set to a custom domain + os.environ['API__WEBHOOK_PREFIX'] = 'https://bot.mycompany.com' + + result = _apply_env_overrides_to_config(cfg) + + assert result['api']['webhook_prefix'] == 'https://bot.mycompany.com' + + # Cleanup + del os.environ['API__WEBHOOK_PREFIX'] + + def test_webhook_prefix_with_subdirectory(self): + """Test webhook_prefix with subdirectory path""" + cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}} + + # Set to a URL with subdirectory + os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com/langbot' + + result = _apply_env_overrides_to_config(cfg) + + assert result['api']['webhook_prefix'] == 'https://example.com/langbot' + + # Cleanup + del os.environ['API__WEBHOOK_PREFIX'] + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index 4288599f..bee5748d 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { IChooseAdapterEntity, IPipelineEntity, @@ -112,11 +112,86 @@ export default function BotForm({ IDynamicFormItemSchema[] >([]); const [, setIsLoading] = useState(false); + const [webhookUrl, setWebhookUrl] = useState(''); + const webhookInputRef = React.useRef(null); useEffect(() => { setBotFormValues(); }, []); + // 复制到剪贴板的辅助函数 - 使用页面上的真实input元素 + const copyToClipboard = () => { + console.log('[Copy] Attempting to copy from input element'); + + const inputElement = webhookInputRef.current; + if (!inputElement) { + console.error('[Copy] Input element not found'); + toast.error(t('common.copyFailed')); + return; + } + + try { + // 确保input元素可见且未被禁用 + inputElement.disabled = false; + inputElement.readOnly = false; + + // 聚焦并选中所有文本 + inputElement.focus(); + inputElement.select(); + + // 尝试使用现代API + if (navigator.clipboard && navigator.clipboard.writeText) { + console.log( + '[Copy] Using Clipboard API with input value:', + inputElement.value, + ); + navigator.clipboard + .writeText(inputElement.value) + .then(() => { + console.log('[Copy] Clipboard API success'); + inputElement.blur(); // 取消选中 + inputElement.readOnly = true; + toast.success(t('bots.webhookUrlCopied')); + }) + .catch((err) => { + console.error( + '[Copy] Clipboard API failed, trying execCommand:', + err, + ); + // 降级到execCommand + const successful = document.execCommand('copy'); + console.log('[Copy] execCommand result:', successful); + inputElement.blur(); + inputElement.readOnly = true; + if (successful) { + toast.success(t('bots.webhookUrlCopied')); + } else { + toast.error(t('common.copyFailed')); + } + }); + } else { + // 直接使用execCommand + console.log( + '[Copy] Using execCommand with input value:', + inputElement.value, + ); + const successful = document.execCommand('copy'); + console.log('[Copy] execCommand result:', successful); + inputElement.blur(); + inputElement.readOnly = true; + if (successful) { + toast.success(t('bots.webhookUrlCopied')); + } else { + toast.error(t('common.copyFailed')); + } + } + } catch (err) { + console.error('[Copy] Copy failed:', err); + inputElement.readOnly = true; + toast.error(t('common.copyFailed')); + } + }; + function setBotFormValues() { initBotFormComponent().then(() => { // 拉取初始化表单信息 @@ -131,12 +206,20 @@ export default function BotForm({ form.setValue('use_pipeline_uuid', val.use_pipeline_uuid || ''); handleAdapterSelect(val.adapter); // dynamicForm.setFieldsValue(val.adapter_config); + + // 设置 webhook 地址(如果有) + if (val.webhook_full_url) { + setWebhookUrl(val.webhook_full_url); + } else { + setWebhookUrl(''); + } }) .catch((err) => { toast.error(t('bots.getBotConfigError') + err.message); }); } else { form.reset(); + setWebhookUrl(''); } }); } @@ -209,7 +292,7 @@ export default function BotForm({ async function getBotConfig( botId: string, - ): Promise> { + ): Promise & { webhook_full_url?: string }> { return new Promise((resolve, reject) => { httpClient .getBot(botId) @@ -222,6 +305,10 @@ export default function BotForm({ adapter_config: bot.adapter_config, enable: bot.enable ?? true, use_pipeline_uuid: bot.use_pipeline_uuid ?? '', + webhook_full_url: bot.adapter_runtime_values + ? ((bot.adapter_runtime_values as Record) + .webhook_full_url as string) + : undefined, }); }) .catch((err) => { @@ -360,51 +447,86 @@ export default function BotForm({
{/* 是否启用 & 绑定流水线 仅在编辑模式 */} {initBotId && ( -
- ( - - {t('common.enable')} - - - - - )} - /> + <> +
+ ( + + {t('common.enable')} + + + + + )} + /> - ( - - {t('bots.bindPipeline')} - - - - - )} - /> -
+ ( + + {t('bots.bindPipeline')} + + + + + )} + /> +
+ + {/* Webhook 地址显示(统一 Webhook 模式) */} + {webhookUrl && ( + + {t('bots.webhookUrl')} +
+ { + // 点击输入框时自动全选 + (e.target as HTMLInputElement).select(); + }} + /> + +
+

+ {t('bots.webhookUrlHint')} +

+
+ )} + )}