@@ -101,6 +101,16 @@ class MessagePluginInterface(PluginInterface):
|
||||
raw_mode = runtime_config.get("message_dispatch_mode") or runtime_config.get("dispatch_mode") or "sync"
|
||||
return self.normalize_message_dispatch_mode(raw_mode)
|
||||
|
||||
def get_message_process_timeout_seconds(self, message: Dict[str, Any]) -> Optional[int]:
|
||||
"""返回当前消息建议使用的插件总超时秒数。
|
||||
|
||||
默认行为:
|
||||
1. 返回 `None`,表示继续沿用插件配置或机器人侧的自动推断逻辑;
|
||||
2. 适合“同一个插件里既有轻命令,也有重命令”的场景,避免所有命令共用同一个超时;
|
||||
3. 子类若需要按命令动态放宽超时,可覆盖本方法并返回正整数秒数。
|
||||
"""
|
||||
return None
|
||||
|
||||
# ---------------- 插件定时调度能力(可选实现) ----------------
|
||||
def get_schedule_actions(self) -> List[Dict[str, Any]]:
|
||||
"""返回插件支持的可调度动作定义列表。
|
||||
|
||||
@@ -591,6 +591,20 @@ class DouyuPlugin(MessagePluginInterface):
|
||||
self._status_check_retry_count = 3
|
||||
self._status_check_retry_delay_seconds = 1
|
||||
self._daily_report_llm_client: Optional[UnifiedLLMClient] = None
|
||||
# 斗鱼插件是典型“快命令 + 慢命令”混合体:
|
||||
# 1. 订阅/列表类命令基本都是 Redis 读写,应该继续走前台同步,保证即时反馈;
|
||||
# 2. 日报类命令会拉历史弹幕、调 LLM、渲染图片,天然属于长任务;
|
||||
# 3. 因此这里把“慢命令名单”集中收口,供分发模式与超时策略共同复用。
|
||||
self._background_report_commands = {
|
||||
"#斗鱼弹幕日报",
|
||||
"斗鱼弹幕日报",
|
||||
"#强制斗鱼弹幕日报",
|
||||
"强制斗鱼弹幕日报",
|
||||
"#斗鱼粉丝日报",
|
||||
"斗鱼粉丝日报",
|
||||
"#强制斗鱼粉丝日报",
|
||||
"强制斗鱼粉丝日报",
|
||||
}
|
||||
# 直播间语义画像:
|
||||
# 1. 允许按房间号补充“主播职业生涯、圈内关系、常见梗来源”等背景;
|
||||
# 2. 这些信息不会直接替代真实弹幕,只用于帮助 LLM 更准确理解圈内黑话;
|
||||
@@ -624,6 +638,36 @@ class DouyuPlugin(MessagePluginInterface):
|
||||
except Exception:
|
||||
return False, day_text
|
||||
|
||||
@staticmethod
|
||||
def _extract_command_token(message: Dict[str, Any]) -> str:
|
||||
"""从消息里提取首个命令词。"""
|
||||
content = str(message.get("content", "") or "").strip()
|
||||
return content.split()[0] if content else ""
|
||||
|
||||
def get_message_dispatch_mode(self, message: Dict[str, Any]) -> str:
|
||||
"""按命令决定斗鱼插件走前台还是后台。
|
||||
|
||||
设计说明:
|
||||
1. 订阅、取消订阅、列表查询都很轻,继续前台执行能保证手感;
|
||||
2. 日报命令一旦命中,后面会进入“查素材 -> 调模型 -> 渲染图片”的长链路;
|
||||
3. 因此只有日报相关命令切后台,避免它们把前台 20 个消息槽位长期占住。
|
||||
"""
|
||||
command = self._extract_command_token(message)
|
||||
if command in self._background_report_commands:
|
||||
return self.normalize_message_dispatch_mode("background")
|
||||
return super().get_message_dispatch_mode(message)
|
||||
|
||||
def get_message_process_timeout_seconds(self, message: Dict[str, Any]) -> Optional[int]:
|
||||
"""只为日报命令放宽总超时,普通命令继续走默认保护值。"""
|
||||
command = self._extract_command_token(message)
|
||||
if command in self._background_report_commands:
|
||||
# 用户已经明确存在 200 秒级长任务:
|
||||
# 1. 斗鱼日报除了 LLM,还包含素材整理与图片渲染;
|
||||
# 2. 这里放宽到 15 分钟,足够覆盖补发历史日报或高峰期模型排队;
|
||||
# 3. 非日报命令不受影响,仍保持原有更紧的保护策略。
|
||||
return 900
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_text_list(values: Any) -> List[str]:
|
||||
"""
|
||||
|
||||
@@ -59,6 +59,11 @@ class GameTaskPlugin(MessagePluginInterface):
|
||||
self.LOG = logger
|
||||
# 注册功能权限
|
||||
self.feature = self.register_feature()
|
||||
# 百科问答里只有“出题 / 判题”会真正走 LLM:
|
||||
# 1. `/t` 需要模型随机生成题目;
|
||||
# 2. `/a` 需要模型判分并给理由;
|
||||
# 3. `/s /r /l /h` 主要是本地 DB 读写,保持前台即可。
|
||||
self._background_commands = {"/t", "/a"}
|
||||
|
||||
def initialize(self, context: Dict[str, Any]) -> bool:
|
||||
"""初始化插件"""
|
||||
@@ -118,6 +123,22 @@ class GameTaskPlugin(MessagePluginInterface):
|
||||
|
||||
return command in self._commands
|
||||
|
||||
def get_message_dispatch_mode(self, message: Dict[str, Any]) -> str:
|
||||
"""只把 LLM 型命令切到后台,避免百科插件拖慢前台消息链。"""
|
||||
content = str(message.get("content", "") or "").strip()
|
||||
command = content.split()[0] if content else ""
|
||||
if command in self._background_commands:
|
||||
return self.normalize_message_dispatch_mode("background")
|
||||
return super().get_message_dispatch_mode(message)
|
||||
|
||||
def get_message_process_timeout_seconds(self, message: Dict[str, Any]) -> Optional[int]:
|
||||
"""为出题/判题命令单独放宽超时,兼容慢模型或排队场景。"""
|
||||
content = str(message.get("content", "") or "").strip()
|
||||
command = content.split()[0] if content else ""
|
||||
if command in self._background_commands:
|
||||
return 120
|
||||
return None
|
||||
|
||||
def calculate_game_points(self, message: Dict[str, Any], success: bool, response: str) -> int:
|
||||
"""计算游戏积分"""
|
||||
if not success:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import time # 添加这一行
|
||||
import time
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
|
||||
from base.plugin_common.message_plugin_interface import MessagePluginInterface
|
||||
@@ -146,31 +146,43 @@ class GlobalNewsPlugin(MessagePluginInterface):
|
||||
self._news_tasks[task_id] = thread
|
||||
self.LOG.info(f"启动新闻获取任务: {task_id}")
|
||||
|
||||
async def _fetch_news_thread(self, task_id: str, sender: str, roomid: str):
|
||||
"""在单独的线程中运行异步新闻获取任务"""
|
||||
def _fetch_news_thread(self, task_id: str, sender: str, roomid: str):
|
||||
"""在单独线程里执行新闻抓取主流程。
|
||||
|
||||
这里必须保持为同步函数:
|
||||
1. `threading.Thread(target=...)` 只能直接执行普通可调用对象;
|
||||
2. 之前把协程函数直接塞给 `target`,线程里只会得到一个未执行的 coroutine,任务实际上不会跑;
|
||||
3. 现在在线程内部显式创建事件循环,再把异步抓取和发消息协程跑完,才能真正脱离主链路执行。
|
||||
"""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
news_result = loop.run_until_complete(self._fetch_news_async())
|
||||
loop.close()
|
||||
|
||||
# 处理结果
|
||||
receiver = roomid if roomid else sender
|
||||
if news_result:
|
||||
# 发送新闻图片
|
||||
receiver = roomid if roomid else sender
|
||||
await self.bot.send_image_message(receiver, news_result)
|
||||
await self.bot.send_text_message("🌍全球新闻获取完成!", receiver, sender)
|
||||
# 在线程自有事件循环里把图片和完成提示真正发出去,
|
||||
# 避免这里只拿到 coroutine 对象却没有执行。
|
||||
loop.run_until_complete(self.bot.send_image_message(receiver, news_result))
|
||||
loop.run_until_complete(self.bot.send_text_message(receiver, "🌍全球新闻获取完成!", sender))
|
||||
else:
|
||||
await self.bot.send_text_message(
|
||||
(roomid if roomid else sender), "❌获取新闻失败,请稍后再试", sender)
|
||||
loop.run_until_complete(self.bot.send_text_message(receiver, "❌获取新闻失败,请稍后再试", sender))
|
||||
except Exception as e:
|
||||
self.LOG.error(f"新闻获取任务出错: {e}")
|
||||
await self.bot.send_text_message((roomid if roomid else sender), f"❌获取新闻出错: {str(e)}",
|
||||
sender)
|
||||
try:
|
||||
receiver = roomid if roomid else sender
|
||||
loop.run_until_complete(self.bot.send_text_message(receiver, f"❌获取新闻出错: {str(e)}", sender))
|
||||
except Exception as send_error:
|
||||
self.LOG.error(f"新闻获取失败后的通知发送异常: {send_error}")
|
||||
finally:
|
||||
# 清理任务
|
||||
if task_id in self._news_tasks:
|
||||
del self._news_tasks[task_id]
|
||||
try:
|
||||
loop.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _fetch_news_async(self) -> str:
|
||||
"""异步获取所有新闻源的新闻"""
|
||||
|
||||
@@ -48,3 +48,11 @@ history_group_summary_limit = 10
|
||||
max_output_chars = 320
|
||||
min_output_chars = 140
|
||||
sharpness_level = "high"
|
||||
|
||||
[runtime]
|
||||
# 成员锐评命令需要同时查画像、拉最近消息,再走一次 LLM 生成:
|
||||
# 1. 这条链路比普通查询明显更重,而且用户已经接受“先提示处理中,再稍后出结果”的交互;
|
||||
# 2. 默认切到后台后,就不会因为某次模型慢响应把前台消息槽位卡住;
|
||||
# 3. 总超时放宽到 4 分钟,兼容群画像较大或模型排队的情况。
|
||||
message_dispatch_mode = "background"
|
||||
plugin_process_timeout_seconds = 240
|
||||
|
||||
@@ -3,4 +3,12 @@ enable = true
|
||||
commands = ["更新系统", "系统更新", "重启系统", "更新重启"]
|
||||
wait_time = 5
|
||||
# 设置管理员微信ID,只有这些ID可以执行更新操作
|
||||
shell_path= "/home/liuwei/abot/restart.sh"
|
||||
shell_path= "/home/liuwei/abot/restart.sh"
|
||||
|
||||
[runtime]
|
||||
# 系统更新属于典型后台维护任务:
|
||||
# 1. 命令命中后会执行重启脚本,整个过程可能持续几十秒到数分钟;
|
||||
# 2. 这类任务不应该长期占住前台消息并发槽位,否则会影响其他插件收消息;
|
||||
# 3. 因此默认切到后台执行,并把总超时放宽到 10 分钟。
|
||||
message_dispatch_mode = "background"
|
||||
plugin_process_timeout_seconds = 600
|
||||
|
||||
@@ -571,6 +571,11 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
self.mention_batch_size = 200
|
||||
self.mention_window_start_minutes = 20
|
||||
self.mention_window_end_minutes = 10
|
||||
# 身价排行里只有少数命令是真正的长任务:
|
||||
# 1. `社交关系图` 需要拼 HTML 再截图渲染;
|
||||
# 2. `重算身价` 会扫描整群候选成员并重写快照;
|
||||
# 3. 其他榜单/说明类命令基本是读库拼文本,不值得全部切到后台。
|
||||
self._background_commands = {"社交关系图", "重算身价"}
|
||||
|
||||
def initialize(self, context: Dict[str, Any]) -> bool:
|
||||
"""初始化插件与配置。"""
|
||||
@@ -639,6 +644,26 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
command = content.split(" ")[0]
|
||||
return command in self._commands
|
||||
|
||||
def get_message_dispatch_mode(self, message: Dict[str, Any]) -> str:
|
||||
"""按命令决定是否切入后台任务池。"""
|
||||
content = str(message.get("content", "") or "").strip()
|
||||
command = content.split()[0] if content else ""
|
||||
if command in self._background_commands:
|
||||
# 这两个命令明显比普通查询重很多:
|
||||
# 1. `社交关系图` 的瓶颈主要在模板渲染与截图;
|
||||
# 2. `重算身价` 会遍历群成员并批量回写快照;
|
||||
# 3. 改成后台后,轻量榜单查询就不会再被这类维护型命令拖住。
|
||||
return self.normalize_message_dispatch_mode("background")
|
||||
return super().get_message_dispatch_mode(message)
|
||||
|
||||
def get_message_process_timeout_seconds(self, message: Dict[str, Any]) -> Optional[int]:
|
||||
"""为重渲染/重算命令单独放宽总超时。"""
|
||||
content = str(message.get("content", "") or "").strip()
|
||||
command = content.split()[0] if content else ""
|
||||
if command in self._background_commands:
|
||||
return 240
|
||||
return None
|
||||
|
||||
@plugin_stats_decorator(plugin_name="身价排行")
|
||||
async def process_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
||||
"""处理用户命令入口。"""
|
||||
|
||||
@@ -4,4 +4,12 @@ command = ["黑丝视频", "黑丝", "来个黑丝", "搞个黑丝"]
|
||||
command-format = """
|
||||
🎬视频指令:
|
||||
黑丝
|
||||
"""
|
||||
"""
|
||||
|
||||
[runtime]
|
||||
# 视频插件会经历“拉取接口 -> 下载文件 -> 抽首帧 -> 发送视频”整条链路:
|
||||
# 1. 任一环节抖动都可能让处理时间明显长于普通文本命令;
|
||||
# 2. 切到后台后,慢下载不会再卡住前台消息处理;
|
||||
# 3. 总超时放宽到 4 分钟,兼容网络波动和大一点的视频文件。
|
||||
message_dispatch_mode = "background"
|
||||
plugin_process_timeout_seconds = 240
|
||||
|
||||
@@ -6,4 +6,12 @@ command-format = """
|
||||
猛男
|
||||
肌肉
|
||||
帅哥
|
||||
"""
|
||||
"""
|
||||
|
||||
[runtime]
|
||||
# 猛男视频和普通视频插件的耗时结构基本一致:
|
||||
# 1. 需要先拉接口,再下载视频文件,并额外抽首帧做封面;
|
||||
# 2. 这些 IO/编解码步骤不适合长期占住前台并发槽位;
|
||||
# 3. 因此同样默认走后台模式,并保留 4 分钟总超时。
|
||||
message_dispatch_mode = "background"
|
||||
plugin_process_timeout_seconds = 240
|
||||
|
||||
26
robot.py
26
robot.py
@@ -674,7 +674,7 @@ class Robot:
|
||||
|
||||
# 检查插件是否可以处理该消息
|
||||
if plugin.can_process(plugin_msg):
|
||||
protection_policy = self._build_message_plugin_protection_policy(plugin)
|
||||
protection_policy = self._build_message_plugin_protection_policy(plugin, plugin_msg)
|
||||
acquire_result = self.plugin_manager.try_acquire_plugin_execution(
|
||||
plugin,
|
||||
recovery_seconds=protection_policy["circuit_recovery_seconds"],
|
||||
@@ -819,7 +819,7 @@ class Robot:
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def _build_message_plugin_protection_policy(self, plugin) -> dict:
|
||||
def _build_message_plugin_protection_policy(self, plugin, plugin_msg: dict = None) -> dict:
|
||||
"""构建消息插件执行保护策略。"""
|
||||
plugin_config = getattr(plugin, "_config", {}) or {}
|
||||
runtime_config = plugin_config.get("runtime", {}) if isinstance(plugin_config, dict) else {}
|
||||
@@ -827,12 +827,32 @@ class Robot:
|
||||
breaker_config = runtime_config.get("circuit_breaker", {}) if isinstance(runtime_config, dict) else {}
|
||||
breaker_config = breaker_config if isinstance(breaker_config, dict) else {}
|
||||
|
||||
dynamic_timeout = 0
|
||||
if plugin_msg and hasattr(plugin, "get_message_process_timeout_seconds"):
|
||||
try:
|
||||
# 允许插件按“当前消息内容”给出更精细的超时建议:
|
||||
# 1. 同一个插件里,日报/渲染/重算命令往往比普通查询慢很多;
|
||||
# 2. 以前只能给整个插件统一配一个超时,容易出现“轻命令超时过大、重命令超时不够”的两难;
|
||||
# 3. 这里把粒度放到单条消息,便于插件只给真正的长任务放宽保护时间。
|
||||
dynamic_timeout = self._safe_positive_int(
|
||||
plugin.get_message_process_timeout_seconds(plugin_msg),
|
||||
0,
|
||||
)
|
||||
except Exception as e:
|
||||
self.LOG.warning(
|
||||
self._trace_message(
|
||||
plugin_msg.get("full_wx_msg"),
|
||||
f"读取插件动态超时失败,已回退默认策略: plugin={plugin.name}, error={e}"
|
||||
)
|
||||
)
|
||||
|
||||
# 超时策略尽量遵循“显式配置优先,已有内部超时参数兜底”的思路:
|
||||
# 1. 新插件如果有特殊需求,只需要在 runtime / circuit_breaker 下声明自己的超时;
|
||||
# 2. 老插件不改代码也能自动复用现有的 request / llm / render 超时字段;
|
||||
# 3. 最终统一加一个缓冲区,避免外层 wait_for 比插件内部自己的超时还更早打断。
|
||||
explicit_timeout = (
|
||||
runtime_config.get("plugin_process_timeout_seconds")
|
||||
dynamic_timeout
|
||||
or runtime_config.get("plugin_process_timeout_seconds")
|
||||
or runtime_config.get("message_timeout_seconds")
|
||||
or breaker_config.get("timeout_seconds")
|
||||
or getattr(plugin, "plugin_process_timeout_seconds", 0)
|
||||
|
||||
Reference in New Issue
Block a user