Compare commits

..

15 Commits

Author SHA1 Message Date
Dong_master
16ec4b4f7c fix: del port 2025-12-01 21:52:43 +08:00
Dong_master
87de625dc9 feat:add lark unified_webhook but bug webhook url 404 2025-12-01 21:52:13 +08:00
wangcham
767ae34ed3 feat: finish the fxxking line adapter 2025-11-30 17:05:31 +08:00
Copilot
13ed6c8d46 feat: add configurable webhook display prefix (#1797)
* Initial plan

* Add webhook_display_prefix configuration option

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* perf: change config field name

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
2025-11-23 14:04:40 +08:00
Junyan Qin
2412e080b4 fix: import errors 2025-11-21 23:39:19 +08:00
wangcham
442c93193c merge: merge master into feat/unified_webhook
Resolved conflicts by keeping current branch changes for webhook feature files:
- src/langbot/libs/wecom_ai_bot_api/api.py
- src/langbot/libs/wecom_ai_bot_api/wecombotevent.py
- src/langbot/pkg/api/http/controller/groups/webhooks.py
- src/langbot/pkg/platform/sources/officialaccount.py
- src/langbot/pkg/platform/sources/qqofficial.py
- src/langbot/pkg/platform/sources/wecom.py
- src/langbot/pkg/platform/sources/wecombot.py

Merged master branch changes including:
- Project restructure: moved files from pkg/ and libs/ to src/langbot/
- New features: API key auth, MCP resources, pipeline extensions
- Documentation updates: AGENTS.md, CLAUDE.md, API docs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 16:50:52 +08:00
wangcham
2a87419fb2 fix: modify wecomcs 2025-11-18 16:09:34 +08:00
wangcham
9855b6d5bc feat: add wecomcs 2025-11-17 16:55:56 +08:00
wangcham
403a721b94 fix: qqo webhook 2025-11-17 16:21:01 +08:00
wangcham
2ef47ebfb1 fix: errors when npm lint 2025-11-13 14:30:26 +08:00
wangcham
81e411c558 feat: qqo 2025-11-07 21:41:10 +08:00
wangcham
ad64e89a86 fix: slack adapter 2025-11-06 16:50:14 +00:00
wangcham
913b9a24c4 feat: add support for wecombot,wxoa,slack and qqo 2025-11-06 09:21:17 +00:00
wangcham
ceb38d91b4 feat: add unified webhook for wecom 2025-11-05 14:23:10 +00:00
wangcham
a0dec39905 fix: wecombot id 2025-11-05 03:54:33 +00:00
59 changed files with 1240 additions and 770 deletions

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.5.4"
version = "4.5.3"
description = "Easy-to-use global IM bot platform designed for LLM era"
readme = "README.md"
license-files = ["LICENSE"]
@@ -63,7 +63,7 @@ dependencies = [
"langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.1.12",
"langbot-plugin==0.1.11",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",

View File

@@ -1,3 +1,3 @@
"""LangBot - Easy-to-use global IM bot platform designed for LLM era"""
__version__ = '4.5.4'
__version__ = '4.5.3'

View File

@@ -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')

View File

@@ -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

View File

@@ -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':

View File

@@ -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' 字段")

View File

@@ -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:
"""

View File

@@ -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('消息解密失败')

View File

@@ -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}')

View File

@@ -18,7 +18,8 @@ class BotsRouterGroup(group.RouterGroup):
@self.route('/<bot_uuid>', 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})

View File

@@ -55,6 +55,17 @@ class SystemRouterGroup(group.RouterGroup):
return self.success(data=exec(py_code, {'ap': ap}))
@self.route('/debug/tools/call', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
if not constants.debug_mode:
return self.http_status(403, 403, 'Forbidden')
data = await quart.request.json
return self.success(
data=await self.ap.tool_mgr.execute_func_call(data['tool_name'], data['tool_parameters'])
)
@self.route(
'/debug/plugin/action',
methods=['POST'],

View File

@@ -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('/<bot_uuid>', 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('/<bot_uuid>/<path:path>', 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('/<int:webhook_id>', 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

View File

@@ -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', "lark"]:
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

View File

@@ -96,7 +96,7 @@ class ResponseWrapper(stage.PipelineStage):
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
function_names = [tc.function.name for tc in result.tool_calls]
reply_text = f'Call {".".join(function_names)}...'
reply_text = f'调用函数 {".".join(function_names)}...'
query.resp_message_chain.append(
platform_message.MessageChain([platform_message.Plain(text=reply_text)])

View File

@@ -247,6 +247,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()

View File

@@ -342,6 +342,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
quart_app: quart.Quart = pydantic.Field(exclude=True)
bot_uuid: str = None
card_id_dict: dict[str, str] # 消息id到卡片id的映射便于创建卡片后的发送消息到指定卡片
seq: int # 用于在发送卡片消息中识别消息顺序直接以seq作为标识
@@ -349,46 +351,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
quart_app = quart.Quart(__name__)
@quart_app.route('/lark/callback', methods=['POST'])
async def lark_callback():
try:
data = await quart.request.json
if 'encrypt' in data:
cipher = AESCipher(config['encrypt-key'])
data = cipher.decrypt_string(data['encrypt'])
data = json.loads(data)
type = data.get('type')
if type is None:
context = EventContext(data)
type = context.header.event_type
if 'url_verification' == type:
# todo 验证verification token
return {'challenge': data.get('challenge')}
context = EventContext(data)
type = context.header.event_type
p2v1 = P2ImMessageReceiveV1()
p2v1.header = context.header
event = P2ImMessageReceiveV1Data()
event.message = EventMessage(context.event['message'])
event.sender = EventSender(context.event['sender'])
p2v1.event = event
p2v1.schema = context.schema
if 'im.message.receive_v1' == type:
try:
event = await self.event_converter.target2yiri(p2v1, self.api_client)
except Exception:
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
return {'code': 200, 'message': 'ok'}
except Exception:
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
return {'code': 500, 'message': 'error'}
async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
lb_event = await self.event_converter.target2yiri(event, self.api_client)
@@ -774,8 +736,65 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
):
self.listeners.pop(event_type)
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:
响应数据
"""
try:
data = await quart.request.json
if 'encrypt' in data:
cipher = AESCipher(config['encrypt-key'])
data = cipher.decrypt_string(data['encrypt'])
data = json.loads(data)
type = data.get('type')
if type is None:
context = EventContext(data)
type = context.header.event_type
if 'url_verification' == type:
# todo 验证verification token
return {'challenge': data.get('challenge')}
context = EventContext(data)
type = context.header.event_type
p2v1 = P2ImMessageReceiveV1()
p2v1.header = context.header
event = P2ImMessageReceiveV1Data()
event.message = EventMessage(context.event['message'])
event.sender = EventSender(context.event['sender'])
p2v1.event = event
p2v1.schema = context.schema
if 'im.message.receive_v1' == type:
try:
event = await self.event_converter.target2yiri(p2v1, self.api_client)
except Exception:
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
if event.__class__ in self.listeners:
await self.listeners[event.__class__](event, self)
return {'code': 200, 'message': 'ok'}
except Exception:
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
return {'code': 500, 'message': 'error'}
async def run_async(self):
port = self.config['port']
enable_webhook = self.config['enable-webhook']
if not enable_webhook:
@@ -791,16 +810,28 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
raise e
else:
async def shutdown_trigger_placeholder():
while True:
await asyncio.sleep(1)
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
await self.quart_app.run_task(
host='0.0.0.0',
port=port,
shutdown_trigger=shutdown_trigger_placeholder,
)
# 打印 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://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('Lark 机器人 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在 Lark 机器人后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
async def keep_alive():
while True:
await asyncio.sleep(1)
await keep_alive()
async def kill(self) -> bool:
# 需要断开连接,不然旧的连接会继续运行,导致飞书消息来时会随机选择一个连接
# 断开时lark.ws.Client的_receive_message_loop会打印error日志: receive message loop exit。然后进行重连

View File

@@ -11,6 +11,16 @@ metadata:
icon: lark.svg
spec:
config:
- name: enable-webhook
label:
en_US: Enable Webhook Mode
zh_Hans: 启用Webhook模式
description:
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
type: boolean
required: true
default: false
- name: app_id
label:
en_US: App ID
@@ -35,26 +45,6 @@ spec:
type: string
required: true
default: ""
- name: enable-webhook
label:
en_US: Enable Webhook Mode
zh_Hans: 启用Webhook模式
description:
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
type: boolean
required: true
default: false
- 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 模式时有效,请填写 Webhook 端口
type: integer
required: true
default: 2285
- name: encrypt-key
label:
en_US: Encrypt Key

View File

@@ -122,6 +122,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
@@ -133,7 +134,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的映射便于创建卡片后的发送消息到指定卡片
@@ -150,7 +151,6 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
super().__init__(
config=config,
logger=logger,
quart_app=quart.Quart(__name__),
listeners={},
card_id_dict={},
seq=1,
@@ -164,29 +164,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
@@ -235,18 +212,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://<Your-Public-IP>:{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

View File

@@ -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

View File

@@ -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://<Your-Public-IP>:{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

View File

@@ -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

View File

@@ -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://<Your-Public-IP>:{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

View File

@@ -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

View File

@@ -86,13 +86,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',
@@ -101,8 +100,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(
@@ -148,16 +157,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://<Your-Public-IP>:{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

View File

@@ -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

View File

@@ -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://<Your-Public-IP>:{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

View File

@@ -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

View File

@@ -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://<Your-Public-IP>:{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

View File

@@ -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

View File

@@ -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://<Your-Public-IP>:{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

View File

@@ -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

View File

@@ -8,7 +8,6 @@ import os
import sys
import httpx
from async_lru import alru_cache
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
from ..core import app
from . import handler
@@ -322,20 +321,13 @@ class PluginRuntimeConnector:
return tools
async def call_tool(
self,
tool_name: str,
parameters: dict[str, Any],
session: provider_session.Session,
query_id: int,
bound_plugins: list[str] | None = None,
self, tool_name: str, parameters: dict[str, Any], bound_plugins: list[str] | None = None
) -> dict[str, Any]:
if not self.is_enable_plugin:
return {'error': 'Tool not found: plugin system is disabled'}
# Pass include_plugins to runtime for validation
return await self.handler.call_tool(
tool_name, parameters, session.model_dump(serialize_as_any=True), query_id, include_plugins=bound_plugins
)
return await self.handler.call_tool(tool_name, parameters, include_plugins=bound_plugins)
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
if not self.is_enable_plugin:

View File

@@ -620,12 +620,7 @@ class RuntimeConnectionHandler(handler.Handler):
)
async def call_tool(
self,
tool_name: str,
parameters: dict[str, Any],
session: dict[str, Any],
query_id: int,
include_plugins: list[str] | None = None,
self, tool_name: str, parameters: dict[str, Any], include_plugins: list[str] | None = None
) -> dict[str, Any]:
"""Call tool"""
result = await self.call_action(
@@ -633,8 +628,6 @@ class RuntimeConnectionHandler(handler.Handler):
{
'tool_name': tool_name,
'tool_parameters': parameters,
'session': session,
'query_id': query_id,
'include_plugins': include_plugins,
},
timeout=60,

View File

@@ -196,7 +196,7 @@ class LocalAgentRunner(runner.RequestRunner):
parameters = json.loads(func.arguments)
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query)
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters)
if is_stream:
msg = provider_message.MessageChunk(
role='tool',

View File

@@ -3,8 +3,6 @@ from __future__ import annotations
import abc
import typing
from langbot_plugin.api.entities.events import pipeline_query
from ...core import app
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
@@ -47,7 +45,7 @@ class ToolLoader(abc.ABC):
pass
@abc.abstractmethod
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
"""执行工具调用"""
pass

View File

@@ -4,7 +4,6 @@ import enum
import typing
from contextlib import AsyncExitStack
import traceback
from langbot_plugin.api.entities.events import pipeline_query
import sqlalchemy
import asyncio
@@ -330,7 +329,7 @@ class MCPLoader(loader.ToolLoader):
return True
return False
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
"""执行工具调用"""
for session in self.sessions.values():
for function in session.get_tools():

View File

@@ -3,8 +3,6 @@ from __future__ import annotations
import typing
import traceback
from langbot_plugin.api.entities.events import pipeline_query
from .. import loader
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
@@ -45,11 +43,9 @@ class PluginToolLoader(loader.ToolLoader):
return tool
return None
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
try:
return await self.ap.plugin_connector.call_tool(
name, parameters, session=query.session, query_id=query.query_id
)
return await self.ap.plugin_connector.call_tool(name, parameters)
except Exception as e:
self.ap.logger.error(f'执行函数 {name} 时发生错误: {e}')
traceback.print_exc()

View File

@@ -7,7 +7,6 @@ from langbot.pkg.utils import importutil
from langbot.pkg.provider.tools import loaders
from langbot.pkg.provider.tools.loaders import mcp as mcp_loader, plugin as plugin_loader
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from langbot_plugin.api.entities.events import pipeline_query
importutil.import_modules_in_pkg(loaders)
@@ -92,13 +91,13 @@ class ToolManager:
return tools
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
async def execute_func_call(self, name: str, parameters: dict) -> typing.Any:
"""执行函数调用"""
if await self.plugin_tool_loader.has_tool(name):
return await self.plugin_tool_loader.invoke_tool(name, parameters, query)
return await self.plugin_tool_loader.invoke_tool(name, parameters)
elif await self.mcp_tool_loader.has_tool(name):
return await self.mcp_tool_loader.invoke_tool(name, parameters, query)
return await self.mcp_tool_loader.invoke_tool(name, parameters)
else:
raise ValueError(f'未找到工具: {name}')

View File

@@ -1,6 +1,7 @@
admins: []
api:
port: 5300
webhook_prefix: 'http://127.0.0.1:5300'
command:
enable: true
prefix:

View File

@@ -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'])

View File

@@ -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<boolean>(false);
const [webhookUrl, setWebhookUrl] = useState<string>('');
const webhookInputRef = React.useRef<HTMLInputElement>(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(() => {
// 拉取初始化表单信息
@@ -132,12 +207,20 @@ export default function BotForm({
console.log('form', form.getValues());
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('');
}
});
}
@@ -212,7 +295,7 @@ export default function BotForm({
async function getBotConfig(
botId: string,
): Promise<z.infer<typeof formSchema>> {
): Promise<z.infer<typeof formSchema> & { webhook_full_url?: string }> {
return new Promise((resolve, reject) => {
httpClient
.getBot(botId)
@@ -225,6 +308,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<string, unknown>)
.webhook_full_url as string)
: undefined,
});
})
.catch((err) => {
@@ -274,6 +361,20 @@ export default function BotForm({
console.log('update bot success', res);
onFormSubmit(form.getValues());
toast.success(t('bots.saveSuccess'));
// 保存成功后重新拉取机器人配置,更新 webhook 地址(依赖后端 runtime 计算)
httpClient
.getBot(initBotId)
.then((getRes) => {
const runtimeValues = getRes.bot.adapter_runtime_values as Record<string, unknown> | undefined;
const newWebhookUrl = runtimeValues
? ((runtimeValues.webhook_full_url as string) || '')
: '';
setWebhookUrl(newWebhookUrl);
})
.catch((err) => {
console.warn('refresh webhook url failed', err);
});
})
.catch((err) => {
toast.error(t('bots.saveError') + err.message);
@@ -328,6 +429,18 @@ export default function BotForm({
}
}
// 计算动态表单渲染列表:启用 Lark 的 Webhook 时隐藏 app_id 与 app_secret
const dynamicFormConfigListToRender = (() => {
const isLark = form.watch('adapter') === 'lark';
const enableWebhook = !!form.watch('adapter_config')?.['enable-webhook'];
if (isLark && enableWebhook) {
return dynamicFormConfigList.filter(
(item) => item.name !== 'app_id' && item.name !== 'app_secret',
);
}
return dynamicFormConfigList;
})();
return (
<div>
<Dialog
@@ -368,51 +481,88 @@ export default function BotForm({
<div className="space-y-4">
{/* 是否启用 & 绑定流水线 仅在编辑模式 */}
{initBotId && (
<div className="flex items-center gap-6">
<FormField
control={form.control}
name="enable"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('common.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<>
<div className="flex items-center gap-6">
<FormField
control={form.control}
name="enable"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('common.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="use_pipeline_uuid"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
<SelectGroup>
{pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="use_pipeline_uuid"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
<SelectGroup>
{pipelineNameList.map((item) => (
<SelectItem
key={item.value}
value={item.value}
>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Webhook 地址显示(统一 Webhook 模式) */}
{form.watch('adapter') === 'lark' &&
!!form.watch('adapter_config')?.['enable-webhook'] &&
webhookUrl && (
<FormItem>
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
<div className="flex items-center gap-2">
<Input
ref={webhookInputRef}
value={webhookUrl}
readOnly
className="flex-1 bg-gray-50 dark:bg-gray-900"
onClick={(e) => {
// 点击输入框时自动全选
(e.target as HTMLInputElement).select();
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={copyToClipboard}
>
{t('common.copy')}
</Button>
</div>
<p className="text-sm text-gray-500 mt-1">
{t('bots.webhookUrlHint')}
</p>
</FormItem>
)}
</>
)}
<FormField
@@ -508,16 +658,17 @@ export default function BotForm({
</div>
)}
{showDynamicForm && dynamicFormConfigList.length > 0 && (
{showDynamicForm && dynamicFormConfigListToRender.length > 0 && (
<div className="space-y-4">
<div className="text-lg font-medium">
{t('bots.adapterConfig')}
</div>
<DynamicFormComponent
itemConfigList={dynamicFormConfigList}
itemConfigList={dynamicFormConfigListToRender}
initialValues={form.watch('adapter_config')}
onSubmit={(values) => {
form.setValue('adapter_config', values);
const prev = form.getValues('adapter_config') || {};
form.setValue('adapter_config', { ...prev, ...values });
}}
/>
</div>

View File

@@ -289,16 +289,12 @@ export default function ApiIntegrationDialog({
{t('common.noApiKeys')}
</div>
) : (
<div className="border rounded-md overflow-x-auto">
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[120px]">
{t('common.name')}
</TableHead>
<TableHead className="min-w-[200px]">
{t('common.apiKeyValue')}
</TableHead>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.apiKeyValue')}</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
@@ -376,20 +372,16 @@ export default function ApiIntegrationDialog({
{t('common.noWebhooks')}
</div>
) : (
<div className="border rounded-md overflow-x-auto max-w-full">
<Table className="table-fixed w-full">
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[150px]">
{t('common.name')}
</TableHead>
<TableHead className="w-[380px]">
{t('common.webhookUrl')}
</TableHead>
<TableHead>{t('common.name')}</TableHead>
<TableHead>{t('common.webhookUrl')}</TableHead>
<TableHead className="w-[80px]">
{t('common.webhookEnabled')}
</TableHead>
<TableHead className="w-[80px]">
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
@@ -397,30 +389,20 @@ export default function ApiIntegrationDialog({
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell className="truncate">
<div className="truncate">
<div
className="font-medium truncate"
title={webhook.name}
>
{webhook.name}
</div>
<TableCell>
<div>
<div className="font-medium">{webhook.name}</div>
{webhook.description && (
<div
className="text-sm text-muted-foreground truncate"
title={webhook.description}
>
<div className="text-sm text-muted-foreground">
{webhook.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="overflow-x-auto max-w-[380px]">
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
{webhook.url}
</code>
</div>
<code className="text-sm bg-muted px-2 py-1 rounded break-all">
{webhook.url}
</code>
</TableCell>
<TableCell>
<Switch

View File

@@ -234,19 +234,7 @@ export default function PipelineExtension({
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={(() => {
const componentKindCount: Record<string, number> =
{};
for (const component of plugin.components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
return componentKindCount;
})()}
components={plugin.components}
showComponentName={true}
showTitle={false}
useBadge={true}
@@ -413,19 +401,7 @@ export default function PipelineExtension({
</div>
<div className="flex gap-1 mt-1">
<PluginComponentList
components={(() => {
const componentKindCount: Record<string, number> =
{};
for (const component of plugin.components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
return componentKindCount;
})()}
components={plugin.components}
showComponentName={true}
showTitle={false}
useBadge={true}

View File

@@ -1,3 +1,4 @@
import { PluginComponent } from '@/app/infra/entities/plugin';
import { TFunction } from 'i18next';
import { Wrench, AudioWaveform, Hash } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
@@ -8,22 +9,31 @@ export default function PluginComponentList({
showTitle,
useBadge,
t,
responsive = false,
}: {
components: Record<string, number>;
components: PluginComponent[];
showComponentName: boolean;
showTitle: boolean;
useBadge: boolean;
t: TFunction;
responsive?: boolean;
}) {
const componentKindCount: Record<string, number> = {};
for (const component of components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-5 h-5" />,
EventListener: <AudioWaveform className="w-5 h-5" />,
Command: <Hash className="w-5 h-5" />,
};
const componentKindList = Object.keys(components || {});
const componentKindList = Object.keys(componentKindCount);
return (
<>
@@ -34,21 +44,11 @@ export default function PluginComponentList({
return (
<>
{useBadge && (
<Badge
key={kind}
variant="outline"
className="flex items-center gap-1"
>
<Badge variant="outline">
{kindIconMap[kind]}
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
{responsive ? (
<span className="hidden md:inline">
{t('plugins.componentName.' + kind)}
</span>
) : (
showComponentName && t('plugins.componentName.' + kind)
)}
<span className="ml-1">{components[kind]}</span>
{showComponentName &&
t('plugins.componentName.' + kind) + ' '}
{componentKindCount[kind]}
</Badge>
)}
@@ -58,15 +58,9 @@ export default function PluginComponentList({
className="flex flex-row items-center justify-start gap-[0.2rem]"
>
{kindIconMap[kind]}
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
{responsive ? (
<span className="hidden md:inline">
{t('plugins.componentName.' + kind)}
</span>
) : (
showComponentName && t('plugins.componentName.' + kind)
)}
<span className="ml-1">{components[kind]}</span>
{showComponentName &&
t('plugins.componentName.' + kind) + ' '}
{componentKindCount[kind]}
</div>
)}
</>

View File

@@ -128,18 +128,7 @@ export default function PluginCardComponent({
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
<PluginComponentList
components={(() => {
const componentKindCount: Record<string, number> = {};
for (const component of cardVO.components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
return componentKindCount;
})()}
components={cardVO.components}
showComponentName={false}
showTitle={true}
useBadge={false}

View File

@@ -160,18 +160,7 @@ export default function PluginForm({
<div className="mb-4 flex flex-row items-center justify-start gap-[0.4rem]">
<PluginComponentList
components={(() => {
const componentKindCount: Record<string, number> = {};
for (const component of pluginInfo.components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
return componentKindCount;
})()}
components={pluginInfo.components}
showComponentName={true}
showTitle={false}
useBadge={true}

View File

@@ -10,8 +10,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { Search, Loader2, Wrench, AudioWaveform, Hash } from 'lucide-react';
import { Search, Loader2 } from 'lucide-react';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
import PluginDetailDialog from './plugin-detail-dialog/PluginDetailDialog';
@@ -39,7 +38,6 @@ function MarketPageContent({
const searchParams = useSearchParams();
const [searchQuery, setSearchQuery] = useState('');
const [componentFilter, setComponentFilter] = useState<string>('all');
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@@ -113,7 +111,6 @@ function MarketPageContent({
),
githubURL: plugin.repository,
version: plugin.latest_version,
components: plugin.components,
});
}, []);
@@ -127,20 +124,25 @@ function MarketPageContent({
}
try {
let response;
const { sortBy, sortOrder } = getCurrentSort();
const filterValue =
componentFilter === 'all' ? undefined : componentFilter;
// Always use searchMarketplacePlugins to support component filtering
const response =
await getCloudServiceClientSync().searchMarketplacePlugins(
isSearch && searchQuery.trim() ? searchQuery.trim() : '',
if (isSearch && searchQuery.trim()) {
response = await getCloudServiceClientSync().searchMarketplacePlugins(
searchQuery.trim(),
page,
pageSize,
sortBy,
sortOrder,
filterValue,
);
} else {
response = await getCloudServiceClientSync().getMarketplacePlugins(
page,
pageSize,
sortBy,
sortOrder,
);
}
const data: ApiRespMarketplacePlugins = response;
const newPlugins = data.plugins.map(transformToVO);
@@ -165,14 +167,7 @@ function MarketPageContent({
setIsLoadingMore(false);
}
},
[
searchQuery,
componentFilter,
pageSize,
transformToVO,
plugins.length,
getCurrentSort,
],
[searchQuery, pageSize, transformToVO, plugins.length, getCurrentSort],
);
// 初始加载
@@ -217,18 +212,10 @@ function MarketPageContent({
// fetchPlugins will be called by useEffect when sortOption changes
}, []);
// 组件筛选变化处理
const handleComponentFilterChange = useCallback((value: string) => {
setComponentFilter(value);
setCurrentPage(1);
setPlugins([]);
// fetchPlugins will be called by useEffect when componentFilter changes
}, []);
// 当排序选项或组件筛选变化时重新加载数据
// 当排序选项变化时重新加载数据
useEffect(() => {
fetchPlugins(1, !!searchQuery.trim(), true);
}, [sortOption, componentFilter]);
}, [sortOption]);
// 处理URL参数检查是否需要打开插件详情对话框
useEffect(() => {
@@ -355,59 +342,9 @@ function MarketPageContent({
</div>
</div>
{/* Component filter and sort */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-3 sm:px-4">
{/* Component filter */}
<div className="flex flex-col sm:flex-row items-center gap-2">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.filterByComponent')}:
</span>
<ToggleGroup
type="single"
spacing={2}
size="sm"
value={componentFilter}
onValueChange={(value) => {
if (value) handleComponentFilterChange(value);
}}
className="justify-start"
>
<ToggleGroupItem
value="all"
aria-label="All components"
className="text-xs sm:text-sm"
>
{t('market.allComponents')}
</ToggleGroupItem>
<ToggleGroupItem
value="Tool"
aria-label="Tool"
className="text-xs sm:text-sm"
>
<Wrench className="h-4 w-4 mr-1" />
{t('plugins.componentName.Tool')}
</ToggleGroupItem>
<ToggleGroupItem
value="Command"
aria-label="Command"
className="text-xs sm:text-sm"
>
<Hash className="h-4 w-4 mr-1" />
{t('plugins.componentName.Command')}
</ToggleGroupItem>
<ToggleGroupItem
value="EventListener"
aria-label="EventListener"
className="text-xs sm:text-sm"
>
<AudioWaveform className="h-4 w-4 mr-1" />
{t('plugins.componentName.EventListener')}
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Sort dropdown */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Sort dropdown */}
<div className="flex items-center justify-center">
<div className="w-full max-w-2xl flex items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.sortBy')}:
</span>

View File

@@ -12,7 +12,6 @@ import { toast } from 'sonner';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
interface PluginDetailDialogProps {
open: boolean;
@@ -105,15 +104,6 @@ export default function PluginDetailDialog({
<Download className="w-4 h-4" />
{plugin!.install_count.toLocaleString()} {t('market.downloads')}
</Badge>
{plugin!.components && Object.keys(plugin!.components).length > 0 && (
<PluginComponentList
components={plugin!.components}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
)}
{plugin!.repository && (
<button
onClick={(e) => {

View File

@@ -1,7 +1,4 @@
import { PluginMarketCardVO } from './PluginMarketCardVO';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Wrench, AudioWaveform, Hash } from 'lucide-react';
export default function PluginMarketCardComponent({
cardVO,
@@ -10,32 +7,18 @@ export default function PluginMarketCardComponent({
cardVO: PluginMarketCardVO;
onPluginClick?: (author: string, pluginName: string) => void;
}) {
const { t } = useTranslation();
function handleCardClick() {
if (onPluginClick) {
onPluginClick(cardVO.author, cardVO.pluginName);
}
}
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-4 h-4" />,
EventListener: <AudioWaveform className="w-4 h-4" />,
Command: <Hash className="w-4 h-4" />,
};
const componentKindNameMap: Record<string, string> = {
Tool: t('plugins.componentName.Tool'),
EventListener: t('plugins.componentName.EventListener'),
Command: t('plugins.componentName.Command'),
};
return (
<div
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] cursor-pointer hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22]"
className="w-[100%] h-auto min-h-[8rem] sm:h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] cursor-pointer hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22]"
onClick={handleCardClick}
>
<div className="w-full h-full flex flex-col justify-between gap-3">
<div className="w-full h-full flex flex-col justify-between gap-2">
{/* 上部分:插件信息 */}
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0">
<img
@@ -77,45 +60,23 @@ export default function PluginMarketCardComponent({
</div>
</div>
{/* 下部分:下载量和组件列表 */}
<div className="w-full flex flex-row items-center justify-between gap-[0.3rem] sm:gap-[0.4rem] px-0 sm:px-[0.4rem] flex-shrink-0">
<div className="flex flex-row items-center justify-start gap-[0.3rem] sm:gap-[0.4rem]">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] font-medium whitespace-nowrap">
{cardVO.installCount.toLocaleString()}
</div>
{/* 下部分:下载量 */}
<div className="w-full flex flex-row items-center justify-start gap-[0.3rem] sm:gap-[0.4rem] px-0 sm:px-[0.4rem] flex-shrink-0">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] font-medium whitespace-nowrap">
{cardVO.installCount.toLocaleString()}
</div>
{/* 组件列表 */}
{cardVO.components && Object.keys(cardVO.components).length > 0 && (
<div className="flex flex-row items-center gap-1">
{Object.entries(cardVO.components).map(([kind, count]) => (
<Badge
key={kind}
variant="outline"
className="flex items-center gap-1"
>
{kindIconMap[kind]}
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
<span className="hidden md:inline">
{componentKindNameMap[kind]}
</span>
<span className="ml-1">{count}</span>
</Badge>
))}
</div>
)}
</div>
</div>
</div>

View File

@@ -8,7 +8,6 @@ export interface IPluginMarketCardVO {
iconURL: string;
githubURL: string;
version: string;
components?: Record<string, number>;
}
export class PluginMarketCardVO implements IPluginMarketCardVO {
@@ -21,7 +20,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
githubURL: string;
installCount: number;
version: string;
components?: Record<string, number>;
constructor(prop: IPluginMarketCardVO) {
this.description = prop.description;
@@ -33,6 +31,5 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
this.installCount = prop.installCount;
this.pluginId = prop.pluginId;
this.version = prop.version;
this.components = prop.components;
}
}

View File

@@ -142,6 +142,7 @@ export interface Bot {
use_pipeline_uuid?: string;
created_at?: string;
updated_at?: string;
adapter_runtime_values?: object;
}
export interface ApiRespKnowledgeBases {

View File

@@ -40,7 +40,6 @@ export interface PluginV4 {
tags: string[];
install_count: number;
latest_version: string;
components: Record<string, number>;
status: PluginV4Status;
created_at: string;
updated_at: string;

View File

@@ -34,7 +34,6 @@ export class CloudServiceClient extends BaseHttpClient {
page_size: number,
sort_by?: string,
sort_order?: string,
component_filter?: string,
): Promise<ApiRespMarketplacePlugins> {
return this.post<ApiRespMarketplacePlugins>(
'/api/v1/marketplace/plugins/search',
@@ -44,7 +43,6 @@ export class CloudServiceClient extends BaseHttpClient {
page_size,
sort_by,
sort_order,
component_filter,
},
);
}

View File

@@ -8,40 +8,32 @@ import { cn } from '@/lib/utils';
import { toggleVariants } from '@/components/ui/toggle';
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number;
}
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
spacing: 0,
});
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number;
}) {
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ '--gap': spacing } as React.CSSProperties}
className={cn(
'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs',
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
@@ -63,14 +55,12 @@ function ToggleGroupItem({
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',
'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l',
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
className,
)}
{...props}

View File

@@ -7,7 +7,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:data-[state=on]:bg-slate-700 dark:data-[state=on]:text-white [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {

View File

@@ -41,6 +41,7 @@ const enUS = {
addRound: 'Add Round',
copy: 'Copy',
copySuccess: 'Copy Successfully',
copyFailed: 'Copy Failed',
test: 'Test',
forgotPassword: 'Forgot Password?',
loading: 'Loading...',
@@ -187,6 +188,10 @@ const enUS = {
log: 'Log',
configuration: 'Configuration',
logs: 'Logs',
webhookUrl: 'Webhook Callback URL',
webhookUrlCopied: 'Webhook URL copied',
webhookUrlHint:
'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button',
},
plugins: {
title: 'Extensions',
@@ -351,8 +356,6 @@ const enUS = {
markAsRead: 'Mark as Read',
markAsReadSuccess: 'Marked as read',
markAsReadFailed: 'Mark as read failed',
filterByComponent: 'Component',
allComponents: 'All Components',
},
mcp: {
title: 'MCP',

View File

@@ -42,6 +42,7 @@ const jaJP = {
addRound: 'ラウンドを追加',
copy: 'コピー',
copySuccess: 'コピーに成功しました',
copyFailed: 'コピーに失敗しました',
test: 'テスト',
forgotPassword: 'パスワードを忘れた?',
loading: '読み込み中...',
@@ -189,6 +190,10 @@ const jaJP = {
log: 'ログ',
configuration: '設定',
logs: 'ログ',
webhookUrl: 'Webhook コールバック URL',
webhookUrlCopied: 'Webhook URL をコピーしました',
webhookUrlHint:
'入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
},
plugins: {
title: '拡張機能',
@@ -353,8 +358,6 @@ const jaJP = {
markAsRead: '既読',
markAsReadSuccess: '既読に設定しました',
markAsReadFailed: '既読に設定に失敗しました',
filterByComponent: 'コンポーネント',
allComponents: '全部コンポーネント',
},
mcp: {
title: 'MCP',

View File

@@ -41,6 +41,7 @@ const zhHans = {
addRound: '添加回合',
copy: '复制',
copySuccess: '复制成功',
copyFailed: '复制失败',
test: '测试',
forgotPassword: '忘记密码?',
loading: '加载中...',
@@ -182,6 +183,10 @@ const zhHans = {
log: '日志',
configuration: '配置',
logs: '日志',
webhookUrl: 'Webhook 回调地址',
webhookUrlCopied: 'Webhook 地址已复制',
webhookUrlHint:
'点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
},
plugins: {
title: '插件扩展',
@@ -335,8 +340,6 @@ const zhHans = {
markAsRead: '已读',
markAsReadSuccess: '已标记为已读',
markAsReadFailed: '标记为已读失败',
filterByComponent: '组件',
allComponents: '全部组件',
},
mcp: {
title: 'MCP',

View File

@@ -41,6 +41,7 @@ const zhHant = {
addRound: '新增回合',
copy: '複製',
copySuccess: '複製成功',
copyFailed: '複製失敗',
test: '測試',
forgotPassword: '忘記密碼?',
loading: '載入中...',
@@ -182,6 +183,10 @@ const zhHant = {
log: '日誌',
configuration: '設定',
logs: '日誌',
webhookUrl: 'Webhook 回調位址',
webhookUrlCopied: 'Webhook 位址已複製',
webhookUrlHint:
'點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
},
plugins: {
title: '外掛擴展',
@@ -333,8 +338,6 @@ const zhHant = {
markAsRead: '已讀',
markAsReadSuccess: '已標記為已讀',
markAsReadFailed: '標記為已讀失敗',
filterByComponent: '組件',
allComponents: '全部組件',
},
mcp: {
title: 'MCP',