diff --git a/libs/dingtalk_api/api.py b/libs/dingtalk_api/api.py index cd4ea136..d0c7bc64 100644 --- a/libs/dingtalk_api/api.py +++ b/libs/dingtalk_api/api.py @@ -17,6 +17,7 @@ class DingTalkClient: robot_name: str, robot_code: str, markdown_card: bool, + logger: None, ): """初始化 WebSocket 连接并自动启动""" self.credential = dingtalk_stream.Credential(client_id, client_secret) @@ -34,6 +35,7 @@ class DingTalkClient: self.robot_code = robot_code self.access_token_expiry_time = '' self.markdown_card = markdown_card + self.logger = logger async def get_access_token(self): url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken' @@ -48,7 +50,7 @@ class DingTalkClient: expires_in = int(response_data.get('expireIn', 7200)) self.access_token_expiry_time = time.time() + expires_in - 60 except Exception as e: - raise Exception(e) + await self.logger.error("failed to get access token in dingtalk") async def is_token_expired(self): """检查token是否过期""" @@ -73,7 +75,7 @@ class DingTalkClient: result = response.json() download_url = result.get('downloadUrl') else: - raise Exception(f'Error: {response.status_code}, {response.text}') + await self.logger.error(f"failed to get download url: {response.json()}") if download_url: return await self.download_url_to_base64(download_url) @@ -87,7 +89,7 @@ class DingTalkClient: base64_str = base64.b64encode(file_bytes).decode('utf-8') # 返回字符串格式 return base64_str else: - raise Exception('获取文件失败') + await self.logger.error(f"failed to get files: {response.json()}") async def get_audio_url(self, download_code: str): if not await self.check_access_token(): @@ -103,7 +105,7 @@ class DingTalkClient: if download_url: return await self.download_url_to_base64(download_url) else: - raise Exception('获取音频失败') + await self.logger.error(f"failed to get audio: {response.json()}") else: raise Exception(f'Error: {response.status_code}, {response.text}') @@ -115,7 +117,7 @@ class DingTalkClient: if event: await self._handle_message(event) - async def send_message(self, content: str, incoming_message,at:bool): + async def send_message(self, content: str, incoming_message,at:bool): if self.markdown_card: if at: self.EchoTextHandler.reply_markdown( @@ -190,8 +192,11 @@ class DingTalkClient: copy_message_data = message_data.copy() del copy_message_data['IncomingMessage'] # print("message_data:", json.dumps(copy_message_data, indent=4, ensure_ascii=False)) - except Exception: - traceback.print_exc() + except Exception as e: + if self.logger: + await self.logger.error(f"Error in get_message: {traceback.format_exc()}") + else: + traceback.print_exc() return message_data @@ -214,9 +219,12 @@ class DingTalkClient: } try: async with httpx.AsyncClient() as client: - await client.post(url, headers=headers, json=data) + response = await client.post(url, headers=headers, json=data) + if response.status_code == 200: + return except Exception: - traceback.print_exc() + await self.logger.error(f"failed to send proactive massage to person: {traceback.format_exc()}") + raise Exception(f"failed to send proactive massage to person: {traceback.format_exc()}") async def send_proactive_message_to_group(self, target_id: str, content: str): if not await self.check_access_token(): @@ -237,9 +245,12 @@ class DingTalkClient: } try: async with httpx.AsyncClient() as client: - await client.post(url, headers=headers, json=data) + response = await client.post(url, headers=headers, json=data) + if response.status_code == 200: + return except Exception: - traceback.print_exc() + await self.logger.error(f"failed to send proactive massage to group: {traceback.format_exc()}") + raise Exception(f"failed to send proactive massage to group: {traceback.format_exc()}") async def start(self): """启动 WebSocket 连接,监听消息""" diff --git a/libs/official_account_api/api.py b/libs/official_account_api/api.py index 094aeb36..9f739885 100644 --- a/libs/official_account_api/api.py +++ b/libs/official_account_api/api.py @@ -23,7 +23,7 @@ xml_template = """ class OAClient: - def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str): + def __init__(self, token: str, EncodingAESKey: str, AppID: str, Appsecret: str, logger: None): self.token = token self.aes = EncodingAESKey self.appid = AppID @@ -43,6 +43,7 @@ class OAClient: self.access_token_expiry_time = None self.msg_id_map = {} self.generated_content = {} + self.logger = logger async def handle_callback_request(self): try: @@ -54,6 +55,7 @@ class OAClient: echostr = request.args.get('echostr', '') msg_signature = request.args.get('msg_signature', '') if msg_signature is None: + await self.logger.error(f'msg_signature不在请求体中') raise Exception('msg_signature不在请求体中') if request.method == 'GET': @@ -64,6 +66,7 @@ class OAClient: if check_signature == signature: return echostr # 验证成功返回echostr else: + await self.logger.error(f'拒绝请求') raise Exception('拒绝请求') elif request.method == 'POST': encryt_msg = await request.data @@ -72,8 +75,9 @@ class OAClient: xml_msg = xml_msg.decode('utf-8') if ret != 0: + await self.logger.error(f'消息解密失败') raise Exception('消息解密失败') - + message_data = await self.get_message(xml_msg) if message_data: event = OAEvent.from_payload(message_data) @@ -114,6 +118,7 @@ class OAClient: return '' except Exception: + await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}') traceback.print_exc() async def get_message(self, xml_msg: str): @@ -176,6 +181,7 @@ class OAClientForLongerResponse: AppID: str, Appsecret: str, LoadingMessage: str, + logger: None, ): self.token = token self.aes = EncodingAESKey @@ -197,6 +203,7 @@ class OAClientForLongerResponse: self.loading_message = LoadingMessage self.msg_queue = {} self.user_msg_queue = {} + self.logger = logger async def handle_callback_request(self): try: @@ -207,6 +214,7 @@ class OAClientForLongerResponse: msg_signature = request.args.get('msg_signature', '') if msg_signature is None: + await self.logger.error(f'msg_signature不在请求体中') raise Exception('msg_signature不在请求体中') if request.method == 'GET': @@ -221,7 +229,9 @@ class OAClientForLongerResponse: xml_msg = xml_msg.decode('utf-8') if ret != 0: + await self.logger.error(f'消息解密失败') raise Exception('消息解密失败') + # 解析 XML root = ET.fromstring(xml_msg) @@ -270,6 +280,7 @@ class OAClientForLongerResponse: return response_xml except Exception: + await self.logger.error(f'handle_callback_request失败: {traceback.format_exc()}') traceback.print_exc() async def get_message(self, xml_msg: str): diff --git a/libs/qq_official_api/api.py b/libs/qq_official_api/api.py index dbdbcf4a..fa38073d 100644 --- a/libs/qq_official_api/api.py +++ b/libs/qq_official_api/api.py @@ -34,7 +34,7 @@ def handle_validation(body: dict, bot_secret: str): class QQOfficialClient: - def __init__(self, secret: str, token: str, app_id: str): + def __init__(self, secret: str, token: str, app_id: str, logger: None): self.app = Quart(__name__) self.app.add_url_rule( '/callback/command', @@ -49,6 +49,7 @@ class QQOfficialClient: self.base_url = 'https://api.sgroup.qq.com' self.access_token = '' self.access_token_expiry_time = None + self.logger = logger async def check_access_token(self): """检查access_token是否存在""" @@ -77,6 +78,7 @@ class QQOfficialClient: if access_token: self.access_token = access_token except Exception as e: + await self.logger.error(f'获取access_token失败: {response_data}') raise Exception(f'获取access_token失败: {e}') async def handle_callback_request(self): @@ -102,7 +104,7 @@ class QQOfficialClient: return {'code': 0, 'message': 'success'} except Exception as e: - traceback.print_exc() + await self.logger.error(f"Error in handle_callback_request: {traceback.format_exc()}") return {'error': str(e)}, 400 async def run_task(self, host: str, port: int, *args, **kwargs): @@ -166,6 +168,7 @@ class QQOfficialClient: if not await self.check_access_token(): await self.get_access_token() + url = self.base_url + '/v2/users/' + user_openid + '/messages' async with httpx.AsyncClient() as client: headers = { @@ -178,9 +181,11 @@ class QQOfficialClient: 'msg_id': msg_id, } response = await client.post(url, headers=headers, json=data) + response_data = response.json() if response.status_code == 200: return else: + await self.logger.error(f'发送私聊消息失败: {response_data}') raise ValueError(response) async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str): @@ -188,6 +193,7 @@ class QQOfficialClient: if not await self.check_access_token(): await self.get_access_token() + url = self.base_url + '/v2/groups/' + group_openid + '/messages' async with httpx.AsyncClient() as client: headers = { @@ -203,6 +209,7 @@ class QQOfficialClient: if response.status_code == 200: return else: + await self.logger.error(f"发送群聊消息失败:{response.json()}") raise Exception(response.read().decode()) async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str): @@ -210,6 +217,7 @@ class QQOfficialClient: if not await self.check_access_token(): await self.get_access_token() + url = self.base_url + '/channels/' + channel_id + '/messages' async with httpx.AsyncClient() as client: headers = { @@ -225,12 +233,14 @@ class QQOfficialClient: if response.status_code == 200: return True else: + await self.logger.error(f'发送频道群聊消息失败: {response.json()}') raise Exception(response) async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str): """发送频道私聊消息""" if not await self.check_access_token(): await self.get_access_token() + url = self.base_url + '/dms/' + guild_id + '/messages' async with httpx.AsyncClient() as client: @@ -247,6 +257,7 @@ class QQOfficialClient: if response.status_code == 200: return True else: + await self.logger.error(f'发送频道私聊消息失败: {response.json()}') raise Exception(response) async def is_token_expired(self): diff --git a/libs/slack_api/api.py b/libs/slack_api/api.py index 441692ab..c291e92f 100644 --- a/libs/slack_api/api.py +++ b/libs/slack_api/api.py @@ -1,4 +1,5 @@ import json +import traceback from quart import Quart, jsonify, request from slack_sdk.web.async_client import AsyncWebClient from .slackevent import SlackEvent @@ -7,7 +8,7 @@ from pkg.platform.types import events as platform_events class SlackClient: - def __init__(self, bot_token: str, signing_secret: str): + def __init__(self, bot_token: str, signing_secret: str, logger: None): self.bot_token = bot_token self.signing_secret = signing_secret self.app = Quart(__name__) @@ -19,6 +20,7 @@ class SlackClient: 'example': [], } self.bot_user_id = None # 避免机器人回复自己的消息 + self.logger = logger async def handle_callback_request(self): try: @@ -32,6 +34,7 @@ class SlackClient: if self.bot_user_id and bot_user_id == self.bot_user_id: return jsonify({'status': 'ok'}) + # 处理私信 if data and data.get('event', {}).get('channel_type') in ['im']: @@ -49,6 +52,7 @@ class SlackClient: return jsonify({'status': 'ok'}) except Exception as e: + await self.logger.error(f"Error in handle_callback_request: {traceback.format_exc()}") raise (e) async def _handle_message(self, event: SlackEvent): @@ -78,6 +82,7 @@ class SlackClient: self.bot_user_id = response['message']['bot_id'] return except Exception as e: + await self.logger.error(f"Error in send_message: {e}") raise e async def send_message_to_one(self, text: str, user_id: str): @@ -88,6 +93,7 @@ class SlackClient: return except Exception as e: + await self.logger.error(f"Error in send_message: {traceback.format_exc()}") raise e async def run_task(self, host: str, port: int, *args, **kwargs): diff --git a/libs/wechatpad_api/client.py b/libs/wechatpad_api/client.py index b3eed16b..f5ded1cb 100644 --- a/libs/wechatpad_api/client.py +++ b/libs/wechatpad_api/client.py @@ -11,13 +11,14 @@ from libs.wechatpad_api.api.chatroom import ChatRoomApi class WeChatPadClient: - def __init__(self,base_url, token): + def __init__(self, base_url, token, logger=None): self._login_api = LoginApi(base_url, token) self._friend_api = FriendApi(base_url, token) self._message_api = MessageApi(base_url, token) self._user_api = UserApi(base_url, token) self._download_api = DownloadApi(base_url, token) self._chatroom_api = ChatRoomApi(base_url, token) + self.logger = logger def get_token(self,admin_key, day: int): '''获取token''' diff --git a/libs/wecom_api/api.py b/libs/wecom_api/api.py index 9b3191c2..cbd1b73f 100644 --- a/libs/wecom_api/api.py +++ b/libs/wecom_api/api.py @@ -3,6 +3,7 @@ from .WXBizMsgCrypt3 import WXBizMsgCrypt import base64 import binascii import httpx +import traceback from quart import Quart import xml.etree.ElementTree as ET from typing import Callable, Dict, Any @@ -19,6 +20,7 @@ class WecomClient: token: str, EncodingAESKey: str, contacts_secret: str, + logger: None, ): self.corpid = corpid self.secret = secret @@ -28,6 +30,7 @@ class WecomClient: self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin' self.access_token = '' self.secret_for_contacts = contacts_secret + self.logger = logger self.app = Quart(__name__) self.app.add_url_rule( '/callback/command', @@ -54,6 +57,7 @@ class WecomClient: if 'access_token' in data: return data['access_token'] else: + await self.logger.error(f"获取accesstoken失败:{response.json()}") raise Exception(f'未获取access token: {data}') async def get_users(self): @@ -125,6 +129,7 @@ class WecomClient: response = await client.post(url, json=params) data = response.json() except Exception as e: + await self.logger.error(f"发送图片失败:{data}") raise Exception('Failed to send image: ' + str(e)) # 企业微信错误码40014和42001,代表accesstoken问题 @@ -159,6 +164,7 @@ class WecomClient: self.access_token = await self.get_access_token(self.secret) return await self.send_private_msg(user_id, agent_id, content) if data['errcode'] != 0: + await self.logger.error(f"发送消息失败:{data}") raise Exception('Failed to send message: ' + str(data)) async def handle_callback_request(self): @@ -175,6 +181,7 @@ class WecomClient: echostr = request.args.get('echostr') ret, reply_echo_str = wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) if ret != 0: + await self.logger.error("验证失败") raise Exception(f'验证失败,错误码: {ret}') return reply_echo_str @@ -182,7 +189,9 @@ class WecomClient: encrypt_msg = await request.data ret, xml_msg = wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce) if ret != 0: + await self.logger.error("消息解密失败") raise Exception(f'消息解密失败,错误码: {ret}') + # 解析消息并处理 message_data = await self.get_message(xml_msg) @@ -193,6 +202,7 @@ class WecomClient: return 'success' except Exception as e: + await self.logger.error(f"Error in handle_callback_request: {traceback.format_exc()}") return f'Error processing request: {str(e)}', 400 async def run_task(self, host: str, port: int, *args, **kwargs): @@ -291,6 +301,7 @@ class WecomClient: except binascii.Error as e: raise ValueError(f'Invalid base64 string: {str(e)}') else: + await self.logger.error("Image对象出错") raise ValueError('image对象出错') # 设置 multipart/form-data 格式的文件 @@ -314,6 +325,7 @@ class WecomClient: self.access_token = await self.get_access_token(self.secret) media_id = await self.upload_to_work(image) if data.get('errcode', 0) != 0: + await self.logger.error(f"上传图片失败:{data}") raise Exception('failed to upload file') media_id = data.get('media_id') diff --git a/libs/wecom_customer_service_api/api.py b/libs/wecom_customer_service_api/api.py index 422abfc3..09805aa9 100644 --- a/libs/wecom_customer_service_api/api.py +++ b/libs/wecom_customer_service_api/api.py @@ -13,7 +13,7 @@ import aiofiles class WecomCSClient: - def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str): + def __init__(self, corpid: str, secret: str, token: str, EncodingAESKey: str, logger: None): self.corpid = corpid self.secret = secret self.access_token_for_contacts = '' @@ -21,6 +21,7 @@ class WecomCSClient: self.aes = EncodingAESKey self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin' self.access_token = '' + self.logger = logger self.app = Quart(__name__) self.app.add_url_rule( '/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'] @@ -186,6 +187,7 @@ class WecomCSClient: self.access_token = await self.get_access_token(self.secret) return await self.send_text_msg(open_kfid, external_userid, msgid, content) if data['errcode'] != 0: + await self.logger.error(f"发送消息失败:{data}") raise Exception('Failed to send message') return data @@ -224,7 +226,10 @@ class WecomCSClient: return 'success' except Exception as e: - traceback.print_exc() + if self.logger: + await self.logger.error(f"Error in handle_callback_request: {traceback.format_exc()}") + else: + traceback.print_exc() return f'Error processing request: {str(e)}', 400 async def run_task(self, host: str, port: int, *args, **kwargs): diff --git a/pkg/api/http/controller/groups/files.py b/pkg/api/http/controller/groups/files.py new file mode 100644 index 00000000..0a8b2210 --- /dev/null +++ b/pkg/api/http/controller/groups/files.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import quart +import mimetypes + +from .. import group + + +@group.group_class('files', '/api/v1/files') +class FilesRouterGroup(group.RouterGroup): + async def initialize(self) -> None: + @self.route('/image/', methods=['GET'], auth_type=group.AuthType.NONE) + async def _(image_key: str) -> quart.Response: + if not await self.ap.storage_mgr.storage_provider.exists(image_key): + return quart.Response(status=404) + + image_bytes = await self.ap.storage_mgr.storage_provider.load(image_key) + mime_type = mimetypes.guess_type(image_key)[0] + if mime_type is None: + mime_type = 'image/jpeg' + + return quart.Response(image_bytes, mimetype=mime_type) diff --git a/pkg/api/http/controller/groups/platform/bots.py b/pkg/api/http/controller/groups/platform/bots.py index af248fac..d6250ac4 100644 --- a/pkg/api/http/controller/groups/platform/bots.py +++ b/pkg/api/http/controller/groups/platform/bots.py @@ -29,3 +29,16 @@ class BotsRouterGroup(group.RouterGroup): elif quart.request.method == 'DELETE': await self.ap.bot_service.delete_bot(bot_uuid) return self.success() + + @self.route('//logs', methods=['POST']) + async def _(bot_uuid: str) -> str: + json_data = await quart.request.json + from_index = json_data.get('from_index', -1) + max_count = json_data.get('max_count', 10) + logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count) + return self.success( + data={ + 'logs': logs, + 'total_count': total_count, + } + ) diff --git a/pkg/api/http/service/bot.py b/pkg/api/http/service/bot.py index e562a310..86308bd8 100644 --- a/pkg/api/http/service/bot.py +++ b/pkg/api/http/service/bot.py @@ -2,6 +2,7 @@ from __future__ import annotations import uuid import sqlalchemy +import typing from ....core import app from ....entity.persistence import bot as persistence_bot @@ -98,3 +99,14 @@ class BotService: await self.ap.persistence_mgr.execute_async( sqlalchemy.delete(persistence_bot.Bot).where(persistence_bot.Bot.uuid == bot_uuid) ) + + async def list_event_logs( + self, bot_uuid: str, from_index: int, max_count: int + ) -> typing.Tuple[list[dict], int, int, int]: + runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid) + if runtime_bot is None: + raise Exception('Bot not found') + + logs, total_count = await runtime_bot.logger.get_logs(from_index, max_count) + + return [log.to_json() for log in logs], total_count diff --git a/pkg/core/app.py b/pkg/core/app.py index 37970008..911acd3d 100644 --- a/pkg/core/app.py +++ b/pkg/core/app.py @@ -23,6 +23,7 @@ from ..api.http.service import model as model_service from ..api.http.service import pipeline as pipeline_service from ..api.http.service import bot as bot_service from ..discover import engine as discover_engine +from ..storage import mgr as storagemgr from ..utils import logcache from . import taskmgr from . import entities as core_entities @@ -96,6 +97,8 @@ class Application: log_cache: logcache.LogCache = None + storage_mgr: storagemgr.StorageMgr = None + # ========= HTTP Services ========= user_service: user_service.UserService = None diff --git a/pkg/core/stages/build_app.py b/pkg/core/stages/build_app.py index 2cce2bc5..6ee35610 100644 --- a/pkg/core/stages/build_app.py +++ b/pkg/core/stages/build_app.py @@ -17,6 +17,7 @@ from ...api.http.service import model as model_service from ...api.http.service import pipeline as pipeline_service from ...api.http.service import bot as bot_service from ...discover import engine as discover_engine +from ...storage import mgr as storagemgr from ...utils import logcache from .. import taskmgr @@ -50,6 +51,10 @@ class BuildAppStage(stage.BootingStage): log_cache = logcache.LogCache() ap.log_cache = log_cache + storage_mgr_inst = storagemgr.StorageMgr(ap) + await storage_mgr_inst.initialize() + ap.storage_mgr = storage_mgr_inst + persistence_mgr_inst = persistencemgr.PersistenceManager(ap) ap.persistence_mgr = persistence_mgr_inst await persistence_mgr_inst.initialize() diff --git a/pkg/pipeline/stage.py b/pkg/pipeline/stage.py index 18636e9f..18a94b73 100644 --- a/pkg/pipeline/stage.py +++ b/pkg/pipeline/stage.py @@ -7,11 +7,11 @@ from ..core import app, entities as core_entities from . import entities -preregistered_stages: dict[str, PipelineStage] = {} +preregistered_stages: dict[str, type[PipelineStage]] = {} -def stage_class(name: str): - def decorator(cls): +def stage_class(name: str) -> typing.Callable[[type[PipelineStage]], type[PipelineStage]]: + def decorator(cls: type[PipelineStage]) -> type[PipelineStage]: preregistered_stages[name] = cls return cls diff --git a/pkg/platform/adapter.py b/pkg/platform/adapter.py index c0fd15c5..f28ad3dc 100644 --- a/pkg/platform/adapter.py +++ b/pkg/platform/adapter.py @@ -8,6 +8,7 @@ import abc from ..core import app from .types import message as platform_message from .types import events as platform_events +from .logger import EventLogger class MessagePlatformAdapter(metaclass=abc.ABCMeta): @@ -22,7 +23,9 @@ class MessagePlatformAdapter(metaclass=abc.ABCMeta): ap: app.Application - def __init__(self, config: dict, ap: app.Application): + logger: EventLogger + + def __init__(self, config: dict, ap: app.Application, logger: EventLogger): """初始化适配器 Args: @@ -31,6 +34,7 @@ class MessagePlatformAdapter(metaclass=abc.ABCMeta): """ self.config = config self.ap = ap + self.logger = logger async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): """主动发送消息 diff --git a/pkg/platform/botmgr.py b/pkg/platform/botmgr.py index 0af7e394..b24e14aa 100644 --- a/pkg/platform/botmgr.py +++ b/pkg/platform/botmgr.py @@ -10,12 +10,14 @@ import sqlalchemy from . import adapter as msadapter from ..core import app, entities as core_entities, taskmgr -from .types import events as platform_events +from .types import events as platform_events, message as platform_message from ..discover import engine from ..entity.persistence import bot as persistence_bot +from .logger import EventLogger + # 处理 3.4 移除了 YiriMirai 之后,插件的兼容性问题 from . import types as mirai @@ -37,23 +39,37 @@ class RuntimeBot: task_context: taskmgr.TaskContext + logger: EventLogger + def __init__( self, ap: app.Application, bot_entity: persistence_bot.Bot, adapter: msadapter.MessagePlatformAdapter, + logger: EventLogger, ): self.ap = ap self.bot_entity = bot_entity self.enable = bot_entity.enable self.adapter = adapter self.task_context = taskmgr.TaskContext() + self.logger = logger async def initialize(self): async def on_friend_message( event: platform_events.FriendMessage, adapter: msadapter.MessagePlatformAdapter, ): + image_components = [ + component for component in event.message_chain if isinstance(component, platform_message.Image) + ] + + await self.logger.info( + f'{event.message_chain}', + images=image_components, + message_session_id=f'person_{event.sender.id}', + ) + await self.ap.query_pool.add_query( bot_uuid=self.bot_entity.uuid, launcher_type=core_entities.LauncherTypes.PERSON, @@ -68,6 +84,16 @@ class RuntimeBot: event: platform_events.GroupMessage, adapter: msadapter.MessagePlatformAdapter, ): + image_components = [ + component for component in event.message_chain if isinstance(component, platform_message.Image) + ] + + await self.logger.info( + f'{event.message_chain}', + images=image_components, + message_session_id=f'group_{event.group.id}', + ) + await self.ap.query_pool.add_query( bot_uuid=self.bot_entity.uuid, launcher_type=core_entities.LauncherTypes.GROUP, @@ -92,10 +118,7 @@ class RuntimeBot: self.task_context.set_current_action('Exited.') return self.task_context.set_current_action('Exited with error.') - self.task_context.log(f'平台适配器运行出错: {e}') - self.task_context.log(f'Traceback: {traceback.format_exc()}') - self.ap.logger.error(f'平台适配器运行出错: {e}') - self.ap.logger.debug(f'Traceback: {traceback.format_exc()}') + await self.logger.error(f'平台适配器运行出错:\n{e}\n{traceback.format_exc()}') self.task_wrapper = self.ap.task_mgr.create_task( exception_wrapper(), @@ -166,9 +189,15 @@ class PlatformManager: elif isinstance(bot_entity, dict): bot_entity = persistence_bot.Bot(**bot_entity) - adapter_inst = self.adapter_dict[bot_entity.adapter](bot_entity.adapter_config, self.ap) + logger = EventLogger(name=f'platform-adapter-{bot_entity.name}', ap=self.ap) - runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst) + adapter_inst = self.adapter_dict[bot_entity.adapter]( + bot_entity.adapter_config, + self.ap, + logger, + ) + + runtime_bot = RuntimeBot(ap=self.ap, bot_entity=bot_entity, adapter=adapter_inst, logger=logger) await runtime_bot.initialize() diff --git a/pkg/platform/logger.py b/pkg/platform/logger.py new file mode 100644 index 00000000..340baa07 --- /dev/null +++ b/pkg/platform/logger.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import typing +import mimetypes +import time +import enum +import pydantic +import traceback +import uuid + +from ..core import app +from .types import message as platform_message + + +class EventLogLevel(enum.Enum): + """日志级别""" + + DEBUG = 'debug' + INFO = 'info' + WARNING = 'warning' + ERROR = 'error' + + +class EventLog(pydantic.BaseModel): + seq_id: int + """日志序号""" + + timestamp: int + """日志时间戳""" + + level: EventLogLevel + """日志级别""" + + text: str + """日志文本""" + + images: typing.Optional[list[str]] = None + """日志图片 URL 列表,需要通过 /api/v1/image/{uuid} 获取图片""" + + message_session_id: typing.Optional[str] = None + """消息会话ID,仅收发消息事件有值""" + + def to_json(self) -> dict: + return { + 'seq_id': self.seq_id, + 'timestamp': self.timestamp, + 'level': self.level.value, + 'text': self.text, + 'images': self.images, + 'message_session_id': self.message_session_id, + } + + +MAX_LOG_COUNT = 200 +DELETE_COUNT_PER_TIME = 50 + + +class EventLogger: + """used for logging bot events""" + + ap: app.Application + + seq_id_inc: int + + logs: list[EventLog] + + def __init__( + self, + name: str, + ap: app.Application, + ): + self.name = name + self.ap = ap + self.logs = [] + self.seq_id_inc = 0 + + async def get_logs(self, from_seq_id: int, max_count: int) -> typing.Tuple[list[EventLog], int]: + """ + 获取日志,从 from_seq_id 开始获取 max_count 条历史日志 + + Args: + from_seq_id: 起始序号,-1 表示末尾 + max_count: 最大数量 + + Returns: + Tuple[list[EventLog], int]: 日志列表,日志总数 + """ + if len(self.logs) == 0: + return [], 0 + + if from_seq_id <= -1: + from_seq_id = self.logs[-1].seq_id + + min_seq_id_in_logs = self.logs[0].seq_id + max_seq_id_in_logs = self.logs[-1].seq_id + + if from_seq_id < min_seq_id_in_logs: # 需要的整个范围都已经被删除 + return [], len(self.logs) + + if ( + from_seq_id > max_seq_id_in_logs and from_seq_id - max_count > max_seq_id_in_logs + ): # 需要的整个范围都还没生成 + return [], len(self.logs) + + end_index = 1 + + for i, log in enumerate(self.logs): + if log.seq_id >= from_seq_id: + end_index = i + 1 + break + + start_index = max(0, end_index - max_count) + + if max_count > 0: + return self.logs[start_index:end_index], len(self.logs) + else: + return [], len(self.logs) + + async def _truncate_logs(self): + if len(self.logs) > MAX_LOG_COUNT: + for i in range(DELETE_COUNT_PER_TIME): + for image_key in self.logs[i].images: + await self.ap.storage_mgr.storage_provider.delete(image_key) + self.logs = self.logs[DELETE_COUNT_PER_TIME:] + + async def _add_log( + self, + level: EventLogLevel, + text: str, + images: typing.Optional[list[platform_message.Image]] = None, + message_session_id: typing.Optional[str] = None, + no_throw: bool = True, + ): + try: + image_keys = [] + + if images is None: + images = [] + + if message_session_id is None: + message_session_id = '' + + if not isinstance(message_session_id, str): + message_session_id = str(message_session_id) + + for img in images: + img_bytes, mime_type = await img.get_bytes() + extension = mimetypes.guess_extension(mime_type) + if extension is None: + extension = '.jpg' + image_key = f'{message_session_id}-{uuid.uuid4()}{extension}' + await self.ap.storage_mgr.storage_provider.save(image_key, img_bytes) + image_keys.append(image_key) + + self.logs.append( + EventLog( + seq_id=self.seq_id_inc, + timestamp=int(time.time()), + level=level, + text=text, + images=image_keys, + message_session_id=message_session_id, + ) + ) + self.seq_id_inc += 1 + + await self._truncate_logs() + + except Exception as e: + if not no_throw: + raise e + else: + traceback.print_exc() + + async def info( + self, + text: str, + images: typing.Optional[list[platform_message.Image]] = None, + message_session_id: typing.Optional[str] = None, + no_throw: bool = True, + ): + await self._add_log( + level=EventLogLevel.INFO, + text=text, + images=images, + message_session_id=message_session_id, + no_throw=no_throw, + ) + + async def debug( + self, + text: str, + images: typing.Optional[list[platform_message.Image]] = None, + message_session_id: typing.Optional[str] = None, + no_throw: bool = True, + ): + await self._add_log( + level=EventLogLevel.DEBUG, + text=text, + images=images, + message_session_id=message_session_id, + no_throw=no_throw, + ) + + async def warning( + self, + text: str, + images: typing.Optional[list[platform_message.Image]] = None, + message_session_id: typing.Optional[str] = None, + no_throw: bool = True, + ): + await self._add_log( + level=EventLogLevel.WARNING, + text=text, + images=images, + message_session_id=message_session_id, + no_throw=no_throw, + ) + + async def error( + self, + text: str, + images: typing.Optional[list[platform_message.Image]] = None, + message_session_id: typing.Optional[str] = None, + no_throw: bool = True, + ): + await self._add_log( + level=EventLogLevel.ERROR, + text=text, + images=images, + message_session_id=message_session_id, + no_throw=no_throw, + ) diff --git a/pkg/platform/sources/aiocqhttp.py b/pkg/platform/sources/aiocqhttp.py index b942b8e9..46c69041 100644 --- a/pkg/platform/sources/aiocqhttp.py +++ b/pkg/platform/sources/aiocqhttp.py @@ -12,6 +12,7 @@ from ..types import message as platform_message from ..types import events as platform_events from ..types import entities as platform_entities from ...utils import image +from ..logger import EventLogger class AiocqhttpMessageConverter(adapter.MessageConverter): @@ -209,8 +210,11 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter): ap: app.Application - def __init__(self, config: dict, ap: app.Application): + on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = [] + + def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config + self.logger = logger async def shutdown_trigger_placeholder(): while True: @@ -219,6 +223,7 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter): self.config['shutdown_trigger'] = shutdown_trigger_placeholder self.ap = ap + self.on_websocket_connection_event_cache = [] if 'access-token' in config: self.bot = aiocqhttp.CQHttp(access_token=config['access-token']) @@ -260,6 +265,7 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter): try: return await callback(await self.event_converter.target2yiri(event,self.bot), self) except Exception: + await self.logger.error(f'Error in on_message: {traceback.format_exc()}') traceback.print_exc() if event_type == platform_events.GroupMessage: @@ -267,6 +273,16 @@ class AiocqhttpAdapter(adapter.MessagePlatformAdapter): elif event_type == platform_events.FriendMessage: self.bot.on_message('private')(on_message) + async def on_websocket_connection(event: aiocqhttp.Event): + for event in self.on_websocket_connection_event_cache: + if event.self_id == event.self_id and event.time == event.time: + return + + self.on_websocket_connection_event_cache.append(event) + await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}') + + self.bot.on_websocket_connection(on_websocket_connection) + def unregister_listener( self, event_type: typing.Type[platform_events.Event], diff --git a/pkg/platform/sources/dingtalk.py b/pkg/platform/sources/dingtalk.py index 836bd851..675911a5 100644 --- a/pkg/platform/sources/dingtalk.py +++ b/pkg/platform/sources/dingtalk.py @@ -9,6 +9,7 @@ from ..types import events as platform_events from ..types import entities as platform_entities from libs.dingtalk_api.api import DingTalkClient import datetime +from ..logger import EventLogger class DingTalkMessageConverter(adapter.MessageConverter): @@ -99,9 +100,10 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter): event_converter: DingTalkEventConverter = DingTalkEventConverter() config: dict - def __init__(self, config: dict, ap: app.Application): + def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config self.ap = ap + self.logger = logger required_keys = [ 'client_id', 'client_secret', @@ -120,6 +122,7 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter): robot_name=config['robot_name'], robot_code=config['robot_code'], markdown_card=config['markdown_card'], + logger=self.logger, ) async def reply_message( @@ -154,8 +157,8 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter): await self.event_converter.target2yiri(event, self.config['robot_name']), self, ) - except Exception: - traceback.print_exc() + except Exception as e: + await self.logger.error(f"Error in dingtalk callback: {traceback.format_exc()}") if event_type == platform_events.FriendMessage: self.bot.on_message('FriendMessage')(on_message) diff --git a/pkg/platform/sources/discord.py b/pkg/platform/sources/discord.py index f5be422d..f159c628 100644 --- a/pkg/platform/sources/discord.py +++ b/pkg/platform/sources/discord.py @@ -16,6 +16,7 @@ from ...core import app from ..types import message as platform_message from ..types import events as platform_events from ..types import entities as platform_entities +from ..logger import EventLogger class DiscordMessageConverter(adapter.MessageConverter): @@ -170,9 +171,10 @@ class DiscordAdapter(adapter.MessagePlatformAdapter): typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None], ] = {} - def __init__(self, config: dict, ap: app.Application): + def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config self.ap = ap + self.logger = logger self.bot_account_id = self.config['client_id'] diff --git a/pkg/platform/sources/gewechat.py b/pkg/platform/sources/gewechat.py index efa58f3d..01d9f946 100644 --- a/pkg/platform/sources/gewechat.py +++ b/pkg/platform/sources/gewechat.py @@ -20,6 +20,7 @@ from ...utils import image import xml.etree.ElementTree as ET from typing import Optional, Tuple from functools import partial +from ..logger import EventLogger class GewechatMessageConverter(adapter.MessageConverter): @@ -371,7 +372,7 @@ class GewechatMessageConverter(adapter.MessageConverter): quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr') # 引用消息的原发送者 ats_bot = ats_bot or (quote_id == tousername) except Exception as e: - print(f'_ats_bot got except: {e}') + print(f'Error in gewechat _ats_bot: {e}') finally: return ats_bot @@ -477,9 +478,10 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter): typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None], ] = {} - def __init__(self, config: dict, ap: app.Application): + def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config self.ap = ap + self.logger = logger self.quart_app = quart.Quart(__name__) self.message_converter = GewechatMessageConverter(config) @@ -503,7 +505,7 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter): try: event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id) except Exception: - traceback.print_exc() + await self.logger.error(f'Error in gewechat callback: {traceback.format_exc()}') if event.__class__ in self.listeners: await self.listeners[event.__class__](event, self) diff --git a/pkg/platform/sources/lark.py b/pkg/platform/sources/lark.py index 87172009..d1116362 100644 --- a/pkg/platform/sources/lark.py +++ b/pkg/platform/sources/lark.py @@ -23,6 +23,7 @@ from ...core import app from ..types import message as platform_message from ..types import events as platform_events from ..types import entities as platform_entities +from ..logger import EventLogger class AESCipher(object): @@ -338,9 +339,10 @@ class LarkAdapter(adapter.MessagePlatformAdapter): quart_app: quart.Quart ap: app.Application - def __init__(self, config: dict, ap: app.Application): + def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config self.ap = ap + self.logger = logger self.quart_app = quart.Quart(__name__) self.listeners = {} @@ -376,15 +378,15 @@ class LarkAdapter(adapter.MessagePlatformAdapter): if 'im.message.receive_v1' == type: try: event = await self.event_converter.target2yiri(p2v1, self.api_client) - except Exception: - traceback.print_exc() + except Exception as e: + 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: - traceback.print_exc() + except Exception as e: + 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): diff --git a/pkg/platform/sources/nakuru.py b/pkg/platform/sources/nakuru.py index 44e2d301..389a2db1 100644 --- a/pkg/platform/sources/nakuru.py +++ b/pkg/platform/sources/nakuru.py @@ -14,6 +14,7 @@ from ...pipeline.longtext.strategies import forward from ...platform.types import message as platform_message from ...platform.types import entities as platform_entities from ...platform.types import events as platform_events +from ..logger import EventLogger class NakuruProjectMessageConverter(adapter_model.MessageConverter): @@ -71,9 +72,8 @@ class NakuruProjectMessageConverter(adapter_model.MessageConverter): content=content_list, ) nakuru_forward_node_list.append(nakuru_forward_node) - except Exception: + except Exception as e: import traceback - traceback.print_exc() nakuru_msg_list.append(nakuru_forward_node_list) @@ -178,12 +178,13 @@ class NakuruAdapter(adapter_model.MessagePlatformAdapter): cfg: dict - def __init__(self, cfg: dict, ap): + def __init__(self, cfg: dict, ap, logger: EventLogger): """初始化nakuru-project的对象""" cfg['port'] = cfg['ws_port'] del cfg['ws_port'] self.cfg = cfg self.ap = ap + self.logger = logger self.listener_list = [] self.bot = nakuru.CQHTTP(**self.cfg) @@ -275,7 +276,7 @@ class NakuruAdapter(adapter_model.MessagePlatformAdapter): # 注册监听器 self.bot.receiver(source_cls.__name__)(listener_wrapper) except Exception as e: - traceback.print_exc() + self.logger.error(f"Error in nakuru register_listener: {traceback.format_exc()}") raise e def unregister_listener( diff --git a/pkg/platform/sources/officialaccount.py b/pkg/platform/sources/officialaccount.py index 8c7831a5..030db56d 100644 --- a/pkg/platform/sources/officialaccount.py +++ b/pkg/platform/sources/officialaccount.py @@ -13,6 +13,7 @@ from .. import adapter from ...core import app from ..types import entities as platform_entities from ...command.errors import ParamNotEnoughError +from ..logger import EventLogger class OAMessageConverter(adapter.MessageConverter): @@ -63,10 +64,10 @@ class OfficialAccountAdapter(adapter.MessagePlatformAdapter): event_converter: OAEventConverter = OAEventConverter() config: dict - def __init__(self, config: dict, ap: app.Application): + def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config - self.ap = ap + self.logger = logger required_keys = [ 'token', @@ -85,6 +86,7 @@ class OfficialAccountAdapter(adapter.MessagePlatformAdapter): EncodingAESKey=config['EncodingAESKey'], Appsecret=config['AppSecret'], AppID=config['AppID'], + logger=self.logger, ) elif self.config['Mode'] == 'passive': self.bot = OAClientForLongerResponse( @@ -93,6 +95,7 @@ class OfficialAccountAdapter(adapter.MessagePlatformAdapter): Appsecret=config['AppSecret'], AppID=config['AppID'], LoadingMessage=config['LoadingMessage'], + logger=self.logger, ) else: raise KeyError('请设置微信公众号通信模式') @@ -122,8 +125,8 @@ class OfficialAccountAdapter(adapter.MessagePlatformAdapter): self.bot_account_id = event.receiver_id try: return await callback(await self.event_converter.target2yiri(event), self) - except Exception: - traceback.print_exc() + except Exception as e: + await self.logger.error(f"Error in officialaccount callback: {traceback.format_exc()}") if event_type == platform_events.FriendMessage: self.bot.on_message('text')(on_message) diff --git a/pkg/platform/sources/qqbotpy.py b/pkg/platform/sources/qqbotpy.py index 74699961..39c8dc8a 100644 --- a/pkg/platform/sources/qqbotpy.py +++ b/pkg/platform/sources/qqbotpy.py @@ -17,6 +17,7 @@ from ...config import manager as cfg_mgr from ...platform.types import entities as platform_entities from ...platform.types import events as platform_events from ...platform.types import message as platform_message +from ..logger import EventLogger class OfficialGroupMessage(platform_events.GroupMessage): @@ -357,10 +358,11 @@ class OfficialAdapter(adapter_model.MessagePlatformAdapter): group_msg_seq = None c2c_msg_seq = None - def __init__(self, cfg: dict, ap: app.Application): + def __init__(self, cfg: dict, ap: app.Application, logger: EventLogger): """初始化适配器""" self.cfg = cfg self.ap = ap + self.logger = logger self.group_msg_seq = 1 self.c2c_msg_seq = 1 @@ -499,7 +501,7 @@ class OfficialAdapter(adapter_model.MessagePlatformAdapter): for event_handler in event_handler_mapping[event_type]: setattr(self.bot, event_handler, wrapper) except Exception as e: - traceback.print_exc() + self.logger.error(f"Error in qqbotpy callback: {traceback.format_exc()}") raise e def unregister_listener( diff --git a/pkg/platform/sources/qqofficial.py b/pkg/platform/sources/qqofficial.py index f9795bcd..c61afea4 100644 --- a/pkg/platform/sources/qqofficial.py +++ b/pkg/platform/sources/qqofficial.py @@ -14,6 +14,7 @@ from ...command.errors import ParamNotEnoughError from libs.qq_official_api.api import QQOfficialClient from libs.qq_official_api.qqofficialevent import QQOfficialEvent from ...utils import image +from ..logger import EventLogger class QQOfficialMessageConverter(adapter.MessageConverter): @@ -139,9 +140,10 @@ class QQOfficialAdapter(adapter.MessagePlatformAdapter): message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter() event_converter: QQOfficialEventConverter = QQOfficialEventConverter() - def __init__(self, config: dict, ap: app.Application): + def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config self.ap = ap + self.logger = logger required_keys = [ 'appid', @@ -155,6 +157,7 @@ class QQOfficialAdapter(adapter.MessagePlatformAdapter): app_id=config['appid'], secret=config['secret'], token=config['token'], + logger=self.logger ) async def reply_message( @@ -221,8 +224,8 @@ class QQOfficialAdapter(adapter.MessagePlatformAdapter): self.bot_account_id = 'justbot' try: return await callback(await self.event_converter.target2yiri(event), self) - except Exception: - traceback.print_exc() + except Exception as e: + await self.logger.error(f"Error in qqofficial callback: {traceback.format_exc()}") if event_type == platform_events.FriendMessage: self.bot.on_message('DIRECT_MESSAGE_CREATE')(on_message) diff --git a/pkg/platform/sources/slack.py b/pkg/platform/sources/slack.py index 62ef4137..6dfcff59 100644 --- a/pkg/platform/sources/slack.py +++ b/pkg/platform/sources/slack.py @@ -14,6 +14,7 @@ from .. import adapter from ..types import entities as platform_entities from ...command.errors import ParamNotEnoughError from ...utils import image +from ..logger import EventLogger class SlackMessageConverter(adapter.MessageConverter): @@ -91,9 +92,10 @@ class SlackAdapter(adapter.MessagePlatformAdapter): event_converter: SlackEventConverter = SlackEventConverter() config: dict - def __init__(self, config: dict, ap: app.Application): + def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config self.ap = ap + self.logger = logger required_keys = [ 'bot_token', 'signing_secret', @@ -102,7 +104,7 @@ class SlackAdapter(adapter.MessagePlatformAdapter): if missing_keys: raise ParamNotEnoughError('Slack机器人缺少相关配置项,请查看文档或联系管理员') - self.bot = SlackClient(bot_token=self.config['bot_token'], signing_secret=self.config['signing_secret']) + self.bot = SlackClient(bot_token=self.config['bot_token'], signing_secret=self.config['signing_secret'], logger=self.logger) async def reply_message( self, @@ -137,8 +139,8 @@ class SlackAdapter(adapter.MessagePlatformAdapter): self.bot_account_id = 'SlackBot' try: return await callback(await self.event_converter.target2yiri(event, self.bot), self) - except: - traceback.print_exc() + except Exception as e: + await self.logger.error(f"Error in slack callback: {traceback.format_exc()}") if event_type == platform_events.FriendMessage: self.bot.on_message('im')(on_message) diff --git a/pkg/platform/sources/telegram.py b/pkg/platform/sources/telegram.py index 5d318cbb..266d994e 100644 --- a/pkg/platform/sources/telegram.py +++ b/pkg/platform/sources/telegram.py @@ -17,6 +17,7 @@ from ...core import app from ..types import message as platform_message from ..types import events as platform_events from ..types import entities as platform_entities +from ..logger import EventLogger class TelegramMessageConverter(adapter.MessageConverter): @@ -147,9 +148,10 @@ class TelegramAdapter(adapter.MessagePlatformAdapter): typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None], ] = {} - def __init__(self, config: dict, ap: app.Application): + def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config self.ap = ap + self.logger = logger async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): if update.message.from_user.is_bot: @@ -158,8 +160,8 @@ class TelegramAdapter(adapter.MessagePlatformAdapter): try: lb_event = await self.event_converter.target2yiri(update, self.bot, self.bot_account_id) await self.listeners[type(lb_event)](lb_event, self) - except Exception: - print(traceback.format_exc()) + except Exception as e: + await self.logger.error(f"Error in telegram callback: {traceback.format_exc()}") self.application = ApplicationBuilder().token(self.config['token']).build() self.bot = self.application.bot diff --git a/pkg/platform/sources/wechatpad.py b/pkg/platform/sources/wechatpad.py index 8b7e5a0e..2f58f0e1 100644 --- a/pkg/platform/sources/wechatpad.py +++ b/pkg/platform/sources/wechatpad.py @@ -30,6 +30,7 @@ from ..types import message as platform_message from ..types import events as platform_events from ..types import entities as platform_entities from ...utils import image +from ..logger import EventLogger import xml.etree.ElementTree as ET from typing import Optional, List, Tuple from functools import partial @@ -533,9 +534,10 @@ class WeChatPadAdapter(adapter.MessagePlatformAdapter): typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None], ] = {} - def __init__(self, config: dict, ap: app.Application): + def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config self.ap = ap + self.logger = logger self.quart_app = quart.Quart(__name__) self.message_converter = WeChatPadMessageConverter(config) @@ -550,7 +552,7 @@ class WeChatPadAdapter(adapter.MessagePlatformAdapter): try: event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id) except Exception as e: - traceback.print_exc() + await self.logger.error(f"Error in wechatpad callback: {traceback.format_exc()}") if event.__class__ in self.listeners: await self.listeners[event.__class__](event, self) @@ -694,7 +696,8 @@ class WeChatPadAdapter(adapter.MessagePlatformAdapter): self.bot = WeChatPadClient( self.config['wechatpad_url'], - self.config["token"] + self.config["token"], + logger=self.logger ) self.ap.logger.info(self.config["token"]) thread_1 = threading.Event() diff --git a/pkg/platform/sources/wecom.py b/pkg/platform/sources/wecom.py index 5c02a632..f1cc677e 100644 --- a/pkg/platform/sources/wecom.py +++ b/pkg/platform/sources/wecom.py @@ -14,6 +14,7 @@ from ...core import app from ..types import entities as platform_entities from ...command.errors import ParamNotEnoughError from ...utils import image +from ..logger import EventLogger class WecomMessageConverter(adapter.MessageConverter): @@ -134,10 +135,10 @@ class WecomAdapter(adapter.MessagePlatformAdapter): event_converter: WecomEventConverter = WecomEventConverter() config: dict - def __init__(self, config: dict, ap: app.Application): + def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config - self.ap = ap + self.logger = logger required_keys = [ 'corpid', @@ -156,6 +157,7 @@ class WecomAdapter(adapter.MessagePlatformAdapter): token=config['token'], EncodingAESKey=config['EncodingAESKey'], contacts_secret=config['contacts_secret'], + logger=self.logger ) async def reply_message( @@ -199,8 +201,8 @@ class WecomAdapter(adapter.MessagePlatformAdapter): self.bot_account_id = event.receiver_id try: return await callback(await self.event_converter.target2yiri(event), self) - except Exception: - traceback.print_exc() + except Exception as e: + await self.logger.error(f"Error in wecom callback: {traceback.format_exc()}") if event_type == platform_events.FriendMessage: self.bot.on_message('text')(on_message) diff --git a/pkg/platform/sources/wecomcs.py b/pkg/platform/sources/wecomcs.py index 94d0e450..aab8d394 100644 --- a/pkg/platform/sources/wecomcs.py +++ b/pkg/platform/sources/wecomcs.py @@ -13,6 +13,7 @@ from pkg.core import app from .. import adapter from ..types import entities as platform_entities from ...command.errors import ParamNotEnoughError +from ..logger import EventLogger class WecomMessageConverter(adapter.MessageConverter): @@ -124,10 +125,10 @@ class WecomCSAdapter(adapter.MessagePlatformAdapter): event_converter: WecomEventConverter = WecomEventConverter() config: dict - def __init__(self, config: dict, ap: app.Application): + def __init__(self, config: dict, ap: app.Application, logger: EventLogger): self.config = config - self.ap = ap + self.logger = logger required_keys = [ 'corpid', @@ -144,6 +145,7 @@ class WecomCSAdapter(adapter.MessagePlatformAdapter): secret=config['secret'], token=config['token'], EncodingAESKey=config['EncodingAESKey'], + logger=self.logger ) async def reply_message( @@ -176,8 +178,8 @@ class WecomCSAdapter(adapter.MessagePlatformAdapter): self.bot_account_id = event.receiver_id try: return await callback(await self.event_converter.target2yiri(event), self) - except: - traceback.print_exc() + except Exception as e: + await self.logger.error(f"Error in wecomcs callback: {traceback.format_exc()}") if event_type == platform_events.FriendMessage: self.bot.on_message('text')(on_message) diff --git a/pkg/platform/types/message.py b/pkg/platform/types/message.py index 8412e8a4..e137ae46 100644 --- a/pkg/platform/types/message.py +++ b/pkg/platform/types/message.py @@ -3,7 +3,10 @@ import logging import typing from datetime import datetime from pathlib import Path +import base64 +import aiofiles +import httpx import pydantic.v1 as pydantic from . import entities as platform_entities @@ -552,52 +555,29 @@ class Image(MessageComponent): image_id = image_id[1:] return image_id - async def download( - self, - filename: typing.Union[str, Path, None] = None, - directory: typing.Union[str, Path, None] = None, - determine_type: bool = True, - ): - """下载图片到本地。 + async def get_bytes(self) -> typing.Tuple[bytes, str]: + """获取图片的 bytes 和 mime type""" + if self.url: + async with httpx.AsyncClient() as client: + response = await client.get(self.url) + response.raise_for_status() + return response.content, response.headers.get('Content-Type') + elif self.base64: + mime_type = 'image/jpeg' - Args: - filename: 下载到本地的文件路径。与 `directory` 二选一。 - directory: 下载到本地的文件夹路径。与 `filename` 二选一。 - determine_type: 是否自动根据图片类型确定拓展名,默认为 True。 - """ - if not self.url: - logger.warning(f'图片 `{self.uuid}` 无 url 参数,下载失败。') - return + split_index = self.base64.find(';base64,') + if split_index == -1: + raise ValueError('Invalid base64 string') - import httpx + mime_type = self.base64[5:split_index] + base64_data = self.base64[split_index + 8 :] - async with httpx.AsyncClient() as client: - response = await client.get(self.url) - response.raise_for_status() - content = response.content - - if filename: - path = Path(filename) - if determine_type: - import imghdr - - path = path.with_suffix('.' + str(imghdr.what(None, content))) - path.parent.mkdir(parents=True, exist_ok=True) - elif directory: - import imghdr - - path = Path(directory) - path.mkdir(parents=True, exist_ok=True) - path = path / f'{self.uuid}.{imghdr.what(None, content)}' - else: - raise ValueError('请指定文件路径或文件夹路径!') - - import aiofiles - - async with aiofiles.open(path, 'wb') as f: - await f.write(content) - - return path + return base64.b64decode(base64_data), mime_type + elif self.path: + async with aiofiles.open(self.path, 'rb') as f: + return await f.read(), 'image/jpeg' + else: + raise ValueError('Can not get bytes from image') @classmethod async def from_local( diff --git a/pkg/storage/__init__.py b/pkg/storage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pkg/storage/mgr.py b/pkg/storage/mgr.py new file mode 100644 index 00000000..8d52e465 --- /dev/null +++ b/pkg/storage/mgr.py @@ -0,0 +1,21 @@ +from __future__ import annotations + + +from ..core import app +from . import provider +from .providers import localstorage + + +class StorageMgr: + """存储管理器""" + + ap: app.Application + + storage_provider: provider.StorageProvider + + def __init__(self, ap: app.Application): + self.ap = ap + self.storage_provider = localstorage.LocalStorageProvider(ap) + + async def initialize(self): + await self.storage_provider.initialize() diff --git a/pkg/storage/provider.py b/pkg/storage/provider.py new file mode 100644 index 00000000..0111c617 --- /dev/null +++ b/pkg/storage/provider.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import abc + +from ..core import app + + +class StorageProvider(abc.ABC): + ap: app.Application + + def __init__(self, ap: app.Application): + self.ap = ap + + async def initialize(self): + pass + + @abc.abstractmethod + async def save( + self, + key: str, + value: bytes, + ): + pass + + @abc.abstractmethod + async def load( + self, + key: str, + ) -> bytes: + pass + + @abc.abstractmethod + async def exists( + self, + key: str, + ) -> bool: + pass + + @abc.abstractmethod + async def delete( + self, + key: str, + ): + pass diff --git a/pkg/storage/providers/__init__.py b/pkg/storage/providers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pkg/storage/providers/localstorage.py b/pkg/storage/providers/localstorage.py new file mode 100644 index 00000000..84ce8a0b --- /dev/null +++ b/pkg/storage/providers/localstorage.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import os +import aiofiles + +from ...core import app + +from .. import provider + + +LOCAL_STORAGE_PATH = os.path.join('data', 'storage') + + +class LocalStorageProvider(provider.StorageProvider): + def __init__(self, ap: app.Application): + super().__init__(ap) + if not os.path.exists(LOCAL_STORAGE_PATH): + os.makedirs(LOCAL_STORAGE_PATH) + + async def save( + self, + key: str, + value: bytes, + ): + async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'wb') as f: + await f.write(value) + + async def load( + self, + key: str, + ) -> bytes: + async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'rb') as f: + return await f.read() + + async def exists( + self, + key: str, + ) -> bool: + return os.path.exists(os.path.join(LOCAL_STORAGE_PATH, f'{key}')) + + async def delete( + self, + key: str, + ): + os.remove(os.path.join(LOCAL_STORAGE_PATH, f'{key}')) diff --git a/pkg/utils/funcschema.py b/pkg/utils/funcschema.py index 52dd6efc..f18b9e6b 100644 --- a/pkg/utils/funcschema.py +++ b/pkg/utils/funcschema.py @@ -1,8 +1,9 @@ import re import inspect +import typing -def get_func_schema(function: callable) -> dict: +def get_func_schema(function: typing.Callable) -> dict: """ Return the data schema of a function. { diff --git a/web/package-lock.json b/web/package-lock.json index ca0c9730..7a622b41 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -36,6 +36,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.56.3", "react-i18next": "^15.5.1", + "react-photo-view": "^1.2.7", "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.5", @@ -6126,6 +6127,15 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/react-photo-view": { + "version": "1.2.7", + "resolved": "https://registry.npmmirror.com/react-photo-view/-/react-photo-view-1.2.7.tgz", + "integrity": "sha512-MfOWVPxuibncRLaycZUNxqYU8D9IA+rbGDDaq6GM8RIoGJal592hEJoRAyRSI7ZxyyJNJTLMUWWL3UIXHJJOpw==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", diff --git a/web/package.json b/web/package.json index f12dd666..70e7edb8 100644 --- a/web/package.json +++ b/web/package.json @@ -44,6 +44,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.56.3", "react-i18next": "^15.5.1", + "react-photo-view": "^1.2.7", "sonner": "^2.0.3", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.5", diff --git a/web/src/app/home/bots/bot-log/BotLogManager.ts b/web/src/app/home/bots/bot-log/BotLogManager.ts new file mode 100644 index 00000000..a6aa433b --- /dev/null +++ b/web/src/app/home/bots/bot-log/BotLogManager.ts @@ -0,0 +1,63 @@ +import { httpClient } from '@/app/infra/http/HttpClient'; +import { + BotLog, + GetBotLogsResponse, +} from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; + +export class BotLogManager { + private botId: string; + private callbacks: ((_: BotLog[]) => void)[] = []; + private intervalIds: number[] = []; + + constructor(botId: string) { + this.botId = botId; + } + + startListenServerPush() { + const timerNumber = setInterval(() => { + this.getLogList(-1, 50).then((response) => { + this.callbacks.forEach((callback) => + callback(this.parseResponse(response)), + ); + }); + }, 3000); + this.intervalIds.push(Number(timerNumber)); + } + + stopServerPush() { + this.intervalIds.forEach((id) => clearInterval(id)); + this.intervalIds = []; + } + + subscribeLogPush(callback: (_: BotLog[]) => void) { + if (!this.callbacks.includes(callback)) { + this.callbacks.push(callback); + } + } + + dispose() { + this.callbacks = []; + } + + /** + * 获取日志页的基本信息 + */ + private getLogList(next: number, count: number = 20) { + return httpClient.getBotLogs(this.botId, { + from_index: next, + max_count: count, + }); + } + + async loadFirstPage() { + return this.parseResponse(await this.getLogList(-1, 10)); + } + + async loadMore(position: number, total: number) { + return this.parseResponse(await this.getLogList(position, total)); + } + + private parseResponse(httpResponse: GetBotLogsResponse): BotLog[] { + return httpResponse.logs; + } +} diff --git a/web/src/app/home/bots/bot-log/view/BotLogCard.tsx b/web/src/app/home/bots/bot-log/view/BotLogCard.tsx new file mode 100644 index 00000000..b7fd2e90 --- /dev/null +++ b/web/src/app/home/bots/bot-log/view/BotLogCard.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; +import styles from './botLog.module.css'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { PhotoProvider } from 'react-photo-view'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +export function BotLogCard({ botLog }: { botLog: BotLog }) { + const { t } = useTranslation(); + const baseURL = httpClient.getBaseUrl(); + + function formatTime(timestamp: number) { + const now = new Date(); + const date = new Date(timestamp * 1000); + + // 获取各个时间部分 + const year = date.getFullYear(); + const month = date.getMonth() + 1; // 月份从0开始,需要+1 + const day = date.getDate(); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + + // 判断时间范围 + const isToday = now.toDateString() === date.toDateString(); + const isYesterday = + new Date(now.setDate(now.getDate() - 1)).toDateString() === + date.toDateString(); + const isThisYear = now.getFullYear() === year; + + if (isToday) { + return `${hours}:${minutes}`; // 今天的消息:小时:分钟 + } else if (isYesterday) { + return `${t('bots.yesterday')} ${hours}:${minutes}`; // 昨天的消息:昨天 小时:分钟 + } else if (isThisYear) { + return t('bots.dateFormat', { month, day }); // 本年消息:x月x日 + } else { + return t('bots.earlier'); // 更早的消息:更久之前 + } + } + + function getSubChatId(str: string) { + const strArr = str.split(''); + return strArr; + } + return ( +
+ {/* 头部标签,时间 */} +
+
+
{botLog.level}
+ {botLog.message_session_id && ( +
{ + navigator.clipboard + .writeText(botLog.message_session_id) + .then(() => { + toast.success(t('common.copySuccess')); + }); + }} + > + + + + + + {/* 会话ID */} + + + {getSubChatId(botLog.message_session_id)} + +
+ )} +
+
{formatTime(botLog.timestamp)}
+
+
+ {botLog.text} +
+ +
+ {botLog.images.map((item) => ( + + ))} +
+
+
+ ); +} diff --git a/web/src/app/home/bots/bot-log/view/BotLogListComponent.tsx b/web/src/app/home/bots/bot-log/view/BotLogListComponent.tsx new file mode 100644 index 00000000..5c8ea0ed --- /dev/null +++ b/web/src/app/home/bots/bot-log/view/BotLogListComponent.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { BotLogManager } from '@/app/home/bots/bot-log/BotLogManager'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; +import { BotLogCard } from '@/app/home/bots/bot-log/view/BotLogCard'; +import styles from './botLog.module.css'; +import { Switch } from '@/components/ui/switch'; +import { debounce } from 'lodash'; +import { useTranslation } from 'react-i18next'; + +export function BotLogListComponent({ botId }: { botId: string }) { + const { t } = useTranslation(); + const manager = useRef(new BotLogManager(botId)).current; + const [botLogList, setBotLogList] = useState([]); + const [autoFlush, setAutoFlush] = useState(true); + const listContainerRef = useRef(null); + const botLogListRef = useRef(botLogList); + + useEffect(() => { + initComponent(); + return () => { + onDestroy(); + }; + }, []); + + useEffect(() => { + botLogListRef.current = botLogList; + }, [botLogList]); + + // 观测自动刷新状态 + useEffect(() => { + if (autoFlush) { + manager.startListenServerPush(); + } else { + manager.stopServerPush(); + } + return () => { + manager.stopServerPush(); + }; + }, [autoFlush]); + + function initComponent() { + // 订阅日志推送 + manager.subscribeLogPush(handleBotLogPush); + // 加载第一页日志 + manager.loadFirstPage().then((response) => { + setBotLogList(response.reverse()); + }); + // 监听滚动 + listenScroll(); + } + + function onDestroy() { + manager.dispose(); + removeScrollListener(); + } + + function listenScroll() { + if (!listContainerRef.current) { + return; + } + const list = listContainerRef.current; + list.addEventListener('scroll', handleScroll); + } + + function removeScrollListener() { + if (!listContainerRef.current) { + return; + } + const list = listContainerRef.current; + list.removeEventListener('scroll', handleScroll); + } + + function loadMore() { + // 加载更多日志 + const list = botLogListRef.current; + const lastSeq = list[list.length - 1].seq_id; + if (lastSeq === 0) { + return; + } + manager.loadMore(lastSeq - 1, 10).then((response) => { + setBotLogList([...list, ...response.reverse()]); + }); + } + + function handleBotLogPush(response: BotLog[]) { + setBotLogList(response.reverse()); + } + + const handleScroll = useCallback( + debounce(() => { + if (!listContainerRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = + listContainerRef.current; + const isBottom = scrollTop + clientHeight >= scrollHeight - 5; + const isTop = scrollTop === 0; + + if (isBottom) { + setAutoFlush(false); + loadMore(); + } + if (isTop) { + setAutoFlush(true); + } + if (!isTop && !isBottom) { + setAutoFlush(false); + } + }, 300), // 防抖延迟 300ms + [botLogList], // 依赖项为空 + ); + + return ( +
+
+
{t('bots.enableAutoRefresh')}
+ setAutoFlush(e)} /> +
+ + {botLogList.map((botLog) => { + return ; + })} +
+ ); +} diff --git a/web/src/app/home/bots/bot-log/view/botLog.module.css b/web/src/app/home/bots/bot-log/view/botLog.module.css new file mode 100644 index 00000000..232d42e4 --- /dev/null +++ b/web/src/app/home/bots/bot-log/view/botLog.module.css @@ -0,0 +1,68 @@ +.botLogListContainer { + width: 100%; + min-height: 10rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + overflow-y: scroll; +} + +.botLogCardContainer { + width: 100%; + background-color: #fff; + border-radius: 10px; + border: 1px solid #cbd5e1; + padding: 1.2rem; + margin-bottom: 1rem; + cursor: pointer; +} + +.listHeader { + width: 100%; + height: 2.5rem; + display: flex; + flex-direction: row; + align-items: center; +} + +.tag { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 0.2rem; + height: 1.5rem; + padding: 0.5rem; + border-radius: 0.4rem; + background-color: #a5d8ff; + color: #ffffff; + max-width: 16rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chatTag { + color: #626262; + background-color: #d1d1d1; +} + +.chatId { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cardTitleContainer { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.cardText { + margin-top: 0.4rem; + color: #64748b; +} diff --git a/web/src/app/home/bots/components/bot-card/BotCard.tsx b/web/src/app/home/bots/components/bot-card/BotCard.tsx index 924032c9..848eb077 100644 --- a/web/src/app/home/bots/components/bot-card/BotCard.tsx +++ b/web/src/app/home/bots/components/bot-card/BotCard.tsx @@ -1,7 +1,34 @@ import { BotCardVO } from '@/app/home/bots/components/bot-card/BotCardVO'; import styles from './botCard.module.css'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { Switch } from '@/components/ui/switch'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +export default function BotCard({ + botCardVO, + clickLogIconCallback, + setBotEnableCallback, +}: { + botCardVO: BotCardVO; + clickLogIconCallback: (id: string) => void; + setBotEnableCallback: (id: string, enable: boolean) => void; +}) { + const { t } = useTranslation(); + function onClickLogIcon() { + clickLogIconCallback(botCardVO.id); + } + + function setBotEnable(enable: boolean) { + return httpClient.updateBot(botCardVO.id, { + name: botCardVO.name, + description: botCardVO.description, + adapter: botCardVO.adapter, + adapter_config: botCardVO.adapterConfig, + enable: enable, + }); + } -export default function BotCard({ botCardVO }: { botCardVO: BotCardVO }) { return (
@@ -47,6 +74,44 @@ export default function BotCard({ botCardVO }: { botCardVO: BotCardVO }) {
+ +
+ { + setBotEnable(e) + .then(() => { + setBotEnableCallback(botCardVO.id, e); + }) + .catch((err) => { + console.error(err); + toast.error(t('bots.setBotEnableError')); + }); + }} + onClick={(e) => { + e.stopPropagation(); + }} + /> +
{ + onClickLogIcon(); + e.stopPropagation(); + }} + > + + + +
+
); diff --git a/web/src/app/home/bots/components/bot-card/BotCardVO.ts b/web/src/app/home/bots/components/bot-card/BotCardVO.ts index c0f7c19e..284284b9 100644 --- a/web/src/app/home/bots/components/bot-card/BotCardVO.ts +++ b/web/src/app/home/bots/components/bot-card/BotCardVO.ts @@ -3,8 +3,11 @@ export interface IBotCardVO { iconURL: string; name: string; description: string; + adapter: string; adapterLabel: string; + adapterConfig: object; usePipelineName: string; + enable: boolean; } export class BotCardVO implements IBotCardVO { @@ -12,15 +15,21 @@ export class BotCardVO implements IBotCardVO { iconURL: string; name: string; description: string; + adapter: string; adapterLabel: string; + adapterConfig: object; usePipelineName: string; + enable: boolean; constructor(props: IBotCardVO) { this.id = props.id; this.iconURL = props.iconURL; this.name = props.name; this.description = props.description; + this.adapter = props.adapter; + this.adapterConfig = props.adapterConfig; this.adapterLabel = props.adapterLabel; this.usePipelineName = props.usePipelineName; + this.enable = props.enable; } } diff --git a/web/src/app/home/bots/components/bot-card/botCard.module.css b/web/src/app/home/bots/components/bot-card/botCard.module.css index 8f8911b0..ea526c4e 100644 --- a/web/src/app/home/bots/components/bot-card/botCard.module.css +++ b/web/src/app/home/bots/components/bot-card/botCard.module.css @@ -19,7 +19,6 @@ flex-direction: row; gap: 0.8rem; user-select: none; - /* background-color: aqua; */ } .iconImage { @@ -30,10 +29,10 @@ } .basicInfoContainer { + position: relative; display: flex; flex-direction: column; gap: 0.2rem; - min-width: 0; width: 100%; } @@ -104,4 +103,14 @@ font-size: 1.4rem; font-weight: bold; max-width: 100%; +} + +.botOperationContainer { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-end; + height: 100%; + width: 3rem; + gap: 0.4rem; } \ No newline at end of file diff --git a/web/src/app/home/bots/page.tsx b/web/src/app/home/bots/page.tsx index cc696300..33d55e61 100644 --- a/web/src/app/home/bots/page.tsx +++ b/web/src/app/home/bots/page.tsx @@ -17,13 +17,18 @@ import { import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { i18nObj } from '@/i18n/I18nProvider'; +import { BotLogListComponent } from '@/app/home/bots/bot-log/view/BotLogListComponent'; export default function BotConfigPage() { const { t } = useTranslation(); + // 编辑机器人的modal const [modalOpen, setModalOpen] = useState(false); + // 机器人日志的modal + const [logModalOpen, setLogModalOpen] = useState(false); const [botList, setBotList] = useState([]); const [isEditForm, setIsEditForm] = useState(false); const [nowSelectedBotUUID, setNowSelectedBotUUID] = useState(); + const [nowSelectedBotLog, setNowSelectedBotLog] = useState(); useEffect(() => { getBotList(); @@ -47,10 +52,13 @@ export default function BotConfigPage() { iconURL: httpClient.getAdapterIconURL(bot.adapter), name: bot.name, description: bot.description, + adapter: bot.adapter, + adapterConfig: bot.adapter_config, adapterLabel: adapterList.find((item) => item.value === bot.adapter)?.label || bot.adapter.substring(0, 10), usePipelineName: bot.use_pipeline_name || '', + enable: bot.enable || false, }); }); setBotList(botList); @@ -76,6 +84,11 @@ export default function BotConfigPage() { setModalOpen(true); } + function onClickLogIcon(botId: string) { + setNowSelectedBotLog(botId); + setLogModalOpen(true); + } + return (
@@ -107,6 +120,15 @@ export default function BotConfigPage() { + + + + {t('bots.botLogTitle')} + + + + + {/* 注意:其余的返回内容需要保持在Spin组件外部 */}
- + { + onClickLogIcon(id); + }} + setBotEnableCallback={(id, enable) => { + setBotList( + botList.map((bot) => { + if (bot.id === id) { + return { ...bot, enable: enable }; + } + return bot; + }), + ); + }} + />
); })} diff --git a/web/src/app/home/models/component/llm-form/LLMForm.tsx b/web/src/app/home/models/component/llm-form/LLMForm.tsx index 9bdf67f3..f483f183 100644 --- a/web/src/app/home/models/component/llm-form/LLMForm.tsx +++ b/web/src/app/home/models/component/llm-form/LLMForm.tsx @@ -169,7 +169,6 @@ export default function LLMForm({ } else { form.reset(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }); }, []); diff --git a/web/src/app/infra/http/HttpClient.ts b/web/src/app/infra/http/HttpClient.ts index 7de29dfc..031be87e 100644 --- a/web/src/app/infra/http/HttpClient.ts +++ b/web/src/app/infra/http/HttpClient.ts @@ -30,6 +30,8 @@ import { GetPipelineMetadataResponseData, AsyncTask, } from '@/app/infra/entities/api'; +import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest'; +import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse'; type JSONValue = string | number | boolean | JSONObject | JSONArray | null; interface JSONObject { @@ -54,12 +56,14 @@ export let systemInfo: ApiRespSystemInfo | null = null; class HttpClient { private instance: AxiosInstance; private disableToken: boolean = false; + private baseURL: string; // 暂不需要SSR // private ssrInstance: AxiosInstance | null = null - constructor(baseURL?: string, disableToken?: boolean) { + constructor(baseURL: string, disableToken?: boolean) { + this.baseURL = baseURL; this.instance = axios.create({ - baseURL: baseURL || this.getBaseUrl(), + baseURL: baseURL, timeout: 15000, headers: { 'Content-Type': 'application/json', @@ -75,15 +79,9 @@ class HttpClient { } } - // 兜底URL,如果使用未配置会走到这里 - private getBaseUrl(): string { - // NOT IMPLEMENT - if (typeof window === 'undefined') { - // 服务端环境 - return ''; - } - // 客户端环境 - return ''; + // 外部获取baseURL的方法 + getBaseUrl(): string { + return this.baseURL; } // 获取Session @@ -345,6 +343,13 @@ class HttpClient { return this.delete(`/api/v1/platform/bots/${uuid}`); } + public getBotLogs( + botId: string, + request: GetBotLogsRequest, + ): Promise { + return this.post(`/api/v1/platform/bots/${botId}/logs`, request); + } + // ============ Plugins API ============ public getPlugins(): Promise { return this.get('/api/v1/plugins'); @@ -450,9 +455,9 @@ class HttpClient { } } -// export const httpClient = new HttpClient("https://version-4.langbot.dev"); +export const httpClient = new HttpClient('https://event-log.langbot.dev'); // export const httpClient = new HttpClient('http://localhost:5300'); -export const httpClient = new HttpClient('/'); +// export const httpClient = new HttpClient('/'); // 临时写法,未来两种Client都继承自HttpClient父类,不允许共享方法 export const spaceClient = new HttpClient('https://space.langbot.app'); diff --git a/web/src/app/infra/http/requestParam/bots/GetBotLogsRequest.ts b/web/src/app/infra/http/requestParam/bots/GetBotLogsRequest.ts new file mode 100644 index 00000000..27a49106 --- /dev/null +++ b/web/src/app/infra/http/requestParam/bots/GetBotLogsRequest.ts @@ -0,0 +1,4 @@ +export interface GetBotLogsRequest { + from_index: number; // 从某索引开始往前找,-1代表结尾,也就是拉取最新的 + max_count: number; // 最大拉取数量 +} diff --git a/web/src/app/infra/http/requestParam/bots/GetBotLogsResponse.ts b/web/src/app/infra/http/requestParam/bots/GetBotLogsResponse.ts new file mode 100644 index 00000000..9cd04ac8 --- /dev/null +++ b/web/src/app/infra/http/requestParam/bots/GetBotLogsResponse.ts @@ -0,0 +1,13 @@ +export interface GetBotLogsResponse { + logs: BotLog[]; + total_count: number; +} + +export interface BotLog { + images: []; + level: string; + message_session_id: string; + seq_id: number; + text: string; + timestamp: number; +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index c7e9267f..58fc9807 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,4 +1,5 @@ import './global.css'; +import 'react-photo-view/dist/react-photo-view.css'; import type { Metadata } from 'next'; import { Toaster } from '@/components/ui/sonner'; import I18nProvider from '@/i18n/I18nProvider'; diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index a544a9ce..7caae6a2 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -60,6 +60,7 @@ function DialogContent({ 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', className, )} + onInteractOutside={() => {}} {...props} > {children} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 655a76d5..f6195c9e 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -37,6 +37,7 @@ const enUS = { deleteSuccess: 'Deleted successfully', deleteError: 'Delete failed: ', addRound: 'Add Round', + copySuccess: 'Copy Successfully', test: 'Test', }, notFound: { @@ -120,6 +121,13 @@ const enUS = { adapterConfig: 'Adapter Configuration', bindPipeline: 'Bind Pipeline', selectPipeline: 'Select Pipeline', + botLogTitle: 'Bot Log', + enableAutoRefresh: 'Enable Auto Refresh', + session: 'Session', + yesterday: 'Yesterday', + earlier: 'Earlier', + dateFormat: '{{month}}/{{day}}', + setBotEnableError: 'Failed to set bot enable status', }, plugins: { title: 'Plugins', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 1f2fe0b6..d0271c38 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -37,6 +37,7 @@ const zhHans = { deleteSuccess: '删除成功', deleteError: '删除失败:', addRound: '添加回合', + copySuccess: '复制成功', test: '测试', }, notFound: { @@ -118,6 +119,13 @@ const zhHans = { adapterConfig: '适配器配置', bindPipeline: '绑定流水线', selectPipeline: '选择流水线', + botLogTitle: '机器人日志', + enableAutoRefresh: '开启自动刷新', + session: '会话', + yesterday: '昨天', + earlier: '更久之前', + dateFormat: '{{month}}月{{day}}日', + setBotEnableError: '设置机器人启用状态失败', }, plugins: { title: '插件管理',