From 4448f46a81f4360b8c429ad7397202567c6612c3 Mon Sep 17 00:00:00 2001 From: zihanjian Date: Thu, 13 Nov 2025 11:53:57 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BD=AC=E5=8F=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.MD | 26 +++++ commands/keyword_triggers.py | 60 ++++++++++++ commands/message_forwarder.py | 176 ++++++++++++++++++++++++++++++++++ config.yaml.template | 13 +++ configuration.py | 4 + robot.py | 53 +++++----- 6 files changed, 306 insertions(+), 26 deletions(-) create mode 100644 commands/keyword_triggers.py create mode 100644 commands/message_forwarder.py diff --git a/README.MD b/README.MD index f8eb8a3..6d8043b 100644 --- a/README.MD +++ b/README.MD @@ -39,6 +39,7 @@ Bubbles 是一个功能丰富的微信机器人框架,基于 [wcferry](https:/ ## 🔩 更新日志 +- 2025-11-13 新增群聊关键词转发配置,并将“想想/总结”等固定触发词重构为可扩展组件 - 2025-10-29 支持在群里 /set /persona 来设置群聊的人设 - 2025-10-23 赋予 AI 可以主动进行回复的能力 - 2025-10-13 由 AI 自主判断回复需要用 flash 还是 resoning 的模型 @@ -93,6 +94,11 @@ Bubbles 是一个功能丰富的微信机器人框架,基于 [wcferry](https:/ - 自动接受好友请求并打招呼(可通过 `auto_accept_friend_request` 配置开启) - 自动响应群聊和私聊消息 +#### 🔁 关键词转发 +- 支持在 `message_forwarding` 中配置多个规则,监听指定群聊的关键词 +- 当命中关键词时,会携带原始内容和来源信息,将完整消息转发到目标群聊 +- 规则支持一个群聊转发到多个目标群,关键词可配置为单个或列表 + ## 🛠️ 安装指南 #### 系统要求 @@ -187,6 +193,26 @@ groups: `random_chitchat_probability` 表示该群的上限概率。命中一次后会直接清零,随后每条符合条件的群消息自动 +0.05 逐步回升,直至回到上限;未配置该字段的群默认关闭随机闲聊。 +#### 消息关键词转发 + +通过 `message_forwarding` 可以监听某些群聊的特定关键词,并把命中的整段消息转发到其他群聊: + +```yaml +message_forwarding: + enable: true + rules: + - source_room_id: "12345678@chatroom" # 需要监听的群 + target_room_ids: + - "87654321@chatroom" # 接受转发的群,可配置多个 + keywords: + - "紧急" + - "求助" +``` + +- `source_room_id`:需要监听的群聊 ID +- `target_room_ids`:接受转发的群 ID,支持单个或多个 +- `keywords`:触发转发的关键词,任何一个命中就会转发 + #### 功能开关 您可以启用或禁用各种功能: diff --git a/commands/keyword_triggers.py b/commands/keyword_triggers.py new file mode 100644 index 0000000..074ff5c --- /dev/null +++ b/commands/keyword_triggers.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from commands.context import MessageContext + + +@dataclass +class KeywordTriggerDecision: + reasoning_requested: bool = False + summary_requested: bool = False + + +class KeywordTriggerProcessor: + """Encapsulate keyword-triggered behaviors (e.g., reasoning and summary).""" + + def __init__(self, message_summary: Any, logger: Any) -> None: + self.message_summary = message_summary + self.logger = logger + + def evaluate(self, ctx: MessageContext) -> KeywordTriggerDecision: + raw_text = ctx.text or "" + text = raw_text.strip() + reasoning_requested = bool( + raw_text + and "想想" in raw_text + and (not ctx.is_group or ctx.is_at_bot) + ) + summary_requested = bool( + ctx.is_group + and ctx.is_at_bot + and text == "总结" + ) + return KeywordTriggerDecision( + reasoning_requested=reasoning_requested, + summary_requested=summary_requested, + ) + + def handle_summary(self, ctx: MessageContext) -> bool: + if not ctx.is_group: + return False + + if not self.message_summary: + ctx.send_text("总结功能尚未启用。") + return True + + chat_model = getattr(ctx, "chat", None) + try: + summary_text = self.message_summary.summarize_messages( + ctx.msg.roomid, + chat_model=chat_model, + ) + except Exception as exc: + if self.logger: + self.logger.error(f"生成聊天总结失败: {exc}", exc_info=True) + summary_text = "抱歉,总结时遇到问题,请稍后再试。" + + ctx.send_text(summary_text, "") + return True diff --git a/commands/message_forwarder.py b/commands/message_forwarder.py new file mode 100644 index 0000000..51d1bea --- /dev/null +++ b/commands/message_forwarder.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Sequence + +from commands.context import MessageContext + + +@dataclass +class ForwardRule: + source_room_id: str + target_room_ids: List[str] + keywords: List[str] + + def matches(self, haystacks: Sequence[str]) -> bool: + """Check whether any keyword is contained in the provided text pool.""" + if not self.keywords: + return False + + for keyword in self.keywords: + if not keyword: + continue + for item in haystacks: + if keyword in item: + return True + return False + + +class MessageForwarder: + """Forward specific group messages to other groups based on keywords.""" + + def __init__(self, robot: Any, config: Dict[str, Any], logger: Any) -> None: + self.robot = robot + self.logger = logger + config = config if isinstance(config, dict) else {} + self.enabled = bool(config.get("enable")) + self.rules: List[ForwardRule] = ( + self._build_rules(config.get("rules", [])) if self.enabled else [] + ) + self.rules_by_room: Dict[str, List[ForwardRule]] = {} + for rule in self.rules: + self.rules_by_room.setdefault(rule.source_room_id, []).append(rule) + + if self.enabled and not self.rules: + # 没有有效规则就视为未启用,避免无意义的检查 + self.enabled = False + if self.logger: + self.logger.warning("消息转发已启用,但未检测到有效规则,功能自动关闭。") + + def forward_if_needed(self, ctx: MessageContext) -> bool: + """Forward the message when it matches a rule.""" + if ( + not self.enabled + or not ctx.is_group + or not ctx.msg + or ctx.msg.from_self() + ): + return False + + room_id = ctx.msg.roomid + candidate_rules = self.rules_by_room.get(room_id) + if not candidate_rules: + return False + + haystacks = self._build_haystacks(ctx) + if not haystacks: + return False + + payload = self._extract_forward_payload(ctx) + if not payload: + return False + + triggered = False + for rule in candidate_rules: + if not rule.matches(haystacks): + continue + triggered = True + self._forward(rule, ctx, payload) + + return triggered + + def _build_rules(self, raw_rules: Sequence[Dict[str, Any]]) -> List[ForwardRule]: + rules: List[ForwardRule] = [] + + for raw in raw_rules or []: + if not isinstance(raw, dict): + continue + + source = raw.get("source_room_id") or raw.get("source") + targets = raw.get("target_room_ids") or raw.get("target_room_id") or raw.get( + "target" + ) + keywords = raw.get("keywords") or raw.get("keyword") + + normalized_targets = self._normalize_str_list(targets) + normalized_keywords = self._normalize_str_list(keywords) + + if not source or not normalized_targets or not normalized_keywords: + if self.logger: + self.logger.warning( + f"忽略无效的消息转发配置: source={source}, targets={targets}, keywords={keywords}" + ) + continue + + rules.append( + ForwardRule( + source_room_id=str(source).strip(), + target_room_ids=normalized_targets, + keywords=normalized_keywords, + ) + ) + + return rules + + @staticmethod + def _normalize_str_list(value: Any) -> List[str]: + if isinstance(value, str): + cleaned = value.strip() + return [cleaned] if cleaned else [] + if isinstance(value, (list, tuple, set)): + result = [] + for item in value: + if not isinstance(item, str): + continue + cleaned = item.strip() + if cleaned: + result.append(cleaned) + return result + return [] + + def _build_haystacks(self, ctx: MessageContext) -> List[str]: + haystacks: List[str] = [] + raw_content = getattr(ctx.msg, "content", None) + if isinstance(raw_content, str) and raw_content: + haystacks.append(raw_content) + if isinstance(ctx.text, str) and ctx.text: + haystacks.append(ctx.text) + return haystacks + + def _extract_forward_payload(self, ctx: MessageContext) -> str: + msg_type = getattr(ctx.msg, "type", None) + if msg_type == 49 and ctx.text: + return ctx.text + raw_content = getattr(ctx.msg, "content", "") + if isinstance(raw_content, str) and raw_content: + return raw_content + if isinstance(ctx.text, str): + return ctx.text + return "" + + def _forward(self, rule: ForwardRule, ctx: MessageContext, payload: str) -> None: + sender_name = getattr(ctx, "sender_name", ctx.msg.sender) + group_alias = self._resolve_group_alias(rule.source_room_id) + forward_message = ( + f"【转发自 {group_alias}|{sender_name}】\n" + f"{payload}" + ) + + for target_id in rule.target_room_ids: + try: + self.robot.sendTextMsg(forward_message, target_id) + if self.logger: + self.logger.info( + f"已将群 {rule.source_room_id} 的关键词消息转发至 {target_id}" + ) + except Exception as exc: + if self.logger: + self.logger.error( + f"转发消息到 {target_id} 失败: {exc}", + exc_info=True, + ) + + def _resolve_group_alias(self, room_id: str) -> str: + contacts = getattr(self.robot, "allContacts", {}) or {} + alias = contacts.get(room_id) + return alias or room_id diff --git a/config.yaml.template b/config.yaml.template index 74229c0..5c51358 100644 --- a/config.yaml.template +++ b/config.yaml.template @@ -78,6 +78,19 @@ groups: model: 8 max_history: 30 # 回顾最近30条消息 +message_forwarding: + enable: false # 是否开启转发功能 + rules: + - source_room_id: example12345@chatroom # 需要监听的群ID + target_room_ids: + - example67890@chatroom # 接受转发消息的群ID + keywords: + - "关键词1" + - "关键词2" + # - source_room_id: another_group@chatroom + # target_room_ids: ["target_group@chatroom"] + # keywords: ["需要的词"] + MAX_HISTORY: 300 # 记录数据库的消息历史 news: diff --git a/configuration.py b/configuration.py index f262ea2..bc08a7d 100644 --- a/configuration.py +++ b/configuration.py @@ -94,3 +94,7 @@ class Config(object): self.AUTO_ACCEPT_FRIEND_REQUEST = yconfig.get("auto_accept_friend_request", False) self.MAX_HISTORY = yconfig.get("MAX_HISTORY", 300) self.SEND_RATE_LIMIT = yconfig.get("send_rate_limit", 0) + self.MESSAGE_FORWARDING = yconfig.get( + "message_forwarding", + {"enable": False, "rules": []} + ) diff --git a/robot.py b/robot.py index 94a87b2..b95462f 100644 --- a/robot.py +++ b/robot.py @@ -35,6 +35,8 @@ from function.func_xml_process import XmlProcessor # 导入上下文及常用处理函数 from commands.context import MessageContext from commands.handlers import handle_chitchat # 导入闲聊处理函数 +from commands.keyword_triggers import KeywordTriggerProcessor +from commands.message_forwarder import MessageForwarder # 导入AI路由系统 from commands.ai_router import ai_router @@ -283,6 +285,14 @@ class Robot(Job): self.LOG.error(f"初始化人设管理器失败: {e}", exc_info=True) self.persona_manager = None + # 初始化关键词触发器与消息转发器 + self.keyword_trigger_processor = KeywordTriggerProcessor( + self.message_summary, + self.LOG, + ) + forwarding_conf = getattr(self.config, "MESSAGE_FORWARDING", {}) + self.message_forwarder = MessageForwarder(self, forwarding_conf, self.LOG) + @staticmethod def value_check(args: dict) -> bool: if args: @@ -312,41 +322,32 @@ class Robot(Job): setattr(ctx, 'specific_max_history', specific_limit) persona_text = fetch_persona_for_context(self, ctx) setattr(ctx, 'persona', persona_text) - ctx.reasoning_requested = bool( - ctx.text - and "想想" in ctx.text - and (not ctx.is_group or ctx.is_at_bot) - ) + trigger_decision = None + if getattr(self, "keyword_trigger_processor", None): + trigger_decision = self.keyword_trigger_processor.evaluate(ctx) + ctx.reasoning_requested = trigger_decision.reasoning_requested + setattr(ctx, 'keyword_trigger_decision', trigger_decision) + else: + ctx.reasoning_requested = bool(getattr(ctx, 'reasoning_requested', False)) + + if getattr(self, "message_forwarder", None): + try: + self.message_forwarder.forward_if_needed(ctx) + except Exception as forward_error: + self.LOG.error(f"消息转发失败: {forward_error}", exc_info=True) if handle_persona_command(self, ctx): return + if trigger_decision and trigger_decision.summary_requested: + if self.keyword_trigger_processor.handle_summary(ctx): + return + if ctx.reasoning_requested: self.LOG.info("检测到推理模式触发词,跳过AI路由,直接进入闲聊推理模式。") self._handle_chitchat(ctx, None) return - if ( - msg.from_group() - and ctx.is_at_bot - and (ctx.text or "").strip() == "总结" - ): - if self.message_summary: - chat_model = getattr(ctx, 'chat', None) - try: - summary_text = self.message_summary.summarize_messages( - msg.roomid, - chat_model=chat_model - ) - except Exception as summary_error: - self.LOG.error(f"生成聊天总结失败: {summary_error}", exc_info=True) - summary_text = "抱歉,总结时遇到问题,请稍后再试。" - else: - summary_text = "总结功能尚未启用。" - - ctx.send_text(summary_text, "") - return - handled = False # 5. 优先尝试使用AI路由器处理消息(仅限私聊或@机器人)