mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-11 16:26:02 +00:00
Compare commits
8 Commits
feat/lark_
...
v4.5.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e3abec72 | ||
|
|
fc2efdf994 | ||
|
|
6ed672d996 | ||
|
|
2bf593fa6b | ||
|
|
3182214663 | ||
|
|
20614b20b7 | ||
|
|
da323817f7 | ||
|
|
763c1a885c |
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.5.3"
|
version = "4.5.4"
|
||||||
description = "Easy-to-use global IM bot platform designed for LLM era"
|
description = "Easy-to-use global IM bot platform designed for LLM era"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -63,7 +63,7 @@ dependencies = [
|
|||||||
"langchain-text-splitters>=0.0.1",
|
"langchain-text-splitters>=0.0.1",
|
||||||
"chromadb>=0.4.24",
|
"chromadb>=0.4.24",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"langbot-plugin==0.1.11",
|
"langbot-plugin==0.1.12",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Easy-to-use global IM bot platform designed for LLM era"""
|
"""LangBot - Easy-to-use global IM bot platform designed for LLM era"""
|
||||||
|
|
||||||
__version__ = '4.5.3'
|
__version__ = '4.5.4'
|
||||||
|
|||||||
@@ -23,25 +23,20 @@ xml_template = """
|
|||||||
|
|
||||||
|
|
||||||
class OAClient:
|
class OAClient:
|
||||||
def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None, unified_mode: bool = False):
|
def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None):
|
||||||
self.token = token
|
self.token = token
|
||||||
self.aes = EncodingAESKey
|
self.aes = EncodingAESKey
|
||||||
self.appid = AppID
|
self.appid = AppID
|
||||||
self.appsecret = Appsecret
|
self.appsecret = Appsecret
|
||||||
self.base_url = 'https://api.weixin.qq.com'
|
self.base_url = 'https://api.weixin.qq.com'
|
||||||
self.access_token = ''
|
self.access_token = ''
|
||||||
self.unified_mode = unified_mode
|
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
|
self.app.add_url_rule(
|
||||||
# 只有在非统一模式下才注册独立路由
|
'/callback/command',
|
||||||
if not self.unified_mode:
|
'handle_callback',
|
||||||
self.app.add_url_rule(
|
self.handle_callback_request,
|
||||||
'/callback/command',
|
methods=['GET', 'POST'],
|
||||||
'handle_callback',
|
)
|
||||||
self.handle_callback_request,
|
|
||||||
methods=['GET', 'POST'],
|
|
||||||
)
|
|
||||||
|
|
||||||
self._message_handlers = {
|
self._message_handlers = {
|
||||||
'example': [],
|
'example': [],
|
||||||
}
|
}
|
||||||
@@ -51,39 +46,19 @@ class OAClient:
|
|||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
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:
|
try:
|
||||||
# 每隔100毫秒查询是否生成ai回答
|
# 每隔100毫秒查询是否生成ai回答
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
signature = req.args.get('signature', '')
|
signature = request.args.get('signature', '')
|
||||||
timestamp = req.args.get('timestamp', '')
|
timestamp = request.args.get('timestamp', '')
|
||||||
nonce = req.args.get('nonce', '')
|
nonce = request.args.get('nonce', '')
|
||||||
echostr = req.args.get('echostr', '')
|
echostr = request.args.get('echostr', '')
|
||||||
msg_signature = req.args.get('msg_signature', '')
|
msg_signature = request.args.get('msg_signature', '')
|
||||||
if msg_signature is None:
|
if msg_signature is None:
|
||||||
await self.logger.error('msg_signature不在请求体中')
|
await self.logger.error('msg_signature不在请求体中')
|
||||||
raise Exception('msg_signature不在请求体中')
|
raise Exception('msg_signature不在请求体中')
|
||||||
|
|
||||||
if req.method == 'GET':
|
if request.method == 'GET':
|
||||||
# 校验签名
|
# 校验签名
|
||||||
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
||||||
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
||||||
@@ -93,8 +68,8 @@ class OAClient:
|
|||||||
else:
|
else:
|
||||||
await self.logger.error('拒绝请求')
|
await self.logger.error('拒绝请求')
|
||||||
raise Exception('拒绝请求')
|
raise Exception('拒绝请求')
|
||||||
elif req.method == 'POST':
|
elif request.method == 'POST':
|
||||||
encryt_msg = await req.data
|
encryt_msg = await request.data
|
||||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||||
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
|
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
|
||||||
xml_msg = xml_msg.decode('utf-8')
|
xml_msg = xml_msg.decode('utf-8')
|
||||||
@@ -207,7 +182,6 @@ class OAClientForLongerResponse:
|
|||||||
Appsecret: str,
|
Appsecret: str,
|
||||||
LoadingMessage: str,
|
LoadingMessage: str,
|
||||||
logger: None,
|
logger: None,
|
||||||
unified_mode: bool = False,
|
|
||||||
):
|
):
|
||||||
self.token = token
|
self.token = token
|
||||||
self.aes = EncodingAESKey
|
self.aes = EncodingAESKey
|
||||||
@@ -215,18 +189,13 @@ class OAClientForLongerResponse:
|
|||||||
self.appsecret = Appsecret
|
self.appsecret = Appsecret
|
||||||
self.base_url = 'https://api.weixin.qq.com'
|
self.base_url = 'https://api.weixin.qq.com'
|
||||||
self.access_token = ''
|
self.access_token = ''
|
||||||
self.unified_mode = unified_mode
|
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
|
self.app.add_url_rule(
|
||||||
# 只有在非统一模式下才注册独立路由
|
'/callback/command',
|
||||||
if not self.unified_mode:
|
'handle_callback',
|
||||||
self.app.add_url_rule(
|
self.handle_callback_request,
|
||||||
'/callback/command',
|
methods=['GET', 'POST'],
|
||||||
'handle_callback',
|
)
|
||||||
self.handle_callback_request,
|
|
||||||
methods=['GET', 'POST'],
|
|
||||||
)
|
|
||||||
|
|
||||||
self._message_handlers = {
|
self._message_handlers = {
|
||||||
'example': [],
|
'example': [],
|
||||||
}
|
}
|
||||||
@@ -237,44 +206,24 @@ class OAClientForLongerResponse:
|
|||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
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:
|
try:
|
||||||
signature = req.args.get('signature', '')
|
signature = request.args.get('signature', '')
|
||||||
timestamp = req.args.get('timestamp', '')
|
timestamp = request.args.get('timestamp', '')
|
||||||
nonce = req.args.get('nonce', '')
|
nonce = request.args.get('nonce', '')
|
||||||
echostr = req.args.get('echostr', '')
|
echostr = request.args.get('echostr', '')
|
||||||
msg_signature = req.args.get('msg_signature', '')
|
msg_signature = request.args.get('msg_signature', '')
|
||||||
|
|
||||||
if msg_signature is None:
|
if msg_signature is None:
|
||||||
await self.logger.error('msg_signature不在请求体中')
|
await self.logger.error('msg_signature不在请求体中')
|
||||||
raise Exception('msg_signature不在请求体中')
|
raise Exception('msg_signature不在请求体中')
|
||||||
|
|
||||||
if req.method == 'GET':
|
if request.method == 'GET':
|
||||||
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
check_str = ''.join(sorted([self.token, timestamp, nonce]))
|
||||||
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
check_signature = hashlib.sha1(check_str.encode('utf-8')).hexdigest()
|
||||||
return echostr if check_signature == signature else '拒绝请求'
|
return echostr if check_signature == signature else '拒绝请求'
|
||||||
|
|
||||||
elif req.method == 'POST':
|
elif request.method == 'POST':
|
||||||
encryt_msg = await req.data
|
encryt_msg = await request.data
|
||||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.appid)
|
||||||
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
|
ret, xml_msg = wxcpt.DecryptMsg(encryt_msg, msg_signature, timestamp, nonce)
|
||||||
xml_msg = xml_msg.decode('utf-8')
|
xml_msg = xml_msg.decode('utf-8')
|
||||||
|
|||||||
@@ -10,20 +10,38 @@ import traceback
|
|||||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
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:
|
class QQOfficialClient:
|
||||||
def __init__(self, secret: str, token: str, app_id: str, logger: None, unified_mode: bool = False):
|
def __init__(self, secret: str, token: str, app_id: str, logger: None):
|
||||||
self.unified_mode = unified_mode
|
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
|
self.app.add_url_rule(
|
||||||
# 只有在非统一模式下才注册独立路由
|
'/callback/command',
|
||||||
if not self.unified_mode:
|
'handle_callback',
|
||||||
self.app.add_url_rule(
|
self.handle_callback_request,
|
||||||
'/callback/command',
|
methods=['GET', 'POST'],
|
||||||
'handle_callback',
|
)
|
||||||
self.handle_callback_request,
|
|
||||||
methods=['GET', 'POST'],
|
|
||||||
)
|
|
||||||
|
|
||||||
self.secret = secret
|
self.secret = secret
|
||||||
self.token = token
|
self.token = token
|
||||||
self.app_id = app_id
|
self.app_id = app_id
|
||||||
@@ -64,45 +82,18 @@ class QQOfficialClient:
|
|||||||
raise Exception(f'获取access_token失败: {e}')
|
raise Exception(f'获取access_token失败: {e}')
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
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:
|
try:
|
||||||
|
# 读取请求数据
|
||||||
body = await req.get_data()
|
body = await request.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)
|
payload = json.loads(body)
|
||||||
|
|
||||||
|
# 验证是否为回调验证请求
|
||||||
if payload.get('op') == 13:
|
if payload.get('op') == 13:
|
||||||
validation_data = payload.get('d')
|
# 生成签名
|
||||||
if not validation_data:
|
response = handle_validation(payload, self.secret)
|
||||||
return {'error': "missing 'd' field"}, 400
|
|
||||||
response = await self.verify(validation_data)
|
return response
|
||||||
return response, 200
|
|
||||||
|
|
||||||
if payload.get('op') == 0:
|
if payload.get('op') == 0:
|
||||||
message_data = await self.get_message(payload)
|
message_data = await self.get_message(payload)
|
||||||
@@ -113,7 +104,6 @@ class QQOfficialClient:
|
|||||||
return {'code': 0, 'message': 'success'}
|
return {'code': 0, 'message': 'success'}
|
||||||
|
|
||||||
except Exception as e:
|
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()}')
|
await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
|
||||||
return {'error': str(e)}, 400
|
return {'error': str(e)}, 400
|
||||||
|
|
||||||
@@ -271,26 +261,3 @@ class QQOfficialClient:
|
|||||||
if self.access_token_expiry_time is None:
|
if self.access_token_expiry_time is None:
|
||||||
return True
|
return True
|
||||||
return time.time() > self.access_token_expiry_time
|
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
|
|
||||||
|
|||||||
@@ -8,19 +8,14 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|||||||
|
|
||||||
|
|
||||||
class SlackClient:
|
class SlackClient:
|
||||||
def __init__(self, bot_token: str, signing_secret: str, logger: None, unified_mode: bool = False):
|
def __init__(self, bot_token: str, signing_secret: str, logger: None):
|
||||||
self.bot_token = bot_token
|
self.bot_token = bot_token
|
||||||
self.signing_secret = signing_secret
|
self.signing_secret = signing_secret
|
||||||
self.unified_mode = unified_mode
|
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
self.client = AsyncWebClient(self.bot_token)
|
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 = {
|
self._message_handlers = {
|
||||||
'example': [],
|
'example': [],
|
||||||
}
|
}
|
||||||
@@ -28,28 +23,8 @@ class SlackClient:
|
|||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
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:
|
try:
|
||||||
body = await req.get_data()
|
body = await request.get_data()
|
||||||
data = json.loads(body)
|
data = json.loads(body)
|
||||||
if 'type' in data:
|
if 'type' in data:
|
||||||
if data['type'] == 'url_verification':
|
if data['type'] == 'url_verification':
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ class StreamSessionManager:
|
|||||||
|
|
||||||
|
|
||||||
class WecomBotClient:
|
class WecomBotClient:
|
||||||
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False):
|
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger):
|
||||||
"""企业微信智能机器人客户端。
|
"""企业微信智能机器人客户端。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -208,7 +208,6 @@ class WecomBotClient:
|
|||||||
EnCodingAESKey: 企业微信消息加解密密钥。
|
EnCodingAESKey: 企业微信消息加解密密钥。
|
||||||
Corpid: 企业 ID。
|
Corpid: 企业 ID。
|
||||||
logger: 日志记录器。
|
logger: 日志记录器。
|
||||||
unified_mode: 是否使用统一 webhook 模式(默认 False)。
|
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger)
|
>>> client = WecomBotClient(Token='token', EnCodingAESKey='aeskey', Corpid='corp', logger=logger)
|
||||||
@@ -218,15 +217,10 @@ class WecomBotClient:
|
|||||||
self.EnCodingAESKey = EnCodingAESKey
|
self.EnCodingAESKey = EnCodingAESKey
|
||||||
self.Corpid = Corpid
|
self.Corpid = Corpid
|
||||||
self.ReceiveId = ''
|
self.ReceiveId = ''
|
||||||
self.unified_mode = unified_mode
|
|
||||||
self.app = Quart(__name__)
|
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 = {
|
self._message_handlers = {
|
||||||
'example': [],
|
'example': [],
|
||||||
}
|
}
|
||||||
@@ -365,7 +359,7 @@ class WecomBotClient:
|
|||||||
return await self._encrypt_and_reply(payload, nonce)
|
return await self._encrypt_and_reply(payload, nonce)
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
async def handle_callback_request(self):
|
||||||
"""企业微信回调入口(独立端口模式,使用全局 request)。
|
"""企业微信回调入口。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Quart Response: 根据请求类型返回验证、首包或刷新结果。
|
Quart Response: 根据请求类型返回验证、首包或刷新结果。
|
||||||
@@ -373,34 +367,15 @@ class WecomBotClient:
|
|||||||
Example:
|
Example:
|
||||||
作为 Quart 路由处理函数直接注册并使用。
|
作为 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:
|
try:
|
||||||
self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '')
|
self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '')
|
||||||
await self.logger.info(f'{req.method} {req.url} {str(req.args)}')
|
await self.logger.info(f'{request.method} {request.url} {str(request.args)}')
|
||||||
|
|
||||||
if req.method == 'GET':
|
if request.method == 'GET':
|
||||||
return await self._handle_get_callback(req)
|
return await self._handle_get_callback()
|
||||||
|
|
||||||
if req.method == 'POST':
|
if request.method == 'POST':
|
||||||
return await self._handle_post_callback(req)
|
return await self._handle_post_callback()
|
||||||
|
|
||||||
return Response('', status=405)
|
return Response('', status=405)
|
||||||
|
|
||||||
@@ -408,13 +383,13 @@ class WecomBotClient:
|
|||||||
await self.logger.error(traceback.format_exc())
|
await self.logger.error(traceback.format_exc())
|
||||||
return Response('Internal Server Error', status=500)
|
return Response('Internal Server Error', status=500)
|
||||||
|
|
||||||
async def _handle_get_callback(self, req) -> tuple[Response, int] | Response:
|
async def _handle_get_callback(self) -> tuple[Response, int] | Response:
|
||||||
"""处理企业微信的 GET 验证请求。"""
|
"""处理企业微信的 GET 验证请求。"""
|
||||||
|
|
||||||
msg_signature = unquote(req.args.get('msg_signature', ''))
|
msg_signature = unquote(request.args.get('msg_signature', ''))
|
||||||
timestamp = unquote(req.args.get('timestamp', ''))
|
timestamp = unquote(request.args.get('timestamp', ''))
|
||||||
nonce = unquote(req.args.get('nonce', ''))
|
nonce = unquote(request.args.get('nonce', ''))
|
||||||
echostr = unquote(req.args.get('echostr', ''))
|
echostr = unquote(request.args.get('echostr', ''))
|
||||||
|
|
||||||
if not all([msg_signature, timestamp, nonce, echostr]):
|
if not all([msg_signature, timestamp, nonce, echostr]):
|
||||||
await self.logger.error('请求参数缺失')
|
await self.logger.error('请求参数缺失')
|
||||||
@@ -427,16 +402,16 @@ class WecomBotClient:
|
|||||||
|
|
||||||
return Response(decrypted_str, mimetype='text/plain')
|
return Response(decrypted_str, mimetype='text/plain')
|
||||||
|
|
||||||
async def _handle_post_callback(self, req) -> tuple[Response, int] | Response:
|
async def _handle_post_callback(self) -> tuple[Response, int] | Response:
|
||||||
"""处理企业微信的 POST 回调请求。"""
|
"""处理企业微信的 POST 回调请求。"""
|
||||||
|
|
||||||
self.stream_sessions.cleanup()
|
self.stream_sessions.cleanup()
|
||||||
|
|
||||||
msg_signature = unquote(req.args.get('msg_signature', ''))
|
msg_signature = unquote(request.args.get('msg_signature', ''))
|
||||||
timestamp = unquote(req.args.get('timestamp', ''))
|
timestamp = unquote(request.args.get('timestamp', ''))
|
||||||
nonce = unquote(req.args.get('nonce', ''))
|
nonce = unquote(request.args.get('nonce', ''))
|
||||||
|
|
||||||
encrypted_json = await req.get_json()
|
encrypted_json = await request.get_json()
|
||||||
encrypted_msg = (encrypted_json or {}).get('encrypt', '')
|
encrypted_msg = (encrypted_json or {}).get('encrypt', '')
|
||||||
if not encrypted_msg:
|
if not encrypted_msg:
|
||||||
await self.logger.error("请求体中缺少 'encrypt' 字段")
|
await self.logger.error("请求体中缺少 'encrypt' 字段")
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ 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
|
@property
|
||||||
def chatname(self) -> str:
|
def chatname(self) -> str:
|
||||||
@@ -65,7 +70,7 @@ class WecomBotEvent(dict):
|
|||||||
消息id
|
消息id
|
||||||
"""
|
"""
|
||||||
return self.get('msgid', '')
|
return self.get('msgid', '')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ai_bot_id(self) -> str:
|
def ai_bot_id(self) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class WecomClient:
|
|||||||
EncodingAESKey: str,
|
EncodingAESKey: str,
|
||||||
contacts_secret: str,
|
contacts_secret: str,
|
||||||
logger: None,
|
logger: None,
|
||||||
unified_mode: bool = False,
|
|
||||||
):
|
):
|
||||||
self.corpid = corpid
|
self.corpid = corpid
|
||||||
self.secret = secret
|
self.secret = secret
|
||||||
@@ -32,18 +31,13 @@ class WecomClient:
|
|||||||
self.access_token = ''
|
self.access_token = ''
|
||||||
self.secret_for_contacts = contacts_secret
|
self.secret_for_contacts = contacts_secret
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.unified_mode = unified_mode
|
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
|
self.app.add_url_rule(
|
||||||
# 只有在非统一模式下才注册独立路由
|
'/callback/command',
|
||||||
if not self.unified_mode:
|
'handle_callback',
|
||||||
self.app.add_url_rule(
|
self.handle_callback_request,
|
||||||
'/callback/command',
|
methods=['GET', 'POST'],
|
||||||
'handle_callback',
|
)
|
||||||
self.handle_callback_request,
|
|
||||||
methods=['GET', 'POST'],
|
|
||||||
)
|
|
||||||
|
|
||||||
self._message_handlers = {
|
self._message_handlers = {
|
||||||
'example': [],
|
'example': [],
|
||||||
}
|
}
|
||||||
@@ -167,43 +161,25 @@ class WecomClient:
|
|||||||
raise Exception('Failed to send message: ' + str(data))
|
raise Exception('Failed to send message: ' + str(data))
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
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)
|
处理回调请求,包括 GET 验证和 POST 消息接收。
|
||||||
|
|
||||||
async def _handle_callback_internal(self, req):
|
|
||||||
"""
|
|
||||||
处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
req: Quart Request 对象
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
msg_signature = req.args.get('msg_signature')
|
msg_signature = request.args.get('msg_signature')
|
||||||
timestamp = req.args.get('timestamp')
|
timestamp = request.args.get('timestamp')
|
||||||
nonce = req.args.get('nonce')
|
nonce = request.args.get('nonce')
|
||||||
|
|
||||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
|
||||||
if req.method == 'GET':
|
if request.method == 'GET':
|
||||||
echostr = req.args.get('echostr')
|
echostr = request.args.get('echostr')
|
||||||
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
await self.logger.error('验证失败')
|
await self.logger.error('验证失败')
|
||||||
raise Exception(f'验证失败,错误码: {ret}')
|
raise Exception(f'验证失败,错误码: {ret}')
|
||||||
return reply_echo_str
|
return reply_echo_str
|
||||||
|
|
||||||
elif req.method == 'POST':
|
elif request.method == 'POST':
|
||||||
encrypt_msg = await req.data
|
encrypt_msg = await request.data
|
||||||
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
|
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
await self.logger.error('消息解密失败')
|
await self.logger.error('消息解密失败')
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import aiofiles
|
|||||||
|
|
||||||
|
|
||||||
class WecomCSClient:
|
class WecomCSClient:
|
||||||
def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None, unified_mode: bool = False):
|
def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None):
|
||||||
self.corpid = corpid
|
self.corpid = corpid
|
||||||
self.secret = secret
|
self.secret = secret
|
||||||
self.access_token_for_contacts = ''
|
self.access_token_for_contacts = ''
|
||||||
@@ -22,15 +22,10 @@ class WecomCSClient:
|
|||||||
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
|
self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin'
|
||||||
self.access_token = ''
|
self.access_token = ''
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.unified_mode = unified_mode
|
|
||||||
self.app = Quart(__name__)
|
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 = {
|
self._message_handlers = {
|
||||||
'example': [],
|
'example': [],
|
||||||
}
|
}
|
||||||
@@ -197,45 +192,27 @@ class WecomCSClient:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
async def handle_callback_request(self):
|
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)
|
处理回调请求,包括 GET 验证和 POST 消息接收。
|
||||||
|
|
||||||
async def _handle_callback_internal(self, req):
|
|
||||||
"""
|
|
||||||
处理回调请求的内部实现,包括 GET 验证和 POST 消息接收。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
req: Quart Request 对象
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
msg_signature = req.args.get('msg_signature')
|
msg_signature = request.args.get('msg_signature')
|
||||||
timestamp = req.args.get('timestamp')
|
timestamp = request.args.get('timestamp')
|
||||||
nonce = req.args.get('nonce')
|
nonce = request.args.get('nonce')
|
||||||
try:
|
try:
|
||||||
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
|
wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f'初始化失败,错误码: {e}')
|
raise Exception(f'初始化失败,错误码: {e}')
|
||||||
|
|
||||||
if req.method == 'GET':
|
if request.method == 'GET':
|
||||||
echostr = req.args.get('echostr')
|
echostr = request.args.get('echostr')
|
||||||
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
raise Exception(f'验证失败,错误码: {ret}')
|
raise Exception(f'验证失败,错误码: {ret}')
|
||||||
return reply_echo_str
|
return reply_echo_str
|
||||||
|
|
||||||
elif req.method == 'POST':
|
elif request.method == 'POST':
|
||||||
encrypt_msg = await req.data
|
encrypt_msg = await request.data
|
||||||
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
|
ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
raise Exception(f'消息解密失败,错误码: {ret}')
|
raise Exception(f'消息解密失败,错误码: {ret}')
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def _(bot_uuid: str) -> str:
|
async def _(bot_uuid: str) -> str:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
# 返回运行时信息,包括webhook地址等
|
bot = await self.ap.bot_service.get_bot(bot_uuid)
|
||||||
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
|
|
||||||
if bot is None:
|
if bot is None:
|
||||||
return self.http_status(404, -1, 'bot not found')
|
return self.http_status(404, -1, 'bot not found')
|
||||||
return self.success(data={'bot': bot})
|
return self.success(data={'bot': bot})
|
||||||
|
|||||||
@@ -55,17 +55,6 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
return self.success(data=exec(py_code, {'ap': ap}))
|
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(
|
@self.route(
|
||||||
'/debug/plugin/action',
|
'/debug/plugin/action',
|
||||||
methods=['POST'],
|
methods=['POST'],
|
||||||
|
|||||||
@@ -1,57 +1,49 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import quart
|
import quart
|
||||||
import traceback
|
|
||||||
|
|
||||||
from .. import group
|
from .. import group
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('webhooks', '/bots')
|
@group.group_class('webhooks', '/api/v1/webhooks')
|
||||||
class WebhookRouterGroup(group.RouterGroup):
|
class WebhooksRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@self.route('/<bot_uuid>', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
|
@self.route('', methods=['GET', 'POST'])
|
||||||
async def handle_webhook(bot_uuid: str):
|
async def _() -> str:
|
||||||
"""处理 bot webhook 回调(无子路径)"""
|
if quart.request.method == 'GET':
|
||||||
return await self._dispatch_webhook(bot_uuid, '')
|
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>/<path:path>', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
|
if not name:
|
||||||
async def handle_webhook_with_path(bot_uuid: str, path: str):
|
return self.http_status(400, -1, 'Name is required')
|
||||||
"""处理 bot webhook 回调(带子路径)"""
|
if not url:
|
||||||
return await self._dispatch_webhook(bot_uuid, path)
|
return self.http_status(400, -1, 'URL is required')
|
||||||
|
|
||||||
async def _dispatch_webhook(self, bot_uuid: str, path: str):
|
webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled)
|
||||||
"""分发 webhook 请求到对应的 bot adapter
|
return self.success(data={'webhook': webhook})
|
||||||
|
|
||||||
Args:
|
@self.route('/<int:webhook_id>', methods=['GET', 'PUT', 'DELETE'])
|
||||||
bot_uuid: Bot 的 UUID
|
async def _(webhook_id: int) -> str:
|
||||||
path: 子路径(如果有的话)
|
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})
|
||||||
|
|
||||||
Returns:
|
elif quart.request.method == 'PUT':
|
||||||
适配器返回的响应
|
json_data = await quart.request.json
|
||||||
"""
|
name = json_data.get('name')
|
||||||
try:
|
url = json_data.get('url')
|
||||||
|
description = json_data.get('description')
|
||||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
enabled = json_data.get('enabled')
|
||||||
|
|
||||||
if not runtime_bot:
|
await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled)
|
||||||
return quart.jsonify({'error': 'Bot not found'}), 404
|
return self.success()
|
||||||
|
|
||||||
if not runtime_bot.enable:
|
elif quart.request.method == 'DELETE':
|
||||||
return quart.jsonify({'error': 'Bot is disabled'}), 403
|
await self.ap.webhook_service.delete_webhook(webhook_id)
|
||||||
|
return self.success()
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -58,16 +58,6 @@ class BotService:
|
|||||||
if runtime_bot is not None:
|
if runtime_bot is not None:
|
||||||
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
|
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
|
persistence_bot['adapter_runtime_values'] = adapter_runtime_values
|
||||||
|
|
||||||
return persistence_bot
|
return persistence_bot
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class ResponseWrapper(stage.PipelineStage):
|
|||||||
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
|
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
|
||||||
function_names = [tc.function.name for tc in result.tool_calls]
|
function_names = [tc.function.name for tc in result.tool_calls]
|
||||||
|
|
||||||
reply_text = f'调用函数 {".".join(function_names)}...'
|
reply_text = f'Call {".".join(function_names)}...'
|
||||||
|
|
||||||
query.resp_message_chain.append(
|
query.resp_message_chain.append(
|
||||||
platform_message.MessageChain([platform_message.Plain(text=reply_text)])
|
platform_message.MessageChain([platform_message.Plain(text=reply_text)])
|
||||||
|
|||||||
@@ -247,10 +247,6 @@ class PlatformManager:
|
|||||||
logger,
|
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)
|
runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger)
|
||||||
|
|
||||||
await runtime_bot.initialize()
|
await runtime_bot.initialize()
|
||||||
|
|||||||
@@ -342,8 +342,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
quart_app: quart.Quart = pydantic.Field(exclude=True)
|
quart_app: quart.Quart = pydantic.Field(exclude=True)
|
||||||
|
|
||||||
bot_uuid: str = None
|
|
||||||
|
|
||||||
card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片
|
card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片
|
||||||
|
|
||||||
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
|
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
|
||||||
@@ -351,6 +349,46 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||||
quart_app = quart.Quart(__name__)
|
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):
|
async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||||
lb_event = await self.event_converter.target2yiri(event, self.api_client)
|
lb_event = await self.event_converter.target2yiri(event, self.api_client)
|
||||||
@@ -736,65 +774,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
):
|
):
|
||||||
self.listeners.pop(event_type)
|
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):
|
async def run_async(self):
|
||||||
|
port = self.config['port']
|
||||||
enable_webhook = self.config['enable-webhook']
|
enable_webhook = self.config['enable-webhook']
|
||||||
|
|
||||||
if not enable_webhook:
|
if not enable_webhook:
|
||||||
@@ -810,28 +791,16 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
raise e
|
raise e
|
||||||
else:
|
else:
|
||||||
|
|
||||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
async def shutdown_trigger_placeholder():
|
||||||
# 保持运行但不启动独立端口
|
while True:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
# 打印 webhook 回调地址
|
await self.quart_app.run_task(
|
||||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
host='0.0.0.0',
|
||||||
try:
|
port=port,
|
||||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
shutdown_trigger=shutdown_trigger_placeholder,
|
||||||
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:
|
async def kill(self) -> bool:
|
||||||
# 需要断开连接,不然旧的连接会继续运行,导致飞书消息来时会随机选择一个连接
|
# 需要断开连接,不然旧的连接会继续运行,导致飞书消息来时会随机选择一个连接
|
||||||
# 断开时lark.ws.Client的_receive_message_loop会打印error日志: receive message loop exit。然后进行重连,
|
# 断开时lark.ws.Client的_receive_message_loop会打印error日志: receive message loop exit。然后进行重连,
|
||||||
|
|||||||
@@ -11,16 +11,6 @@ metadata:
|
|||||||
icon: lark.svg
|
icon: lark.svg
|
||||||
spec:
|
spec:
|
||||||
config:
|
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
|
- name: app_id
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
@@ -45,6 +35,26 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
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
|
- name: encrypt-key
|
||||||
label:
|
label:
|
||||||
en_US: Encrypt Key
|
en_US: Encrypt Key
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
bot: MessagingApi
|
bot: MessagingApi
|
||||||
api_client: ApiClient
|
api_client: ApiClient
|
||||||
parser: WebhookParser
|
|
||||||
|
|
||||||
bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识
|
bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识
|
||||||
message_converter: LINEMessageConverter
|
message_converter: LINEMessageConverter
|
||||||
@@ -134,7 +133,7 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
]
|
]
|
||||||
|
|
||||||
config: dict
|
config: dict
|
||||||
bot_uuid: str = None
|
quart_app: quart.Quart
|
||||||
|
|
||||||
card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片
|
card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片
|
||||||
|
|
||||||
@@ -151,6 +150,7 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
super().__init__(
|
super().__init__(
|
||||||
config=config,
|
config=config,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
|
quart_app=quart.Quart(__name__),
|
||||||
listeners={},
|
listeners={},
|
||||||
card_id_dict={},
|
card_id_dict={},
|
||||||
seq=1,
|
seq=1,
|
||||||
@@ -164,6 +164,29 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
bot_account_id=bot_account_id,
|
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):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -212,73 +235,18 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
):
|
):
|
||||||
self.listeners.pop(event_type)
|
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:
|
|
||||||
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):
|
async def run_async(self):
|
||||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
port = self.config['port']
|
||||||
# 保持运行但不启动独立端口
|
|
||||||
|
|
||||||
# 打印 webhook 回调地址
|
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('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:
|
while True:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
await keep_alive()
|
await self.quart_app.run_task(
|
||||||
|
host='0.0.0.0',
|
||||||
|
port=port,
|
||||||
|
shutdown_trigger=shutdown_trigger_placeholder,
|
||||||
|
)
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
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
|
- name: channel_secret
|
||||||
label:
|
label:
|
||||||
en_US: Channel secret
|
en_US: Channel secret
|
||||||
|
|||||||
@@ -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.entities as platform_entities
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
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.events as platform_events
|
||||||
from ..logger import EventLogger
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
|
|
||||||
class OAMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class OAMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@@ -58,16 +58,13 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
|
|||||||
message_converter: OAMessageConverter = OAMessageConverter()
|
message_converter: OAMessageConverter = OAMessageConverter()
|
||||||
event_converter: OAEventConverter = OAEventConverter()
|
event_converter: OAEventConverter = OAEventConverter()
|
||||||
bot: typing.Union[OAClient, OAClientForLongerResponse] = pydantic.Field(exclude=True)
|
bot: typing.Union[OAClient, OAClientForLongerResponse] = pydantic.Field(exclude=True)
|
||||||
bot_uuid: str = None
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
# 校验必填项
|
|
||||||
required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode']
|
required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode']
|
||||||
missing_keys = [k for k in required_keys if k not in config]
|
missing_keys = [k for k in required_keys if k not in config]
|
||||||
if missing_keys:
|
if missing_keys:
|
||||||
raise Exception(f'OfficialAccount 缺少配置项: {missing_keys}')
|
raise Exception(f'OfficialAccount 缺少配置项: {missing_keys}')
|
||||||
|
|
||||||
# 创建运行时 bot 对象,始终使用统一 webhook 模式
|
|
||||||
if config['Mode'] == 'drop':
|
if config['Mode'] == 'drop':
|
||||||
bot = OAClient(
|
bot = OAClient(
|
||||||
token=config['token'],
|
token=config['token'],
|
||||||
@@ -75,7 +72,6 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
|
|||||||
Appsecret=config['AppSecret'],
|
Appsecret=config['AppSecret'],
|
||||||
AppID=config['AppID'],
|
AppID=config['AppID'],
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=True,
|
|
||||||
)
|
)
|
||||||
elif config['Mode'] == 'passive':
|
elif config['Mode'] == 'passive':
|
||||||
bot = OAClientForLongerResponse(
|
bot = OAClientForLongerResponse(
|
||||||
@@ -85,7 +81,6 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
|
|||||||
AppID=config['AppID'],
|
AppID=config['AppID'],
|
||||||
LoadingMessage=config.get('LoadingMessage', ''),
|
LoadingMessage=config.get('LoadingMessage', ''),
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=True,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise KeyError('请设置微信公众号通信模式')
|
raise KeyError('请设置微信公众号通信模式')
|
||||||
@@ -134,46 +129,16 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
|
|||||||
elif event_type == platform_events.GroupMessage:
|
elif event_type == platform_events.GroupMessage:
|
||||||
pass
|
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 run_async(self):
|
||||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
async def 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('微信公众号 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:
|
while True:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
await keep_alive()
|
await self.bot.run_task(
|
||||||
|
host=self.config['host'],
|
||||||
|
port=self.config['port'],
|
||||||
|
shutdown_trigger=shutdown_trigger_placeholder,
|
||||||
|
)
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "AI正在思考中,请发送任意内容获取回复。"
|
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:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./officialaccount.py
|
path: ./officialaccount.py
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|||||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
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.api import QQOfficialClient
|
||||||
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
|
from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent
|
||||||
from ...utils import image
|
from langbot.pkg.utils import image
|
||||||
from ..logger import EventLogger
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
|
|
||||||
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@@ -134,14 +134,11 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
bot: QQOfficialClient
|
bot: QQOfficialClient
|
||||||
config: dict
|
config: dict
|
||||||
bot_account_id: str
|
bot_account_id: str
|
||||||
bot_uuid: str = None
|
|
||||||
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
||||||
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
bot = QQOfficialClient(
|
bot = QQOfficialClient(app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger)
|
||||||
app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
config=config,
|
config=config,
|
||||||
@@ -226,46 +223,16 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
self.bot.on_message('GROUP_AT_MESSAGE_CREATE')(on_message)
|
self.bot.on_message('GROUP_AT_MESSAGE_CREATE')(on_message)
|
||||||
self.bot.on_message('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 run_async(self):
|
||||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
async def 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('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:
|
while True:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
await keep_alive()
|
await self.bot.run_task(
|
||||||
|
host='0.0.0.0',
|
||||||
|
port=self.config['port'],
|
||||||
|
shutdown_trigger=shutdown_trigger_placeholder,
|
||||||
|
)
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
- name: port
|
||||||
|
label:
|
||||||
|
en_US: Port
|
||||||
|
zh_Hans: 监听端口
|
||||||
|
type: integer
|
||||||
|
required: true
|
||||||
|
default: 2284
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
|
|||||||
@@ -86,12 +86,13 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
bot: SlackClient
|
bot: SlackClient
|
||||||
bot_account_id: str
|
bot_account_id: str
|
||||||
bot_uuid: str = None
|
|
||||||
message_converter: SlackMessageConverter = SlackMessageConverter()
|
message_converter: SlackMessageConverter = SlackMessageConverter()
|
||||||
event_converter: SlackEventConverter = SlackEventConverter()
|
event_converter: SlackEventConverter = SlackEventConverter()
|
||||||
config: dict
|
config: dict
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
|
self.config = config
|
||||||
|
self.logger = logger
|
||||||
required_keys = [
|
required_keys = [
|
||||||
'bot_token',
|
'bot_token',
|
||||||
'signing_secret',
|
'signing_secret',
|
||||||
@@ -100,18 +101,8 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
if missing_keys:
|
if missing_keys:
|
||||||
raise command_errors.ParamNotEnoughError('Slack机器人缺少相关配置项,请查看文档或联系管理员')
|
raise command_errors.ParamNotEnoughError('Slack机器人缺少相关配置项,请查看文档或联系管理员')
|
||||||
|
|
||||||
bot = SlackClient(
|
self.bot = SlackClient(
|
||||||
bot_token=config['bot_token'],
|
bot_token=self.config['bot_token'], signing_secret=self.config['signing_secret'], logger=self.logger
|
||||||
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(
|
async def reply_message(
|
||||||
@@ -157,45 +148,16 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
elif event_type == platform_events.GroupMessage:
|
elif event_type == platform_events.GroupMessage:
|
||||||
self.bot.on_message('channel')(on_message)
|
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 run_async(self):
|
||||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
async def 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(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:
|
while True:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
await keep_alive()
|
|
||||||
|
await self.bot.run_task(
|
||||||
|
host='0.0.0.0',
|
||||||
|
port=self.config['port'],
|
||||||
|
shutdown_trigger=shutdown_trigger_placeholder,
|
||||||
|
)
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ spec:
|
|||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
- name: port
|
||||||
|
label:
|
||||||
|
en_US: Port
|
||||||
|
zh_Hans: 监听端口
|
||||||
|
type: int
|
||||||
|
required: true
|
||||||
|
default: 2288
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./slack.py
|
path: ./slack.py
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import datetime
|
|||||||
from langbot.libs.wecom_api.api import WecomClient
|
from langbot.libs.wecom_api.api import WecomClient
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
from langbot.libs.wecom_api.wecomevent import WecomEvent
|
from langbot.libs.wecom_api.wecomevent import WecomEvent
|
||||||
from ...utils import image
|
from langbot.pkg.utils import image
|
||||||
from ..logger import EventLogger
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
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.events as platform_events
|
||||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
@@ -131,7 +131,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
message_converter: WecomMessageConverter = WecomMessageConverter()
|
message_converter: WecomMessageConverter = WecomMessageConverter()
|
||||||
event_converter: WecomEventConverter = WecomEventConverter()
|
event_converter: WecomEventConverter = WecomEventConverter()
|
||||||
config: dict
|
config: dict
|
||||||
bot_uuid: str = None
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
# 校验必填项
|
# 校验必填项
|
||||||
@@ -142,12 +141,11 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'EncodingAESKey',
|
'EncodingAESKey',
|
||||||
'contacts_secret',
|
'contacts_secret',
|
||||||
]
|
]
|
||||||
|
|
||||||
missing_keys = [key for key in required_keys if key not in config]
|
missing_keys = [key for key in required_keys if key not in config]
|
||||||
if missing_keys:
|
if missing_keys:
|
||||||
raise Exception(f'Wecom 缺少配置项: {missing_keys}')
|
raise Exception(f'Wecom 缺少配置项: {missing_keys}')
|
||||||
|
|
||||||
# 创建运行时 bot 对象,始终使用统一 webhook 模式
|
# 创建运行时 bot 对象
|
||||||
bot = WecomClient(
|
bot = WecomClient(
|
||||||
corpid=config['corpid'],
|
corpid=config['corpid'],
|
||||||
secret=config['secret'],
|
secret=config['secret'],
|
||||||
@@ -155,7 +153,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
EncodingAESKey=config['EncodingAESKey'],
|
EncodingAESKey=config['EncodingAESKey'],
|
||||||
contacts_secret=config['contacts_secret'],
|
contacts_secret=config['contacts_secret'],
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -165,10 +162,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
bot_account_id='',
|
bot_account_id='',
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_bot_uuid(self, bot_uuid: str):
|
|
||||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
|
||||||
self.bot_uuid = bot_uuid
|
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
message_source: platform_events.MessageEvent,
|
message_source: platform_events.MessageEvent,
|
||||||
@@ -187,6 +180,9 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
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):
|
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)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
parts = target_id.split('|')
|
parts = target_id.split('|')
|
||||||
user_id = parts[0]
|
user_id = parts[0]
|
||||||
@@ -218,38 +214,16 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
elif event_type == platform_events.GroupMessage:
|
elif event_type == platform_events.GroupMessage:
|
||||||
pass
|
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 run_async(self):
|
||||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
async def shutdown_trigger_placeholder():
|
||||||
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:
|
while True:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
await keep_alive()
|
await self.bot.run_task(
|
||||||
|
host=self.config['host'],
|
||||||
|
port=self.config['port'],
|
||||||
|
shutdown_trigger=shutdown_trigger_placeholder,
|
||||||
|
)
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -11,6 +11,23 @@ metadata:
|
|||||||
icon: wecom.png
|
icon: wecom.png
|
||||||
spec:
|
spec:
|
||||||
config:
|
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
|
- name: corpid
|
||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
|
|||||||
@@ -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.message as platform_message
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
from ..logger import EventLogger
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
||||||
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
|
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
|
||||||
|
|
||||||
@@ -88,20 +88,19 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
|
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
|
||||||
event_converter: WecomBotEventConverter = WecomBotEventConverter()
|
event_converter: WecomBotEventConverter = WecomBotEventConverter()
|
||||||
config: dict
|
config: dict
|
||||||
bot_uuid: str = None
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId']
|
required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId', 'port']
|
||||||
missing_keys = [key for key in required_keys if key not in config]
|
missing_keys = [key for key in required_keys if key not in config]
|
||||||
if missing_keys:
|
if missing_keys:
|
||||||
raise Exception(f'WecomBot 缺少配置项: {missing_keys}')
|
raise Exception(f'WecomBot 缺少配置项: {missing_keys}')
|
||||||
|
|
||||||
|
# 创建运行时 bot 对象
|
||||||
bot = WecomBotClient(
|
bot = WecomBotClient(
|
||||||
Token=config['Token'],
|
Token=config['Token'],
|
||||||
EnCodingAESKey=config['EncodingAESKey'],
|
EnCodingAESKey=config['EncodingAESKey'],
|
||||||
Corpid=config['Corpid'],
|
Corpid=config['Corpid'],
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=True,
|
|
||||||
)
|
)
|
||||||
bot_account_id = config['BotId']
|
bot_account_id = config['BotId']
|
||||||
|
|
||||||
@@ -190,46 +189,16 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
except Exception:
|
except Exception:
|
||||||
print(traceback.format_exc())
|
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 run_async(self):
|
||||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
async def 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('企业微信机器人 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:
|
while True:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
await keep_alive()
|
await self.bot.run_task(
|
||||||
|
host='0.0.0.0',
|
||||||
|
port=self.config['port'],
|
||||||
|
shutdown_trigger=shutdown_trigger_placeholder,
|
||||||
|
)
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ metadata:
|
|||||||
icon: wecombot.png
|
icon: wecombot.png
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
|
- name: port
|
||||||
|
label:
|
||||||
|
en_US: Port
|
||||||
|
zh_Hans: 监听端口
|
||||||
|
type: integer
|
||||||
|
required: true
|
||||||
|
default: 2291
|
||||||
- name: Corpid
|
- name: Corpid
|
||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
bot: WecomCSClient = pydantic.Field(exclude=True)
|
bot: WecomCSClient = pydantic.Field(exclude=True)
|
||||||
message_converter: WecomMessageConverter = WecomMessageConverter()
|
message_converter: WecomMessageConverter = WecomMessageConverter()
|
||||||
event_converter: WecomEventConverter = WecomEventConverter()
|
event_converter: WecomEventConverter = WecomEventConverter()
|
||||||
bot_uuid: str = None
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||||
required_keys = [
|
required_keys = [
|
||||||
@@ -140,7 +139,6 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
token=config['token'],
|
token=config['token'],
|
||||||
EncodingAESKey=config['EncodingAESKey'],
|
EncodingAESKey=config['EncodingAESKey'],
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -172,10 +170,6 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def set_bot_uuid(self, bot_uuid: str):
|
|
||||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
|
||||||
self.bot_uuid = bot_uuid
|
|
||||||
|
|
||||||
def register_listener(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
event_type: typing.Type[platform_events.Event],
|
event_type: typing.Type[platform_events.Event],
|
||||||
@@ -196,41 +190,16 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
elif event_type == platform_events.GroupMessage:
|
elif event_type == platform_events.GroupMessage:
|
||||||
pass
|
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 run_async(self):
|
||||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
async def 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(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:
|
while True:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
await keep_alive()
|
|
||||||
|
await self.bot.run_task(
|
||||||
|
host='0.0.0.0',
|
||||||
|
port=self.config['port'],
|
||||||
|
shutdown_trigger=shutdown_trigger_placeholder,
|
||||||
|
)
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ metadata:
|
|||||||
icon: wecom.png
|
icon: wecom.png
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
|
- name: port
|
||||||
|
label:
|
||||||
|
en_US: Port
|
||||||
|
zh_Hans: 监听端口
|
||||||
|
type: int
|
||||||
|
required: true
|
||||||
|
default: 2289
|
||||||
- name: corpid
|
- name: corpid
|
||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import httpx
|
import httpx
|
||||||
from async_lru import alru_cache
|
from async_lru import alru_cache
|
||||||
|
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
|
||||||
|
|
||||||
from ..core import app
|
from ..core import app
|
||||||
from . import handler
|
from . import handler
|
||||||
@@ -321,13 +322,20 @@ class PluginRuntimeConnector:
|
|||||||
return tools
|
return tools
|
||||||
|
|
||||||
async def call_tool(
|
async def call_tool(
|
||||||
self, tool_name: str, parameters: dict[str, Any], bound_plugins: list[str] | None = None
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
parameters: dict[str, Any],
|
||||||
|
session: provider_session.Session,
|
||||||
|
query_id: int,
|
||||||
|
bound_plugins: list[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not self.is_enable_plugin:
|
if not self.is_enable_plugin:
|
||||||
return {'error': 'Tool not found: plugin system is disabled'}
|
return {'error': 'Tool not found: plugin system is disabled'}
|
||||||
|
|
||||||
# Pass include_plugins to runtime for validation
|
# Pass include_plugins to runtime for validation
|
||||||
return await self.handler.call_tool(tool_name, parameters, include_plugins=bound_plugins)
|
return await self.handler.call_tool(
|
||||||
|
tool_name, parameters, session.model_dump(serialize_as_any=True), query_id, include_plugins=bound_plugins
|
||||||
|
)
|
||||||
|
|
||||||
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
|
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
|
||||||
if not self.is_enable_plugin:
|
if not self.is_enable_plugin:
|
||||||
|
|||||||
@@ -620,7 +620,12 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def call_tool(
|
async def call_tool(
|
||||||
self, tool_name: str, parameters: dict[str, Any], include_plugins: list[str] | None = None
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
parameters: dict[str, Any],
|
||||||
|
session: dict[str, Any],
|
||||||
|
query_id: int,
|
||||||
|
include_plugins: list[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Call tool"""
|
"""Call tool"""
|
||||||
result = await self.call_action(
|
result = await self.call_action(
|
||||||
@@ -628,6 +633,8 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
{
|
{
|
||||||
'tool_name': tool_name,
|
'tool_name': tool_name,
|
||||||
'tool_parameters': parameters,
|
'tool_parameters': parameters,
|
||||||
|
'session': session,
|
||||||
|
'query_id': query_id,
|
||||||
'include_plugins': include_plugins,
|
'include_plugins': include_plugins,
|
||||||
},
|
},
|
||||||
timeout=60,
|
timeout=60,
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
|
|
||||||
parameters = json.loads(func.arguments)
|
parameters = json.loads(func.arguments)
|
||||||
|
|
||||||
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters)
|
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query)
|
||||||
if is_stream:
|
if is_stream:
|
||||||
msg = provider_message.MessageChunk(
|
msg = provider_message.MessageChunk(
|
||||||
role='tool',
|
role='tool',
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
import abc
|
import abc
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
|
|
||||||
from ...core import app
|
from ...core import app
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ class ToolLoader(abc.ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
|
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||||
"""执行工具调用"""
|
"""执行工具调用"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import enum
|
|||||||
import typing
|
import typing
|
||||||
from contextlib import AsyncExitStack
|
from contextlib import AsyncExitStack
|
||||||
import traceback
|
import traceback
|
||||||
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
@@ -329,7 +330,7 @@ class MCPLoader(loader.ToolLoader):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
|
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||||
"""执行工具调用"""
|
"""执行工具调用"""
|
||||||
for session in self.sessions.values():
|
for session in self.sessions.values():
|
||||||
for function in session.get_tools():
|
for function in session.get_tools():
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
import typing
|
import typing
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
from langbot_plugin.api.entities.events import pipeline_query
|
||||||
|
|
||||||
from .. import loader
|
from .. import loader
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||||
|
|
||||||
@@ -43,9 +45,11 @@ class PluginToolLoader(loader.ToolLoader):
|
|||||||
return tool
|
return tool
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def invoke_tool(self, name: str, parameters: dict) -> typing.Any:
|
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||||
try:
|
try:
|
||||||
return await self.ap.plugin_connector.call_tool(name, parameters)
|
return await self.ap.plugin_connector.call_tool(
|
||||||
|
name, parameters, session=query.session, query_id=query.query_id
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.ap.logger.error(f'执行函数 {name} 时发生错误: {e}')
|
self.ap.logger.error(f'执行函数 {name} 时发生错误: {e}')
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from langbot.pkg.utils import importutil
|
|||||||
from langbot.pkg.provider.tools import loaders
|
from langbot.pkg.provider.tools import loaders
|
||||||
from langbot.pkg.provider.tools.loaders import mcp as mcp_loader, plugin as plugin_loader
|
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
|
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)
|
importutil.import_modules_in_pkg(loaders)
|
||||||
|
|
||||||
@@ -91,13 +92,13 @@ class ToolManager:
|
|||||||
|
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
async def execute_func_call(self, name: str, parameters: dict) -> typing.Any:
|
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||||
"""执行函数调用"""
|
"""执行函数调用"""
|
||||||
|
|
||||||
if await self.plugin_tool_loader.has_tool(name):
|
if await self.plugin_tool_loader.has_tool(name):
|
||||||
return await self.plugin_tool_loader.invoke_tool(name, parameters)
|
return await self.plugin_tool_loader.invoke_tool(name, parameters, query)
|
||||||
elif await self.mcp_tool_loader.has_tool(name):
|
elif await self.mcp_tool_loader.has_tool(name):
|
||||||
return await self.mcp_tool_loader.invoke_tool(name, parameters)
|
return await self.mcp_tool_loader.invoke_tool(name, parameters, query)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f'未找到工具: {name}')
|
raise ValueError(f'未找到工具: {name}')
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
admins: []
|
admins: []
|
||||||
api:
|
api:
|
||||||
port: 5300
|
port: 5300
|
||||||
webhook_prefix: 'http://127.0.0.1:5300'
|
|
||||||
command:
|
command:
|
||||||
enable: true
|
enable: true
|
||||||
prefix:
|
prefix:
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
"""
|
|
||||||
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'])
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
IChooseAdapterEntity,
|
IChooseAdapterEntity,
|
||||||
IPipelineEntity,
|
IPipelineEntity,
|
||||||
@@ -112,86 +112,11 @@ export default function BotForm({
|
|||||||
IDynamicFormItemSchema[]
|
IDynamicFormItemSchema[]
|
||||||
>([]);
|
>([]);
|
||||||
const [, setIsLoading] = useState<boolean>(false);
|
const [, setIsLoading] = useState<boolean>(false);
|
||||||
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
|
||||||
const webhookInputRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBotFormValues();
|
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() {
|
function setBotFormValues() {
|
||||||
initBotFormComponent().then(() => {
|
initBotFormComponent().then(() => {
|
||||||
// 拉取初始化表单信息
|
// 拉取初始化表单信息
|
||||||
@@ -207,20 +132,12 @@ export default function BotForm({
|
|||||||
console.log('form', form.getValues());
|
console.log('form', form.getValues());
|
||||||
handleAdapterSelect(val.adapter);
|
handleAdapterSelect(val.adapter);
|
||||||
// dynamicForm.setFieldsValue(val.adapter_config);
|
// dynamicForm.setFieldsValue(val.adapter_config);
|
||||||
|
|
||||||
// 设置 webhook 地址(如果有)
|
|
||||||
if (val.webhook_full_url) {
|
|
||||||
setWebhookUrl(val.webhook_full_url);
|
|
||||||
} else {
|
|
||||||
setWebhookUrl('');
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error(t('bots.getBotConfigError') + err.message);
|
toast.error(t('bots.getBotConfigError') + err.message);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
form.reset();
|
form.reset();
|
||||||
setWebhookUrl('');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -295,7 +212,7 @@ export default function BotForm({
|
|||||||
|
|
||||||
async function getBotConfig(
|
async function getBotConfig(
|
||||||
botId: string,
|
botId: string,
|
||||||
): Promise<z.infer<typeof formSchema> & { webhook_full_url?: string }> {
|
): Promise<z.infer<typeof formSchema>> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
httpClient
|
httpClient
|
||||||
.getBot(botId)
|
.getBot(botId)
|
||||||
@@ -308,10 +225,6 @@ export default function BotForm({
|
|||||||
adapter_config: bot.adapter_config,
|
adapter_config: bot.adapter_config,
|
||||||
enable: bot.enable ?? true,
|
enable: bot.enable ?? true,
|
||||||
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
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) => {
|
.catch((err) => {
|
||||||
@@ -361,20 +274,6 @@ export default function BotForm({
|
|||||||
console.log('update bot success', res);
|
console.log('update bot success', res);
|
||||||
onFormSubmit(form.getValues());
|
onFormSubmit(form.getValues());
|
||||||
toast.success(t('bots.saveSuccess'));
|
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) => {
|
.catch((err) => {
|
||||||
toast.error(t('bots.saveError') + err.message);
|
toast.error(t('bots.saveError') + err.message);
|
||||||
@@ -429,18 +328,6 @@ 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -481,88 +368,51 @@ export default function BotForm({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 是否启用 & 绑定流水线 仅在编辑模式 */}
|
{/* 是否启用 & 绑定流水线 仅在编辑模式 */}
|
||||||
{initBotId && (
|
{initBotId && (
|
||||||
<>
|
<div className="flex items-center gap-6">
|
||||||
<div className="flex items-center gap-6">
|
<FormField
|
||||||
<FormField
|
control={form.control}
|
||||||
control={form.control}
|
name="enable"
|
||||||
name="enable"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
|
||||||
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
|
<FormLabel>{t('common.enable')}</FormLabel>
|
||||||
<FormLabel>{t('common.enable')}</FormLabel>
|
<FormControl>
|
||||||
<FormControl>
|
<Switch
|
||||||
<Switch
|
checked={field.value}
|
||||||
checked={field.value}
|
onCheckedChange={field.onChange}
|
||||||
onCheckedChange={field.onChange}
|
/>
|
||||||
/>
|
</FormControl>
|
||||||
</FormControl>
|
</FormItem>
|
||||||
</FormItem>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="use_pipeline_uuid"
|
name="use_pipeline_uuid"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
|
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
|
||||||
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
|
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select onValueChange={field.onChange} {...field}>
|
<Select onValueChange={field.onChange} {...field}>
|
||||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={t('bots.selectPipeline')}
|
placeholder={t('bots.selectPipeline')}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="fixed z-[1000]">
|
<SelectContent className="fixed z-[1000]">
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{pipelineNameList.map((item) => (
|
{pipelineNameList.map((item) => (
|
||||||
<SelectItem
|
<SelectItem key={item.value} value={item.value}>
|
||||||
key={item.value}
|
{item.label}
|
||||||
value={item.value}
|
</SelectItem>
|
||||||
>
|
))}
|
||||||
{item.label}
|
</SelectGroup>
|
||||||
</SelectItem>
|
</SelectContent>
|
||||||
))}
|
</Select>
|
||||||
</SelectGroup>
|
</FormControl>
|
||||||
</SelectContent>
|
</FormItem>
|
||||||
</Select>
|
)}
|
||||||
</FormControl>
|
/>
|
||||||
</FormItem>
|
</div>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</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
|
<FormField
|
||||||
@@ -658,17 +508,16 @@ export default function BotForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showDynamicForm && dynamicFormConfigListToRender.length > 0 && (
|
{showDynamicForm && dynamicFormConfigList.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-lg font-medium">
|
<div className="text-lg font-medium">
|
||||||
{t('bots.adapterConfig')}
|
{t('bots.adapterConfig')}
|
||||||
</div>
|
</div>
|
||||||
<DynamicFormComponent
|
<DynamicFormComponent
|
||||||
itemConfigList={dynamicFormConfigListToRender}
|
itemConfigList={dynamicFormConfigList}
|
||||||
initialValues={form.watch('adapter_config')}
|
initialValues={form.watch('adapter_config')}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
const prev = form.getValues('adapter_config') || {};
|
form.setValue('adapter_config', values);
|
||||||
form.setValue('adapter_config', { ...prev, ...values });
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -289,12 +289,16 @@ export default function ApiIntegrationDialog({
|
|||||||
{t('common.noApiKeys')}
|
{t('common.noApiKeys')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border rounded-md">
|
<div className="border rounded-md overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t('common.name')}</TableHead>
|
<TableHead className="min-w-[120px]">
|
||||||
<TableHead>{t('common.apiKeyValue')}</TableHead>
|
{t('common.name')}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="min-w-[200px]">
|
||||||
|
{t('common.apiKeyValue')}
|
||||||
|
</TableHead>
|
||||||
<TableHead className="w-[100px]">
|
<TableHead className="w-[100px]">
|
||||||
{t('common.actions')}
|
{t('common.actions')}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -372,16 +376,20 @@ export default function ApiIntegrationDialog({
|
|||||||
{t('common.noWebhooks')}
|
{t('common.noWebhooks')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border rounded-md">
|
<div className="border rounded-md overflow-x-auto max-w-full">
|
||||||
<Table>
|
<Table className="table-fixed w-full">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t('common.name')}</TableHead>
|
<TableHead className="w-[150px]">
|
||||||
<TableHead>{t('common.webhookUrl')}</TableHead>
|
{t('common.name')}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[380px]">
|
||||||
|
{t('common.webhookUrl')}
|
||||||
|
</TableHead>
|
||||||
<TableHead className="w-[80px]">
|
<TableHead className="w-[80px]">
|
||||||
{t('common.webhookEnabled')}
|
{t('common.webhookEnabled')}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[100px]">
|
<TableHead className="w-[80px]">
|
||||||
{t('common.actions')}
|
{t('common.actions')}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -389,20 +397,30 @@ export default function ApiIntegrationDialog({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{webhooks.map((webhook) => (
|
{webhooks.map((webhook) => (
|
||||||
<TableRow key={webhook.id}>
|
<TableRow key={webhook.id}>
|
||||||
<TableCell>
|
<TableCell className="truncate">
|
||||||
<div>
|
<div className="truncate">
|
||||||
<div className="font-medium">{webhook.name}</div>
|
<div
|
||||||
|
className="font-medium truncate"
|
||||||
|
title={webhook.name}
|
||||||
|
>
|
||||||
|
{webhook.name}
|
||||||
|
</div>
|
||||||
{webhook.description && (
|
{webhook.description && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div
|
||||||
|
className="text-sm text-muted-foreground truncate"
|
||||||
|
title={webhook.description}
|
||||||
|
>
|
||||||
{webhook.description}
|
{webhook.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<code className="text-sm bg-muted px-2 py-1 rounded break-all">
|
<div className="overflow-x-auto max-w-[380px]">
|
||||||
{webhook.url}
|
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
|
||||||
</code>
|
{webhook.url}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -234,7 +234,19 @@ export default function PipelineExtension({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 mt-1">
|
<div className="flex gap-1 mt-1">
|
||||||
<PluginComponentList
|
<PluginComponentList
|
||||||
components={plugin.components}
|
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;
|
||||||
|
})()}
|
||||||
showComponentName={true}
|
showComponentName={true}
|
||||||
showTitle={false}
|
showTitle={false}
|
||||||
useBadge={true}
|
useBadge={true}
|
||||||
@@ -401,7 +413,19 @@ export default function PipelineExtension({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 mt-1">
|
<div className="flex gap-1 mt-1">
|
||||||
<PluginComponentList
|
<PluginComponentList
|
||||||
components={plugin.components}
|
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;
|
||||||
|
})()}
|
||||||
showComponentName={true}
|
showComponentName={true}
|
||||||
showTitle={false}
|
showTitle={false}
|
||||||
useBadge={true}
|
useBadge={true}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { PluginComponent } from '@/app/infra/entities/plugin';
|
|
||||||
import { TFunction } from 'i18next';
|
import { TFunction } from 'i18next';
|
||||||
import { Wrench, AudioWaveform, Hash } from 'lucide-react';
|
import { Wrench, AudioWaveform, Hash } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -9,31 +8,22 @@ export default function PluginComponentList({
|
|||||||
showTitle,
|
showTitle,
|
||||||
useBadge,
|
useBadge,
|
||||||
t,
|
t,
|
||||||
|
responsive = false,
|
||||||
}: {
|
}: {
|
||||||
components: PluginComponent[];
|
components: Record<string, number>;
|
||||||
showComponentName: boolean;
|
showComponentName: boolean;
|
||||||
showTitle: boolean;
|
showTitle: boolean;
|
||||||
useBadge: boolean;
|
useBadge: boolean;
|
||||||
t: TFunction;
|
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> = {
|
const kindIconMap: Record<string, React.ReactNode> = {
|
||||||
Tool: <Wrench className="w-5 h-5" />,
|
Tool: <Wrench className="w-5 h-5" />,
|
||||||
EventListener: <AudioWaveform className="w-5 h-5" />,
|
EventListener: <AudioWaveform className="w-5 h-5" />,
|
||||||
Command: <Hash className="w-5 h-5" />,
|
Command: <Hash className="w-5 h-5" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const componentKindList = Object.keys(componentKindCount);
|
const componentKindList = Object.keys(components || {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -44,11 +34,21 @@ export default function PluginComponentList({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{useBadge && (
|
{useBadge && (
|
||||||
<Badge variant="outline">
|
<Badge
|
||||||
|
key={kind}
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
{kindIconMap[kind]}
|
{kindIconMap[kind]}
|
||||||
{showComponentName &&
|
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
|
||||||
t('plugins.componentName.' + kind) + ' '}
|
{responsive ? (
|
||||||
{componentKindCount[kind]}
|
<span className="hidden md:inline">
|
||||||
|
{t('plugins.componentName.' + kind)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
showComponentName && t('plugins.componentName.' + kind)
|
||||||
|
)}
|
||||||
|
<span className="ml-1">{components[kind]}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -58,9 +58,15 @@ export default function PluginComponentList({
|
|||||||
className="flex flex-row items-center justify-start gap-[0.2rem]"
|
className="flex flex-row items-center justify-start gap-[0.2rem]"
|
||||||
>
|
>
|
||||||
{kindIconMap[kind]}
|
{kindIconMap[kind]}
|
||||||
{showComponentName &&
|
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
|
||||||
t('plugins.componentName.' + kind) + ' '}
|
{responsive ? (
|
||||||
{componentKindCount[kind]}
|
<span className="hidden md:inline">
|
||||||
|
{t('plugins.componentName.' + kind)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
showComponentName && t('plugins.componentName.' + kind)
|
||||||
|
)}
|
||||||
|
<span className="ml-1">{components[kind]}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -128,7 +128,18 @@ export default function PluginCardComponent({
|
|||||||
|
|
||||||
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
|
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
|
||||||
<PluginComponentList
|
<PluginComponentList
|
||||||
components={cardVO.components}
|
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;
|
||||||
|
})()}
|
||||||
showComponentName={false}
|
showComponentName={false}
|
||||||
showTitle={true}
|
showTitle={true}
|
||||||
useBadge={false}
|
useBadge={false}
|
||||||
|
|||||||
@@ -160,7 +160,18 @@ export default function PluginForm({
|
|||||||
|
|
||||||
<div className="mb-4 flex flex-row items-center justify-start gap-[0.4rem]">
|
<div className="mb-4 flex flex-row items-center justify-start gap-[0.4rem]">
|
||||||
<PluginComponentList
|
<PluginComponentList
|
||||||
components={pluginInfo.components}
|
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;
|
||||||
|
})()}
|
||||||
showComponentName={true}
|
showComponentName={true}
|
||||||
showTitle={false}
|
showTitle={false}
|
||||||
useBadge={true}
|
useBadge={true}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Search, Loader2 } from 'lucide-react';
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
|
import { Search, Loader2, Wrench, AudioWaveform, Hash } from 'lucide-react';
|
||||||
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
||||||
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
||||||
import PluginDetailDialog from './plugin-detail-dialog/PluginDetailDialog';
|
import PluginDetailDialog from './plugin-detail-dialog/PluginDetailDialog';
|
||||||
@@ -38,6 +39,7 @@ function MarketPageContent({
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [componentFilter, setComponentFilter] = useState<string>('all');
|
||||||
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
|
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
@@ -111,6 +113,7 @@ function MarketPageContent({
|
|||||||
),
|
),
|
||||||
githubURL: plugin.repository,
|
githubURL: plugin.repository,
|
||||||
version: plugin.latest_version,
|
version: plugin.latest_version,
|
||||||
|
components: plugin.components,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -124,25 +127,20 @@ function MarketPageContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response;
|
|
||||||
const { sortBy, sortOrder } = getCurrentSort();
|
const { sortBy, sortOrder } = getCurrentSort();
|
||||||
|
const filterValue =
|
||||||
|
componentFilter === 'all' ? undefined : componentFilter;
|
||||||
|
|
||||||
if (isSearch && searchQuery.trim()) {
|
// Always use searchMarketplacePlugins to support component filtering
|
||||||
response = await getCloudServiceClientSync().searchMarketplacePlugins(
|
const response =
|
||||||
searchQuery.trim(),
|
await getCloudServiceClientSync().searchMarketplacePlugins(
|
||||||
|
isSearch && searchQuery.trim() ? searchQuery.trim() : '',
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
filterValue,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
response = await getCloudServiceClientSync().getMarketplacePlugins(
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: ApiRespMarketplacePlugins = response;
|
const data: ApiRespMarketplacePlugins = response;
|
||||||
const newPlugins = data.plugins.map(transformToVO);
|
const newPlugins = data.plugins.map(transformToVO);
|
||||||
@@ -167,7 +165,14 @@ function MarketPageContent({
|
|||||||
setIsLoadingMore(false);
|
setIsLoadingMore(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[searchQuery, pageSize, transformToVO, plugins.length, getCurrentSort],
|
[
|
||||||
|
searchQuery,
|
||||||
|
componentFilter,
|
||||||
|
pageSize,
|
||||||
|
transformToVO,
|
||||||
|
plugins.length,
|
||||||
|
getCurrentSort,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 初始加载
|
// 初始加载
|
||||||
@@ -212,10 +217,18 @@ function MarketPageContent({
|
|||||||
// fetchPlugins will be called by useEffect when sortOption changes
|
// 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(() => {
|
useEffect(() => {
|
||||||
fetchPlugins(1, !!searchQuery.trim(), true);
|
fetchPlugins(1, !!searchQuery.trim(), true);
|
||||||
}, [sortOption]);
|
}, [sortOption, componentFilter]);
|
||||||
|
|
||||||
// 处理URL参数,检查是否需要打开插件详情对话框
|
// 处理URL参数,检查是否需要打开插件详情对话框
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -342,9 +355,59 @@ function MarketPageContent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort dropdown */}
|
{/* Component filter and sort */}
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-3 sm:px-4">
|
||||||
<div className="w-full max-w-2xl flex items-center gap-2 sm:gap-3">
|
{/* 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">
|
||||||
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
|
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{t('market.sortBy')}:
|
{t('market.sortBy')}:
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { toast } from 'sonner';
|
|||||||
import { PluginV4 } from '@/app/infra/entities/plugin';
|
import { PluginV4 } from '@/app/infra/entities/plugin';
|
||||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
|
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||||
|
|
||||||
interface PluginDetailDialogProps {
|
interface PluginDetailDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -104,6 +105,15 @@ export default function PluginDetailDialog({
|
|||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
{plugin!.install_count.toLocaleString()} {t('market.downloads')}
|
{plugin!.install_count.toLocaleString()} {t('market.downloads')}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{plugin!.components && Object.keys(plugin!.components).length > 0 && (
|
||||||
|
<PluginComponentList
|
||||||
|
components={plugin!.components}
|
||||||
|
showComponentName={true}
|
||||||
|
showTitle={false}
|
||||||
|
useBadge={true}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{plugin!.repository && (
|
{plugin!.repository && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { PluginMarketCardVO } from './PluginMarketCardVO';
|
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({
|
export default function PluginMarketCardComponent({
|
||||||
cardVO,
|
cardVO,
|
||||||
@@ -7,18 +10,32 @@ export default function PluginMarketCardComponent({
|
|||||||
cardVO: PluginMarketCardVO;
|
cardVO: PluginMarketCardVO;
|
||||||
onPluginClick?: (author: string, pluginName: string) => void;
|
onPluginClick?: (author: string, pluginName: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
function handleCardClick() {
|
function handleCardClick() {
|
||||||
if (onPluginClick) {
|
if (onPluginClick) {
|
||||||
onPluginClick(cardVO.author, cardVO.pluginName);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
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]"
|
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]"
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
>
|
>
|
||||||
<div className="w-full h-full flex flex-col justify-between gap-2">
|
<div className="w-full h-full flex flex-col justify-between gap-3">
|
||||||
{/* 上部分:插件信息 */}
|
{/* 上部分:插件信息 */}
|
||||||
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0">
|
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0">
|
||||||
<img
|
<img
|
||||||
@@ -60,23 +77,45 @@ export default function PluginMarketCardComponent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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">
|
||||||
<svg
|
<div className="flex flex-row items-center justify-start gap-[0.3rem] sm:gap-[0.4rem]">
|
||||||
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] flex-shrink-0"
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] flex-shrink-0"
|
||||||
viewBox="0 0 24 24"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
strokeWidth="2"
|
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" />
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
<line x1="12" y1="15" x2="12" y2="3" />
|
<polyline points="7,10 12,15 17,10" />
|
||||||
</svg>
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
<div className="text-xs sm:text-sm text-[#2563eb] font-medium whitespace-nowrap">
|
</svg>
|
||||||
{cardVO.installCount.toLocaleString()}
|
<div className="text-xs sm:text-sm text-[#2563eb] font-medium whitespace-nowrap">
|
||||||
|
{cardVO.installCount.toLocaleString()}
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface IPluginMarketCardVO {
|
|||||||
iconURL: string;
|
iconURL: string;
|
||||||
githubURL: string;
|
githubURL: string;
|
||||||
version: string;
|
version: string;
|
||||||
|
components?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PluginMarketCardVO implements IPluginMarketCardVO {
|
export class PluginMarketCardVO implements IPluginMarketCardVO {
|
||||||
@@ -20,6 +21,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
|||||||
githubURL: string;
|
githubURL: string;
|
||||||
installCount: number;
|
installCount: number;
|
||||||
version: string;
|
version: string;
|
||||||
|
components?: Record<string, number>;
|
||||||
|
|
||||||
constructor(prop: IPluginMarketCardVO) {
|
constructor(prop: IPluginMarketCardVO) {
|
||||||
this.description = prop.description;
|
this.description = prop.description;
|
||||||
@@ -31,5 +33,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
|||||||
this.installCount = prop.installCount;
|
this.installCount = prop.installCount;
|
||||||
this.pluginId = prop.pluginId;
|
this.pluginId = prop.pluginId;
|
||||||
this.version = prop.version;
|
this.version = prop.version;
|
||||||
|
this.components = prop.components;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ export interface Bot {
|
|||||||
use_pipeline_uuid?: string;
|
use_pipeline_uuid?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
adapter_runtime_values?: object;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiRespKnowledgeBases {
|
export interface ApiRespKnowledgeBases {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface PluginV4 {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
install_count: number;
|
install_count: number;
|
||||||
latest_version: string;
|
latest_version: string;
|
||||||
|
components: Record<string, number>;
|
||||||
status: PluginV4Status;
|
status: PluginV4Status;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
|||||||
page_size: number,
|
page_size: number,
|
||||||
sort_by?: string,
|
sort_by?: string,
|
||||||
sort_order?: string,
|
sort_order?: string,
|
||||||
|
component_filter?: string,
|
||||||
): Promise<ApiRespMarketplacePlugins> {
|
): Promise<ApiRespMarketplacePlugins> {
|
||||||
return this.post<ApiRespMarketplacePlugins>(
|
return this.post<ApiRespMarketplacePlugins>(
|
||||||
'/api/v1/marketplace/plugins/search',
|
'/api/v1/marketplace/plugins/search',
|
||||||
@@ -43,6 +44,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
|||||||
page_size,
|
page_size,
|
||||||
sort_by,
|
sort_by,
|
||||||
sort_order,
|
sort_order,
|
||||||
|
component_filter,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,32 +8,40 @@ import { cn } from '@/lib/utils';
|
|||||||
import { toggleVariants } from '@/components/ui/toggle';
|
import { toggleVariants } from '@/components/ui/toggle';
|
||||||
|
|
||||||
const ToggleGroupContext = React.createContext<
|
const ToggleGroupContext = React.createContext<
|
||||||
VariantProps<typeof toggleVariants>
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number;
|
||||||
|
}
|
||||||
>({
|
>({
|
||||||
size: 'default',
|
size: 'default',
|
||||||
variant: 'default',
|
variant: 'default',
|
||||||
|
spacing: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
function ToggleGroup({
|
function ToggleGroup({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
|
spacing = 0,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||||
VariantProps<typeof toggleVariants>) {
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
spacing?: number;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<ToggleGroupPrimitive.Root
|
<ToggleGroupPrimitive.Root
|
||||||
data-slot="toggle-group"
|
data-slot="toggle-group"
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
data-size={size}
|
data-size={size}
|
||||||
|
data-spacing={spacing}
|
||||||
|
style={{ '--gap': spacing } as React.CSSProperties}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
|
'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||||
{children}
|
{children}
|
||||||
</ToggleGroupContext.Provider>
|
</ToggleGroupContext.Provider>
|
||||||
</ToggleGroupPrimitive.Root>
|
</ToggleGroupPrimitive.Root>
|
||||||
@@ -55,12 +63,14 @@ function ToggleGroupItem({
|
|||||||
data-slot="toggle-group-item"
|
data-slot="toggle-group-item"
|
||||||
data-variant={context.variant || variant}
|
data-variant={context.variant || variant}
|
||||||
data-size={context.size || size}
|
data-size={context.size || size}
|
||||||
|
data-spacing={context.spacing}
|
||||||
className={cn(
|
className={cn(
|
||||||
toggleVariants({
|
toggleVariants({
|
||||||
variant: context.variant || variant,
|
variant: context.variant || variant,
|
||||||
size: context.size || size,
|
size: context.size || size,
|
||||||
}),
|
}),
|
||||||
'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',
|
'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',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const toggleVariants = cva(
|
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 [&_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 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",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const enUS = {
|
|||||||
addRound: 'Add Round',
|
addRound: 'Add Round',
|
||||||
copy: 'Copy',
|
copy: 'Copy',
|
||||||
copySuccess: 'Copy Successfully',
|
copySuccess: 'Copy Successfully',
|
||||||
copyFailed: 'Copy Failed',
|
|
||||||
test: 'Test',
|
test: 'Test',
|
||||||
forgotPassword: 'Forgot Password?',
|
forgotPassword: 'Forgot Password?',
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
@@ -188,10 +187,6 @@ const enUS = {
|
|||||||
log: 'Log',
|
log: 'Log',
|
||||||
configuration: 'Configuration',
|
configuration: 'Configuration',
|
||||||
logs: 'Logs',
|
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: {
|
plugins: {
|
||||||
title: 'Extensions',
|
title: 'Extensions',
|
||||||
@@ -356,6 +351,8 @@ const enUS = {
|
|||||||
markAsRead: 'Mark as Read',
|
markAsRead: 'Mark as Read',
|
||||||
markAsReadSuccess: 'Marked as read',
|
markAsReadSuccess: 'Marked as read',
|
||||||
markAsReadFailed: 'Mark as read failed',
|
markAsReadFailed: 'Mark as read failed',
|
||||||
|
filterByComponent: 'Component',
|
||||||
|
allComponents: 'All Components',
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
title: 'MCP',
|
title: 'MCP',
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ const jaJP = {
|
|||||||
addRound: 'ラウンドを追加',
|
addRound: 'ラウンドを追加',
|
||||||
copy: 'コピー',
|
copy: 'コピー',
|
||||||
copySuccess: 'コピーに成功しました',
|
copySuccess: 'コピーに成功しました',
|
||||||
copyFailed: 'コピーに失敗しました',
|
|
||||||
test: 'テスト',
|
test: 'テスト',
|
||||||
forgotPassword: 'パスワードを忘れた?',
|
forgotPassword: 'パスワードを忘れた?',
|
||||||
loading: '読み込み中...',
|
loading: '読み込み中...',
|
||||||
@@ -190,10 +189,6 @@ const jaJP = {
|
|||||||
log: 'ログ',
|
log: 'ログ',
|
||||||
configuration: '設定',
|
configuration: '設定',
|
||||||
logs: 'ログ',
|
logs: 'ログ',
|
||||||
webhookUrl: 'Webhook コールバック URL',
|
|
||||||
webhookUrlCopied: 'Webhook URL をコピーしました',
|
|
||||||
webhookUrlHint:
|
|
||||||
'入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
|
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
title: '拡張機能',
|
title: '拡張機能',
|
||||||
@@ -358,6 +353,8 @@ const jaJP = {
|
|||||||
markAsRead: '既読',
|
markAsRead: '既読',
|
||||||
markAsReadSuccess: '既読に設定しました',
|
markAsReadSuccess: '既読に設定しました',
|
||||||
markAsReadFailed: '既読に設定に失敗しました',
|
markAsReadFailed: '既読に設定に失敗しました',
|
||||||
|
filterByComponent: 'コンポーネント',
|
||||||
|
allComponents: '全部コンポーネント',
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
title: 'MCP',
|
title: 'MCP',
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const zhHans = {
|
|||||||
addRound: '添加回合',
|
addRound: '添加回合',
|
||||||
copy: '复制',
|
copy: '复制',
|
||||||
copySuccess: '复制成功',
|
copySuccess: '复制成功',
|
||||||
copyFailed: '复制失败',
|
|
||||||
test: '测试',
|
test: '测试',
|
||||||
forgotPassword: '忘记密码?',
|
forgotPassword: '忘记密码?',
|
||||||
loading: '加载中...',
|
loading: '加载中...',
|
||||||
@@ -183,10 +182,6 @@ const zhHans = {
|
|||||||
log: '日志',
|
log: '日志',
|
||||||
configuration: '配置',
|
configuration: '配置',
|
||||||
logs: '日志',
|
logs: '日志',
|
||||||
webhookUrl: 'Webhook 回调地址',
|
|
||||||
webhookUrlCopied: 'Webhook 地址已复制',
|
|
||||||
webhookUrlHint:
|
|
||||||
'点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
|
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
title: '插件扩展',
|
title: '插件扩展',
|
||||||
@@ -340,6 +335,8 @@ const zhHans = {
|
|||||||
markAsRead: '已读',
|
markAsRead: '已读',
|
||||||
markAsReadSuccess: '已标记为已读',
|
markAsReadSuccess: '已标记为已读',
|
||||||
markAsReadFailed: '标记为已读失败',
|
markAsReadFailed: '标记为已读失败',
|
||||||
|
filterByComponent: '组件',
|
||||||
|
allComponents: '全部组件',
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
title: 'MCP',
|
title: 'MCP',
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const zhHant = {
|
|||||||
addRound: '新增回合',
|
addRound: '新增回合',
|
||||||
copy: '複製',
|
copy: '複製',
|
||||||
copySuccess: '複製成功',
|
copySuccess: '複製成功',
|
||||||
copyFailed: '複製失敗',
|
|
||||||
test: '測試',
|
test: '測試',
|
||||||
forgotPassword: '忘記密碼?',
|
forgotPassword: '忘記密碼?',
|
||||||
loading: '載入中...',
|
loading: '載入中...',
|
||||||
@@ -183,10 +182,6 @@ const zhHant = {
|
|||||||
log: '日誌',
|
log: '日誌',
|
||||||
configuration: '設定',
|
configuration: '設定',
|
||||||
logs: '日誌',
|
logs: '日誌',
|
||||||
webhookUrl: 'Webhook 回調位址',
|
|
||||||
webhookUrlCopied: 'Webhook 位址已複製',
|
|
||||||
webhookUrlHint:
|
|
||||||
'點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
|
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
title: '外掛擴展',
|
title: '外掛擴展',
|
||||||
@@ -338,6 +333,8 @@ const zhHant = {
|
|||||||
markAsRead: '已讀',
|
markAsRead: '已讀',
|
||||||
markAsReadSuccess: '已標記為已讀',
|
markAsReadSuccess: '已標記為已讀',
|
||||||
markAsReadFailed: '標記為已讀失敗',
|
markAsReadFailed: '標記為已讀失敗',
|
||||||
|
filterByComponent: '組件',
|
||||||
|
allComponents: '全部組件',
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
title: 'MCP',
|
title: 'MCP',
|
||||||
|
|||||||
Reference in New Issue
Block a user