加入了openclaw插件,用于与openclaw对接

This commit is contained in:
liuwei
2026-03-03 09:02:32 +08:00
parent 469283e587
commit 8ba7bb57c0
6 changed files with 453 additions and 2 deletions

View File

@@ -14,7 +14,7 @@
<el-col :span="6">
<div class="user-avatar-column">
<div class="user-avatar-container">
<img :src="currentUser.data.avatar" alt="用户头像" style="width: 70px; height: 70px;" />
<img :src="currentUser.data.avatar" alt="用户头像" style="width: 100%; height: 100%;" />
</div>
</div>
</el-col>

View File

@@ -106,7 +106,7 @@ class MessageSummaryPlugin(MessagePluginInterface):
@plugin_stats_decorator(plugin_name="群聊总结")
@plugin_points_cost(10, "群聊总结消耗积分", FEATURE_KEY)
@group_feature_rate_limit(max_per_minute=1, feature_key=FEATURE_KEY)
@group_feature_rate_limit(max_per_minute=30, feature_key=FEATURE_KEY)
async def process_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
"""处理消息"""
try:

View File

@@ -0,0 +1,7 @@
# Import OpenClawPlugin from the current package
from .main import OpenClawPlugin
def get_plugin():
"""Return plugin instance."""
return OpenClawPlugin()

View File

@@ -0,0 +1,18 @@
[OpenClaw]
enable = true
# Command prefix that triggers forwarding to OpenClaw
command_prefix = "龙虾>"
# OpenClaw inbound webhook (forward WeChat messages to OpenClaw)
webhook_url = "http://192.168.2.142:18789/wechat_abot/webhook"
webhook_secret = "change_me"
webhook_timeout = 5
# HTTP server for OpenClaw to call back (send messages to WeChat)
server_host = "0.0.0.0"
server_port = 18790
bot_token = "change_me"
# Optional IP allowlist for the callback server (empty = allow all)
allowed_ips = []

300
plugins/openclaw/main.py Normal file
View File

@@ -0,0 +1,300 @@
# -*- coding: utf-8 -*-
import asyncio
import hmac
import hashlib
import json
import logging
import os
import threading
import time
from collections import deque
from typing import Dict, Any, Tuple, Optional, List
import requests
from flask import Flask, jsonify, request
from loguru import logger
from werkzeug.serving import make_server
from base.plugin_common.message_plugin_interface import MessagePluginInterface
from base.plugin_common.plugin_interface import PluginStatus
class OpenClawPlugin(MessagePluginInterface):
"""OpenClaw webhook bridge plugin."""
FEATURE_KEY = "OPENCLAW"
FEATURE_DESCRIPTION = "🦞 OpenClaw 指令转发 [龙虾>]"
@property
def name(self) -> str:
return "OpenClaw"
@property
def version(self) -> str:
return "1.0.0"
@property
def description(self) -> str:
return "将指定指令消息转发到 OpenClaw并提供回调接口发送微信消息"
@property
def author(self) -> str:
return "liu.wei"
@property
def command_prefix(self) -> Optional[str]:
return self._command_prefix
@property
def feature_key(self) -> Optional[str]:
return self.FEATURE_KEY
@property
def feature_description(self) -> Optional[str]:
return self.FEATURE_DESCRIPTION
def __init__(self):
super().__init__()
self.feature = self.register_feature()
self._command_prefix = "龙虾>"
self._recent_ids = deque(maxlen=200)
self._recent_ids_set = set()
self._recent_lock = threading.Lock()
self._server = None
self._server_thread = None
self._app = None
self._warned_no_token = False
def initialize(self, context: Dict[str, Any]) -> bool:
"""Initialize plugin."""
self.LOG = logger
cfg = self._config.get("OpenClaw", {})
self.enable = cfg.get("enable", True)
self._command_prefix = cfg.get("command_prefix", "龙虾>")
self.webhook_url = os.getenv(
"OPENCLAW_WEBHOOK_URL",
cfg.get("webhook_url", "http://127.0.0.1:18789/wechat_abot/webhook"),
)
self.webhook_secret = os.getenv(
"OPENCLAW_WEBHOOK_SECRET",
cfg.get("webhook_secret", ""),
)
self.webhook_timeout = cfg.get("webhook_timeout", 5)
self.server_host = cfg.get("server_host", "0.0.0.0")
self.server_port = cfg.get("server_port", 18790)
self.bot_token = os.getenv("OPENCLAW_BOT_TOKEN", cfg.get("bot_token", ""))
self.allowed_ips = cfg.get("allowed_ips", [])
self._app = self._create_app()
self.LOG.debug(f"[{self.name}] Initialized with webhook_url={self.webhook_url}")
return True
def start(self) -> bool:
"""Start plugin."""
if not self._app:
self.LOG.error(f"[{self.name}] Flask app not initialized")
self.status = PluginStatus.ERROR
return False
if self._server_thread and self._server_thread.is_alive():
self.status = PluginStatus.RUNNING
return True
self._server_thread = threading.Thread(target=self._run_server, daemon=True)
self._server_thread.start()
self.status = PluginStatus.RUNNING
self.LOG.debug(f"[{self.name}] Plugin started")
return True
def stop(self) -> bool:
"""Stop plugin."""
if self._server:
try:
self._server.shutdown()
except Exception as e:
self.LOG.error(f"[{self.name}] Server shutdown error: {e}")
self.status = PluginStatus.STOPPED
self.LOG.info(f"[{self.name}] Plugin stopped")
return True
def can_process(self, message: Dict[str, Any]) -> bool:
if not self.enable:
return False
content = str(message.get("content", "")).strip()
return content.startswith(self._command_prefix)
async def process_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
"""Forward message to OpenClaw when command is triggered."""
try:
content = str(message.get("content", "")).strip()
if not content.startswith(self._command_prefix):
return False, None
full_msg = message.get("full_wx_msg")
raw_data = getattr(full_msg, "raw_data", None)
if not isinstance(raw_data, dict):
self.LOG.warning(f"[{self.name}] Missing raw_data, fallback to message dict")
raw_data = {"content": content}
msg_id = self._extract_message_id(raw_data)
if msg_id and self._is_duplicate(msg_id):
self.LOG.info(f"[{self.name}] Duplicate message ignored: {msg_id}")
return True, "duplicate"
raw_body = json.dumps(
raw_data,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=False,
).encode("utf-8")
ok, info = await asyncio.to_thread(self._post_to_openclaw, raw_body)
if ok:
if msg_id:
self._mark_recent(msg_id)
return True, "forwarded"
return False, info
except Exception as e:
self.LOG.error(f"[{self.name}] Process message error: {e}")
return False, f"error: {e}"
def _create_app(self) -> Flask:
app = Flask(__name__)
# Reduce werkzeug logs
logging.getLogger("werkzeug").setLevel(logging.ERROR)
@app.get("/healthz")
def healthz():
return jsonify({"ok": True, "name": self.name, "ts": int(time.time())})
@app.post("/api/messages/send")
def api_messages_send():
if not self._ip_allowed(request):
return jsonify({"error": "forbidden"}), 403
auth = request.headers.get("Authorization", "")
if not self._verify_bearer(auth):
return jsonify({"error": "unauthorized"}), 401
data = request.get_json(silent=True) or {}
to = data.get("to")
text = data.get("text") or data.get("content")
reply_to = data.get("replyToMessageId")
if not to or not text:
return jsonify({"error": "missing to/text"}), 400
if not getattr(self, "bot", None) or not getattr(self.bot, "wxid", ""):
return jsonify({"error": "bot_not_ready"}), 503
at_user = data.get("at") or data.get("at_user") or data.get("atUser") or ""
ok, message_id = self._send_text_via_wechat_api(to, text, at_user)
if not ok:
return jsonify({"error": "send_failed", "detail": message_id}), 502
if reply_to:
self.LOG.debug(f"[{self.name}] replyToMessageId ignored: {reply_to}")
return jsonify({"messageId": message_id}), 200
return app
def _run_server(self):
self.LOG.info(f"[{self.name}] Webhook server starting: {self.server_host}:{self.server_port}")
try:
self._server = make_server(self.server_host, self.server_port, self._app)
self._server.serve_forever()
except Exception as e:
self.LOG.error(f"[{self.name}] Webhook server error: {e}")
def _post_to_openclaw(self, raw_body: bytes) -> Tuple[bool, str]:
headers = {"Content-Type": "application/json"}
if self.webhook_secret is not None:
signature = self._sign_sha256(raw_body, self.webhook_secret)
headers["X-Wechat-Abot-Signature"] = f"sha256={signature}"
try:
resp = requests.post(
self.webhook_url,
data=raw_body,
headers=headers,
timeout=self.webhook_timeout,
)
if resp.status_code >= 200 and resp.status_code < 300:
return True, f"ok:{resp.status_code}"
return False, f"status:{resp.status_code}"
except requests.RequestException as e:
self.LOG.error(f"[{self.name}] Webhook request failed: {e}")
return False, f"request_error:{e}"
def _send_text_via_wechat_api(self, to: str, text: str, at_user: Any = "") -> Tuple[bool, str]:
"""Use existing bot API to send message."""
if not getattr(self, "bot", None):
return False, "bot_not_ready"
try:
client_msg_id, create_time, new_msg_id = asyncio.run(
self.bot.send_text_message(to, text, at_user)
)
message_id = client_msg_id or new_msg_id or create_time
return True, str(message_id)
except Exception as e:
self.LOG.error(f"[{self.name}] bot.send_text_message error: {e}")
return False, str(e)
def _verify_bearer(self, auth_header: str) -> bool:
if not self.bot_token:
# If no token configured, allow (but log once)
if not self._warned_no_token:
self.LOG.warning(f"[{self.name}] bot_token not set, allowing all requests")
self._warned_no_token = True
return True
if not auth_header or not auth_header.startswith("Bearer "):
return False
token = auth_header[7:].strip()
return hmac.compare_digest(token, self.bot_token)
def _ip_allowed(self, req) -> bool:
if not self.allowed_ips:
return True
# Try X-Forwarded-For first
forwarded = req.headers.get("X-Forwarded-For", "")
if forwarded:
ip = forwarded.split(",")[0].strip()
else:
ip = req.remote_addr
return ip in self.allowed_ips
@staticmethod
def _sign_sha256(raw_body: bytes, secret: str) -> str:
return hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
@staticmethod
def _extract_message_id(raw_data: Dict[str, Any]) -> Optional[str]:
for key in ("MsgId", "NewMsgId", "ClientMsgId", "clientMsgId", "newMsgId"):
value = raw_data.get(key)
if value:
return str(value)
return None
def _is_duplicate(self, msg_id: str) -> bool:
with self._recent_lock:
return msg_id in self._recent_ids_set
def _mark_recent(self, msg_id: str):
with self._recent_lock:
if msg_id in self._recent_ids_set:
return
if len(self._recent_ids) >= self._recent_ids.maxlen:
old = self._recent_ids.popleft()
self._recent_ids_set.discard(old)
self._recent_ids.append(msg_id)
self._recent_ids_set.add(msg_id)

126
plugins/openclaw/webhook.MD Normal file
View File

@@ -0,0 +1,126 @@
你要的 IM实现逻辑清单按这个做就能稳定接
入站推送到 OpenClaw
调用POST /wechat_abot/webhook
Body直接发你现在 message.py 的原始 JSON
HeaderX-Wechat-Abot-Signature: sha256=<hmac_sha256(raw_body, webhookSecret)>
超时建议3~5s失败重试指数退避
签名实现(必须)
算法HMAC-SHA256
输入HTTP原始body字节不要重排JSON
secret和 channels.wechat_abot.webhookSecret 一致
防重放:建议带时间戳并限制窗口(可选增强)
出站发送接口你IM提供
POST <apiBase>/messages/send
HeaderAuthorization: Bearer <botToken>
入参:{ to, text, replyToMessageId? }
返回:{ messageId }
错误码要清晰401/403/429/5xx
幂等与去重
入站按 MsgId/NewMsgId 去重(你侧做一次,插件侧也做一次更稳)
出站按你生成的 requestId/messageId 防重复发送
群聊处理
保持原始 Content.stringsender:\n正文格式即可
插件会自动提取真实发送者并清理前缀
群里是否触发可在你侧先做过滤(如必须@机器人)
可靠性
出站接口支持重试(网络错误/502/503
429 限流要返回 retry_after
全链路打日志eventId、sender、conversationId、status、耗时
安全
webhook 只允许你的 IM 源IP可选
token/secret 不写死代码,走环境变量
关闭调试日志里的敏感字段token、签名原文
可以,给你一份 Python 直连接口参考逻辑(入站推 OpenClaw + 出站发送接口),你按自己项目替换发送实现就行。
import os
import json
import hmac
import hashlib
import requests
from flask import Flask, request, jsonify
app = Flask(__name__)
# ===== 配置 =====
OPENCLAW_WEBHOOK_URL = os.getenv("OPENCLAW_WEBHOOK_URL", "http://127.0.0.1:18789/wechat_abot/webhook")
OPENCLAW_WEBHOOK_SECRET = os.getenv("OPENCLAW_WEBHOOK_SECRET", "change_me")
IM_BOT_TOKEN = os.getenv("IM_BOT_TOKEN", "change_me") # 给 OpenClaw 插件鉴权用
# 你自己的 IM SDK/client 在这里初始化
# im_client = YourIMClient(...)
def sign_sha256(raw_body: bytes, secret: str) -> str:
return hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
def verify_bearer(auth_header: str, expected_token: str) -> bool:
if not auth_header or not auth_header.startswith("Bearer "):
return False
token = auth_header[7:].strip()
return hmac.compare_digest(token, expected_token)
# 1) 你 IM 收到消息后的入口(示例)
@app.post("/im/inbound")
def im_inbound():
# 这里 body 就是你 message.py 能解析的原始 JSON
raw = request.get_data() # 原始字节,签名必须用它
if not raw:
return jsonify({"error": "empty body"}), 400
# 直接转发给 OpenClaw 插件 webhook
sig = sign_sha256(raw, OPENCLAW_WEBHOOK_SECRET)
headers = {
"Content-Type": "application/json",
"X-Wechat-Abot-Signature": f"sha256={sig}",
}
try:
resp = requests.post(
OPENCLAW_WEBHOOK_URL,
data=raw,
headers=headers,
timeout=5
)
# 建议你自己加重试策略429/5xx
return jsonify({
"ok": resp.ok,
"status": resp.status_code,
"body": resp.json() if "application/json" in resp.headers.get("Content-Type", "") else resp.text
}), 200
except requests.RequestException as e:
return jsonify({"ok": False, "error": str(e)}), 502
# 2) 给 OpenClaw 插件调用的发送接口
@app.post("/api/messages/send")
def api_messages_send():
# 鉴权:对应 channels.wechat_abot.botToken
auth = request.headers.get("Authorization", "")
if not verify_bearer(auth, IM_BOT_TOKEN):
return jsonify({"error": "unauthorized"}), 401
data = request.get_json(silent=True) or {}
to = data.get("to")
text = data.get("text", "")
reply_to = data.get("replyToMessageId")
if not to or not text:
return jsonify({"error": "missing to/text"}), 400
# TODO: 这里换成你真实 IM 发送逻辑
# result = im_client.send_text(to=to, text=text, reply_to=reply_to)
# message_id = result["message_id"]
message_id = f"out_{hashlib.md5((to + text).encode()).hexdigest()[:12]}" # demo
return jsonify({"messageId": message_id}), 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8088, debug=False)
你只需要改两处:
/im/inbound挂到你现有消息接收流程里
/api/messages/send替换成你真实 IM 发消息函数