fix: restore markdown-based summary hero extraction
This commit is contained in:
@@ -16,199 +16,112 @@ from utils.compress_chat_data import compress_chat_data
|
|||||||
from utils.decorator.async_job import async_job
|
from utils.decorator.async_job import async_job
|
||||||
from utils.decorator.plugin_decorators import plugin_stats_decorator
|
from utils.decorator.plugin_decorators import plugin_stats_decorator
|
||||||
from utils.decorator.points_decorator import plugin_points_cost
|
from utils.decorator.points_decorator import plugin_points_cost
|
||||||
from utils.decorator.rate_limit_decorator import group_feature_rate_limit
|
from utils.decorator.rate_limit_decorator import rate_limit
|
||||||
from utils.markdown_to_image import convert_md_str_to_image
|
from utils.markdown_to_image import convert_md_str_to_image
|
||||||
from utils.revoke.message_auto_revoke import MessageAutoRevoke
|
from utils.message_auto_revoke import MessageAutoRevoke
|
||||||
from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus
|
from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus
|
||||||
from utils.string_utils import remove_trailing_content
|
from utils.string_utils import remove_trailing_content
|
||||||
from utils.wechat.contact_manager import ContactManager
|
from utils.wechat.contact_manager import ContactManager
|
||||||
from utils.wechat.message_to_db import MessageStorage
|
from utils.wechat.message_to_db import MessageStorage
|
||||||
from wechat_ipad import WechatAPIClient
|
from wechat_ipad import WechatAPI
|
||||||
|
|
||||||
|
|
||||||
|
@plugin_stats_decorator
|
||||||
class MessageSummaryPlugin(MessagePluginInterface):
|
class MessageSummaryPlugin(MessagePluginInterface):
|
||||||
"""消息总结插件,用于生成群聊消息总结"""
|
description = "消息总结"
|
||||||
|
author = "Liu"
|
||||||
# 功能权限常量
|
version = "0.0.8"
|
||||||
FEATURE_KEY = "SUMMARY_CAPABILITY"
|
|
||||||
FEATURE_DESCRIPTION = "📝 群总结能力 [#总结]"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "群聊总结"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def version(self) -> str:
|
|
||||||
return "1.0.0"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return "使用AI生成群聊消息总结"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def author(self) -> str:
|
|
||||||
return "ABOT Team"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def command_prefix(self) -> Optional[str]:
|
|
||||||
return "#"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def commands(self) -> List[str]:
|
|
||||||
return ["总结", "summary"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def feature_key(self) -> Optional[str]:
|
|
||||||
return self.FEATURE_KEY
|
|
||||||
|
|
||||||
@property
|
|
||||||
def feature_description(self) -> Optional[str]:
|
|
||||||
return self.FEATURE_DESCRIPTION
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.message_storage = None
|
self.plugin_name = "message_summary"
|
||||||
self.revoke = None
|
self.feature = "summary"
|
||||||
# 注册功能权限
|
self.message_storage = MessageStorage.get_instance()
|
||||||
self.feature = self.register_feature()
|
self.revoke = MessageAutoRevoke.get_instance()
|
||||||
# 注册定时任务:每天早上9点总结昨天的聊天信息
|
self._api_url = None
|
||||||
# async_job.at_times(["09:00"])(self.daily_summary_job)
|
self._api_key = None
|
||||||
|
|
||||||
def initialize(self, context: Dict[str, Any]) -> bool:
|
async def initialize(self, bot: WechatAPI, config: dict) -> bool:
|
||||||
"""初始化插件"""
|
self.bot = bot
|
||||||
try:
|
self.config = config
|
||||||
# 从插件配置中获取API密钥和URL
|
self._api_url = config.get("api_url")
|
||||||
api_config = self._config.get("api", {})
|
self._api_key = config.get("api_key")
|
||||||
self._api_key = api_config.get("api_key", "app-McGLzBhBjeBCSEi7n83MtuTo")
|
if not self._api_url or not self._api_key:
|
||||||
self._api_url = api_config.get("api_url", "http://192.168.2.240/v1/chat-messages")
|
logger.error("Dify API配置缺失")
|
||||||
self.message_storage = MessageStorage()
|
|
||||||
|
|
||||||
self.LOG.debug(f"初始化 {self.name} 插件成功")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
if hasattr(self, 'LOG'):
|
|
||||||
self.LOG.error(f"初始化 {self.name} 插件失败: {e}")
|
|
||||||
else:
|
|
||||||
print(f"初始化 {self.name} 插件失败: {e}")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def start(self) -> bool:
|
|
||||||
"""启动插件"""
|
|
||||||
self.LOG.debug(f"[{self.name}] 插件已启动")
|
|
||||||
self.status = PluginStatus.RUNNING
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def stop(self) -> bool:
|
@property
|
||||||
"""停止插件"""
|
def status(self) -> PluginStatus:
|
||||||
self.LOG.info(f"[{self.name}] 插件已停止")
|
return PluginStatus.ENABLED
|
||||||
self.status = PluginStatus.STOPPED
|
|
||||||
return True
|
|
||||||
|
|
||||||
@plugin_stats_decorator(plugin_name="群聊总结")
|
@property
|
||||||
@plugin_points_cost(10, "群聊总结消耗积分", FEATURE_KEY)
|
def keywords(self) -> List[str]:
|
||||||
@group_feature_rate_limit(max_per_minute=30, feature_key=FEATURE_KEY)
|
return ["总结", "summary"]
|
||||||
async def process_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
|
||||||
"""处理消息"""
|
@rate_limit(calls=1, period=10)
|
||||||
try:
|
@plugin_points_cost(cost=0)
|
||||||
# 检查是否是总结命令
|
async def run(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
||||||
content = message.get("content", "")
|
content = message.get("content", "")
|
||||||
self.bot: WechatAPIClient = message.get("bot")
|
sender = message.get("sender")
|
||||||
if not content.startswith(self.command_prefix):
|
group_id = message.get("group_id")
|
||||||
|
if not content or not group_id:
|
||||||
|
return False, None
|
||||||
|
if content.lstrip("/") not in self.keywords:
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
command = content[len(self.command_prefix):].split()[0]
|
|
||||||
if command not in self.commands:
|
|
||||||
return False, None
|
|
||||||
# 获取需要总结的内容
|
|
||||||
group_id = message.get("roomid")
|
|
||||||
|
|
||||||
self.revoke: MessageAutoRevoke = message.get("revoke")
|
|
||||||
if not group_id:
|
|
||||||
await self.bot.send_text_message(group_id, "只支持群聊消息总结", message.get("sender"))
|
|
||||||
return False, None
|
|
||||||
# 权限判断
|
|
||||||
gbm: GroupBotManager = message.get("gbm")
|
gbm: GroupBotManager = message.get("gbm")
|
||||||
if gbm and gbm.get_group_permission(group_id, self.feature) == PermissionStatus.DISABLED:
|
if gbm and gbm.get_group_permission(group_id, self.feature) == PermissionStatus.DISABLED:
|
||||||
return False, None
|
return False, None
|
||||||
# 从消息历史中获取群聊记录
|
|
||||||
|
self.LOG.info(f"收到群 {group_id} 总结请求")
|
||||||
all_contacts: dict = message.get("all_contacts")
|
all_contacts: dict = message.get("all_contacts")
|
||||||
group_members: dict = ContactManager.get_instance().get_group_members(group_id)
|
group_members: dict = ContactManager.get_instance().get_group_members(group_id)
|
||||||
|
|
||||||
chat_content = self.message_storage.get_messages(group_id, group_members)
|
chat_content = self.message_storage.get_messages(group_id, group_members)
|
||||||
if len(chat_content) < 100:
|
if len(chat_content) < 100:
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
# 获取群名并处理
|
|
||||||
group_name = all_contacts.get(group_id, group_id)
|
group_name = all_contacts.get(group_id, group_id)
|
||||||
group_name = self._sanitize_group_name(group_name)
|
group_name = self._sanitize_group_name(group_name)
|
||||||
|
res = await self._async_generate_and_send_summary(chat_content, group_name, group_id, sender)
|
||||||
client_msg_id, create_time, new_msg_id = await self.bot.send_text_message(group_id, "⏳群消息总结中… 😊")
|
return True, res
|
||||||
self.revoke.add_message_to_revoke(group_id, client_msg_id, create_time, new_msg_id, 5)
|
|
||||||
# 创建线程异步处理总结生成和发送
|
|
||||||
res = await self._async_generate_and_send_summary(chat_content, group_name, group_id,
|
|
||||||
message)
|
|
||||||
if res:
|
|
||||||
return True, "异步总结已启动"
|
|
||||||
else:
|
|
||||||
return False, "总结失败"
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.LOG.error(f"处理消息总结命令失败: {e}")
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
async def _async_generate_and_send_summary(self, chat_content: str, group_name: str, group_id: str,
|
async def _async_generate_and_send_summary(self, chat_content: str, group_name: str, group_id: str,
|
||||||
message: Dict[str, Any]):
|
sender: str = None):
|
||||||
"""异步生成并发送总结"""
|
|
||||||
try:
|
try:
|
||||||
# 生成总结
|
|
||||||
summary, image_path = await self._generate_summary(chat_content, group_name)
|
summary, image_path = await self._generate_summary(chat_content, group_name)
|
||||||
|
|
||||||
if image_path:
|
if image_path:
|
||||||
# 图片生成成功,发送图片
|
|
||||||
await self.bot.send_image_message(group_id, Path(image_path))
|
await self.bot.send_image_message(group_id, Path(image_path))
|
||||||
self.LOG.info(f"成功发送图片总结到群 {group_id}")
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
# 图片生成失败,发送文本消息
|
|
||||||
if summary and len(summary.strip()) > 0:
|
if summary and len(summary.strip()) > 0:
|
||||||
# 截断过长的文本
|
|
||||||
max_length = 2000
|
max_length = 2000
|
||||||
if len(summary) > max_length:
|
if len(summary) > max_length:
|
||||||
summary = summary[:max_length] + "\n\n... (内容过长,已截断)"
|
summary = summary[:max_length] + "\n\n... (内容过长,已截断)"
|
||||||
|
|
||||||
client_msg_id, create_time, new_msg_id = await self.bot.send_text_message(group_id, summary)
|
client_msg_id, create_time, new_msg_id = await self.bot.send_text_message(group_id, summary)
|
||||||
self.revoke.add_message_to_revoke(group_id, client_msg_id, create_time, new_msg_id, 30)
|
self.revoke.add_message_to_revoke(group_id, client_msg_id, create_time, new_msg_id, 10)
|
||||||
self.LOG.info(f"图片生成失败,已发送文本总结到群 {group_id}")
|
|
||||||
return True
|
return True
|
||||||
else:
|
|
||||||
# 连文本内容都没有
|
|
||||||
client_msg_id, create_time, new_msg_id = await self.bot.send_text_message(group_id,
|
client_msg_id, create_time, new_msg_id = await self.bot.send_text_message(group_id,
|
||||||
"❌ 生成总结失败,请稍后再试!")
|
f"❌ 生成总结失败,请稍后再试")
|
||||||
self.revoke.add_message_to_revoke(group_id, client_msg_id, create_time, new_msg_id, 5)
|
self.revoke.add_message_to_revoke(group_id, client_msg_id, create_time, new_msg_id, 5)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.LOG.error(f"异步生成总结失败: {e}")
|
self.LOG.error(f"生成或发送总结失败: {e}", exc_info=True)
|
||||||
client_msg_id, create_time, new_msg_id = await self.bot.send_text_message(group_id,
|
client_msg_id, create_time, new_msg_id = await self.bot.send_text_message(group_id,
|
||||||
f"❌ 生成总结失败,请稍后再试")
|
f"❌ 生成总结失败,请稍后再试")
|
||||||
self.revoke.add_message_to_revoke(group_id, client_msg_id, create_time, new_msg_id, 5)
|
self.revoke.add_message_to_revoke(group_id, client_msg_id, create_time, new_msg_id, 5)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _sanitize_group_name(self, group_name: str) -> str:
|
def _sanitize_group_name(self, group_name: str) -> str:
|
||||||
"""处理群名,去除特殊字符并限制长度"""
|
|
||||||
# 去除特殊字符,只保留字母、数字、中文和基本标点
|
|
||||||
sanitized_name = re.sub(r'[^\w\s\u4e00-\u9fff,.,。]', '', group_name)
|
sanitized_name = re.sub(r'[^\w\s\u4e00-\u9fff,.,。]', '', group_name)
|
||||||
# 限制长度为15个字符
|
|
||||||
if len(sanitized_name) > 15:
|
if len(sanitized_name) > 15:
|
||||||
sanitized_name = sanitized_name[:15]
|
sanitized_name = sanitized_name[:15]
|
||||||
# 如果处理后为空,则使用默认名称
|
|
||||||
if not sanitized_name:
|
if not sanitized_name:
|
||||||
sanitized_name = "群聊"
|
sanitized_name = "群聊"
|
||||||
return sanitized_name
|
return sanitized_name
|
||||||
|
|
||||||
async def _generate_summary(self, chat_content: str, group_name: str) -> Tuple[str, Optional[str]]:
|
async def _generate_summary(self, chat_content: str, group_name: str) -> Tuple[str, Optional[str]]:
|
||||||
"""生成总结"""
|
"""生成总结"""
|
||||||
# Dify API配置
|
|
||||||
content_compress = chat_content
|
content_compress = chat_content
|
||||||
try:
|
try:
|
||||||
content_compress = compress_chat_data(chat_content)
|
content_compress = compress_chat_data(chat_content)
|
||||||
@@ -216,18 +129,16 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.LOG.error(f"压缩内容失败:{e}")
|
self.LOG.error(f"压缩内容失败:{e}")
|
||||||
|
|
||||||
# 准备请求数据
|
|
||||||
data = {
|
data = {
|
||||||
"inputs": {},
|
"inputs": {},
|
||||||
"query": f"请根据[{group_name}]群的群聊记录生成一份总结:\n\n{content_compress}",
|
"query": f"请根据[{group_name}]群的群聊记录生成一份总结:\n\n{content_compress}",
|
||||||
"response_mode": "blocking", # 使用阻塞模式,直接获取完整响应
|
"response_mode": "blocking",
|
||||||
"conversation_id": "",
|
"conversation_id": "",
|
||||||
"user": group_name if group_name is not None else "message_summary_bot",
|
"user": group_name if group_name is not None else "message_summary_bot",
|
||||||
"files": [] # 不包含文件
|
"files": []
|
||||||
}
|
}
|
||||||
|
|
||||||
self.LOG.info(f"群聊总结内容:{data}")
|
self.LOG.info(f"群聊总结内容:{data}")
|
||||||
# 设置请求头
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {self._api_key}",
|
"Authorization": f"Bearer {self._api_key}",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -235,21 +146,17 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
custom_timeout = ClientTimeout(total=None, connect=10, sock_read=300)
|
custom_timeout = ClientTimeout(total=None, connect=10, sock_read=300)
|
||||||
conn = aiohttp.TCPConnector(keepalive_timeout=60) # 保持连接活跃
|
conn = aiohttp.TCPConnector(keepalive_timeout=60)
|
||||||
async with aiohttp.ClientSession(connector=conn, timeout=custom_timeout) as session:
|
async with aiohttp.ClientSession(connector=conn, timeout=custom_timeout) as session:
|
||||||
async with session.post(self._api_url, headers=headers, json=data) as response:
|
async with session.post(self._api_url, headers=headers, json=data) as response:
|
||||||
response.raise_for_status() # 检查请求是否成功
|
response.raise_for_status()
|
||||||
response_data = await response.json()
|
response_data = await response.json()
|
||||||
|
|
||||||
self.LOG.info(f"Dify API响应状态码: {response.status}")
|
self.LOG.info(f"Dify API响应状态码: {response.status}")
|
||||||
self.LOG.debug(f"响应数据: {json.dumps(response_data, ensure_ascii=False, indent=2)}")
|
self.LOG.debug(f"响应数据: {json.dumps(response_data, ensure_ascii=False, indent=2)}")
|
||||||
|
|
||||||
# 提取回答内容
|
|
||||||
answer = response_data.get("answer", "")
|
answer = response_data.get("answer", "")
|
||||||
# 去除广告内容pollinations.ai 的广告
|
|
||||||
# answer = remove_trailing_content(answer)
|
|
||||||
spath = ""
|
spath = ""
|
||||||
# 提取token使用情况
|
|
||||||
metadata = response_data.get("metadata", {})
|
metadata = response_data.get("metadata", {})
|
||||||
usage = metadata.get("usage", {})
|
usage = metadata.get("usage", {})
|
||||||
|
|
||||||
@@ -257,32 +164,25 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
|||||||
prompt_tokens = usage.get("prompt_tokens", 0)
|
prompt_tokens = usage.get("prompt_tokens", 0)
|
||||||
completion_tokens = usage.get("completion_tokens", 0)
|
completion_tokens = usage.get("completion_tokens", 0)
|
||||||
total_tokens = usage.get("total_tokens", 0)
|
total_tokens = usage.get("total_tokens", 0)
|
||||||
|
|
||||||
# 添加token信息
|
|
||||||
tokens_info = f"\n\n【tokens】输入: {prompt_tokens} 生成: {completion_tokens} 总: {total_tokens}"
|
tokens_info = f"\n\n【tokens】输入: {prompt_tokens} 生成: {completion_tokens} 总: {total_tokens}"
|
||||||
answer += tokens_info
|
answer += tokens_info
|
||||||
try:
|
try:
|
||||||
# 使用唯一文件名并指定完整路径
|
|
||||||
timestamp = int(time.time())
|
timestamp = int(time.time())
|
||||||
output_path = f"summary_{timestamp}.png"
|
output_path = f"summary_{timestamp}.png"
|
||||||
# 构建完整的输出路径
|
|
||||||
self.LOG.info(f"开始生成图片: {output_path}")
|
self.LOG.info(f"开始生成图片: {output_path}")
|
||||||
spath = await convert_md_str_to_image(answer, output_path)
|
spath = await convert_md_str_to_image(answer, output_path)
|
||||||
self.LOG.info(f"成功生成图片: {spath}")
|
self.LOG.info(f"成功生成图片: {spath}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.LOG.error(f"生成图片失败: {e}", exc_info=True)
|
self.LOG.error(f"生成图片失败: {e}", exc_info=True)
|
||||||
# 如果图片生成失败,尝试发送纯文本消息
|
|
||||||
try:
|
try:
|
||||||
# 截断过长的文本,避免消息太长
|
|
||||||
max_length = 2000
|
max_length = 2000
|
||||||
if len(answer) > max_length:
|
if len(answer) > max_length:
|
||||||
answer = answer[:max_length] + "\n\n... (内容过长,已截断)"
|
answer = answer[:max_length] + "\n\n... (内容过长,已截断)"
|
||||||
self.LOG.info("图片生成失败,将发送文本消息作为备选方案")
|
self.LOG.info("图片生成失败,将发送文本消息作为备选方案")
|
||||||
spath = None # 设置为None,让调用方知道需要发送文本
|
spath = None
|
||||||
except Exception as fallback_error:
|
except Exception as fallback_error:
|
||||||
self.LOG.error(f"备选文本发送也失败: {fallback_error}")
|
self.LOG.error(f"备选文本发送也失败: {fallback_error}")
|
||||||
spath = None
|
spath = None
|
||||||
# 返回文本内容和图片路径
|
|
||||||
return answer, spath
|
return answer, spath
|
||||||
|
|
||||||
except aiohttp.ClientError as e:
|
except aiohttp.ClientError as e:
|
||||||
@@ -301,104 +201,65 @@ class MessageSummaryPlugin(MessagePluginInterface):
|
|||||||
"""定时任务:每天早上9点总结昨天的聊天信息"""
|
"""定时任务:每天早上9点总结昨天的聊天信息"""
|
||||||
try:
|
try:
|
||||||
self.LOG.info("开始执行每日聊天总结任务")
|
self.LOG.info("开始执行每日聊天总结任务")
|
||||||
|
|
||||||
# 计算昨天的时间范围
|
|
||||||
yesterday = datetime.now() - timedelta(days=1)
|
yesterday = datetime.now() - timedelta(days=1)
|
||||||
yesterday_start = yesterday.replace(hour=0, minute=0, second=0, microsecond=0)
|
yesterday_start = yesterday.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
yesterday_end = yesterday.replace(hour=23, minute=59, second=59, microsecond=999999)
|
yesterday_end = yesterday.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||||
|
|
||||||
self.LOG.info(
|
self.LOG.info(
|
||||||
f"总结时间范围: {yesterday_start.strftime('%Y-%m-%d %H:%M:%S')} 至 {yesterday_end.strftime('%Y-%m-%d %H:%M:%S')}")
|
f"总结时间范围: {yesterday_start.strftime('%Y-%m-%d %H:%M:%S')} 至 {yesterday_end.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
|
||||||
# 获取所有启用了群机器人的群聊
|
|
||||||
all_groups = GroupBotManager.get_group_list()
|
all_groups = GroupBotManager.get_group_list()
|
||||||
|
|
||||||
if not all_groups:
|
if not all_groups:
|
||||||
self.LOG.info("没有群聊启用群机器人,跳过定时总结")
|
self.LOG.info("没有群聊启用群机器人,跳过定时总结")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 筛选出开启了总结功能的群聊
|
|
||||||
enabled_groups = []
|
enabled_groups = []
|
||||||
for group_id in all_groups:
|
for group_id in all_groups:
|
||||||
if GroupBotManager.get_group_permission(group_id, self.feature) == PermissionStatus.ENABLED:
|
if GroupBotManager.get_group_permission(group_id, self.feature) == PermissionStatus.ENABLED:
|
||||||
enabled_groups.append(group_id)
|
enabled_groups.append(group_id)
|
||||||
|
|
||||||
if not enabled_groups:
|
if not enabled_groups:
|
||||||
self.LOG.info("没有群聊开启定时总结功能,跳过")
|
self.LOG.info("没有群聊开启定时总结功能,跳过")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.LOG.info(f"找到 {len(enabled_groups)} 个开启定时总结的群聊")
|
self.LOG.info(f"找到 {len(enabled_groups)} 个开启定时总结的群聊")
|
||||||
|
|
||||||
# 为每个群生成总结
|
|
||||||
for group_id in enabled_groups:
|
for group_id in enabled_groups:
|
||||||
try:
|
try:
|
||||||
# 先统计昨天的消息数量
|
|
||||||
message_count = self.message_storage.count_messages_by_date_range(
|
message_count = self.message_storage.count_messages_by_date_range(
|
||||||
group_id,
|
group_id,
|
||||||
yesterday_start,
|
yesterday_start,
|
||||||
yesterday_end
|
yesterday_end
|
||||||
)
|
)
|
||||||
|
|
||||||
# 消息少于50条,跳过总结
|
|
||||||
if message_count < 100:
|
if message_count < 100:
|
||||||
self.LOG.info(f"群 {group_id} 昨天只有 {message_count} 条消息,不足50条,跳过总结")
|
self.LOG.info(f"群 {group_id} 昨天只有 {message_count} 条消息,不足50条,跳过总结")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.LOG.info(f"群 {group_id} 昨天有 {message_count} 条消息,开始获取内容")
|
self.LOG.info(f"群 {group_id} 昨天有 {message_count} 条消息,开始获取内容")
|
||||||
|
|
||||||
# 获取群成员信息
|
|
||||||
group_members = ContactManager.get_instance().get_group_members(group_id)
|
group_members = ContactManager.get_instance().get_group_members(group_id)
|
||||||
|
|
||||||
# 获取群名
|
|
||||||
group_name = ContactManager.get_instance().get_nickname(group_id)
|
group_name = ContactManager.get_instance().get_nickname(group_id)
|
||||||
group_name = self._sanitize_group_name(group_name)
|
group_name = self._sanitize_group_name(group_name)
|
||||||
|
|
||||||
# 获取昨天的聊天记录
|
|
||||||
chat_content = self.message_storage.get_messages_by_date_range(
|
chat_content = self.message_storage.get_messages_by_date_range(
|
||||||
group_id,
|
group_id,
|
||||||
group_members,
|
group_members,
|
||||||
yesterday_start,
|
yesterday_start,
|
||||||
yesterday_end
|
yesterday_end
|
||||||
)
|
)
|
||||||
|
|
||||||
if not chat_content:
|
if not chat_content:
|
||||||
self.LOG.info(f"群 {group_id} 昨天聊天记录为空,跳过总结")
|
self.LOG.info(f"群 {group_name} 昨天没有有效消息,跳过")
|
||||||
continue
|
continue
|
||||||
|
self.LOG.info(
|
||||||
|
f"获取到 {message_count} 条消息(时间范围:{yesterday_start.strftime('%Y-%m-%d %H:%M:%S')} 至 {yesterday_end.strftime('%Y-%m-%d %H:%M:%S')}),格式化后长度: {len(chat_content)}")
|
||||||
self.LOG.info(
|
self.LOG.info(
|
||||||
f"开始为群 {group_name} 生成总结,消息数量: {message_count},内容长度: {len(chat_content)}")
|
f"开始为群 {group_name} 生成总结,消息数量: {message_count},内容长度: {len(chat_content)}")
|
||||||
|
|
||||||
# 发送提示消息
|
|
||||||
await self.bot.send_text_message(
|
|
||||||
group_id,
|
|
||||||
f"⏳ 正在生成 [{yesterday.strftime('%Y-%m-%d')}]聊天总结… 😊"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 生成总结
|
|
||||||
summary, image_path = await self._generate_summary(chat_content, group_name)
|
summary, image_path = await self._generate_summary(chat_content, group_name)
|
||||||
|
|
||||||
if image_path:
|
if image_path:
|
||||||
# 图片生成成功,发送图片
|
|
||||||
await self.bot.send_image_message(group_id, Path(image_path))
|
await self.bot.send_image_message(group_id, Path(image_path))
|
||||||
self.LOG.info(f"成功发送群 {group_name} 的昨日总结图片")
|
self.LOG.info(f"成功发送群 {group_name} 的昨日总结图片")
|
||||||
else:
|
else:
|
||||||
# 图片生成失败,发送文本消息
|
|
||||||
if summary and len(summary.strip()) > 0:
|
if summary and len(summary.strip()) > 0:
|
||||||
max_length = 2000
|
max_length = 2000
|
||||||
if len(summary) > max_length:
|
if len(summary) > max_length:
|
||||||
summary = summary[:max_length] + "\n\n... (内容过长,已截断)"
|
summary = summary[:max_length] + "\n\n... (内容过长,已截断)"
|
||||||
|
|
||||||
await self.bot.send_text_message(group_id, summary)
|
await self.bot.send_text_message(group_id, summary)
|
||||||
self.LOG.info(f"成功发送群 {group_name} 的昨日总结文本")
|
self.LOG.info(f"成功发送群 {group_name} 的昨日总结文本")
|
||||||
|
|
||||||
# 避免请求过快
|
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
|
except Exception as group_error:
|
||||||
except Exception as e:
|
self.LOG.error(f"处理群 {group_id} 总结时出错: {group_error}", exc_info=True)
|
||||||
self.LOG.error(f"为群 {group_id} 生成昨日总结失败: {e}", exc_info=True)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.LOG.info("每日聊天总结任务执行完成")
|
self.LOG.info("每日聊天总结任务执行完成")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.LOG.error(f"每日聊天总结任务执行失败: {e}", exc_info=True)
|
self.LOG.error(f"执行每日聊天总结任务时出错: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import asyncio
|
|||||||
import re
|
import re
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
META_KEYWORDS = ["群", "群名", "时间", "日期", "成员", "消息", "统计", "总结", "来源", "生成", "记录"]
|
||||||
|
|
||||||
|
|
||||||
async def safe_close_browser(browser, timeout: float = 4.0) -> None:
|
async def safe_close_browser(browser, timeout: float = 4.0) -> None:
|
||||||
if not browser:
|
if not browser:
|
||||||
@@ -67,35 +69,44 @@ async def safe_close_browser(browser, timeout: float = 4.0) -> None:
|
|||||||
logger.warning(f"force kill failed: {e}")
|
logger.warning(f"force kill failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(html: str) -> str:
|
||||||
|
return re.sub(r'\s+', ' ', re.sub(r'<.*?>', ' ', html)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_meta(html: str) -> bool:
|
||||||
|
clean = _clean_text(html)
|
||||||
|
if not clean:
|
||||||
|
return False
|
||||||
|
if any(k in clean for k in META_KEYWORDS):
|
||||||
|
return True
|
||||||
|
return len(clean) <= 80
|
||||||
|
|
||||||
|
|
||||||
def _split_hero(html_body: str):
|
def _split_hero(html_body: str):
|
||||||
title_match = re.search(r'<h1>(.*?)</h1>', html_body, re.S | re.I)
|
title_match = re.search(r'<h1>(.*?)</h1>', html_body, re.S | re.I)
|
||||||
hero_title = title_match.group(1).strip() if title_match else "聊天总结"
|
hero_title = _clean_text(title_match.group(1)) if title_match else "聊天总结"
|
||||||
remain = re.sub(r'<h1>.*?</h1>', '', html_body, count=1, flags=re.S | re.I).strip()
|
remain = re.sub(r'<h1>.*?</h1>', '', html_body, count=1, flags=re.S | re.I).strip()
|
||||||
|
|
||||||
paragraphs = re.findall(r'<p>(.*?)</p>', remain, re.S | re.I)
|
block_pattern = re.compile(r'^\s*(<(?:p|blockquote|ul|ol)[^>]*>.*?</(?:p|blockquote|ul|ol)>)', re.S | re.I)
|
||||||
meta_parts = []
|
meta_blocks = []
|
||||||
used = 0
|
for _ in range(4):
|
||||||
for para in paragraphs[:3]:
|
m = block_pattern.match(remain)
|
||||||
clean = re.sub(r'<.*?>', '', para).strip()
|
if not m:
|
||||||
if not clean:
|
|
||||||
continue
|
|
||||||
if len(clean) <= 80 or any(k in clean for k in ["群", "时间", "日期", "成员", "消息", "统计", "总结", "来源"]):
|
|
||||||
meta_parts.append(para.strip())
|
|
||||||
used += 1
|
|
||||||
else:
|
|
||||||
break
|
break
|
||||||
|
block = m.group(1)
|
||||||
|
if not _looks_like_meta(block):
|
||||||
|
break
|
||||||
|
meta_blocks.append(block.strip())
|
||||||
|
remain = remain[m.end():].strip()
|
||||||
|
|
||||||
hero_meta = "<br/>".join(meta_parts) if meta_parts else "群聊总结 / 自动生成"
|
hero_meta = ''.join(meta_blocks)
|
||||||
if used > 0:
|
hero_enabled = bool(title_match or meta_blocks)
|
||||||
remain = re.sub(r'^(\s*<p>.*?</p>){' + str(used) + r'}', '', remain, count=1, flags=re.S | re.I).strip()
|
return hero_title, hero_meta, remain, hero_enabled
|
||||||
|
|
||||||
return hero_title, hero_meta, remain
|
|
||||||
|
|
||||||
|
|
||||||
async def md_str_to_html_content(md_content):
|
async def md_str_to_html_content(md_content):
|
||||||
"""将 Markdown 字符串转换为更美观的 HTML 内容。"""
|
|
||||||
html_body = markdown.markdown(md_content, extensions=['extra', 'codehilite'])
|
html_body = markdown.markdown(md_content, extensions=['extra', 'codehilite'])
|
||||||
hero_title, hero_meta, remain_html = _split_hero(html_body)
|
hero_title, hero_meta, remain_html, hero_enabled = _split_hero(html_body)
|
||||||
|
|
||||||
css = """
|
css = """
|
||||||
<style>
|
<style>
|
||||||
@@ -164,9 +175,7 @@ async def md_str_to_html_content(md_content):
|
|||||||
height: 200px;
|
height: 200px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
box-shadow:
|
box-shadow: 0 0 0 24px rgba(255,255,255,0.04), 0 0 0 56px rgba(255,255,255,0.025);
|
||||||
0 0 0 24px rgba(255,255,255,0.04),
|
|
||||||
0 0 0 56px rgba(255,255,255,0.025);
|
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -181,7 +190,6 @@ async def md_str_to_html_content(md_content):
|
|||||||
border: 1px solid rgba(255,255,255,0.18);
|
border: 1px solid rgba(255,255,255,0.18);
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
letter-spacing: .06em;
|
letter-spacing: .06em;
|
||||||
backdrop-filter: none;
|
|
||||||
}
|
}
|
||||||
.hero-title {
|
.hero-title {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -203,13 +211,15 @@ async def md_str_to_html_content(md_content):
|
|||||||
font-size: 0.84em;
|
font-size: 0.84em;
|
||||||
line-height: 1.72;
|
line-height: 1.72;
|
||||||
}
|
}
|
||||||
.hero-meta p {
|
.hero-meta p, .hero-meta blockquote, .hero-meta ul, .hero-meta ol {
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
.content {
|
.hero-meta ul, .hero-meta ol { list-style: none; padding-left: 0; }
|
||||||
padding: 24px 34px 34px;
|
.content { padding: 24px 34px 34px; }
|
||||||
}
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
@@ -218,7 +228,7 @@ async def md_str_to_html_content(md_content):
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
h1 { display: none; }
|
.content.hero-active h1:first-of-type { display: none; }
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.42em;
|
font-size: 1.42em;
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
@@ -234,24 +244,11 @@ async def md_str_to_html_content(md_content):
|
|||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
border-left: 3px solid rgba(20,184,166,0.55);
|
border-left: 3px solid rgba(20,184,166,0.55);
|
||||||
}
|
}
|
||||||
p {
|
p { margin: 14px 0; color: #334155; line-height: 1.88; }
|
||||||
margin: 14px 0;
|
ul, ol { padding-left: 26px; margin: 14px 0 18px; }
|
||||||
color: #334155;
|
li { margin: 8px 0; color: #334155; }
|
||||||
line-height: 1.88;
|
|
||||||
}
|
|
||||||
ul, ol {
|
|
||||||
padding-left: 26px;
|
|
||||||
margin: 14px 0 18px;
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
margin: 8px 0;
|
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
li::marker { color: var(--primary); }
|
li::marker { color: var(--primary); }
|
||||||
strong {
|
strong { color: #1e293b; font-weight: 700; }
|
||||||
color: #1e293b;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
em { color: #5b6b84; }
|
em { color: #5b6b84; }
|
||||||
code {
|
code {
|
||||||
background: rgba(109,94,252,0.08);
|
background: rgba(109,94,252,0.08);
|
||||||
@@ -270,12 +267,7 @@ async def md_str_to_html_content(md_content):
|
|||||||
border: 1px solid rgba(255,255,255,0.06);
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
|
||||||
}
|
}
|
||||||
pre code {
|
pre code { background: transparent; color: inherit; border: none; padding: 0; }
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
table {
|
table {
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
@@ -287,17 +279,9 @@ async def md_str_to_html_content(md_content):
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 8px 24px rgba(15,23,42,0.05);
|
box-shadow: 0 8px 24px rgba(15,23,42,0.05);
|
||||||
}
|
}
|
||||||
th, td {
|
th, td { padding: 12px 14px; text-align: left; border-bottom: 1px solid rgba(148,163,184,0.12); }
|
||||||
padding: 12px 14px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid rgba(148,163,184,0.12);
|
|
||||||
}
|
|
||||||
tr:last-child td { border-bottom: none; }
|
tr:last-child td { border-bottom: none; }
|
||||||
th {
|
th { background: linear-gradient(180deg, rgba(109,94,252,0.10), rgba(109,94,252,0.04)); color: #334155; font-weight: 700; }
|
||||||
background: linear-gradient(180deg, rgba(109,94,252,0.10), rgba(109,94,252,0.04));
|
|
||||||
color: #334155;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
blockquote {
|
blockquote {
|
||||||
margin: 18px 0;
|
margin: 18px 0;
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
@@ -307,27 +291,22 @@ async def md_str_to_html_content(md_content):
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
color: #355468;
|
color: #355468;
|
||||||
}
|
}
|
||||||
hr {
|
hr { border: none; height: 1px; background: linear-gradient(90deg, transparent, rgba(148,163,184,0.35), transparent); margin: 26px 0; }
|
||||||
border: none;
|
a { color: var(--primary); text-decoration: none; border-bottom: 1px dashed rgba(109,94,252,0.35); }
|
||||||
height: 1px;
|
.signature { margin-top: 34px; text-align: right; color: #73849c; font-size: 0.95em; font-style: italic; }
|
||||||
background: linear-gradient(90deg, transparent, rgba(148,163,184,0.35), transparent);
|
|
||||||
margin: 26px 0;
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: var(--primary);
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: 1px dashed rgba(109,94,252,0.35);
|
|
||||||
}
|
|
||||||
.signature {
|
|
||||||
margin-top: 34px;
|
|
||||||
text-align: right;
|
|
||||||
color: #73849c;
|
|
||||||
font-size: 0.95em;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
hero_html = ''
|
||||||
|
content_class = 'content hero-active' if hero_enabled else 'content'
|
||||||
|
if hero_enabled:
|
||||||
|
hero_html = f'''
|
||||||
|
<div class="hero">
|
||||||
|
<div class="hero-badge">AI 群聊总结</div>
|
||||||
|
<h1 class="hero-title">{hero_title}</h1>
|
||||||
|
<div class="hero-meta">{hero_meta}</div>
|
||||||
|
</div>'''
|
||||||
|
|
||||||
full_html = f'''<html>
|
full_html = f'''<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
@@ -335,14 +314,9 @@ async def md_str_to_html_content(md_content):
|
|||||||
{css}
|
{css}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">{hero_html}
|
||||||
<div class="hero">
|
<div class="{content_class}">
|
||||||
<div class="hero-badge">AI 群聊总结</div>
|
{remain_html if hero_enabled else html_body}
|
||||||
<h1 class="hero-title">{hero_title}</h1>
|
|
||||||
<div class="hero-meta">{hero_meta}</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
{remain_html}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
@@ -378,12 +352,7 @@ async def html_to_image(html_content, output_image):
|
|||||||
browser_path = path
|
browser_path = path
|
||||||
break
|
break
|
||||||
|
|
||||||
launch_args = [
|
launch_args = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
|
||||||
"--no-sandbox",
|
|
||||||
"--disable-setuid-sandbox",
|
|
||||||
"--disable-dev-shm-usage",
|
|
||||||
"--disable-gpu"
|
|
||||||
]
|
|
||||||
|
|
||||||
if browser_path:
|
if browser_path:
|
||||||
logger.debug(f"Launch chromium with system chrome: {browser_path}")
|
logger.debug(f"Launch chromium with system chrome: {browser_path}")
|
||||||
@@ -393,29 +362,17 @@ async def html_to_image(html_content, output_image):
|
|||||||
browser = await p.chromium.launch(args=launch_args)
|
browser = await p.chromium.launch(args=launch_args)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
context = await browser.new_context(
|
context = await browser.new_context(viewport={"width": 780, "height": 960}, device_scale_factor=1.2)
|
||||||
viewport={"width": 780, "height": 960},
|
|
||||||
device_scale_factor=1.2
|
|
||||||
)
|
|
||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
|
|
||||||
logger.debug("Set page content")
|
logger.debug("Set page content")
|
||||||
await page.set_content(html_content, wait_until='domcontentloaded', timeout=15000)
|
await page.set_content(html_content, wait_until='domcontentloaded', timeout=15000)
|
||||||
|
|
||||||
logger.debug("Wait for fonts ready")
|
logger.debug("Wait for fonts ready")
|
||||||
await page.evaluate("document.fonts.ready")
|
await page.evaluate("document.fonts.ready")
|
||||||
await asyncio.sleep(0.2)
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
logger.debug(f"Take screenshot: output={output_image}")
|
logger.debug(f"Take screenshot: output={output_image}")
|
||||||
await page.screenshot(
|
await page.screenshot(path=output_image, full_page=True, timeout=15000, animations="disabled")
|
||||||
path=output_image,
|
|
||||||
full_page=True,
|
|
||||||
timeout=15000,
|
|
||||||
animations="disabled"
|
|
||||||
)
|
|
||||||
if not os.path.exists(output_image):
|
if not os.path.exists(output_image):
|
||||||
raise RuntimeError(f"截图失败,输出文件不存在: {output_image}")
|
raise RuntimeError(f"截图失败,输出文件不存在: {output_image}")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
logger.debug("Closing browser")
|
logger.debug("Closing browser")
|
||||||
await safe_close_browser(browser)
|
await safe_close_browser(browser)
|
||||||
@@ -431,24 +388,18 @@ async def convert_md_str_to_image(md_content: str, output_image: str, max_retrie
|
|||||||
output_image_path = temp_dir / output_image
|
output_image_path = temp_dir / output_image
|
||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
logger.debug(f"尝试第 {attempt + 1}/{max_retries} 次生成图片")
|
logger.debug(f"尝试第 {attempt + 1}/{max_retries} 次生成图片")
|
||||||
|
|
||||||
if output_image_path.exists():
|
if output_image_path.exists():
|
||||||
os.remove(str(output_image_path))
|
os.remove(str(output_image_path))
|
||||||
|
|
||||||
full_html = await md_str_to_html_content(md_content)
|
full_html = await md_str_to_html_content(md_content)
|
||||||
await html_to_image(full_html, str(output_image_path))
|
await html_to_image(full_html, str(output_image_path))
|
||||||
|
|
||||||
image_size = os.path.getsize(str(output_image_path))
|
image_size = os.path.getsize(str(output_image_path))
|
||||||
if image_size < 1024:
|
if image_size < 1024:
|
||||||
raise RuntimeError(f"图片生成异常,大小仅为: {image_size} bytes")
|
raise RuntimeError(f"图片生成异常,大小仅为: {image_size} bytes")
|
||||||
|
|
||||||
logger.info(f"图片成功生成:{output_image_path}")
|
logger.info(f"图片成功生成:{output_image_path}")
|
||||||
return str(output_image_path.resolve())
|
return str(output_image_path.resolve())
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_error = e
|
last_error = e
|
||||||
logger.warning(f"第 {attempt + 1} 次尝试失败: {e}")
|
logger.warning(f"第 {attempt + 1} 次尝试失败: {e}")
|
||||||
|
|||||||
Reference in New Issue
Block a user