import base64 import hashlib import json from collections import deque import threading import time from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Tuple from urllib.parse import quote_plus from app.chain.message import MessageChain from app.command import Command from app.core.config import settings from app.core.event import Event, eventmanager from app.db.message_oper import MessageOper from app.log import logger from app.plugins import _PluginBase from app.schemas import CommingMessage, MessageResponse, Notification from app.schemas.types import EventType, MessageChannel from app.utils.http import RequestUtils from app.utils.string import StringUtils from fastapi.responses import Response from .ilink import ILinkClient, ILinkIncomingMessage class WechatClawBot(_PluginBase): """WeChat-ClawBot 插件(纯插件实现,不修改系统模块)。""" plugin_name = "WeChat-ClawBot" plugin_desc = ( "基于 OpenClaw/ClawBot 协议接入个人微信,支持扫码登录、消息通知转发与命令控制。" ) plugin_version = "0.2.1" plugin_author = "mijjjj" author_url = "https://github.com/mijjjj/MoviePilot-Plugins-WeChat-ClawBot" plugin_label = "微信,消息通知,clawBot" plugin_icon = "Wechat_A.png" plugin_order = 60 _CREDENTIALS_KEY = "credentials" _QRCODE_KEY = "qrcode" _QRCODE_COMMAND = "/wechatclawbot_qrcode" _MAX_LOG_ITEMS = 1000 _QRCODE_REFRESH_HINT_SECONDS = 5 _LOGIN_WATCH_SECONDS = 240 _LOGIN_WATCH_INTERVAL_SECONDS = 3 _MAX_API_RETRY_FAILURES = 10 _INCOMING_DEDUP_TTL_SECONDS = 120 _MAX_INCOMING_CACHE_ITEMS = 4096 def __init__(self): super().__init__() self._enabled = False self._config: Dict[str, Any] = {} self._client: Optional[ILinkClient] = None self._poll_thread: Optional[threading.Thread] = None self._poll_stop = threading.Event() self._lock = threading.Lock() self._logs = deque(maxlen=self._MAX_LOG_ITEMS) self._logs_lock = threading.Lock() self._qrcode_prepare_thread: Optional[threading.Thread] = None self._qrcode_prepare_started_at: int = 0 self._qrcode_prepare_lock = threading.Lock() self._command_login_wait_threads: Dict[str, threading.Thread] = {} self._command_login_wait_lock = threading.Lock() self._incoming_seen_cache: Dict[str, int] = {} self._incoming_seen_order = deque() self._incoming_seen_lock = threading.Lock() def _log(self, level: str, message: str): """记录插件日志到内存并输出到全局日志。""" timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") entry = { "time": timestamp, "level": (level or "INFO").upper(), "message": str(message), } with self._logs_lock: self._logs.append(entry) msg = f"[WechatClawBot] {message}" lv = (level or "info").lower() if lv == "debug": logger.debug(msg) elif lv == "warning": logger.warning(msg) elif lv == "error": logger.error(msg) else: logger.info(msg) @staticmethod def _default_config() -> Dict[str, Any]: return { "enabled": False, "base_url": "https://ilinkai.weixin.qq.com", "force_generate_qrcode": False, "admins": "", "notify_enabled": True, "notify_types": [], "command_enabled": True, "poll_timeout": 25, "reconnect_delay": 3, } def init_plugin(self, config: dict = None): cfg = self._default_config() if config: cfg.update(config) force_generate = bool(cfg.get("force_generate_qrcode")) if force_generate: # 强制生码开关按“一次性按钮”处理,执行后自动复位。 cfg["force_generate_qrcode"] = False self.update_config(cfg) self._config = cfg self._log("info", "插件配置已加载") self.stop_service() self._enabled = bool(cfg.get("enabled")) if not self._enabled: self._log("info", "插件未启用") return creds = self.get_data(self._CREDENTIALS_KEY) or {} token = creds.get("bot_token") if token: self._client = ILinkClient( base_url=creds.get("base_url") or cfg.get("base_url"), bot_token=token, account_id=creds.get("account_id"), sync_buf=creds.get("sync_buf"), log_func=self._log, ) self._start_polling() self._log("info", "检测到历史 token,启动轮询") else: self._log("info", "未检测到历史 token,等待扫码登录") if force_generate or not token: self._trigger_qrcode_prepare(force=force_generate, reason="init_plugin") def get_state(self) -> bool: return self._enabled @staticmethod def get_command() -> List[Dict[str, Any]]: return [ { "cmd": WechatClawBot._QRCODE_COMMAND, "event": EventType.PluginAction, "desc": "获取 WeChat-ClawBot 登录二维码", "category": "插件", "data": { "plugin_id": "WechatClawBot", "action": "get_qrcode", }, } ] def get_api(self) -> List[Dict[str, Any]]: return [ { "path": "/qrcode", "endpoint": self.get_qrcode, "methods": ["GET"], "summary": "获取 WeChat-ClawBot 登录二维码", }, { "path": "/qrcode/image", "endpoint": self.get_qrcode_image, "methods": ["GET"], "summary": "获取 WeChat-ClawBot 登录二维码图片", "allow_anonymous": True, }, { "path": "/status", "endpoint": self.get_status, "methods": ["GET"], "summary": "获取 WeChat-ClawBot 登录状态", }, { "path": "/logout", "endpoint": self.logout, "methods": ["POST"], "summary": "退出 WeChat-ClawBot 登录", }, { "path": "/test_connection", "endpoint": self.test_connection_api, "methods": ["GET"], "summary": "测试 WeChat-ClawBot 连接", }, { "path": "/logs", "endpoint": self.get_logs, "methods": ["GET"], "summary": "获取 WeChat-ClawBot 插件日志", }, { "path": "/logs/clear", "endpoint": self.clear_logs, "methods": ["POST"], "summary": "清空 WeChat-ClawBot 插件日志", }, ] def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: return [ { "component": "VForm", "content": [ # ── 行1:启用开关 + 强制生码开关 ────────────────────────── { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12, "md": 6}, "content": [ { "component": "VSwitch", "props": { "model": "enabled", "label": "启用 WeChat-ClawBot", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 6}, "content": [ { "component": "VSwitch", "props": { "model": "force_generate_qrcode", "label": "保存时强制刷新登录二维码", "hint": "开启后保存配置会立即刷新二维码,随后自动关闭该开关。", "persistent-hint": True, }, } ], }, ], }, # ── 行2:命令控制 + 通知转发开关 ────────────────────────── { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12, "md": 6}, "content": [ { "component": "VSwitch", "props": { "model": "command_enabled", "label": "允许微信命令控制", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 6}, "content": [ { "component": "VSwitch", "props": { "model": "notify_enabled", "label": "启用系统通知转发", }, } ], }, ], }, # ── 行3:轮询超时 + 重连间隔 ────────────────────────────── { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12, "md": 6}, "content": [ { "component": "VTextField", "props": { "model": "poll_timeout", "label": "轮询超时(秒)", "type": "number", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 6}, "content": [ { "component": "VTextField", "props": { "model": "reconnect_delay", "label": "重连间隔(秒)", "type": "number", }, } ], }, ], }, # ── 行4:ClawBot Base URL(全宽)───────────────────────── { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextField", "props": { "model": "base_url", "label": "ClawBot Base URL", "placeholder": "https://ilinkai.weixin.qq.com", }, } ], } ], }, # ── 行5:管理员用户ID(全宽)───────────────────────────── { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextarea", "props": { "model": "admins", "label": "管理员用户ID(逗号分隔)", "rows": 2, "placeholder": "多个ID用英文逗号分隔", }, } ], } ], }, # ── 行6:使用说明提示 ───────────────────────────────────── { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VAlert", "props": { "type": "info", "variant": "tonal", "text": ( "启用插件后,请前往插件详情页扫码登录微信。" "如需重新登录,可开启「保存时强制刷新登录二维码」后保存配置,或在详情页刷新二维码。" ), }, } ], } ], }, ], } ], self._default_config() def get_page(self) -> List[dict]: # 详情页只读取缓存状态,避免阻塞页面渲染。 status = self._build_status() connected = bool(status.get("connected")) qrcode_image_src = status.get("qrcode_image_src") preparing = False elapsed = 0 if self._enabled and not connected: qrcode = self.get_data(self._QRCODE_KEY) or {} need_prepare = (not qrcode.get("qrcode")) or self._qrcode_expired( qrcode.get("updated_at") ) self._trigger_qrcode_prepare(force=need_prepare, reason="page_open") preparing, elapsed = self._qrcode_prepare_state() status_lines = [ f"启用状态:{'已启用' if status.get('enabled') else '未启用'}", f"连接状态:{'已连接' if connected else '未连接'}", f"账号ID:{status.get('account_id') or '-'}", f"已互动用户数:{status.get('known_users') or 0}", f"二维码状态:{status.get('qrcode_status') or 'waiting'}", ] if preparing: status_lines.append("二维码任务:生成中") # ── 状态信息行 ───────────────────────────────────────────────────── content: List[dict] = [ { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VAlert", "props": { "type": "success" if connected else "info", "variant": "tonal", "text": "\n".join(status_lines), "style": "white-space: pre-line;", }, } ], } ], } ] if not self._enabled: content.append( { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VAlert", "props": { "type": "warning", "variant": "outlined", "text": "请先在插件配置中启用 WeChat-ClawBot 并保存,然后返回此页扫码登录。", }, } ], } ], } ) return [ { "component": "VCard", "props": { "title": "WeChat-ClawBot 登录", "variant": "outlined", }, "content": content, } ] if not connected: refresh_after = self._QRCODE_REFRESH_HINT_SECONDS if preparing: refresh_after = max(1, self._QRCODE_REFRESH_HINT_SECONDS - elapsed) # ── 扫码提示行 ───────────────────────────────────────────────── content.append( { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VAlert", "props": { "type": "warning", "variant": "outlined", "text": ( "请使用微信扫一扫下方二维码完成登录。\n" f"若二维码未显示或已失效,请在约 {refresh_after} 秒后刷新本页面。" ), }, } ], } ], } ) if qrcode_image_src: # ── 二维码图片居中行 ─────────────────────────────────────── content.append( { "component": "VRow", "props": {"justify": "center"}, "content": [ { "component": "VCol", "props": {"cols": "auto"}, "content": [ { "component": "VImg", "props": { "src": qrcode_image_src, "width": 280, "height": 280, "maxWidth": 280, "aspectRatio": 1, "cover": False, }, } ], } ], } ) elif preparing: content.append( { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VAlert", "props": { "type": "info", "variant": "outlined", "text": f"二维码正在后台生成中,请在约 {refresh_after} 秒后刷新本页面。", }, } ], } ], } ) else: content.append( { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VAlert", "props": { "type": "error", "variant": "outlined", "text": "二维码暂不可用,请检查 iLink 服务后刷新页面重试。", }, } ], } ], } ) return [ { "component": "VCard", "props": { "title": "WeChat-ClawBot 登录", "variant": "outlined", }, "content": content, } ] if preparing: status_lines.append("二维码任务: 生成中") content: List[dict] = [ { "component": "VAlert", "props": { "type": "success" if connected else "info", "variant": "tonal", "text": "\n".join(status_lines), }, } ] if not self._enabled: content.append( { "component": "VAlert", "props": { "type": "warning", "variant": "outlined", "text": "请先在插件配置中启用 WeChat-ClawBot 并保存,然后返回此页扫码登录。", }, } ) return [ { "component": "VCard", "props": { "title": "WeChat-ClawBot 登录", "variant": "outlined", }, "content": content, } ] if not connected: refresh_after = self._QRCODE_REFRESH_HINT_SECONDS if preparing: refresh_after = max(1, self._QRCODE_REFRESH_HINT_SECONDS - elapsed) content.append( { "component": "VAlert", "props": { "type": "warning", "variant": "outlined", "text": ( "请使用微信扫一扫下方二维码完成登录。\n" f"若二维码未显示或已失效,请在约 {refresh_after} 秒后刷新本页面。" ), }, } ) if qrcode_image_src: content.append( { "component": "VImg", "props": { "src": qrcode_image_src, "width": 280, "height": 280, "maxWidth": 280, "aspectRatio": 1, "cover": False, }, } ) elif preparing: content.append( { "component": "VAlert", "props": { "type": "info", "variant": "outlined", "text": f"二维码正在后台生成中,请在约 {refresh_after} 秒后刷新本页面。", }, } ) else: content.append( { "component": "VAlert", "props": { "type": "error", "variant": "outlined", "text": "二维码暂不可用,请检查 iLink 服务后刷新页面重试。", }, } ) return [ { "component": "VCard", "props": { "title": "WeChat-ClawBot 登录", "variant": "outlined", }, "content": content, } ] def get_module(self) -> Dict[str, Any]: # 仅接管列表类回包,普通通知通过 NoticeMessage 事件处理, # 避免劫持系统通用 post_message 链路。 return { "post_medias_message": self._module_post_medias_message, "post_torrents_message": self._module_post_torrents_message, "send_direct_message": self._module_send_direct_message, } def stop_service(self): self._poll_stop.set() if self._poll_thread and self._poll_thread.is_alive(): self._poll_thread.join(timeout=3) self._poll_thread = None self._log("info", "轮询服务已停止") def _invalidate_token(self, reason: str, force_qrcode: bool = True) -> bool: """判定 token 失效并清理登录凭据。""" creds = self.get_data(self._CREDENTIALS_KEY) or {} old_token = creds.get("bot_token") if not old_token: return False self._save_credentials( bot_token=None, account_id=None, sync_buf=None, user_login_tokens={}, ) self._client = None self._poll_stop.set() self._log("warning", f"{reason},已判定当前 token 失效并自动清理") if self._enabled and force_qrcode: self._trigger_qrcode_prepare(force=True, reason="token_invalid") return True def get_logs(self, limit: int = 200, level: Optional[str] = None): """获取插件日志。""" try: limit = int(limit) except Exception: limit = 200 limit = max(1, min(limit, self._MAX_LOG_ITEMS)) with self._logs_lock: logs = list(self._logs) if level: level_value = level.upper().strip() logs = [item for item in logs if item.get("level") == level_value] return { "success": True, "count": len(logs), "logs": logs[-limit:], } def clear_logs(self): """清空插件日志。""" with self._logs_lock: self._logs.clear() self._log("info", "插件日志已清空") return {"success": True} def _ensure_client(self) -> ILinkClient: if self._client: return self._client creds = self.get_data(self._CREDENTIALS_KEY) or {} self._client = ILinkClient( base_url=creds.get("base_url") or self._config.get("base_url"), bot_token=creds.get("bot_token"), account_id=creds.get("account_id"), sync_buf=creds.get("sync_buf"), timeout=20, log_func=self._log, ) return self._client def _save_credentials(self, **kwargs) -> None: creds = self.get_data(self._CREDENTIALS_KEY) or {} creds.update(kwargs) creds.setdefault("known_users", []) creds.setdefault("user_last_active", {}) creds.setdefault("user_context_tokens", {}) creds.setdefault("user_login_tokens", {}) self.save_data(self._CREDENTIALS_KEY, creds) def _build_status(self) -> Dict[str, Any]: creds = self.get_data(self._CREDENTIALS_KEY) or {} qrcode = self.get_data(self._QRCODE_KEY) or {} qrcode_id = qrcode.get("qrcode") qrcode_url = qrcode.get("qrcode_url") if not qrcode_url and qrcode_id: qrcode_url = self._compose_qrcode_url(qrcode_id) qrcode_image_src = qrcode.get("qrcode_image_src") if not qrcode_image_src and qrcode_id: qrcode_image_src = self._compose_qrcode_image_api_url( qrcode.get("updated_at") ) return { "enabled": self._enabled, "connected": bool(creds.get("bot_token")), "account_id": creds.get("account_id"), "known_users": len(creds.get("known_users") or []), "qrcode": qrcode_id, "qrcode_status": qrcode.get("status"), "qrcode_url": qrcode_url, "qrcode_image_src": qrcode_image_src, } @staticmethod def _compose_qrcode_url(qrcode: str) -> str: """由 qrcode id 组装可扫码链接。""" return f"https://liteapp.weixin.qq.com/q/7GiQu1?qrcode={qrcode}&bot_type=3" def _compose_qrcode_image_api_url(self, updated_at: Optional[int] = None) -> str: """详情页二维码图片使用插件匿名 API 渲染,避免前端直接加载网页链接。""" suffix = f"?ts={int(updated_at)}" if updated_at else "" return f"/api/v1/plugin/{self.__class__.__name__}/qrcode/image{suffix}" @staticmethod def _qrcode_expired(updated_at: Optional[int], ttl_seconds: int = 240) -> bool: """二维码有效期较短,详情页打开时按时间窗口自动刷新。""" if not updated_at: return True return (int(time.time()) - int(updated_at)) > ttl_seconds def _qrcode_prepare_state(self) -> Tuple[bool, int]: with self._qrcode_prepare_lock: if self._qrcode_prepare_thread and self._qrcode_prepare_thread.is_alive(): elapsed = max( 0, int(time.time()) - int(self._qrcode_prepare_started_at or 0) ) return True, elapsed return False, 0 def _trigger_qrcode_prepare(self, force: bool = False, reason: str = "page"): with self._qrcode_prepare_lock: if self._qrcode_prepare_thread and self._qrcode_prepare_thread.is_alive(): return self._qrcode_prepare_started_at = int(time.time()) self._qrcode_prepare_thread = threading.Thread( target=self._qrcode_prepare_worker, kwargs={"force": force, "reason": reason}, daemon=True, ) self._qrcode_prepare_thread.start() def _qrcode_prepare_worker(self, force: bool = False, reason: str = "page"): try: self._log("info", f"后台二维码任务启动: reason={reason}, force={force}") qr = self._ensure_qrcode(force=force) if qr.get("success"): self._log("info", "后台二维码任务完成") self._watch_login_status(reason=reason) else: self._log( "warning", f"后台二维码任务失败: {qr.get('message') or 'unknown error'}", ) except Exception as err: self._log("error", f"后台二维码任务异常: {err}") def _watch_login_status(self, reason: str = "page"): """二维码生成后短时观察扫码状态,扫码成功即启动消息轮询。""" if not self._enabled: return status = self._build_status() if status.get("connected"): return qrcode = self.get_data(self._QRCODE_KEY) or {} if not qrcode.get("qrcode"): return self._log("debug", f"后台登录状态监听启动: reason={reason}") result = self._wait_for_login_status(reason=reason) if result.get("connected"): self._log("info", "后台登录状态监听:检测到已登录") return qr_state = str(result.get("status") or "").lower() if qr_state in { "expired", "timeout", "canceled", "cancelled", "not_initialized", }: self._log("warning", f"后台登录状态监听结束: qrcode_status={qr_state}") return if qr_state == "watch_timeout": self._log("debug", "后台登录状态监听结束: 观察超时") return if qr_state == "error": self._log( "warning", f"后台登录状态监听异常: {result.get('message') or 'unknown error'}", ) return self._log("debug", f"后台登录状态监听结束: status={qr_state or 'unknown'}") def _wait_for_login_status( self, reason: str = "page", timeout_seconds: Optional[int] = None, interval_seconds: Optional[int] = None, expected_qrcode: Optional[str] = None, require_qrcode_scan: bool = False, ) -> Dict[str, Any]: """轮询登录状态直到成功、终止状态或超时。""" timeout = int(timeout_seconds or self._LOGIN_WATCH_SECONDS) interval = max(1, int(interval_seconds or self._LOGIN_WATCH_INTERVAL_SECONDS)) max_failures = self._MAX_API_RETRY_FAILURES retry_failures = 0 if timeout <= 0: timeout = self._LOGIN_WATCH_SECONDS status = self._build_status() if status.get("connected") and not require_qrcode_scan: return {"connected": True, "status": "connected", **status} qrcode = self.get_data(self._QRCODE_KEY) or {} qrcode_id = qrcode.get("qrcode") if not qrcode_id: return {"connected": False, "status": "not_initialized", **status} if expected_qrcode and str(qrcode_id) != str(expected_qrcode): return {"connected": False, "status": "replaced", **status} started_at = time.time() self._log("debug", f"登录状态等待启动: reason={reason}, timeout={timeout}s") while self._enabled and (time.time() - started_at) < timeout: if expected_qrcode: current_qrcode = (self.get_data(self._QRCODE_KEY) or {}).get("qrcode") if current_qrcode and str(current_qrcode) != str(expected_qrcode): return { "connected": False, "status": "replaced", **self._build_status(), } try: result = self.get_status(force_qrcode_check=require_qrcode_scan) except Exception as err: retry_failures += 1 self._log( "warning", f"登录状态接口异常,重试 {retry_failures}/{max_failures}: {err}", ) if retry_failures >= max_failures: self._invalidate_token( reason=f"登录状态接口连续异常重试超过 {max_failures} 次", force_qrcode=False, ) return { "connected": False, "status": "error", "message": f"登录状态接口连续异常重试超过 {max_failures} 次", **self._build_status(), } time.sleep(interval) continue qr_state = str( result.get("qrcode_status") or result.get("status") or "" ).lower() if not result.get("success") and qr_state not in { "expired", "timeout", "canceled", "cancelled", "not_initialized", }: retry_failures += 1 self._log( "warning", f"登录状态接口返回异常结果,重试 {retry_failures}/{max_failures}: status={qr_state or 'unknown'}", ) if retry_failures >= max_failures: self._invalidate_token( reason=f"登录状态接口连续异常重试超过 {max_failures} 次", force_qrcode=False, ) return { "connected": False, "status": "error", "message": f"登录状态接口连续异常重试超过 {max_failures} 次", **self._build_status(), } time.sleep(interval) continue retry_failures = 0 if result.get("connected"): return {"connected": True, "status": "connected", **result} if qr_state in { "expired", "timeout", "canceled", "cancelled", "not_initialized", }: return {"connected": False, "status": qr_state, **result} time.sleep(interval) if not self._enabled: return {"connected": False, "status": "disabled", **self._build_status()} return {"connected": False, "status": "watch_timeout", **self._build_status()} @staticmethod def _decode_data_image(data_image: str) -> Tuple[Optional[bytes], str]: if not data_image or not data_image.startswith("data:image/"): return None, "image/png" try: header, raw = data_image.split(",", 1) media_type = "image/png" if ":" in header and ";" in header: media_type = header.split(":", 1)[1].split(";", 1)[0] or media_type return base64.b64decode(raw), media_type except Exception: return None, "image/png" def _fetch_qrcode_png(self, qr_text: str) -> Optional[bytes]: if not qr_text: return None image_sources = [ f"https://api.qrserver.com/v1/create-qr-code/?size=320x320&format=png&data={quote_plus(qr_text)}", f"https://quickchart.io/qr?size=320&margin=1&text={quote_plus(qr_text)}", ] for source in image_sources: try: resp = RequestUtils(timeout=15).get_res(source) if not resp or resp.status_code != 200: continue content_type = (resp.headers.get("Content-Type") or "").lower() if "image" not in content_type: continue if resp.content: return resp.content except Exception: continue return None def get_qrcode_image(self, force: bool = False): if force: self._ensure_qrcode(force=True) qrcode = self.get_data(self._QRCODE_KEY) or {} raw = qrcode.get("qrcode_url") qrcode_id = qrcode.get("qrcode") if not raw and qrcode_id: raw = self._compose_qrcode_url(str(qrcode_id)) if not raw: return Response( content=b"qrcode not ready", media_type="text/plain", status_code=404 ) if str(raw).startswith("data:image/"): decoded, media_type = self._decode_data_image(str(raw)) if decoded: return Response( content=decoded, media_type=media_type, headers={ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0" }, ) img = self._fetch_qrcode_png(str(raw)) if not img: return Response( content=b"qrcode image build failed", media_type="text/plain", status_code=502, ) return Response( content=img, media_type="image/png", headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}, ) def _prepare_qrcode_for_page(self) -> Dict[str, Any]: """详情页展示前,尽可能准备好可显示的二维码和登录状态。""" status = self._build_status() if status.get("connected"): return status if not self._enabled: return status qrcode = self.get_data(self._QRCODE_KEY) or {} need_new_qr = not qrcode.get("qrcode") or self._qrcode_expired( qrcode.get("updated_at") ) if need_new_qr: qr = self._ensure_qrcode(force=True) if qr.get("success"): status = self._build_status() else: # 已有二维码时尝试刷新一次状态,便于扫码后详情页立刻反映连接结果 st = self.get_status() if st.get("success") or st.get("status"): status = self._build_status() return status def _ensure_qrcode(self, force: bool = False) -> Dict[str, Any]: """在可复用时优先返回现有二维码,必要时重新生成。""" qrcode = self.get_data(self._QRCODE_KEY) or {} if ( not force and qrcode.get("qrcode") and not self._qrcode_expired(qrcode.get("updated_at")) ): qrcode_id = qrcode.get("qrcode") qrcode_url = qrcode.get("qrcode_url") if not qrcode_url and qrcode_id: qrcode["qrcode_url"] = self._compose_qrcode_url(str(qrcode_id)) if not qrcode.get("qrcode_image_src") and qrcode_id: qrcode["qrcode_image_src"] = self._compose_qrcode_image_api_url( qrcode.get("updated_at") ) self.save_data(self._QRCODE_KEY, qrcode) return {"success": True, "reused": True, **qrcode} qr = self.get_qrcode() if qr.get("success"): qr["reused"] = False return qr def get_qrcode(self): self._log("info", "开始请求二维码") client = ILinkClient(base_url=self._config.get("base_url"), log_func=self._log) qr = client.get_qrcode() if not qr.get("success"): self._log( "warning", f"获取二维码失败: {qr.get('message') or 'unknown error'}" ) return qr qrcode_id = qr.get("qrcode") qrcode_url = qr.get("qrcode_url") if not qrcode_url and qrcode_id: qrcode_url = self._compose_qrcode_url(str(qrcode_id)) now_ts = int(time.time()) payload = { "qrcode": qrcode_id, "qrcode_url": qrcode_url, "qrcode_image_src": self._compose_qrcode_image_api_url(now_ts), "status": "waiting", "updated_at": now_ts, } self.save_data(self._QRCODE_KEY, payload) self._log("info", f"二维码获取成功: has_url={bool(qrcode_url)}") return {"success": True, **payload} def get_status(self, force_qrcode_check: bool = False): qrcode = self.get_data(self._QRCODE_KEY) or {} creds = self.get_data(self._CREDENTIALS_KEY) or {} previous_status = qrcode.get("status") if creds.get("bot_token") and not force_qrcode_check: return {"success": True, "status": "connected", **self._build_status()} qrcode_id = qrcode.get("qrcode") if not qrcode_id: self._log("warning", "状态查询失败:二维码尚未初始化") return { "success": False, "status": "not_initialized", **self._build_status(), } client = ILinkClient(base_url=self._config.get("base_url"), log_func=self._log) status = client.get_qrcode_status(str(qrcode_id)) current_status = status.get("status") qrcode["status"] = current_status qrcode["updated_at"] = int(time.time()) self.save_data(self._QRCODE_KEY, qrcode) token = status.get("token") if token: resolved_base_url = status.get("base_url") or self._config.get("base_url") self._save_credentials( bot_token=token, account_id=status.get("account_id"), base_url=resolved_base_url, sync_buf=None, ) self._client = ILinkClient( base_url=resolved_base_url, bot_token=token, account_id=status.get("account_id"), log_func=self._log, ) self._start_polling() self._log("info", "扫码登录成功,轮询已启动") else: if current_status != previous_status: self._log("debug", f"扫码状态: {current_status}") merged_status = self._build_status() if force_qrcode_check and not token: merged_status["connected"] = False return {"success": bool(status.get("success")), **merged_status} def logout(self): self.stop_service() self._client = None self.del_data(self._CREDENTIALS_KEY) self.del_data(self._QRCODE_KEY) self._log("info", "已退出登录并清理凭据") return {"success": True, "message": "已退出 WeChat-ClawBot 登录"} def test_connection_api(self): ok, message = self.test_connection() return {"success": ok, "message": message, **self._build_status()} def test_connection(self) -> Tuple[bool, str]: client = self._ensure_client() return client.test_connection() def _start_polling(self): if self._poll_thread and self._poll_thread.is_alive(): return self._poll_stop.clear() self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True) self._poll_thread.start() self._log("info", "后台轮询线程已启动") def _poll_loop(self): backoff = [1, 2, 5, 10, 30] attempt = 0 max_failures = self._MAX_API_RETRY_FAILURES while not self._poll_stop.is_set() and self._enabled: try: client = self._ensure_client() timeout = int(self._config.get("poll_timeout") or 25) messages, sync_buf, result = client.poll_updates( timeout_seconds=timeout ) if sync_buf is not None: self._save_credentials(sync_buf=sync_buf) if not result.get("success"): raise RuntimeError(result.get("message") or "poll failed") for msg in messages: self._handle_incoming(msg) attempt = 0 except Exception as err: attempt += 1 if attempt >= max_failures: self._log( "error", f"轮询接口连续异常重试超过 {max_failures} 次,准备清理 token", ) self._invalidate_token( reason=f"轮询接口连续异常重试超过 {max_failures} 次" ) break delay = backoff[min(max(attempt - 1, 0), len(backoff) - 1)] self._log("warning", f"轮询异常,{delay}s 后重试: {err}") self._poll_stop.wait(delay) def _is_admin(self, user_id: str) -> bool: admins = str(self._config.get("admins") or "").strip() if not admins: return True admin_list = [x.strip() for x in admins.split(",") if x.strip()] return str(user_id) in admin_list def _touch_user(self, user_id: str, context_token: Optional[str] = None): with self._lock: creds = self.get_data(self._CREDENTIALS_KEY) or {} known_users = creds.get("known_users") or [] if user_id not in known_users: known_users.append(user_id) user_last_active = creds.get("user_last_active") or {} user_last_active[user_id] = int(time.time()) user_context_tokens = creds.get("user_context_tokens") or {} if context_token: user_context_tokens[user_id] = str(context_token) self._save_credentials( known_users=known_users, user_last_active=user_last_active, user_context_tokens=user_context_tokens, ) def _update_user_login_token(self, user_id: str) -> bool: user_id = str(user_id or "").strip() if not user_id: return False creds = self.get_data(self._CREDENTIALS_KEY) or {} bot_token = creds.get("bot_token") if not bot_token: return False known_users = creds.get("known_users") or [] if user_id not in known_users: known_users.append(user_id) user_last_active = creds.get("user_last_active") or {} user_last_active[user_id] = int(time.time()) user_login_tokens = creds.get("user_login_tokens") or {} user_login_tokens[user_id] = str(bot_token) self._save_credentials( known_users=known_users, user_last_active=user_last_active, user_login_tokens=user_login_tokens, ) return True def _get_user_context_token(self, user_id: str) -> Optional[str]: creds = self.get_data(self._CREDENTIALS_KEY) or {} tokens = creds.get("user_context_tokens") or {} token = tokens.get(str(user_id)) return str(token) if token else None def _active_users(self) -> List[str]: creds = self.get_data(self._CREDENTIALS_KEY) or {} known_users = creds.get("known_users") or [] user_last_active = creds.get("user_last_active") or {} now = datetime.now() active = [] for user_id in known_users: ts = user_last_active.get(user_id) if not ts: continue dt = datetime.fromtimestamp(ts) if now - dt <= timedelta(hours=24): active.append(user_id) return active def _record_incoming_message(self, msg: ILinkIncomingMessage, text: str): """将入站微信消息写入 MoviePilot 消息记录(action=0)。""" try: note = { "username": msg.username, "message_id": msg.message_id, "chat_id": msg.chat_id, "context_token": msg.context_token, } MessageOper().add( channel=MessageChannel.Wechat, source=self.__class__.__name__, title="微信入站消息", text=text, userid=str(msg.user_id), action=0, note=note, ) except Exception as err: self._log("warning", f"入站消息写入记录失败: {err}") def _cleanup_incoming_seen_cache_locked(self, now_ts: int) -> None: """清理过期/溢出的入站消息去重缓存。""" ttl = self._INCOMING_DEDUP_TTL_SECONDS while self._incoming_seen_order: key, ts = self._incoming_seen_order[0] if ( now_ts - ts <= ttl and len(self._incoming_seen_order) <= self._MAX_INCOMING_CACHE_ITEMS ): break self._incoming_seen_order.popleft() cached_ts = self._incoming_seen_cache.get(key) if cached_ts == ts: self._incoming_seen_cache.pop(key, None) def _build_incoming_dedup_key( self, msg: ILinkIncomingMessage, text: str ) -> Optional[str]: """构建入站消息去重 key。优先使用 message_id,缺失时退化为消息指纹。""" user_id = str(msg.user_id or "").strip() message_id = str(msg.message_id or "").strip() if user_id and message_id: return f"id:{user_id}:{message_id}" if not user_id: return None raw_payload = msg.raw if isinstance(msg.raw, dict) else {"raw": str(msg.raw)} payload = { "user_id": user_id, "chat_id": str(msg.chat_id or ""), "context_token": str(msg.context_token or ""), "text": text or "", "raw": raw_payload, } try: fingerprint = hashlib.sha1( json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8") ).hexdigest() except Exception: return None return f"fp:{fingerprint}" def _is_duplicate_incoming(self, msg: ILinkIncomingMessage, text: str) -> bool: """判断入站消息是否为短时间内重复投递。""" key = self._build_incoming_dedup_key(msg=msg, text=text) if not key: return False now_ts = int(time.time()) with self._incoming_seen_lock: self._cleanup_incoming_seen_cache_locked(now_ts) last_ts = self._incoming_seen_cache.get(key) if ( last_ts is not None and now_ts - last_ts <= self._INCOMING_DEDUP_TTL_SECONDS ): return True self._incoming_seen_cache[key] = now_ts self._incoming_seen_order.append((key, now_ts)) self._cleanup_incoming_seen_cache_locked(now_ts) return False def _is_plugin_command(self, text: str) -> bool: """判断是否为插件内置命令(需要插件自行处理)。""" if not text or not text.startswith("/"): return False cmd = text.split()[0].strip().lower() return cmd == self._QRCODE_COMMAND.lower() def _dispatch_to_official_message_chain( self, msg: ILinkIncomingMessage, text: str ) -> bool: """将入站消息转交官方微信消息处理链,保持与默认微信通道一致。""" try: MessageChain().handle_message( channel=MessageChannel.Wechat, source=self.__class__.__name__, userid=str(msg.user_id), username=str(msg.username or msg.user_id), text=text, original_message_id=msg.message_id, original_chat_id=msg.chat_id, images=None, ) self._log("info", f"消息已转交官方微信链路处理: user={msg.user_id}") return True except Exception as err: self._log("error", f"转交官方微信链路失败: user={msg.user_id}, err={err}") return False def _send_direct_reply_for_ilink_user( self, source: str, userid: Any, title: str, text: str ) -> bool: """当命令来自 WeChat-ClawBot 用户时,直接回包,避免链路差异导致漏回。""" if source != self.__class__.__name__: return False if userid in (None, ""): return False payload = "\n".join([p for p in [title, text] if p]).strip() if not payload: return False sent = self._send_text_with_retry(str(userid), payload) if sent: self._log("info", f"命令结果已直发 WeChat-ClawBot 用户: user={userid}") return sent def _send_qrcode_image_for_ilink_user( self, source: str, userid: Any, qrcode_url: Optional[str] ) -> bool: """给 WeChat-ClawBot 用户直发二维码图片(非链接)。""" if source != self.__class__.__name__: return False if userid in (None, ""): return False qrcode_value = qrcode_url if not qrcode_value: qrcode = self.get_data(self._QRCODE_KEY) or {} qrcode_value = qrcode.get("qrcode_url") if not qrcode_value and qrcode.get("qrcode"): qrcode_value = self._compose_qrcode_url(str(qrcode.get("qrcode"))) if not qrcode_value: self._log("warning", f"二维码图片发送失败:qrcode_url 为空, user={userid}") return False image_bytes = self._fetch_qrcode_png(str(qrcode_value)) if not image_bytes: self._log("warning", f"二维码图片发送失败:二维码渲染失败, user={userid}") return False client = self._ensure_client() context_token = self._get_user_context_token(str(userid)) sent = client.send_image_png( str(userid), image_bytes, context_token=context_token ) if sent: self._log("info", f"二维码图片已发送: user={userid}") return sent def _handle_incoming(self, msg: ILinkIncomingMessage): text = (msg.text or "").strip() if not text: return if self._is_duplicate_incoming(msg=msg, text=text): self._log( "warning", f"检测到重复入站消息,已忽略: user={msg.user_id}, message_id={msg.message_id or '-'}", ) return self._log("info", f"收到入站消息: user={msg.user_id}, text={text[:64]}") self._touch_user(msg.user_id, msg.context_token) is_command = text.startswith("/") if is_command and not self._config.get("command_enabled", True): self._log( "info", f"命令控制已关闭,忽略命令: user={msg.user_id}, text={text[:64]}", ) return if is_command and self._is_plugin_command(text): if not self._is_admin(msg.user_id): self._log( "warning", f"用户 {msg.user_id} 非管理员,拒绝插件命令: {text}" ) self._send_text_with_retry( msg.user_id, "只有管理员才有权限执行此命令", context_token=msg.context_token, ) return self._record_incoming_message(msg, text) cmd = text.split()[0] args = " ".join(text.split()[1:]) try: Command().execute( cmd=cmd, data_str=args, channel=MessageChannel.Wechat, source=self.__class__.__name__, userid=msg.user_id, ) self._log("info", f"已执行插件命令: {cmd} (user={msg.user_id})") except Exception as err: self._log( "error", f"插件命令执行异常: cmd={cmd}, user={msg.user_id}, err={err}", ) self._send_text_with_retry( msg.user_id, f"命令执行失败: {err}", context_token=msg.context_token ) return # 非插件内置命令全部转交官方 MessageChain 处理,以对齐默认微信通道行为。 if not self._dispatch_to_official_message_chain(msg, text): self._send_text_with_retry( msg.user_id, "消息处理异常,请稍后重试", context_token=msg.context_token ) @staticmethod def _compose_text(message: Notification) -> str: parts: List[str] = [] if message.title: parts.append(str(message.title)) if message.text: parts.append(str(message.text)) if message.link: parts.append(str(message.link)) return "\n".join([p for p in parts if p]).strip() def _send_text_with_retry( self, user_id: str, text: str, retries: int = 3, context_token: Optional[str] = None, ) -> bool: if not user_id or not text: return False client = self._ensure_client() token = context_token or self._get_user_context_token(user_id) for idx in range(retries): if client.send_text(user_id, text, context_token=token): self._log("debug", f"发送消息成功: user={user_id}, attempt={idx + 1}") return True if idx < retries - 1: time.sleep(1) self._log("error", f"发送消息失败: user={user_id}, retries={retries}") return False def _can_send_notify_type(self, message: Notification) -> bool: if not self._config.get("notify_enabled", True): return False filters = self._config.get("notify_types") or [] if not filters or not message.mtype: return True return message.mtype.value in filters def _resolve_targets(self, message: Notification) -> List[str]: if message.userid: return [str(message.userid)] if message.targets: uid = ( message.targets.get("wechatclawbot_userid") or message.targets.get("wechatilink_userid") or message.targets.get("wechat_userid") ) if uid: return [str(uid)] return self._active_users() def _notification_to_title_lines( self, message: Notification ) -> Tuple[Optional[str], List[str]]: """统一整理通知消息文案结构。""" title = str(message.title).strip() if message.title else None lines: List[str] = [] if message.text: lines.extend( [ line.strip() for line in str(message.text).splitlines() if line.strip() ] ) if message.link: lines.append(f"查看详情:{message.link}") return title, lines def _load_image_bytes(self, image: Optional[str]) -> Optional[bytes]: """将图片URL或data:image转换为图片字节。""" if not image: return None image_value = str(image).strip() if not image_value: return None if image_value.startswith("data:image/"): decoded, _ = self._decode_data_image(image_value) return decoded if image_value.startswith("/"): image_value = settings.MP_DOMAIN(image_value) if not image_value.lower().startswith("http"): return None try: # iLink 侧需要先拉取图片再上传 CDN,这里复用系统代理提升外链可达性。 resp = RequestUtils( timeout=20, proxies=settings.PROXY, ua=settings.USER_AGENT ).get_res(image_value) if not resp or resp.status_code != 200 or not resp.content: return None content_type = (resp.headers.get("Content-Type") or "").lower() if content_type and "image" not in content_type: return None return resp.content except Exception as err: self._log("warning", f"加载图片失败: {err}") return None def _load_first_image_bytes(self, image: Any) -> Optional[bytes]: """支持候选图片列表,返回首个可加载的图片字节。""" if isinstance(image, (list, tuple, set)): for one in image: data = self._load_image_bytes(str(one) if one is not None else None) if data: return data return None return self._load_image_bytes(str(image) if image is not None else None) def _send_image_bytes_with_retry( self, user_id: str, image_bytes: bytes, retries: int = 2, ) -> bool: """发送图片并重试。""" if not user_id or not image_bytes: return False client = self._ensure_client() token = self._get_user_context_token(user_id) for idx in range(retries): if client.send_image_png(user_id, image_bytes, context_token=token): return True if idx < retries - 1: time.sleep(0.6) self._log("warning", f"发送图片失败: user={user_id}") return False @staticmethod def _truncate_text(text: Any, max_len: int = 120) -> str: value = str(text or "").replace("\n", " ").replace("\r", " ").strip() if len(value) <= max_len: return value return f"{value[: max_len - 3]}..." def _is_ilink_wechat_message(self, message: Notification) -> bool: """仅处理来自 WechatClawBot 源的微信消息。""" if not message: return False if message.source != self.__class__.__name__: return False if message.channel and message.channel != MessageChannel.Wechat: return False return True def _send_lines_with_title( self, users: List[str], title: Optional[str], lines: Optional[List[str]] = None, image: Optional[str] = None, link: Optional[str] = None, max_chunk_len: int = 1500, ) -> bool: """将标题与列表分段发送,避免单条消息过长。""" image_bytes = self._load_first_image_bytes(image) chunks: List[str] = [] if title: chunks.append(str(title).strip()) if lines: current: List[str] = [] current_len = 0 for line in lines: line = str(line or "").strip() if not line: continue extra = len(line) + (1 if current else 0) if current and current_len + extra > max_chunk_len: chunks.append("\n".join(current)) current = [line] current_len = len(line) else: current.append(line) current_len += extra if current: chunks.append("\n".join(current)) if link: chunks.append(str(link).strip()) ok = True for user_id in users: user_chunks = [chunk for chunk in chunks if chunk] if image_bytes: token = self._get_user_context_token(user_id) rich_text = "" if user_chunks: rich_text = user_chunks.pop(0) # 标题单独成块时,优先和正文首块合并成一条图文。 if title and rich_text == str(title).strip() and user_chunks: rich_text = f"{rich_text}\n{user_chunks.pop(0)}" rich_ok = False if rich_text: try: rich_ok = self._ensure_client().send_image_text_png( user_id, image_bytes, rich_text, context_token=token, ) except Exception as err: self._log("warning", f"发送图文失败,回退到分开发送: {err}") if not rich_ok: image_ok = self._send_image_bytes_with_retry(user_id, image_bytes) ok = ok and image_ok if rich_text: text_ok = self._send_text_with_retry( user_id, rich_text, context_token=token ) ok = ok and text_ok for chunk in user_chunks: if not chunk: continue one_ok = self._send_text_with_retry(user_id, chunk) ok = ok and one_ok return ok def _normalize_notice_message(self, event_data: Any) -> Optional[Notification]: """将 NoticeMessage 事件数据统一转换为 Notification。""" if isinstance(event_data, Notification): return event_data if not isinstance(event_data, dict): return None data = dict(event_data) if "mtype" not in data and data.get("type") is not None: data["mtype"] = data.get("type") try: return Notification(**data) except Exception as err: self._log("warning", f"通知事件数据解析失败: {err}") return None @eventmanager.register(EventType.NoticeMessage) def notice_message_event(self, event: Event): """监听系统通知并转发到 WeChat-ClawBot。""" if not self._enabled: return event_data = event.event_data if not event_data: return message = self._normalize_notice_message(event_data) if not message: return self._module_post_message(message) def _module_post_message( self, message: Notification, **kwargs ) -> Optional[MessageResponse]: if not self._enabled: return None # 插件命令回包:source 为插件类名时强制接管。 force_handle = message.source == self.__class__.__name__ if not force_handle: # 普通系统通知:仅在未指定渠道时接管,避免劫持其他渠道消息。 if message.channel is not None: return None if not self._can_send_notify_type(message): return None title, lines = self._notification_to_title_lines(message) if not title and not lines and not message.image: return MessageResponse(success=False, source=self.__class__.__name__) users = self._resolve_targets(message) if not users: return MessageResponse(success=False, source=self.__class__.__name__) ok = self._send_lines_with_title( users=users, title=title, lines=lines, image=message.image, ) return MessageResponse(success=ok, source=self.__class__.__name__) def _module_send_direct_message( self, message: Notification ) -> Optional[MessageResponse]: """处理直接发送消息接口,覆盖即时回包场景。""" if not self._enabled: return None if not self._is_ilink_wechat_message(message): return None title, lines = self._notification_to_title_lines(message) if not title and not lines and not message.image: return MessageResponse(success=False, source=self.__class__.__name__) users = self._resolve_targets(message) if not users: return MessageResponse(success=False, source=self.__class__.__name__) ok = self._send_lines_with_title( users=users, title=title, lines=lines, image=message.image, ) # iLink 当前不支持消息编辑,message_id/chat_id 返回空即可。 return MessageResponse( success=ok, channel=MessageChannel.Wechat, source=self.__class__.__name__, chat_id=users[0] if len(users) == 1 else None, ) def _module_post_medias_message( self, message: Notification, medias: List[Any] ) -> Optional[MessageResponse]: """处理媒体列表回包(如“搜索 XXX”后的候选列表)。""" if not self._enabled: return None if not self._is_ilink_wechat_message(message): return None users = self._resolve_targets(message) if not users: return MessageResponse(success=False, source=self.__class__.__name__) lines: List[str] = [] for idx, media in enumerate(medias or [], start=1): title = self._truncate_text( getattr(media, "title_year", None) or getattr(media, "title", None) or "未知媒体", 80, ) mtype = ( getattr(getattr(media, "type", None), "value", None) or getattr(media, "type", None) or "未知类型" ) score = getattr(media, "vote_average", None) if score: lines.append(f"{idx}. {title}|{mtype}|评分 {score}") else: lines.append(f"{idx}. {title}|{mtype}") ok = self._send_lines_with_title( users=users, title=message.title, lines=lines, link=message.link, ) return MessageResponse(success=ok, source=self.__class__.__name__) def _module_post_torrents_message( self, message: Notification, torrents: List[Any] ) -> Optional[MessageResponse]: """处理种子列表回包(如资源选择页)。""" if not self._enabled: return None if not self._is_ilink_wechat_message(message): return None users = self._resolve_targets(message) if not users: return MessageResponse(success=False, source=self.__class__.__name__) lines: List[str] = [] for idx, context in enumerate(torrents or [], start=1): torrent = getattr(context, "torrent_info", None) if not torrent: lines.append(f"{idx}. 未知资源") continue site_name = self._truncate_text( getattr(torrent, "site_name", None) or "未知站点", 20 ) title = self._truncate_text( getattr(torrent, "title", None) or "未知标题", 80 ) seeders = getattr(torrent, "seeders", None) size = getattr(torrent, "size", None) size_text = ( StringUtils.str_filesize(size) if isinstance(size, (int, float)) else str(size or "未知大小") ) if seeders is not None: lines.append(f"{idx}. 【{site_name}】{title}|{size_text}|{seeders}↑") else: lines.append(f"{idx}. 【{site_name}】{title}|{size_text}") ok = self._send_lines_with_title( users=users, title=message.title, lines=lines, link=message.link, ) return MessageResponse(success=ok, source=self.__class__.__name__) def _module_message_parser( self, source: str, body: Any, form: Any, args: Any ) -> Optional[CommingMessage]: """纯插件方案下不接管 /api/v1/message 解析,返回 None 交由系统模块处理。""" return None @eventmanager.register(EventType.PluginAction) def plugin_action_event(self, event: Event): """处理插件命令事件。""" data = event.event_data or {} if data.get("plugin_id") != self.__class__.__name__: return if data.get("action") != "get_qrcode": return force = False arg_str = str(data.get("arg_str") or "").strip().lower() if arg_str in {"force", "new", "refresh", "--force", "-f", "强制"}: force = True # 命令态下若已登录,默认刷新二维码,避免复用旧会话导致立即判定已登录。 if not force: creds = self.get_data(self._CREDENTIALS_KEY) or {} if creds.get("bot_token"): force = True channel = data.get("channel") userid = data.get("user") source = data.get("source") or self.__class__.__name__ result = self._ensure_qrcode(force=force) if result.get("success"): qrcode_url = result.get("qrcode_url") qrcode_id = result.get("qrcode") if not qrcode_url and qrcode_id: qrcode_url = self._compose_qrcode_url(str(qrcode_id)) title = "WeChat-ClawBot 登录二维码" ilink_direct = source == self.__class__.__name__ and userid not in ( None, "", ) lines = [ "已生成登录二维码。", f"系统将在 {self._LOGIN_WATCH_SECONDS} 秒内等待扫码登录,登录成功后自动更新用户 token。", ] if ilink_direct: image_sent = self._send_qrcode_image_for_ilink_user( source=source, userid=userid, qrcode_url=qrcode_url, ) if image_sent: lines.append("二维码图片已发送,请直接扫码完成登录。") else: lines.append("二维码图片发送失败,请稍后重试或在插件详情页扫码。") else: lines.append("可在插件详情页直接扫码。") if qrcode_url: lines.append(f"二维码链接:{qrcode_url}") else: lines.append("二维码链接暂不可用,请稍后重试。") content = "\n".join(lines) self._notify_command_feedback( channel=channel, source=source, userid=userid, title=title, text=content, ) self._start_command_login_wait( channel=channel, source=source, userid=userid, expected_qrcode=str(qrcode_id) if qrcode_id else None, ) self._log("info", f"命令 {self._QRCODE_COMMAND} 执行成功") else: fail_text = result.get("message") or "未知错误" self._notify_command_feedback( channel=channel, source=source, userid=userid, title="WeChat-ClawBot 登录二维码获取失败", text=fail_text, ) self._log( "warning", f"命令 {self._QRCODE_COMMAND} 执行失败: {result.get('message') or 'unknown error'}", ) def _notify_command_feedback( self, channel: Any, source: str, userid: Any, title: str, text: str ): if not self._send_direct_reply_for_ilink_user( source=source, userid=userid, title=title, text=text, ): self.chain.post_message( Notification( channel=channel, source=source, userid=userid, title=title, text=text, ) ) def _start_command_login_wait( self, channel: Any, source: str, userid: Any, expected_qrcode: Optional[str] ): user_id = str(userid or "").strip() if not user_id: self._log("debug", "命令扫码等待跳过:缺少 user id") return with self._command_login_wait_lock: thread = threading.Thread( target=self._command_login_wait_worker, kwargs={ "channel": channel, "source": source, "user_id": user_id, "expected_qrcode": expected_qrcode, }, daemon=True, ) self._command_login_wait_threads[user_id] = thread thread.start() def _command_login_wait_worker( self, channel: Any, source: str, user_id: str, expected_qrcode: Optional[str], ): try: result = self._wait_for_login_status( reason=f"command_user:{user_id}", expected_qrcode=expected_qrcode, require_qrcode_scan=True, ) if result.get("connected"): token_updated = self._update_user_login_token(user_id) if token_updated: text = "扫码登录成功,用户 token 已更新。" else: text = "扫码登录成功,登录状态已更新。" self._notify_command_feedback( channel=channel, source=source, userid=user_id, title="WeChat-ClawBot 登录成功", text=text, ) self._log( "info", f"命令扫码等待成功: user={user_id}, token_updated={token_updated}", ) return wait_status = str(result.get("status") or "").lower() if wait_status == "replaced": self._log("info", f"命令扫码等待结束: user={user_id}, status=replaced") return if wait_status == "expired": text = "二维码已过期,请重新发送命令获取新的二维码。" elif wait_status == "timeout": text = "二维码已超时,请重新发送命令获取二维码。" elif wait_status == "watch_timeout": text = "在等待时间内未检测到登录成功,请重新发送命令并扫码。" elif wait_status in {"canceled", "cancelled"}: text = "扫码已取消,请重新发送命令获取二维码。" elif wait_status == "disabled": text = "插件已停用,已停止等待扫码登录。" elif wait_status == "not_initialized": text = "二维码尚未初始化,请重新发送命令。" elif wait_status == "error": detail = result.get("message") or "状态查询异常" text = f"等待扫码时发生异常:{detail}" else: text = "在等待时间内未检测到登录成功,请重新发送命令并扫码。" self._notify_command_feedback( channel=channel, source=source, userid=user_id, title="WeChat-ClawBot 登录状态", text=text, ) self._log( "info", f"命令扫码等待结束: user={user_id}, status={wait_status or 'unknown'}", ) finally: with self._command_login_wait_lock: current = self._command_login_wait_threads.get(user_id) if current is threading.current_thread(): self._command_login_wait_threads.pop(user_id, None) def close(self): self.stop_service()