完整 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:
537
src/process.py
537
src/process.py
@ -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
|
||||
|
||||
Reference in New Issue
Block a user