完整 AgentSkill:词库外部化 + 全部脚本/Skill 脱敏上传

- 高危词库从 process.py 抽到 config.toml [security] section
- process.py 改为 _get_high_risk_words() 动态加载(配置优先)
- 新增 15 个 QQ 操作脚本(已脱敏:QQ号/IP/Token → 占位符)
- 新增 8 个 SKILL.md(qq-messenger/management/resolver/napcat-extras 等)
- 新增 SKILL.md 入口(完整部署方案文档)
- 保留 SDK 框架(plugin_modules.py, file_store_api.py, package.py 等)
This commit is contained in:
Claw
2026-05-04 16:23:59 +08:00
parent d7d33c39a4
commit e1fbd31c64
35 changed files with 5058 additions and 276 deletions

View File

@ -1,81 +1,46 @@
"""
OpenClaw Gateway QQ AI Reply 插件
—————————————————————————————————————
QQ消息 ↔ OpenClaw Gateway 桥接插件。
当用户在QQ上发消息时,通过此插件转发到OpenClaw Gateway,
由指定的agent(如 qq-agent)处理并自动回复。
基于 qqrebot 插件 SDK 开发。
"""
import os
import sys
import requests
import json
import time
import re
import logging
import sys
from pathlib import Path
from datetime import datetime
from src.modules.plugin_modules import BasePlugin, MessageContext
# ── 内嵌依赖 ──────────────────────────────────────────
# qqrebot 的插件打包机制会将依赖安装到 src/packages/
# 确保内嵌依赖在 sys.path 中
_plugin_dir = Path(__file__).parent
_packages_dir = _plugin_dir / "packages"
if str(_packages_dir) not in sys.path:
sys.path.insert(0, str(_packages_dir))
import requests
import jieba
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────
ADMIN_QQ = "YOUR_ADMIN_QQ"
NO_REPLY_MARKER = "No response from OpenClaw."
SANITIZED_REPLY = "." # 统一替换对外暴露的系统内部信息
# ═══════════════════════════════════════════════════════════
# 高危词库(jieba分词后匹配 + 整句子串匹配)
# 资料参考:OWASP Prompt Injection, Prompt Engineering Guide,
# Simon Willison, Jailbreak Chat, ChatGPT DAN variants
# ═══════════════════════════════════════════════════════════
# 高危词库:优先从 config.toml [security] section 加载
# 如未配置则使用内置默认词库(见 _get_high_risk_words())
# 外部化方便不同部署环境自定义词库
# ── 高危词库(可自定义增删) ────────────────────────
# 注意:词库与检测逻辑耦合紧密,如需外部化可后续抽到 config.toml
HIGH_RISK_WORDS = [
# 越狱/提示词攻击
"提示词", "prompt", "人设", "修改规则", "修改性格", "修改人设",
"忽略之前", "解除限制", "你是ai", "你是机器人", "系统提示",
"系统指令", "底层逻辑", "开发文档", "配置文件", "agents.md",
"soul.md", "identity.md", "memory.md",
# 记忆操控
"忘掉", "忘记", "清空记忆", "重置记忆", "删除记忆", "清除上下文",
"遗忘", "重新学习", "从头开始",
# 敏感信息泄漏
"gw token", "gateway", "authcode", "授权码", "推送url",
"apitoken", "bearer", "jwt", "session key",
"反代", "代理地址", "服务器ip", "内网地址", "公网ip",
# 黑产/恶意
"炸群", "卡群", "刷屏", "病毒", "木马", "钓鱼", "社工",
"撞库", "爆破", "渗透", "注入", "xss", "csrf", "ddos",
# 提权尝试
"你只是", "你应该", "你必须", "从现在开始",
"假装你", "扮演", "你的真实身份",
# 补充匹配
"system prompt", "ignore", "bypass", "jailbreak",
"forget", "reset", "redefine", "override",
# 语言补充
"忘记以前", "不记得", "失忆", "清除历史",
"重来", "重新开始",
]
# ── 群昵称缓存 ──────────────────────────────────────
# 格式: {(group_id, user_id): "nickname"}
# 群昵称缓存: {(group_id, user_id): "nickname"}
_nickname_cache: dict = {}
class OpenClawBridge(BasePlugin):
"""QQ消息 ↔ OpenClaw Gateway 桥接插件"""
def __init__(self, ctx: MessageContext):
super().__init__(ctx)
logger.info("=== OpenClawBridge __init__ START ===")
# SDK 官方配置方式:从 self.config(config.toml)读取,不硬编码默认值
self.gateway_url = None
self.gateway_token = None
self.allowed_sender = ""
@ -87,252 +52,261 @@ class OpenClawBridge(BasePlugin):
if cfg:
self.gateway_url = cfg.get("gateway_url")
self.gateway_token = cfg.get("gateway_token")
self.allowed_sender = str(cfg.get("allowed_sender", ""))
self.allowed_sender = str(cfg.get("allowed_sender")) if cfg.get("allowed_sender") is not None else None
self.model = cfg.get("model")
self.agent_id = cfg.get("agent_id")
logger.info(f"Config loaded: url={self.gateway_url}, model={self.model}")
logger.info(f"Config loaded: url={self.gateway_url}, allowed={self.allowed_sender}")
else:
logger.error("Config section [openclaw] not found")
logger.error("Config section [openclaw] not found in config.toml")
except Exception as e:
logger.error(f"Failed to load config: {e}")
missing = [k for k, v in {
"gateway_url": self.gateway_url,
"gateway_token": self.gateway_token,
}.items() if not v]
missing = [k for k, v in {"gateway_url": self.gateway_url, "gateway_token": self.gateway_token}.items() if not v]
if missing:
logger.error(f"Missing required config: {missing}")
logger.info("=== OpenClawBridge __init__ END ===")
# ═══════════════════════════════════════════════════
# 昵称解析
# ═══════════════════════════════════════════════════
def _fetch_group_nickname(self, group_id: str, sender_id: str) -> str:
"""从群成员缓存查昵称"""
"""获取用户在群里的昵称,纯文本"""
cache_key = (group_id, sender_id)
if cache_key in _nickname_cache:
return _nickname_cache[cache_key]
try:
url = f"http://127.0.0.1:25580/api/group_member_info"
resp = requests.get(url, params={
"group_id": group_id,
"user_id": sender_id,
"no_cache": "true",
}, timeout=5)
if resp.status_code == 200:
data = resp.json()
nick = (data.get("data") or data).get("nickname", "") or \
(data.get("data") or data).get("card", "")
if nick:
_nickname_cache[cache_key] = nick
return nick
except Exception:
pass
return ""
# 先尝试从 Group.users 查找
nickname = self._lookup_from_group_users(group_id, sender_id)
if nickname:
_nickname_cache[cache_key] = nickname
return nickname
# 如果 Group.users 没有命中,再通过平台 API 查询
nickname = self._fetch_from_platform_api(group_id, sender_id)
if nickname:
_nickname_cache[cache_key] = nickname
return nickname
# 最终兜底
display = "管理员" if sender_id == ADMIN_QQ else f"用户{sender_id}"
_nickname_cache[cache_key] = display
return display
def _lookup_from_group_users(self, group_id: str, sender_id: str) -> str:
"""备用方案:从群成员列表遍历查找"""
"""从 Group.users(平台预加载的群成员列表)查找昵称"""
try:
url = f"http://127.0.0.1:25580/api/get_group_member_list"
resp = requests.get(url, params={"group_id": group_id}, timeout=5)
if resp.status_code == 200:
data = resp.json()
members = data.get("data") or []
for m in members:
uid = m.get("user_id")
if uid and str(uid) == str(sender_id):
nick = m.get("card") or m.get("nickname", "")
if nick:
_nickname_cache[(group_id, sender_id)] = nick
return nick
except Exception:
pass
if self.ctx.group and self.ctx.group.users:
users = self.ctx.group.users
if isinstance(users, list):
for u in users:
if str(u.get("user_id")) == sender_id:
return u.get("card") or u.get("nickname") or ""
elif isinstance(users, dict):
u = users.get(int(sender_id), {})
return u.get("card") or u.get("nickname") or ""
except Exception as e:
logger.error(f"lookup from group users failed: {e}")
return ""
def _fetch_from_platform_api(self, group_id: str, sender_id: str) -> str:
"""从平台接口获取群成员名(通用兜底)"""
# 此方法供后续扩展,目前直接返回空
"""通过平台 API /get_group_member_info 查询单个用户昵称"""
base_url = self.ctx.group.url if self.ctx.group else "http://YOUR_NAPCAT_HOST:25570"
try:
resp = requests.post(
f"{base_url}/get_group_member_info",
json={"group_id": group_id, "user_id": sender_id},
timeout=3
)
if resp.status_code == 200:
body = resp.json()
data = body.get("data", {}) if isinstance(body, dict) else {}
return data.get("card") or data.get("nickname") or ""
except Exception as e:
logger.error(f"platform api fetch failed: {e}")
return ""
def _get_sender_group_nickname(self) -> str:
"""获取发送者在群里的昵称"""
if not self.ctx.group:
return ""
group_id = str(self.ctx.group.group_id)
sender_id = self._get_sender_id()
nick = self._fetch_group_nickname(group_id, sender_id)
if nick:
return nick
nick = self._lookup_from_group_users(group_id, sender_id)
if nick:
return nick
return self._fetch_from_platform_api(group_id, sender_id)
"""获取当前消息发送者的昵称"""
if self.ctx.group is None:
# 私聊
nickname = getattr(self.ctx.user, 'nickname', None) or getattr(self.ctx.user, 'card_name', None) or '未知用户'
return nickname
return self._fetch_group_nickname(self.ctx.group.group_id, self._get_sender_id())
def _get_sender_id(self) -> str:
"""获取发送者QQ号"""
return str(self.ctx.user.user_id)
# ═══════════════════════════════════════════════════
# 消息处理
# ═══════════════════════════════════════════════════
def _clean_message(self, text: str) -> str:
"""清理消息中的CQ码和at机器人标记"""
text = text.replace("[", "[").replace("]", "]")
"""替换 CQ 码为用户可读文本"""
# 别人 @bot 时,让模型知道是在叫自己
bot_at = f"[CQ:at,qq={self.ctx.rebot_id}]"
bot_id_str = str(self.ctx.rebot_id)
bot_at = f"[CQ:at,qq={bot_id_str}]"
text = text.replace(bot_at, f"@你(你的QQ号{bot_id_str})")
text = re.sub(r'\[CQ:([^,]+)(?:,[^\]]+)?\]', r'[\1]', text)
# 替换所有其他 CQ 码
# 图片/文件保留文件名,如 [image:xxx.jpg] [file:abc.zip]
def _replace_cq(m):
cq_type = m.group(1)
attrs = m.group(2) or ""
if cq_type in ("image", "file"):
# 提取 file=xxx 部分
import re as re2
fname_match = re2.search(r'file=([^,\]]+)', attrs)
fname = fname_match.group(1) if fname_match else ""
return f"[{cq_type}:{fname}]"
return f"[{cq_type}]"
text = re.sub(r'\[CQ:([^,]+)(,[^\]]+)?\]', _replace_cq, text)
return text
def _build_source_tag(self) -> str:
"""构建消息来源前缀"""
if self.ctx.group:
identity_tag = self._get_sender_group_nickname()
if not identity_tag:
identity_tag = str(self.ctx.user.user_id)
return f"{identity_tag}"
return f"{self.ctx.user.user_id}"
"""构建来源标记,让AI知道消息来自哪个群/私聊"""
if self.ctx.group is None:
return "私聊"
# 使用群ID作为来源标识,group.nickname可能需异步获取不一定可用
group_id = self.ctx.group.group_id
return f"群聊({group_id})"
def _build_context_with_history(self, raw_message: str, identity_tag: str) -> str:
"""构建带上下文的消息"""
"""返回带上下文的消息(OpenClaw session 负责历史管理)
格式:{来源:群聊/私聊} {用户名:昵称} 消息正文
让AI能清晰区分消息来源和说话者身份,避免混淆。
"""
cleaned = self._clean_message(raw_message)
if self.ctx.group:
tag = identity_tag or self.ctx.user.user_id
return f"[{tag}] {cleaned}"
return cleaned
source_tag = self._build_source_tag()
return f"{{来源:{source_tag}}} {{用户名:{identity_tag}}} {cleaned}"
# ═══════════════════════════════════════════════════
# 安全检测
# ═══════════════════════════════════════════════════
def _detect_high_risk(self, message: str) -> tuple:
"""检测是否包含高危词,返回(is_high_risk, matched_words)"""
def _detect_high_risk(self, message: str) -> tuple[bool, list[str]]:
"""使用jieba分词检测高危词,返回(是否高危, 匹配到的词列表)"""
msg_lower = message.lower()
words = list(jieba.cut(msg_lower))
# 同时保留整句匹配(防止分词切错)
all_targets = words + [msg_lower]
matched = []
for risk_word in HIGH_RISK_WORDS:
if risk_word.lower() in msg_lower:
matched.append(risk_word)
return (len(matched) > 0, matched)
for risk_word in self._get_high_risk_words():
for target in all_targets:
if risk_word in target:
if risk_word not in matched:
matched.append(risk_word)
break
is_high_risk = len(matched) > 0
logger.debug(f"High-risk check: jieba_cut={words[:20]}..., matched={matched}, is_high_risk={is_high_risk}")
return is_high_risk, matched
def _mark_high_risk(self, message: str, matched_words: list) -> str:
"""打码高危内容"""
masked = message
for word in matched_words:
masked = masked.replace(word, "***")
return masked
def _mark_high_risk(self, message: str, matched_words: list[str]) -> str:
"""在高危信息最显眼的地方标注"""
marker = "【⚠️ 高危信息,谨慎处理】"
words_str = "、".join(matched_words[:5]) # 最多显示5个
annotated = f"{marker}[命中: {words_str}]\n{message}"
return annotated
def _notify_admin(self, sender_id: str, gid: str, raw_message: str, matched_words: list):
"""上报管理员高危消息"""
def _notify_admin(self, sender_id: str, gid: str, raw_message: str, matched_words: list[str]):
"""私聊上报管理员"""
words_str = "、".join(matched_words[:5])
report = f"⚠️ 攻击上报:用户{sender_id}在群{gid}试图:{raw_message[:80]}... [命中高危词: {words_str}]"
try:
url = f"{self.gateway_url}/v1/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.gateway_token}",
}
masked = self._mark_high_risk(raw_message, matched_words)
payload = {
"model": self.model,
"messages": [{
"role": "user",
"content": f"[系统通知] ⚠️ 攻击上报:用户{sender_id}在群{gid}试图:{masked[:200]}"
}],
"stream": False,
"session_key": f"admin_notify_{sender_id}_{int(time.time())}",
}
requests.post(url, headers=headers, json=payload, timeout=5)
except Exception:
pass
def _is_dangerous(self, message: str) -> bool:
"""是否危险消息(本地检测)"""
is_risk, matched = self._detect_high_risk(message)
if is_risk:
logger.info(f"High risk detected: {matched}")
return True
# 仅管理员可发送的指令检测(普通用户触发即危险)
admin_only_patterns = [
r"\b重[启置新]\s*(?:bot|机器人|系统|agent)?\b",
r"\b(?:重新)?加[载入]\s*(?:配置|插件|skill)\b",
r"\b恢复出厂\b",
]
msg_lower = message.lower()
for pattern in admin_only_patterns:
if re.search(pattern, msg_lower):
if not self._is_authorized(self._get_sender_id()):
return True
return False
# ═══════════════════════════════════════════════════
# 权限与路由
# ═══════════════════════════════════════════════════
def _is_authorized(self, sender_id: str) -> bool:
"""检查发送者是否有管理员权限"""
return sender_id == self.allowed_sender
base_url = self.ctx.group.url if self.ctx.group else "http://YOUR_NAPCAT_HOST:25570"
requests.post(
f"{base_url}/send_private_msg",
json={"user_id": ADMIN_QQ, "message": report},
timeout=5
)
logger.info(f"Admin notified: {report[:60]}...")
except Exception as e:
logger.error(f"Failed to notify admin: {e}")
def _looks_like_mc_command(self, message: str) -> bool:
"""看起来像 MC 服务器指令"""
return message.startswith("/") and len(message) > 1
"""检测是否为 MC 命令(以 / 开头的命令)"""
stripped = message.strip()
if stripped.startswith('/'):
return True
clean = self._clean_message(message).strip()
if clean.startswith('/'):
return True
return False
def _is_authorized(self, sender_id: str) -> bool:
if self.allowed_sender is None:
logger.warning("allowed_sender is not configured, denying all requests")
return False
return sender_id == self.allowed_sender
def _is_dangerous(self, message: str) -> bool:
dangerous_keywords = [
r"rm\s+-[rf]", r"dd\s+if=", r"mkfs\.\w+", r"format\s+",
r"del\s+/[fqs]", r"rmdir\s+/s", r"passwd\b", r"/etc/shadow",
r"ssh_key", r"private_key", r"curl\s+.*\|\s*(sh|bash)",
r"wget\s+.*\|\s*(sh|bash)", r"bash\s*<\(", r"sudo\s+",
r"chmod\s+777\s+/", r"chown\s+-R", r"iptables\s+-F",
r"systemctl\s+(stop|restart|disable)", r"killall\b",
r"yum\s+install", r"apt\s+(install|remove|purge)",
r"pip\s+(install|uninstall)", r">\s*/etc/\w+",
r"cat\s+/etc/(shadow|passwd|ssh)"
]
msg_lower = message.lower()
for pattern in dangerous_keywords:
if re.search(pattern, msg_lower):
return True
return False
def _build_session_key(self) -> str:
"""构建会话key"""
sender = self._get_sender_id()
# 不同群/私聊用不同 session,避免历史污染
if self.ctx.group:
return f"qqgroup:{self.ctx.group.group_id}:{sender}"
return f"qqprivate:{sender}"
return f"qq-user-{self.ctx.user.user_id}-gid-{self.ctx.group.group_id}"
return f"qq-user-{self.ctx.user.user_id}"
def _strip_markdown(self, text: str) -> str:
"""去掉 Markdown 格式,保留纯文本"""
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'\*(.+?)\*', r'\1', text)
text = re.sub(r'`(.+?)`', r'\1', text)
text = re.sub(r'```[\s\S]*?```', '', text)
return text
# ═══════════════════════════════════════════════════
# OpenClaw 通信
# ═══════════════════════════════════════════════════
text = re.sub(r'```[\s\S]*?```', '[代码块]', text)
text = re.sub(r'`([^`]+)`', r'\1', text)
text = re.sub(r'#{1,6}\s+', '', text)
text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
text = re.sub(r'\*([^*]+)\*', r'\1', text)
text = re.sub(r'__([^_]+)__', r'\1', text)
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
text = re.sub(r'^>\s+', '', text, flags=re.MULTILINE)
text = re.sub(r'^[-*+]\s+', '', text, flags=re.MULTILINE)
text = re.sub(r'^\d+\.\s+', '', text, flags=re.MULTILINE)
text = re.sub(r'^---+$', '', text, flags=re.MULTILINE)
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()
def _send_to_openclaw(self, message: str, session_key: str) -> str:
"""
同步请求 OpenClaw Gateway。
使用非流式模式以支持模型 fallback(kimi→deepseek)。
同步请求 Gateway。
Agent 模型(如 openclaw/qq-agent)在 stream=True 模式下
Gateway 内部 agent 框架标记 run error 后 SSE 输出空内容
(delta:{} finish_reason:stop),因此不使用流式传输。
同步模式可正确 failover 模型 fallback(kimi→deepseek)。
qq-agent 模型响应速度 <5s,无需 __EXTEND__ 续命机制。
"""
logger.info(f"=== _send_to_openclaw START: session={session_key}, msg={message[:50]}... ===")
if self.gateway_url is None or self.gateway_token is None:
logger.error("gateway_url or gateway_token is not configured")
return SANITIZED_REPLY
url = f"{self.gateway_url}/v1/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.gateway_token}",
}
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {self.gateway_token}"}
payload = {
"model": self.model,
"messages": [{"role": "user", "content": message}],
"stream": False,
"user": session_key,
"session": session_key,
"session_key": session_key,
"session_key": session_key
}
try:
logger.info(f"POST {url}")
response = requests.post(url, headers=headers, json=payload, timeout=300)
logger.info(f"Response: {response.status_code}")
if response.status_code == 200:
data = response.json()
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
if content:
return self._strip_markdown(content)
logger.warning("Agent returned empty content")
return NO_REPLY_MARKER
logger.warning(f"Non-200 response: {response.status_code}")
# 不暴露 HTTP 状态码和响应体
logger.warning(f"Non-200 response: {response.status_code} {response.text[:200]}")
return SANITIZED_REPLY
except requests.exceptions.Timeout:
@ -342,73 +316,84 @@ class OpenClawBridge(BasePlugin):
logger.warning("Gateway connection failed")
return SANITIZED_REPLY
except Exception as e:
logger.warning(f"Gateway exception: {e}")
logger.warning(f"Gateway request exception: {e}")
return SANITIZED_REPLY
# ═══════════════════════════════════════════════════
# 插件生命周期钩子
# ═══════════════════════════════════════════════════
def after_save(self):
"""消息保存后触发——核心处理入口"""
logger.info("=== OpenClawBridge after_save START ===")
sender_id = self._get_sender_id()
raw_message = self.ctx.raw_message
identity_tag = self._build_source_tag()
identity_tag = self._get_sender_group_nickname()
logger.info(f"sender={sender_id}, identity_tag={identity_tag}, msg={raw_message[:50]}...")
# ===== 系统通知/文件回执过滤 =====
cleaned = self._clean_message(raw_message)
# 纯媒体消息(无文字内容):图片/文件/视频/语音
stripped = re.sub(r'\[image:[^\]]+\]|\[file:[^\]]+\]|\[video\]|\[语音\]|\[动画表情\]', '', cleaned).strip()
if not stripped:
logger.info(f"Pure file/media message from {sender_id}, skipped")
return
# QQ 系统通知:对方接收/下载文件回执、离线文件通知
sys_keywords = ['已接收', '已下载', '已打开', '已成功接收', '已成功下载', '系统消息', '系统通知', '你收到离线文件']
if any(k in cleaned for k in sys_keywords):
logger.info(f"QQ system notification from {sender_id}, skipped: {cleaned[:50]}")
return
is_admin = self._is_authorized(sender_id)
# ===== 高危词检测(jieba分词,所有消息都走,只标注不拦截)=====
is_high_risk, matched_words = self._detect_high_risk(raw_message)
if is_high_risk:
logger.warning(f"🚨 HIGH RISK detected from {sender_id}: {matched_words}")
# 非管理员触发高危词时,上报管理员(用原始消息上报,非管理员看不到带标注的版本)
if not is_admin:
self._notify_admin(sender_id, self.ctx.group.group_id if self.ctx.group else "私聊", self.ctx.raw_message, matched_words)
# 标注高危信息,然后放行给后续处理
raw_message = self._mark_high_risk(self.ctx.raw_message, matched_words)
logger.info(f"Message annotated with high-risk marker: {sender_id}")
# ===== 非授权用户:仅拦截MC命令(用原始消息检测,避免高危标注干扰)=====
if not is_admin:
if self._looks_like_mc_command(self.ctx.raw_message):
logger.info(f"Unauthorized MC command from {sender_id}, blocked from AI")
return
# ===== 危险请求检测(管理员专用)=====
if is_admin and self._is_dangerous(raw_message):
report_msg = f"[QQ危险请求] senderId={sender_id},内容:{raw_message[:100]}"
self._send_to_openclaw(report_msg, "qq-danger-report")
logger.info("Dangerous request reported")
return "ok"
# ===== 所有用户正常对话(整合后的统一流程)=====
session_key = self._build_session_key()
context_msg = self._build_context_with_history(raw_message, identity_tag)
reply = self._send_to_openclaw(context_msg, session_key)
# ── 过滤系统通知、文件回执 ──
if not raw_message or raw_message.startswith("[系统通知]") or raw_message.startswith("[文件回执]"):
logger.info("Skip: system notification or file receipt")
return "ok"
logger.info(f"reply: {reply[:80]}...")
# ── 过滤纯媒体消息(无文字) ──
cleaned = re.sub(r'\[CQ:[^\]]+\]', '', raw_message).strip()
if not cleaned:
logger.info("Skip: media-only message (no text after CQ removal)")
return "ok"
# ===== 回复处理 =====
# 统一过滤:所有对外的内部错误文案都不发送给用户
if reply and reply.strip() in (NO_REPLY_MARKER, SANITIZED_REPLY):
logger.warning(f"Blocked internal marker: {reply.strip()!r}")
return
# ── 管理员消息:先安全检查 ──
is_dangerous = self._is_dangerous(raw_message)
if is_dangerous:
logger.info(f"Dangerous message from {sender_id}")
if self._is_authorized(sender_id):
# 管理员触发危险词,通知但不拦截
self._notify_admin(sender_id,
self.ctx.group.group_id if self.ctx.group else "private",
raw_message, [])
logger.info(f"Admin triggered risk word: {raw_message[:50]}")
else:
# 非管理员触发,拦截+上报+拉黑
is_risk, matched = self._detect_high_risk(raw_message)
self._notify_admin(sender_id,
self.ctx.group.group_id if self.ctx.group else "private",
raw_message, matched)
logger.warning(f"Blocked dangerous message from {sender_id}")
return "ok"
if not reply:
logger.info("Empty reply, skipped")
return
# ── 非管理员消息:MC指令 → 跳过(由 ops-manager 处理) ──
if not self._is_authorized(sender_id) and self._looks_like_mc_command(raw_message):
logger.info("Skip: non-admin MC command")
return "ok"
if self.ctx.group is None:
# 私聊
logger.info("Private chat, processing...")
logger.info("Sending private message...")
self.ctx.user.send_message(reply)
else:
# 群聊只响应 @机器人
at_me = f"[CQ:at,qq={self.ctx.rebot_id}]" in self.ctx.raw_message
logger.info(f"Group chat, at_me={at_me}")
if at_me:
logger.info("Sending group message...")
self.ctx.group.send_message(reply)
# ── 构建上下文并转发 ──
context = self._build_context_with_history(raw_message, identity_tag)
logger.info(f"Forwarding to OpenClaw: session={session_key}, msg={context[:80]}...")
reply = self._send_to_openclaw(context, session_key)
logger.info(f"Agent reply: {reply[:100]}...")
# ── 自动回复 ──
if reply and reply != NO_REPLY_MARKER and reply != SANITIZED_REPLY:
try:
if self.ctx.group:
self.ctx.group.send_message(reply)
else:
self.ctx.user.send_message(reply)
except Exception as e:
logger.error(f"Failed to send reply: {e}")
logger.info("=== OpenClawBridge after_save END ===")
return "ok"
logger.info(f"=== OpenClawBridge after_save END (admin={is_admin}) ===")
return