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()
