重构为 openclaw_bridge 模板插件

- 将 process.py 替换为 OpenClaw Gateway QQ AI Reply 插件
- 新增 config/openclawbridge/config.toml(SDK 标准路径)
- 遵循 SDK 官方配置方式,移除硬编码默认值
- 内置高危词检测、管理员白名单、MC指令过滤
- 完善的 README.md 文档(使用说明+架构图+安全特性)
- packup.sh 增加『必须在目标主机打包』警告
- 保留 SDK 框架文件(plugin_modules.py, file_store_api.py 等)
- 更新 test.py 适配 openclaw_bridge 测试

注意:打包必须在目标主机执行,避免 C 扩展兼容性问题
This commit is contained in:
Claw
2026-05-04 16:14:42 +08:00
parent b88e868160
commit 8c4c5074f2
7 changed files with 699 additions and 211 deletions

View File

@ -1,68 +1,414 @@
import threading
from src.modules.plugin_modules import BasePlugin, MessageContext
from openai import OpenAI
"""
OpenClaw Gateway QQ AI Reply 插件
—————————————————————————————————————
QQ消息 ↔ OpenClaw Gateway 桥接插件。
当用户在QQ上发消息时,通过此插件转发到OpenClaw Gateway,
由指定的agent(如 qq-agent)处理并自动回复。
基于 qqrebot 插件 SDK 开发。
"""
import os
import sys
import json
import time
import re
import logging
from pathlib import Path
from datetime import datetime
from src.modules.plugin_modules import BasePlugin, MessageContext
# ── 内嵌依赖 ──────────────────────────────────────────
# qqrebot 的插件打包机制会将依赖安装到 src/packages/
_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
logger = logging.getLogger(__name__)
class SiloconFlowAPI:
def __init__(self, api_key,modules,base_url):
self.api_key = api_key
self.module = modules
self.url = base_url
self.count = 0
def get_ai_message(self, message,maxcount,i=0):
with OpenAI(api_key=self.api_key, base_url=self.url) as client:
try:
response = client.chat.completions.create(
model=self.module[i],
messages=message,
temperature=0.7,
max_tokens=4096
)
print("token usage {0}".format(response.usage.total_tokens))
return response.choices[0].message.content
except:
if i<len(self.module)-1:
i+=1
self.count = self.count+1
return self.get_ai_message(message,i)
else:
print("server busy,waiting")
time.sleep(10)
i = 0
if(self.count>maxcount):
return " "
return self.get_ai_message(message,i)
# ── 常量 ──────────────────────────────────────────────
NO_REPLY_MARKER = "No response from OpenClaw."
SANITIZED_REPLY = "." # 统一替换对外暴露的系统内部信息
# ── 高危词库(可自定义增删) ────────────────────────
# 注意:词库与检测逻辑耦合紧密,如需外部化可后续抽到 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"}
_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 = ""
self.model = None
self.agent_id = None
try:
cfg = self.config.get("openclaw", {})
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.model = cfg.get("model")
self.agent_id = cfg.get("agent_id")
logger.info(f"Config loaded: url={self.gateway_url}, model={self.model}")
else:
logger.error("Config section [openclaw] not found")
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]
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 ""
def _lookup_from_group_users(self, group_id: str, sender_id: str) -> str:
"""备用方案:从群成员列表遍历查找"""
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
return ""
def _fetch_from_platform_api(self, group_id: str, sender_id: str) -> str:
"""从平台接口获取群成员名(通用兜底)"""
# 此方法供后续扩展,目前直接返回空
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)
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("&#91;", "[").replace("&#93;", "]")
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)
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}"
def _build_context_with_history(self, raw_message: str, identity_tag: str) -> str:
"""构建带上下文的消息"""
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
# ═══════════════════════════════════════════════════
# 安全检测
# ═══════════════════════════════════════════════════
def _detect_high_risk(self, message: str) -> tuple:
"""检测是否包含高危词,返回(is_high_risk, matched_words)"""
msg_lower = message.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)
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 _notify_admin(self, sender_id: str, gid: str, raw_message: str, matched_words: list):
"""上报管理员高危消息"""
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
def _looks_like_mc_command(self, message: str) -> bool:
"""看起来像 MC 服务器指令"""
return message.startswith("/") and len(message) > 1
def _build_session_key(self) -> str:
"""构建会话key"""
sender = self._get_sender_id()
if self.ctx.group:
return f"qqgroup:{self.ctx.group.group_id}:{sender}"
return f"qqprivate:{sender}"
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 通信
# ═══════════════════════════════════════════════════
def _send_to_openclaw(self, message: str, session_key: str) -> str:
"""
同步请求 OpenClaw Gateway。
使用非流式模式以支持模型 fallback(kimi→deepseek)。
"""
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}",
}
payload = {
"model": self.model,
"messages": [{"role": "user", "content": message}],
"stream": False,
"user": session_key,
"session": session_key,
"session_key": session_key,
}
try:
response = requests.post(url, headers=headers, json=payload, timeout=300)
if response.status_code == 200:
data = response.json()
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
if content:
return self._strip_markdown(content)
return NO_REPLY_MARKER
logger.warning(f"Non-200 response: {response.status_code}")
return SANITIZED_REPLY
except requests.exceptions.Timeout:
logger.warning("Gateway request timed out")
return SANITIZED_REPLY
except requests.exceptions.ConnectionError:
logger.warning("Gateway connection failed")
return SANITIZED_REPLY
except Exception as e:
logger.warning(f"Gateway exception: {e}")
return SANITIZED_REPLY
# ═══════════════════════════════════════════════════
# 插件生命周期钩子
# ═══════════════════════════════════════════════════
class Ai_reply(BasePlugin):
def after_save(self):
config = self.config.get("siloconflow")
maxcount = self.config.get("base").get("maxcount")
ai_server = SiloconFlowAPI(base_url= config.get("base_url"), api_key= config.get("api_key"), modules= config.get("modules"))
if self.ctx.group is None:
self.ctx.user.messages.append({'role': 'user',
'content':
f"当前用户为:{self.ctx.user.nickname},其uid为:{self.ctx.user.user_id},以上为背景信息,根据背景信息回复用户消息。用户消息:{self.ctx.raw_message}"})
typing_thread = threading.Thread(target=self.ctx.user.set_input_status, args=(1,))
typing_thread.start()
#获取ai返回值
message = ai_server.get_ai_message(self.ctx.user.messages,maxcount)
#结束刷新状态
self.ctx.user.signal = False
#回收线程
typing_thread.join()
#指示器归零
self.ctx.user.signal = True
#发送消息
self.ctx.user.send_message(message)
else:
if "CQ:at,qq={0}".format(self.ctx.rebot_id) in self.ctx.raw_message:
self.ctx.group.messages.append({'role': 'user',
'content':
f"群聊名称为:{self.ctx.group.nickname},用户在群中的近十条消息为:{self.ctx.group.current_user.messages},用户名叫:{self.ctx.group.current_user.nickname},以上为背景信息,根据背景信息回复消息。消息为:{self.ctx.raw_message}"})
message = ai_server.get_ai_message(self.ctx.group.messages,maxcount)
self.ctx.group.send_message(message)
return "ok"
"""消息保存后触发——核心处理入口"""
logger.info("=== OpenClawBridge after_save START ===")
sender_id = self._get_sender_id()
raw_message = self.ctx.raw_message
identity_tag = self._build_source_tag()
session_key = self._build_session_key()
# ── 过滤系统通知、文件回执 ──
if not raw_message or raw_message.startswith("[系统通知]") or raw_message.startswith("[文件回执]"):
logger.info("Skip: system notification or file receipt")
return "ok"
# ── 过滤纯媒体消息(无文字) ──
cleaned = re.sub(r'\[CQ:[^\]]+\]', '', raw_message).strip()
if not cleaned:
logger.info("Skip: media-only message (no text after CQ removal)")
return "ok"
# ── 管理员消息:先安全检查 ──
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"
# ── 非管理员消息: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"
# ── 构建上下文并转发 ──
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"