diff --git a/plugins/openclaw/__init__.py b/plugins/openclaw/__init__.py deleted file mode 100644 index 11a9ba1..0000000 --- a/plugins/openclaw/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Import OpenClawPlugin from the current package -from .main import OpenClawPlugin - - -def get_plugin(): - """Return plugin instance.""" - return OpenClawPlugin() diff --git a/plugins/openclaw/config.toml b/plugins/openclaw/config.toml deleted file mode 100644 index 21365d9..0000000 --- a/plugins/openclaw/config.toml +++ /dev/null @@ -1,18 +0,0 @@ -[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 = "SKpDsX7w3pKh1iQU" -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 = "SKpDsX7w3pKh1iQU" - -# Optional IP allowlist for the callback server (empty = allow all) -allowed_ips = [] diff --git a/plugins/openclaw/main.py b/plugins/openclaw/main.py deleted file mode 100644 index 0a467fb..0000000 --- a/plugins/openclaw/main.py +++ /dev/null @@ -1,301 +0,0 @@ -# -*- 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 {} - logger.debug("openclaw data:{data}") - 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) diff --git a/plugins/openclaw/webhook.MD b/plugins/openclaw/webhook.MD deleted file mode 100644 index 91b20f4..0000000 --- a/plugins/openclaw/webhook.MD +++ /dev/null @@ -1,126 +0,0 @@ -你要的 IM实现逻辑清单(按这个做就能稳定接): - -入站推送到 OpenClaw -调用:POST /wechat_abot/webhook -Body:直接发你现在 message.py 的原始 JSON -Header:X-Wechat-Abot-Signature: sha256= -超时建议:3~5s;失败重试(指数退避) -签名实现(必须) -算法:HMAC-SHA256 -输入:HTTP原始body字节(不要重排JSON) -secret:和 channels.wechat_abot.webhookSecret 一致 -防重放:建议带时间戳并限制窗口(可选增强) -出站发送接口(你IM提供) -POST /messages/send -Header:Authorization: Bearer -入参:{ to, text, replyToMessageId? } -返回:{ messageId } -错误码要清晰(401/403/429/5xx) -幂等与去重 -入站按 MsgId/NewMsgId 去重(你侧做一次,插件侧也做一次更稳) -出站按你生成的 requestId/messageId 防重复发送 -群聊处理 -保持原始 Content.string(sender:\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 发消息函数 \ No newline at end of file