转发功能

This commit is contained in:
zihanjian
2025-11-13 11:53:57 +08:00
parent f1912a5b84
commit 4448f46a81
6 changed files with 306 additions and 26 deletions

View File

@@ -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`:触发转发的关键词,任何一个命中就会转发
#### 功能开关
您可以启用或禁用各种功能:

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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": []}
)

View File

@@ -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路由器处理消息仅限私聊或@机器人)