Feat/complete adapter features (#1849)

* feat: add voice and file supports for wecom

* feat: add   and  in query variables

* feat: supports for lark recv file message

* feat: kook recv voice msg

* feat: supports for Voice and File in discord

* chore: remove debug msg

* perf: remove unnecessary bot logs

* feat: implement bot log filtering and per label color (#1839)

* feat: add sender_name and group_name in query variables
This commit is contained in:
Junyan Qin (Chin)
2025-12-06 21:11:01 +08:00
committed by GitHub
parent daf56e5dc2
commit 6421a6f5cb
22 changed files with 464 additions and 145 deletions

View File

@@ -394,7 +394,6 @@ class WecomBotClient:
"""
try:
self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '')
await self.logger.info(f'{req.method} {req.url} {str(req.args)}')
if req.method == 'GET':
return await self._handle_get_callback(req)

View File

@@ -139,6 +139,58 @@ class WecomClient:
await self.logger.error(f'发送图片失败:{data}')
raise Exception('Failed to send image: ' + str(data))
async def send_voice(self, user_id: str, agent_id: int, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/message/send?access_token=' + self.access_token
async with httpx.AsyncClient() as client:
params = {
'touser': user_id,
'msgtype': 'voice',
'agentid': agent_id,
'voice': {
'media_id': media_id,
},
'safe': 0,
'enable_id_trans': 0,
'enable_duplicate_check': 0,
'duplicate_check_interval': 1800,
}
response = await client.post(url, json=params)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_voice(user_id, agent_id, media_id)
if data['errcode'] != 0:
await self.logger.error(f'发送语音失败:{data}')
raise Exception('Failed to send voice: ' + str(data))
async def send_file(self, user_id: str, agent_id: int, media_id: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/message/send?access_token=' + self.access_token
async with httpx.AsyncClient() as client:
params = {
'touser': user_id,
'msgtype': 'file',
'agentid': agent_id,
'file': {
'media_id': media_id,
},
'safe': 0,
'enable_id_trans': 0,
'enable_duplicate_check': 0,
'duplicate_check_interval': 1800,
}
response = await client.post(url, json=params)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
return await self.send_file(user_id, agent_id, media_id)
if data['errcode'] != 0:
await self.logger.error(f'发送文件失败:{data}')
raise Exception('Failed to send file: ' + str(data))
async def send_private_msg(self, user_id: str, agent_id: int, content: str):
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
@@ -287,7 +339,7 @@ class WecomClient:
return ext
return 'jpg' # 默认返回jpg
async def upload_to_work(self, image: platform_message.Image):
async def upload_image_to_work(self, image: platform_message.Image):
"""
获取 media_id
"""
@@ -304,7 +356,7 @@ class WecomClient:
file_bytes = await f.read()
file_name = image.path.split('/')[-1]
elif image.url:
file_bytes = await self.download_image_to_bytes(image.url)
file_bytes = await self.download_media_to_bytes(image.url)
file_name = image.url.split('/')[-1]
elif image.base64:
try:
@@ -339,7 +391,7 @@ class WecomClient:
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_to_work(image)
media_id = await self.upload_image_to_work(image)
if data.get('errcode', 0) != 0:
await self.logger.error(f'上传图片失败:{data}')
raise Exception('failed to upload file')
@@ -347,13 +399,128 @@ class WecomClient:
media_id = data.get('media_id')
return media_id
async def download_image_to_bytes(self, url: str) -> bytes:
async def upload_voice_to_work(self, voice: platform_message.Voice):
"""
上传语音文件到企业微信
"""
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
file_bytes = None
file_name = 'voice.mp3'
if voice.path:
async with aiofiles.open(voice.path, 'rb') as f:
file_bytes = await f.read()
file_name = voice.path.split('/')[-1]
elif voice.url:
file_bytes = await self.download_media_to_bytes(voice.url)
file_name = voice.url.split('/')[-1]
elif voice.base64:
try:
base64_data = voice.base64
if ',' in base64_data:
base64_data = base64_data.split(',', 1)[1]
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
padded_base64 = base64_data + '=' * padding
file_bytes = base64.b64decode(padded_base64)
except binascii.Error as e:
raise ValueError(f'Invalid base64 string: {str(e)}')
else:
await self.logger.error('Voice对象出错')
raise ValueError('voice对象出错')
boundary = '-------------------------acebdf13572468'
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
body = (
(
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n'
f'Content-Type: application/octet-stream\r\n\r\n'
).encode('utf-8')
+ file_bytes
+ f'\r\n--{boundary}--\r\n'.encode('utf-8')
)
# print(body)
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, content=body)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_voice_to_work(voice)
if data.get('errcode', 0) != 0:
await self.logger.error(f'上传语音文件失败:{data}')
raise Exception('failed to upload file')
media_id = data.get('media_id')
return media_id
async def upload_file_to_work(self, file: platform_message.File):
"""
上传文件到企业微信
"""
if not await self.check_access_token():
self.access_token = await self.get_access_token(self.secret)
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
file_bytes = None
file_name = 'file.txt'
if file.path:
async with aiofiles.open(file.path, 'rb') as f:
file_bytes = await f.read()
file_name = file.path.split('/')[-1]
elif file.url:
file_bytes = await self.download_media_to_bytes(file.url)
file_name = file.url.split('/')[-1]
elif file.base64:
try:
base64_data = file.base64
if ',' in base64_data:
base64_data = base64_data.split(',', 1)[1]
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
padded_base64 = base64_data + '=' * padding
file_bytes = base64.b64decode(padded_base64)
except binascii.Error as e:
raise ValueError(f'Invalid base64 string: {str(e)}')
else:
await self.logger.error('File对象出错')
raise ValueError('file对象出错')
boundary = '-------------------------acebdf13572468'
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
body = (
(
f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n'
f'Content-Type: application/octet-stream\r\n\r\n'
).encode('utf-8')
+ file_bytes
+ f'\r\n--{boundary}--\r\n'.encode('utf-8')
)
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, content=body)
data = response.json()
if data['errcode'] == 40014 or data['errcode'] == 42001:
self.access_token = await self.get_access_token(self.secret)
media_id = await self.upload_file_to_work(file)
if data.get('errcode', 0) != 0:
await self.logger.error(f'上传文件失败:{data}')
raise Exception('failed to upload file')
media_id = data.get('media_id')
return media_id
async def download_media_to_bytes(self, url: str) -> bytes:
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
return response.content
# 进行media_id的获取
async def get_media_id(self, image: platform_message.Image):
media_id = await self.upload_to_work(image=image)
async def get_media_id(self, media: platform_message.Image | platform_message.Voice | platform_message.File):
if isinstance(media, platform_message.Image):
media_id = await self.upload_image_to_work(image=media)
elif isinstance(media, platform_message.Voice):
media_id = await self.upload_voice_to_work(voice=media)
elif isinstance(media, platform_message.File):
media_id = await self.upload_file_to_work(file=media)
else:
raise ValueError('Unsupported media type')
return media_id

View File

@@ -7,6 +7,7 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes
import langbot_plugin.api.entities.events as events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.events as platform_events
@stage.stage_class('PreProcessor')
@@ -74,12 +75,26 @@ class PreProcessor(stage.PipelineStage):
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
sender_name = ''
if isinstance(query.message_event, platform_events.GroupMessage):
sender_name = query.message_event.sender.member_name
elif isinstance(query.message_event, platform_events.FriendMessage):
sender_name = query.message_event.sender.nickname
variables = {
'launcher_type': query.session.launcher_type.value,
'launcher_id': query.session.launcher_id,
'sender_id': query.sender_id,
'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
'conversation_id': conversation.uuid,
'msg_create_time': (
int(query.message_event.time) if query.message_event.time else int(datetime.datetime.now().timestamp())
),
'group_name': query.message_event.group.name
if isinstance(query.message_event, platform_events.GroupMessage)
else '',
'sender_name': sender_name,
}
query.variables.update(variables)

View File

@@ -76,7 +76,7 @@ class ChatMessageHandler(handler.MessageHandler):
runner = r(self.ap, query.pipeline_config)
break
else:
raise ValueError(f'未找到请求运行器: {query.pipeline_config["ai"]["runner"]["runner"]}')
raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}')
if is_stream:
resp_message_id = uuid.uuid4()
@@ -91,7 +91,9 @@ class ChatMessageHandler(handler.MessageHandler):
await query.adapter.create_message_card(str(resp_message_id), query.message_event)
is_create_card = True
query.resp_messages.append(result)
self.ap.logger.info(f'对话({query.query_id})流式响应: {self.cut_str(result.readable_str())}')
self.ap.logger.info(
f'Conversation({query.query_id}) Streaming Response: {self.cut_str(result.readable_str())}'
)
if result.content is not None:
text_length += len(result.content)
@@ -102,7 +104,9 @@ class ChatMessageHandler(handler.MessageHandler):
async for result in runner.run(query):
query.resp_messages.append(result)
self.ap.logger.info(f'对话({query.query_id})响应: {self.cut_str(result.readable_str())}')
self.ap.logger.info(
f'Conversation({query.query_id}) Response: {self.cut_str(result.readable_str())}'
)
if result.content is not None:
text_length += len(result.content)
@@ -113,7 +117,7 @@ class ChatMessageHandler(handler.MessageHandler):
query.session.using_conversation.messages.extend(query.resp_messages)
except Exception as e:
self.ap.logger.error(f'对话({query.query_id})请求失败: {type(e).__name__} {str(e)}')
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {type(e).__name__} {str(e)}')
traceback.print_exc()
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']

View File

@@ -8,6 +8,9 @@ import base64
import uuid
import os
import datetime
# 使用BytesIO创建文件对象避免路径问题
import io
import asyncio
from enum import Enum
@@ -594,7 +597,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
break
text_string = ''
image_files = []
files = []
for ele in message_chain:
if isinstance(ele, platform_message.Image):
@@ -668,22 +671,67 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
continue # 跳过读取失败的文件
if image_bytes:
# 使用BytesIO创建文件对象避免路径问题
import io
image_files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
elif isinstance(ele, platform_message.Plain):
text_string += ele.text
elif isinstance(ele, platform_message.Voice):
file_bytes = None
filename = f'{uuid.uuid4()}.mp3'
if ele.base64:
if ele.base64.startswith('data:'):
data_header = ele.base64.split(',')[0]
if 'wav' in data_header:
filename = f'{uuid.uuid4()}.wav'
elif 'mp3' in data_header:
filename = f'{uuid.uuid4()}.mp3'
elif 'ogg' in data_header:
filename = f'{uuid.uuid4()}.ogg'
elif 'm4a' in data_header:
filename = f'{uuid.uuid4()}.m4a'
elif 'aac' in data_header:
filename = f'{uuid.uuid4()}.aac'
elif 'flac' in data_header:
filename = f'{uuid.uuid4()}.flac'
elif 'alac' in data_header:
filename = f'{uuid.uuid4()}.alac'
elif 'opus' in data_header:
filename = f'{uuid.uuid4()}.opus'
elif 'webm' in data_header:
filename = f'{uuid.uuid4()}.webm'
file_base64 = ele.base64.split(',')[-1]
file_bytes = base64.b64decode(file_base64)
elif ele.url:
async with aiohttp.ClientSession() as session:
async with session.get(ele.url) as response:
file_bytes = await response.read()
if file_bytes:
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(ele, platform_message.File):
file_bytes = None
filename = f'{uuid.uuid4()}.{ele.name.split(".")[-1]}'
if ele.base64:
if ele.base64.startswith('data:'):
file_base64 = ele.base64.split(',')[1]
file_bytes = base64.b64decode(file_base64)
else:
file_bytes = base64.b64decode(ele.base64)
elif ele.url:
async with aiohttp.ClientSession() as session:
async with session.get(ele.url) as response:
file_bytes = await response.read()
if file_bytes:
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
elif isinstance(ele, platform_message.Forward):
for node in ele.node_list:
(
node_text,
node_images,
node_files,
) = await DiscordMessageConverter.yiri2target(node.message_chain)
text_string += node_text
image_files.extend(node_images)
files.extend(node_files)
return text_string, image_files
return text_string, files
@staticmethod
async def target2yiri(message: discord.Message) -> platform_message.MessageChain:
@@ -990,7 +1038,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.voice_manager.cleanup_inactive_connections()
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
msg_to_send, image_files = await self.message_converter.yiri2target(message)
msg_to_send, files = await self.message_converter.yiri2target(message)
try:
# 获取频道对象
@@ -1003,8 +1051,8 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'content': msg_to_send,
}
if len(image_files) > 0:
args['files'] = image_files
if len(files) > 0:
args['files'] = files
await channel.send(**args)
@@ -1018,15 +1066,16 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
message: platform_message.MessageChain,
quote_origin: bool = False,
):
msg_to_send, image_files = await self.message_converter.yiri2target(message)
msg_to_send, files = await self.message_converter.yiri2target(message)
assert isinstance(message_source.source_platform_object, discord.Message)
args = {
'content': msg_to_send,
}
if len(image_files) > 0:
args['files'] = image_files
if len(files) > 0:
args['files'] = files
if quote_origin:
args['reference'] = message_source.source_platform_object

View File

@@ -137,7 +137,11 @@ class KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
# For file messages, content is typically the file URL
attachments = extra.get('attachments', {})
file_name = attachments.get('name', 'file')
components.append(platform_message.Plain(text=f'[File: {file_name}]'))
components.append(platform_message.File(url=content, name=file_name))
elif msg_type == 8: # Audio message
# For audio messages, content is typically the audio URL
attachments = extra.get('attachments', {})
components.append(platform_message.Voice(url=content))
elif msg_type == 9: # KMarkdown message
# Note: content is already stripped of mention patterns above
if content:
@@ -317,9 +321,6 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
data = await response.json()
if data.get('code') == 0:
user_info = data['data']
await self.logger.info(
f'Retrieved bot user info: {user_info.get("username")} (ID: {user_info.get("id")})'
)
return user_info
else:
raise Exception(f'Failed to get bot user info: {data.get("message")}')
@@ -343,11 +344,10 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# Ignore messages from bot itself to prevent infinite loops
if self.bot_account_id and str(author_id) == self.bot_account_id:
await self.logger.debug(f'Ignoring message from bot itself (author_id: {author_id})')
return
# Only process text messages (type 1, 2, 4, 9, 10) in GROUP or PERSON channels
if event_type in [1, 2, 4, 9, 10] and channel_type in ['GROUP', 'PERSON']:
# Only process text messages (type 1, 2, 4, 8, 9, 10) in GROUP or PERSON channels
if event_type in [1, 2, 4, 8, 9, 10] and channel_type in ['GROUP', 'PERSON']:
try:
# Convert to LangBot event
lb_event = await self.event_converter.target2yiri(data, self.bot_account_id)
@@ -377,7 +377,6 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'sn': self.current_sn,
}
await self.ws.send(json.dumps(ping_msg))
await self.logger.debug(f'Sent PING with sn={self.current_sn}')
except Exception:
# Connection closed or send failed, exit loop
break
@@ -398,10 +397,9 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
self.gateway_url = await self._get_gateway_url()
# Connect to WebSocket
await self.logger.info(f'Connecting to KOOK WebSocket: {self.gateway_url}')
async with websockets.connect(self.gateway_url) as ws:
await self.logger.info(f'Connected to KOOK WebSocket: {self.gateway_url}')
self.ws = ws
await self.logger.info('KOOK WebSocket connected')
# Start heartbeat
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
@@ -452,10 +450,11 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
elif signal == 3: # PONG
await self._handle_pong(msg_data.get('d', {}))
elif signal == 5: # RECONNECT
await self.logger.info('Received RECONNECT signal')
# await self.logger.info('Received RECONNECT signal')
break # Break to reconnect
elif signal == 6: # RESUME ACK
await self.logger.info('Resume successful')
# await self.logger.info('Resume successful')
pass
except json.JSONDecodeError:
await self.logger.error(f'Failed to parse message: {message}')
except Exception as e:
@@ -568,6 +567,8 @@ class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
if quote_origin and msg_id:
payload['quote'] = msg_id
payload['reply_msg_id'] = msg_id
headers = {
'Authorization': f'Bot {self.config["token"]}',
'Content-Type': 'application/json',

View File

@@ -297,6 +297,10 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
message_content['content'] = new_list
elif message.message_type == 'image':
message_content['content'] = [{'tag': 'img', 'image_key': message_content['image_key'], 'style': []}]
elif message.message_type == 'file':
message_content['content'] = [
{'tag': 'file', 'file_key': message_content['file_key'], 'file_name': message_content['file_name']}
]
for ele in message_content['content']:
if ele['tag'] == 'text':
@@ -327,6 +331,33 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
image_format = response.raw.headers['content-type']
lb_msg_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
elif ele['tag'] == 'file':
file_key = ele['file_key']
file_name = ele['file_name']
request: GetMessageResourceRequest = (
GetMessageResourceRequest.builder()
.message_id(message.message_id)
.file_key(file_key)
.type('file')
.build()
)
response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)
if not response.success():
raise Exception(
f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
file_bytes = response.file.read()
file_base64 = base64.b64encode(file_bytes).decode()
file_format = response.raw.headers['content-type']
lb_msg_list.append(
platform_message.File(base64=f'data:{file_format};base64,{file_base64}', name=file_name)
)
return platform_message.MessageChain(lb_msg_list)

View File

@@ -259,19 +259,6 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('LINE Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在 LINE 后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
async def keep_alive():
while True:
await asyncio.sleep(1)

View File

@@ -155,20 +155,6 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('微信公众号 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在微信公众号后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
async def keep_alive():
while True:
await asyncio.sleep(1)

View File

@@ -241,20 +241,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('QQ 官方机器人 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在 QQ 官方机器人后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
async def keep_alive():
while True:
await asyncio.sleep(1)

View File

@@ -188,21 +188,6 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
async def run_async(self):
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('Slack 机器人 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在 Slack 后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
async def keep_alive():
while True:
await asyncio.sleep(1)

View File

@@ -97,8 +97,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
# 推送到所有相关连接
await self.outbound_message_queue.put(message_data)
await self.logger.info(f'Send message to {target_id}: {message}')
return message_data
async def reply_message(
@@ -242,7 +240,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
async def run_async(self):
"""运行适配器"""
await self.logger.info('WebSocket适配器已启动')
try:
while True:
@@ -258,12 +255,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
await asyncio.sleep(0.1)
except asyncio.CancelledError:
await self.logger.info('WebSocket适配器已停止')
raise
async def kill(self):
"""停止适配器"""
await self.logger.info('WebSocket适配器正在停止')
pass
async def _process_image_components(self, message_chain_obj: list):
"""

View File

@@ -35,6 +35,20 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
'media_id': await bot.get_media_id(msg),
}
)
elif type(msg) is platform_message.Voice:
content_list.append(
{
'type': 'voice',
'media_id': await bot.get_media_id(msg),
}
)
elif type(msg) is platform_message.File:
content_list.append(
{
'type': 'file',
'media_id': await bot.get_media_id(msg),
}
)
elif type(msg) is platform_message.Forward:
for node in msg.node_list:
content_list.extend((await WecomMessageConverter.yiri2target(node.message_chain, bot)))
@@ -185,6 +199,10 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content'])
elif content['type'] == 'image':
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])
elif content['type'] == 'voice':
await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id'])
elif content['type'] == 'file':
await self.bot.send_file(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):
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
@@ -197,6 +215,10 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.bot.send_private_msg(user_id, agent_id, content['content'])
if content['type'] == 'image':
await self.bot.send_image(user_id, agent_id, content['media'])
if content['type'] == 'voice':
await self.bot.send_voice(user_id, agent_id, content['media'])
if content['type'] == 'file':
await self.bot.send_file(user_id, agent_id, content['media'])
def register_listener(
self,
@@ -232,19 +254,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('企业微信 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在企业微信后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
async def keep_alive():
while True:
await asyncio.sleep(1)

View File

@@ -72,7 +72,7 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
voice_payload = voice_info.get('base64') or voice_info.get('url')
if voice_payload:
if voice_info.get('base64') and not voice_payload.startswith('data:'):
voice_payload = f"data:audio/mpeg;base64,{voice_info.get('base64')}"
voice_payload = f'data:audio/mpeg;base64,{voice_info.get("base64")}'
try:
yiri_msg_list.append(platform_message.Voice(base64=voice_payload))
except Exception:
@@ -113,7 +113,10 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if event.msgtype == 'link' and event.link:
link = event.link
summary = '\n'.join(
filter(None, [link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')])
filter(
None,
[link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')],
)
)
if summary:
yiri_msg_list.append(platform_message.Plain(text=summary))
@@ -301,20 +304,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
await self.logger.info('企业微信机器人 Webhook 回调地址:')
await self.logger.info(f' 本地地址: {webhook_url}')
await self.logger.info(f' 公网地址: {webhook_url_public}')
await self.logger.info('请在企业微信后台配置此回调地址')
except Exception as e:
await self.logger.warning(f'无法生成 webhook URL: {e}')
async def keep_alive():
while True:
await asyncio.sleep(1)

View File

@@ -213,23 +213,10 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
# 打印 webhook 回调地址
if self.bot_uuid and hasattr(self.logger, 'ap'):
try:
api_port = self.logger.ap.instance_config.data['api']['port']
webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}"
webhook_url_public = f"http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}"
await self.logger.info(f"企业微信客服 Webhook 回调地址:")
await self.logger.info(f" 本地地址: {webhook_url}")
await self.logger.info(f" 公网地址: {webhook_url_public}")
await self.logger.info(f"请在企业微信后台配置此回调地址")
except Exception as e:
await self.logger.warning(f"无法生成 webhook URL: {e}")
async def keep_alive():
while True:
await asyncio.sleep(1)
await keep_alive()
async def kill(self) -> bool:

View File

@@ -94,7 +94,6 @@ class LangflowAPIRunner(runner.RequestRunner):
if is_stream:
# 流式请求
async with client.stream('POST', url, json=payload, headers=headers, timeout=120.0) as response:
print(response)
response.raise_for_status()
accumulated_content = ''

View File

@@ -44,12 +44,35 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
const strArr = str.split('');
return strArr;
}
// 根据日志级别返回对应的样式类
function getLevelStyles(level: string) {
switch (level.toLowerCase()) {
case 'error':
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
case 'warning':
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400';
case 'info':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
case 'debug':
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
}
}
return (
<div className={`${styles.botLogCardContainer}`}>
{/* 头部标签,时间 */}
<div className={`${styles.cardTitleContainer}`}>
<div className={`flex flex-row gap-2 items-center`}>
<div className={`${styles.tag}`}>{botLog.level}</div>
<div
className={`px-2 py-1 rounded text-xs font-medium uppercase ${getLevelStyles(
botLog.level,
)}`}
>
{botLog.level}
</div>
{botLog.message_session_id && (
<div
className={`${styles.tag} ${styles.chatTag}`}

View File

@@ -1,11 +1,19 @@
'use client';
import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import { BotLogCard } from '@/app/home/bots/components/bot-log/view/BotLogCard';
import styles from './botLog.module.css';
import { Switch } from '@/components/ui/switch';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { ChevronDownIcon } from 'lucide-react';
import { debounce } from 'lodash';
import { useTranslation } from 'react-i18next';
@@ -14,9 +22,21 @@ export function BotLogListComponent({ botId }: { botId: string }) {
const manager = useRef(new BotLogManager(botId)).current;
const [botLogList, setBotLogList] = useState<BotLog[]>([]);
const [autoFlush, setAutoFlush] = useState(true);
const [selectedLevels, setSelectedLevels] = useState<string[]>([
'info',
'warning',
'error',
]);
const listContainerRef = useRef<HTMLDivElement>(null);
const botLogListRef = useRef<BotLog[]>(botLogList);
const logLevels = [
{ value: 'error', label: 'ERROR' },
{ value: 'warning', label: 'WARNING' },
{ value: 'info', label: 'INFO' },
{ value: 'debug', label: 'DEBUG' },
];
useEffect(() => {
initComponent();
return () => {
@@ -28,6 +48,42 @@ export function BotLogListComponent({ botId }: { botId: string }) {
botLogListRef.current = botLogList;
}, [botLogList]);
// 根据级别过滤日志
const filteredLogs = useMemo(() => {
if (selectedLevels.length === 0) {
return botLogList;
}
return botLogList.filter((log) => selectedLevels.includes(log.level));
}, [botLogList, selectedLevels]);
const handleLevelToggle = (levelValue: string) => {
setSelectedLevels((prev) => {
if (prev.includes(levelValue)) {
return prev.filter((l) => l !== levelValue);
} else {
return [...prev, levelValue];
}
});
};
const getDisplayText = () => {
if (selectedLevels.length === 0) {
return t('bots.selectLevel');
}
if (selectedLevels.length === logLevels.length) {
return t('bots.allLevels');
}
// 如果选中3个或以上显示数量
if (selectedLevels.length >= 3) {
return `${selectedLevels.length} ${t('bots.levelsSelected')}`;
}
// 显示选中级别的标签(大写形式)
return logLevels
.filter((level) => selectedLevels.includes(level.value))
.map((level) => level.label)
.join(', ');
};
// 观测自动刷新状态
useEffect(() => {
if (autoFlush) {
@@ -116,9 +172,43 @@ export function BotLogListComponent({ botId }: { botId: string }) {
<div className={`${styles.listHeader}`}>
<div className={'mr-2'}>{t('bots.enableAutoRefresh')}</div>
<Switch checked={autoFlush} onCheckedChange={(e) => setAutoFlush(e)} />
<div className={'ml-4 mr-2'}>{t('bots.logLevel')}</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-[180px] flex items-center justify-between"
>
<span className="text-sm truncate flex-1 text-left">
{getDisplayText()}
</span>
<ChevronDownIcon className="ml-2 h-4 w-4 flex-shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[180px] p-2">
<div className="flex flex-col gap-2">
{logLevels.map((level) => (
<div key={level.value} className="flex items-center space-x-2">
<Checkbox
id={level.value}
checked={selectedLevels.includes(level.value)}
onCheckedChange={() => handleLevelToggle(level.value)}
/>
<label
htmlFor={level.value}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{level.label}
</label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</div>
{botLogList.map((botLog) => {
{filteredLogs.map((botLog) => {
return <BotLogCard botLog={botLog} key={botLog.seq_id} />;
})}
</div>

View File

@@ -192,6 +192,10 @@ const enUS = {
webhookUrlCopied: 'Webhook URL copied',
webhookUrlHint:
'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button',
logLevel: 'Log Level',
allLevels: 'All Levels',
selectLevel: 'Select Level',
levelsSelected: 'levels selected',
},
plugins: {
title: 'Extensions',

View File

@@ -194,6 +194,10 @@ const jaJP = {
webhookUrlCopied: 'Webhook URL をコピーしました',
webhookUrlHint:
'入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
logLevel: 'ログレベル',
allLevels: 'すべてのレベル',
selectLevel: 'レベルを選択',
levelsSelected: 'レベル選択済み',
},
plugins: {
title: '拡張機能',

View File

@@ -187,6 +187,10 @@ const zhHans = {
webhookUrlCopied: 'Webhook 地址已复制',
webhookUrlHint:
'点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
logLevel: '日志级别',
allLevels: '全部级别',
selectLevel: '选择级别',
levelsSelected: '个级别已选',
},
plugins: {
title: '插件扩展',

View File

@@ -187,6 +187,10 @@ const zhHant = {
webhookUrlCopied: 'Webhook 位址已複製',
webhookUrlHint:
'點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
logLevel: '日誌級別',
allLevels: '全部級別',
selectLevel: '選擇級別',
levelsSelected: '個級別已選',
},
plugins: {
title: '外掛擴展',