mirror of
https://github.com/Zippland/Bubbles.git
synced 2026-01-19 01:21:15 +08:00
转发功能
This commit is contained in:
26
README.MD
26
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`:触发转发的关键词,任何一个命中就会转发
|
||||
|
||||
#### 功能开关
|
||||
|
||||
您可以启用或禁用各种功能:
|
||||
|
||||
60
commands/keyword_triggers.py
Normal file
60
commands/keyword_triggers.py
Normal 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
|
||||
176
commands/message_forwarder.py
Normal file
176
commands/message_forwarder.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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": []}
|
||||
)
|
||||
|
||||
53
robot.py
53
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路由器处理消息(仅限私聊或@机器人)
|
||||
|
||||
Reference in New Issue
Block a user