"""
WeCom (企业微信) AI Bot channel via WebSocket long connection.

Supports:
- Single chat and group chat (text / image / file input & output)
- Scheduled task push via aibot_send_msg
- Heartbeat keep-alive and auto-reconnect
"""

import base64
import hashlib
import json
import math
import os
import re
import threading
import time
import uuid

import requests
import web
import websocket

from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel, check_prefix
from channel.wecom_bot.wecom_bot_crypt import WecomBotCrypt
from channel.wecom_bot.wecom_bot_message import WecomBotMessage
from common.expired_dict import ExpiredDict
from common.log import logger
from common.singleton import singleton
from common.ws_client_compat import websocket_app_run_forever
from config import conf

WECOM_WS_URL = "wss://openws.work.weixin.qq.com"
HEARTBEAT_INTERVAL = 30
MEDIA_CHUNK_SIZE = 512 * 1024  # 512KB per chunk (before base64 encoding)
# Fixed URL path for the callback (webhook) HTTP server. The bot's
# receive-message URL must point at this path, e.g. http://host:9892/wecombot
CALLBACK_PATH = "/wecombot"


def _escape_control_chars_inside_json_strings(s: str) -> str:
    """Escape U+0000–U+001F inside JSON string values so json.loads accepts WeCom payloads.

    The server occasionally emits raw newlines/tabs inside quoted fields, which is
    invalid strict JSON but recoverable without touching escapes like \\n or \\".
    """
    out = []
    in_string = False
    escape = False
    for c in s:
        if escape:
            out.append(c)
            escape = False
            continue
        if in_string and c == "\\":
            out.append(c)
            escape = True
            continue
        if c == '"':
            out.append(c)
            in_string = not in_string
            continue
        if in_string and ord(c) < 32:
            out.append("\\u%04x" % ord(c))
            continue
        out.append(c)
    return "".join(out)


def _loads_wecom_ws_json(raw):
    """Parse WebSocket JSON; tolerate unescaped control characters inside strings."""
    if isinstance(raw, bytes):
        raw = raw.decode("utf-8", errors="replace")
    if not isinstance(raw, str):
        raw = str(raw)
    try:
        return json.loads(raw)
    except json.JSONDecodeError as e:
        msg = str(e).lower()
        if "control character" in msg:
            return json.loads(_escape_control_chars_inside_json_strings(raw))
        raise


@singleton
class WecomBotChannel(ChatChannel):

    NOT_SUPPORT_REPLYTYPE = []

    def __init__(self):
        super().__init__()
        self.bot_id = ""
        self.bot_secret = ""
        self.received_msgs = ExpiredDict(60 * 60 * 7.1)
        self._ws = None
        self._ws_thread = None
        self._heartbeat_thread = None
        self._connected = False
        self._stop_event = threading.Event()
        self._pending_responses = {}  # req_id -> (threading.Event, result_holder)
        self._pending_lock = threading.Lock()
        self._stream_states = {}  # req_id -> {"stream_id": str, "content": str}

        # Transport mode: "websocket" (long connection) or "webhook" (HTTP callback)
        self.mode = "websocket"
        self._crypt = None
        self._http_server = None
        # stream_id -> {"committed", "current", "finished", "images", "last_access"}
        self._callback_streams = ExpiredDict(60 * 10)  # auto-expire after 10min (max poll window is 6min)
        self._callback_lock = threading.Lock()

        conf()["group_name_white_list"] = ["ALL_GROUP"]
        conf()["single_chat_prefix"] = [""]

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def startup(self):
        self.mode = conf().get("wecom_bot_mode", "websocket")
        if self.mode == "webhook":
            self._startup_callback()
            return

        self.bot_id = conf().get("wecom_bot_id", "")
        self.bot_secret = conf().get("wecom_bot_secret", "")

        if not self.bot_id or not self.bot_secret:
            err = "[WecomBot] wecom_bot_id and wecom_bot_secret are required"
            logger.error(err)
            self.report_startup_error(err)
            return

        self._stop_event.clear()
        self._start_ws()

    def stop(self):
        logger.info("[WecomBot] stop() called")
        self._stop_event.set()
        if self._ws:
            try:
                self._ws.close()
            except Exception:
                pass
        self._ws = None
        self._connected = False
        if self._http_server:
            try:
                self._http_server.stop()
                logger.info("[WecomBot] Callback HTTP server stopped")
            except Exception as e:
                logger.warning(f"[WecomBot] Error stopping HTTP server: {e}")
            self._http_server = None

    # ------------------------------------------------------------------
    # WebSocket connection
    # ------------------------------------------------------------------

    def _start_ws(self):
        def _on_open(ws):
            logger.info("[WecomBot] WebSocket connected, sending subscribe...")
            self._send_subscribe()

        def _on_message(ws, raw):
            try:
                data = _loads_wecom_ws_json(raw)
                self._handle_ws_message(data)
            except Exception as e:
                logger.error(f"[WecomBot] Failed to handle ws message: {e}", exc_info=True)

        def _on_error(ws, error):
            logger.error(f"[WecomBot] WebSocket error: {error}")

        def _on_close(ws, close_status_code, close_msg):
            logger.warning(f"[WecomBot] WebSocket closed: status={close_status_code}, msg={close_msg}")
            self._connected = False
            if not self._stop_event.is_set():
                logger.info("[WecomBot] Will reconnect in 5s...")
                time.sleep(5)
                if not self._stop_event.is_set():
                    self._start_ws()

        self._ws = websocket.WebSocketApp(
            WECOM_WS_URL,
            on_open=_on_open,
            on_message=_on_message,
            on_error=_on_error,
            on_close=_on_close,
        )

        def run_forever():
            try:
                websocket_app_run_forever(self._ws, ping_interval=0, reconnect=0)
            except (SystemExit, KeyboardInterrupt):
                logger.info("[WecomBot] WebSocket thread interrupted")
            except Exception as e:
                logger.error(f"[WecomBot] WebSocket run_forever error: {e}")

        self._ws_thread = threading.Thread(target=run_forever, daemon=True)
        self._ws_thread.start()
        self._ws_thread.join()

    def _ws_send(self, data: dict):
        if self._ws:
            self._ws.send(json.dumps(data, ensure_ascii=False))

    def _gen_req_id(self) -> str:
        return uuid.uuid4().hex[:16]

    # ------------------------------------------------------------------
    # Callback (webhook) mode
    # ------------------------------------------------------------------

    def _startup_callback(self):
        """Start an HTTP server that receives encrypted callbacks (webhook mode).

        The bot's "接收消息" URL in the WeCom admin console should point at this
        server (any path is accepted). Verification (GET) and message delivery
        (POST) are both handled by ``WecomBotCallbackController``.
        """
        token = conf().get("wecom_bot_token", "")
        aes_key = conf().get("wecom_bot_encoding_aes_key", "")
        if not token or not aes_key:
            err = "[WecomBot] callback mode requires wecom_bot_token and wecom_bot_encoding_aes_key"
            logger.error(err)
            self.report_startup_error(err)
            return

        try:
            # Enterprise-internal smart bot: receive_id is an empty string.
            self._crypt = WecomBotCrypt(token, aes_key, "")
        except Exception as e:
            err = f"[WecomBot] invalid callback credentials: {e}"
            logger.error(err)
            self.report_startup_error(err)
            return

        port = int(conf().get("wecom_bot_port", 9892))
        logger.info(f"[WecomBot] Starting callback (webhook) server on port {port}, path {CALLBACK_PATH} ...")
        # Only serve the fixed callback path; everything else 404s instead of being
        # treated as a (signature-failing) WeCom callback.
        urls = (re.escape(CALLBACK_PATH), "channel.wecom_bot.wecom_bot_channel.WecomBotCallbackController")
        app = web.application(urls, globals(), autoreload=False)
        func = web.httpserver.StaticMiddleware(app.wsgifunc())
        func = web.httpserver.LogMiddleware(func)
        server = web.httpserver.WSGIServer(("0.0.0.0", port), func)
        self._http_server = server
        self.report_startup_success()
        try:
            server.start()
        except (KeyboardInterrupt, SystemExit):
            server.stop()

    def _new_callback_stream(self, response_url: str = "") -> str:
        """Create a new stream state and return its id."""
        stream_id = uuid.uuid4().hex[:16]
        now = time.time()
        with self._callback_lock:
            self._callback_streams[stream_id] = {
                "committed": "",
                "current": "",
                "finished": False,
                "images": [],  # list of (base64_str, md5_str), flushed only at finish
                "image_urls": [],  # public http(s) image urls (usable in response_url markdown)
                "image_pending": False,  # an image reply is being prepared; don't finish on text yet
                "last_access": now,
                "created_at": now,
                "response_url": response_url or "",
                "delivered": False,  # final answer handed to WeCom via a poll
                "url_sent": False,   # final answer pushed via response_url (active reply)
            }
        return stream_id

    def _callback_handle_message(self, data: dict) -> dict:
        """Handle a freshly-received user message in callback mode.

        Produces the context for async processing and returns the initial passive
        reply (a stream packet with finish=false) so WeCom starts polling for the
        agent's streamed answer. Returns ``None`` when there's nothing to reply
        (e.g. an image/file silently cached for the next query).
        """
        msg_id = data.get("msgid", "")
        if msg_id and self.received_msgs.get(msg_id):
            logger.debug(f"[WecomBot] Duplicate msg filtered: {msg_id}")
            return None
        if msg_id:
            self.received_msgs[msg_id] = True

        chattype = data.get("chattype", "single")
        is_group = chattype == "group"

        default_aeskey = conf().get("wecom_bot_encoding_aes_key", "")
        result = self._build_context(data, is_group, default_aeskey=default_aeskey)
        if not result:
            return None
        context, wecom_msg = result

        # response_url lets us actively reply once within 1h, used as a fallback
        # when the agent finishes after WeCom stops polling (max ~6min window).
        response_url = data.get("response_url", "") or ""
        stream_id = self._new_callback_stream(response_url=response_url)
        wecom_msg.stream_id = stream_id
        context["wecom_stream_id"] = stream_id
        context["on_event"] = self._make_callback_stream_callback(stream_id)
        self.produce(context)

        # First passive reply: register the stream id, WeCom will poll for updates.
        return {
            "msgtype": "stream",
            "stream": {"id": stream_id, "finish": False, "content": ""},
        }

    def _callback_handle_stream_poll(self, data: dict) -> dict:
        """Handle a "流式消息刷新" poll: return the latest accumulated content."""
        stream_id = data.get("stream", {}).get("id", "")
        with self._callback_lock:
            state = self._callback_streams.get(stream_id)
            if state is None:
                # Unknown / expired stream: tell WeCom we're done to stop polling.
                return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
            state["last_access"] = time.time()
            if state.get("url_sent"):
                # Final answer already pushed via response_url; finish silently.
                return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
            # We never force-finish on a timer: while a task is still running the
            # bubble should keep spinning until either the task finishes or the
            # user cancels. If WeCom's 6min window closes before completion, the
            # answer is delivered later via response_url instead.
            finished = state["finished"]
            content = state["committed"] + state["current"]
            images = state["images"] if finished else []
            if finished:
                state["delivered"] = True
                logger.debug(f"[WecomBot] stream {stream_id} delivered via poll, len={len(content)}, images={len(images)}")

        stream = {"id": stream_id, "finish": finished, "content": content}
        if images:
            stream["msg_item"] = [
                {"msgtype": "image", "image": {"base64": b64, "md5": md5}}
                for (b64, md5) in images
            ]
        return {"msgtype": "stream", "stream": stream}

    def _make_callback_stream_callback(self, stream_id: str):
        """Build an on_event callback that accumulates agent output into stream state.

        Mirrors the websocket streaming behaviour: intermediate turns (text before
        a tool call) are committed with a '---' separator; WeCom reads the full
        accumulated content on each poll.
        """
        def on_event(event: dict):
            event_type = event.get("type")
            edata = event.get("data", {})
            cancelled = False
            with self._callback_lock:
                state = self._callback_streams.get(stream_id)
                if not state:
                    return

                if event_type == "turn_start":
                    state["current"] = ""
                elif event_type == "message_update":
                    delta = edata.get("delta", "")
                    if delta:
                        state["current"] += delta
                elif event_type == "message_end":
                    tool_calls = edata.get("tool_calls", [])
                    if tool_calls:
                        if state["current"].strip():
                            state["committed"] += state["current"].strip() + "\n\n---\n\n"
                            state["current"] = ""
                    else:
                        state["committed"] += state["current"]
                        state["current"] = ""
                elif event_type == "agent_cancelled":
                    # Mechanism 1: a cancelled run never reaches send(), so finalize
                    # its stream here to stop the "···" bubble immediately.
                    if state["current"]:
                        state["committed"] += state["current"]
                        state["current"] = ""
                    state["committed"] = state["committed"].rstrip()
                    if state["committed"].endswith("---"):
                        state["committed"] = state["committed"][:-3].rstrip()
                    if not state["committed"].strip():
                        state["committed"] = "🛑 已中止"
                    state["finished"] = True
                    state["last_access"] = time.time()
                    cancelled = True

            if cancelled:
                # Outside the lock: response_url fallback re-acquires it.
                self._schedule_response_url_fallback(stream_id)

        return on_event

    # ------------------------------------------------------------------
    # Subscribe & heartbeat
    # ------------------------------------------------------------------

    def _send_subscribe(self):
        self._ws_send({
            "cmd": "aibot_subscribe",
            "headers": {"req_id": self._gen_req_id()},
            "body": {
                "bot_id": self.bot_id,
                "secret": self.bot_secret,
            },
        })

    def _start_heartbeat(self):
        if self._heartbeat_thread and self._heartbeat_thread.is_alive():
            return

        def heartbeat_loop():
            while not self._stop_event.is_set() and self._connected:
                try:
                    self._ws_send({
                        "cmd": "ping",
                        "headers": {"req_id": self._gen_req_id()},
                    })
                except Exception as e:
                    logger.warning(f"[WecomBot] Heartbeat send failed: {e}")
                    break
                self._stop_event.wait(HEARTBEAT_INTERVAL)

        self._heartbeat_thread = threading.Thread(target=heartbeat_loop, daemon=True)
        self._heartbeat_thread.start()

    # ------------------------------------------------------------------
    # Incoming message dispatch
    # ------------------------------------------------------------------

    def _send_and_wait(self, data: dict, timeout: float = 15) -> dict:
        """Send a ws message and wait for the matching response by req_id."""
        req_id = data.get("headers", {}).get("req_id", "")
        event = threading.Event()
        holder = {"data": None}
        with self._pending_lock:
            self._pending_responses[req_id] = (event, holder)
        self._ws_send(data)
        event.wait(timeout=timeout)
        with self._pending_lock:
            self._pending_responses.pop(req_id, None)
        return holder["data"] or {}

    def _handle_ws_message(self, data: dict):
        cmd = data.get("cmd", "")
        errcode = data.get("errcode")
        req_id = data.get("headers", {}).get("req_id", "")

        # Check if this is a response to a pending request
        if req_id:
            with self._pending_lock:
                pending = self._pending_responses.get(req_id)
            if pending:
                event, holder = pending
                holder["data"] = data
                event.set()
                return

        # Subscribe response (only handle once before connected)
        if errcode is not None and cmd == "":
            if not self._connected:
                if errcode == 0:
                    logger.info("[WecomBot] ✅ Subscribe success")
                    self._connected = True
                    self._start_heartbeat()
                    self.report_startup_success()
                else:
                    errmsg = data.get("errmsg", "unknown error")
                    logger.error(f"[WecomBot] Subscribe failed: errcode={errcode}, errmsg={errmsg}")
                    self.report_startup_error(errmsg)
            return

        if cmd == "aibot_msg_callback":
            self._handle_msg_callback(data)
        elif cmd == "aibot_event_callback":
            self._handle_event_callback(data)
        elif cmd == "":
            if errcode and errcode != 0:
                logger.warning(f"[WecomBot] Response error: {data}")

    # ------------------------------------------------------------------
    # Message callback
    # ------------------------------------------------------------------

    def _handle_msg_callback(self, data: dict):
        body = data.get("body", {})
        req_id = data.get("headers", {}).get("req_id", "")
        msg_id = body.get("msgid", "")

        if self.received_msgs.get(msg_id):
            logger.debug(f"[WecomBot] Duplicate msg filtered: {msg_id}")
            return
        self.received_msgs[msg_id] = True

        chattype = body.get("chattype", "single")
        is_group = chattype == "group"

        result = self._build_context(body, is_group)
        if not result:
            return
        context, wecom_msg = result
        wecom_msg.req_id = req_id
        if req_id:
            context["on_event"] = self._make_stream_callback(req_id)
        self.produce(context)

    def _build_context(self, body: dict, is_group: bool, default_aeskey: str = ""):
        """Parse a wecom message body into a Context, applying file-cache logic.

        Shared by both the websocket (long-connection) and callback (webhook)
        receive paths. Returns ``(context, wecom_msg)`` when the message should be
        handed to the agent, or ``None`` when it was consumed (cached image/file,
        parse failure, etc.).
        """
        try:
            wecom_msg = WecomBotMessage(body, is_group=is_group, default_aeskey=default_aeskey)
        except NotImplementedError as e:
            logger.warning(f"[WecomBot] {e}")
            return None
        except Exception as e:
            logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True)
            return None

        # File cache logic (same pattern as feishu)
        from channel.file_cache import get_file_cache
        file_cache = get_file_cache()

        if is_group:
            if conf().get("group_shared_session", True):
                session_id = body.get("chatid", "")
            else:
                session_id = wecom_msg.from_user_id + "_" + body.get("chatid", "")
        else:
            session_id = wecom_msg.from_user_id

        if wecom_msg.ctype == ContextType.IMAGE:
            if hasattr(wecom_msg, "image_path") and wecom_msg.image_path:
                file_cache.add(session_id, wecom_msg.image_path, file_type="image")
                logger.info(f"[WecomBot] Image cached for session {session_id}")
            return None

        if wecom_msg.ctype == ContextType.FILE:
            wecom_msg.prepare()
            file_cache.add(session_id, wecom_msg.content, file_type="file")
            logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}")
            return None

        if wecom_msg.ctype == ContextType.TEXT:
            cached_files = file_cache.get(session_id)
            if cached_files:
                file_refs = []
                for fi in cached_files:
                    ftype = fi["type"]
                    fpath = fi["path"]
                    if ftype == "image":
                        file_refs.append(f"[图片: {fpath}]")
                    elif ftype == "video":
                        file_refs.append(f"[视频: {fpath}]")
                    else:
                        file_refs.append(f"[文件: {fpath}]")
                wecom_msg.content = wecom_msg.content + "\n" + "\n".join(file_refs)
                logger.info(f"[WecomBot] Attached {len(cached_files)} cached file(s)")
                file_cache.clear(session_id)

        context = self._compose_context(
            wecom_msg.ctype,
            wecom_msg.content,
            isgroup=is_group,
            msg=wecom_msg,
            no_need_at=True,
        )
        if not context:
            return None
        return context, wecom_msg

    # ------------------------------------------------------------------
    # Event callback
    # ------------------------------------------------------------------

    def _handle_event_callback(self, data: dict):
        body = data.get("body", {})
        event = body.get("event", {})
        event_type = event.get("eventtype", "")

        if event_type == "enter_chat":
            logger.info(f"[WecomBot] User entered chat: {body.get('from', {}).get('userid')}")
        elif event_type == "disconnected_event":
            logger.warning("[WecomBot] Received disconnected_event, another connection took over")
        else:
            logger.debug(f"[WecomBot] Event: {event_type}")

    # ------------------------------------------------------------------
    # Stream callback (for agent on_event)
    # ------------------------------------------------------------------

    def _make_stream_callback(self, req_id: str):
        """Build an on_event callback that pushes agent stream deltas to wecom via stream message.

        All intermediate segments (thinking before tool calls) and the final answer
        are accumulated into a single stream message, separated by '---'.
        Throttles push to at most once per 100ms to avoid WebSocket congestion.
        """
        stream_id = uuid.uuid4().hex[:16]
        self._stream_states[req_id] = {
            "stream_id": stream_id,
            "committed": "",
            "current": "",
            "last_push_time": 0,
            "last_push_len": 0,
        }

        def _push_stream(state: dict, force: bool = False):
            """Push current stream content to wecom (throttled unless forced)."""
            now = time.time()
            if not force and now - state["last_push_time"] < 0.1:
                return
            content = state["committed"] + state["current"]
            if len(content) == state["last_push_len"]:
                return
            state["last_push_time"] = now
            state["last_push_len"] = len(content)
            try:
                self._ws_send({
                    "cmd": "aibot_respond_msg",
                    "headers": {"req_id": req_id},
                    "body": {
                        "msgtype": "stream",
                        "stream": {
                            "id": state["stream_id"],
                            "finish": False,
                            "content": content,
                        },
                    },
                })
            except Exception as e:
                logger.warning(f"[WecomBot] Stream push failed: {e}")

        def on_event(event: dict):
            event_type = event.get("type")
            data = event.get("data", {})
            state = self._stream_states.get(req_id)
            if not state:
                return

            if event_type == "turn_start":
                state["current"] = ""

            elif event_type == "message_update":
                delta = data.get("delta", "")
                if delta:
                    state["current"] += delta
                    _push_stream(state)

            elif event_type == "message_end":
                tool_calls = data.get("tool_calls", [])
                if tool_calls:
                    if state["current"].strip():
                        state["committed"] += state["current"].strip() + "\n\n---\n\n"
                        state["current"] = ""
                else:
                    state["committed"] += state["current"]
                    state["current"] = ""
                _push_stream(state, force=True)

            elif event_type == "agent_cancelled":
                # Flush partial output and strip trailing "---" separator
                # left over from previous turn, to avoid a dangling divider.
                if state["current"]:
                    state["committed"] += state["current"]
                    state["current"] = ""
                state["committed"] = state["committed"].rstrip()
                if state["committed"].endswith("---"):
                    state["committed"] = state["committed"][:-3].rstrip()
                _push_stream(state, force=True)

        return on_event

    # ------------------------------------------------------------------
    # _compose_context (same pattern as feishu)
    # ------------------------------------------------------------------

    def _compose_context(self, ctype: ContextType, content, **kwargs):
        context = Context(ctype, content)
        context.kwargs = kwargs
        if "channel_type" not in context:
            context["channel_type"] = self.channel_type
        if "origin_ctype" not in context:
            context["origin_ctype"] = ctype

        cmsg = context["msg"]

        if cmsg.is_group:
            if conf().get("group_shared_session", True):
                context["session_id"] = cmsg.other_user_id
            else:
                context["session_id"] = f"{cmsg.from_user_id}:{cmsg.other_user_id}"
        else:
            context["session_id"] = cmsg.from_user_id

        context["receiver"] = cmsg.other_user_id

        if ctype == ContextType.TEXT:
            img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
            if img_match_prefix:
                content = content.replace(img_match_prefix, "", 1)
                context.type = ContextType.IMAGE_CREATE
            else:
                context.type = ContextType.TEXT
            context.content = content.strip()
            if "desire_rtype" not in context and conf().get("always_reply_voice"):
                context["desire_rtype"] = ReplyType.VOICE

        return context

    # ------------------------------------------------------------------
    # Callback (webhook) send: write the final reply into the stream state
    # so the next "流式消息刷新" poll returns it with finish=true.
    # ------------------------------------------------------------------

    def _callback_send(self, reply: Reply, context: Context):
        msg = context.get("msg")
        stream_id = getattr(msg, "stream_id", None) if msg else None
        if not stream_id:
            stream_id = context.get("wecom_stream_id")
        if not stream_id:
            logger.warning("[WecomBot] callback send without stream_id, dropping reply")
            return

        if reply.type == ReplyType.TEXT:
            self._callback_finalize_text(stream_id, reply.content)
        elif reply.type in (ReplyType.IMAGE_URL, ReplyType.IMAGE):
            self._callback_finalize_image(stream_id, reply.content)
        elif reply.type == ReplyType.FILE:
            # Passive callback replies only support text + image (base64); files
            # are not supported by the protocol, so append a notice to whatever
            # text the agent already streamed (do not drop it).
            text = getattr(reply, "text_content", "") or ""
            note = (text + "\n\n" if text else "") + "（文件无法在企微回调模式下直接发送）"
            self._callback_finalize_text(stream_id, note, append=True)
        elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL, ReplyType.VOICE):
            logger.warning(f"[WecomBot] reply type {reply.type} not supported in callback mode")
            text = getattr(reply, "text_content", "") or ""
            note = (text + "\n\n" if text else "") + "（该消息类型无法在企微回调模式下直接发送）"
            self._callback_finalize_text(stream_id, note, append=True)
        else:
            self._callback_finalize_text(stream_id, str(reply.content))

    def _callback_get_or_create_state(self, stream_id: str) -> dict:
        state = self._callback_streams.get(stream_id)
        if state is None:
            now = time.time()
            state = {
                "committed": "",
                "current": "",
                "finished": False,
                "images": [],
                "image_urls": [],
                "image_pending": False,
                "last_access": now,
                "created_at": now,
                "response_url": "",
                "delivered": False,
                "url_sent": False,
            }
            self._callback_streams[stream_id] = state
        return state

    def _callback_finalize_text(self, stream_id: str, content: str, append: bool = False):
        with self._callback_lock:
            state = self._callback_get_or_create_state(stream_id)
            accumulated = (state["committed"] + state["current"]).strip()
            if append and accumulated:
                state["committed"] = (accumulated + "\n\n" + (content or "")).strip()
            else:
                state["committed"] = accumulated if accumulated else (content or "")
            state["current"] = ""
            state["last_access"] = time.time()
        # Don't finish synchronously: chat_channel splits an image-with-caption
        # reply into a TEXT send followed (0.3s later) by the IMAGE send. If the
        # text finished the stream immediately, WeCom would close it before the
        # image arrives. Defer the finish so a trailing image can merge in.
        self._schedule_text_finish(stream_id)

    def _schedule_text_finish(self, stream_id: str, delay: float = 1.2):
        def _run():
            time.sleep(delay)
            with self._callback_lock:
                state = self._callback_streams.get(stream_id)
                if not state or state["finished"] or state.get("image_pending"):
                    return  # already finished, or an image reply is on its way
                state["finished"] = True
                state["last_access"] = time.time()
            self._schedule_response_url_fallback(stream_id)

        threading.Thread(target=_run, daemon=True, name=f"wecom-textfin-{stream_id}").start()

    def _callback_finalize_image(self, stream_id: str, img_path_or_url: str):
        # Mark the image as pending up front (before the slow load/compress) so a
        # preceding text finalize won't close the stream while we work.
        with self._callback_lock:
            self._callback_get_or_create_state(stream_id)["image_pending"] = True
        b64md5 = self._load_image_base64(img_path_or_url)
        with self._callback_lock:
            state = self._callback_get_or_create_state(stream_id)
            accumulated = (state["committed"] + state["current"]).strip()
            state["current"] = ""
            if b64md5:
                state["images"].append(b64md5)
                state["committed"] = accumulated
                # Remember the public url (if any) so the response_url fallback
                # can embed it as markdown when the poll window has closed.
                if img_path_or_url.startswith(("http://", "https://")):
                    state["image_urls"].append(img_path_or_url)
            else:
                state["committed"] = accumulated or "[图片发送失败]"
            state["finished"] = True
            state["image_pending"] = False
            state["last_access"] = time.time()
        self._schedule_response_url_fallback(stream_id)

    # ------------------------------------------------------------------
    # Active reply fallback (response_url): rescue replies that finish after
    # WeCom stops polling (the passive stream window is ~6 min from the user's
    # message). A short delay lets an in-flight poll deliver first; only if no
    # poll picks up the finished answer do we push it actively via response_url.
    # ------------------------------------------------------------------

    def _schedule_response_url_fallback(self, stream_id: str, delay: float = 3.0):
        def _run():
            time.sleep(delay)
            with self._callback_lock:
                state = self._callback_streams.get(stream_id)
                if not state:
                    return
                if state.get("delivered") or state.get("url_sent"):
                    return  # a poll already delivered (or fallback already ran)
                response_url = state.get("response_url") or ""
                if not response_url:
                    logger.warning(
                        f"[WecomBot] stream {stream_id} finished after poll window but no response_url; reply dropped"
                    )
                    return
                content = (state["committed"] + state["current"]).strip()
                image_urls = list(state.get("image_urls") or [])
                has_images = bool(state.get("images"))
                state["url_sent"] = True

            self._send_via_response_url(stream_id, response_url, content, image_urls, has_images)

        threading.Thread(target=_run, daemon=True, name=f"wecom-respurl-{stream_id}").start()

    def _send_via_response_url(self, stream_id, response_url, content, image_urls, has_images):
        """Push a one-shot active markdown reply to response_url (valid 1h, single use)."""
        md = content or ""
        if image_urls:
            md += ("\n\n" if md else "") + "\n".join(f"![]({u})" for u in image_urls)
        elif has_images:
            md += ("\n\n" if md else "") + "（图片已生成，但因处理超时无法通过回调发送）"
        if not md:
            md = "（处理完成）"
        payload = {"msgtype": "markdown", "markdown": {"content": md}}
        try:
            resp = requests.post(response_url, json=payload, timeout=15)
            logger.info(
                f"[WecomBot] response_url active reply sent for {stream_id}: "
                f"status={resp.status_code}, body={resp.text[:200]}"
            )
        except Exception as e:
            logger.error(f"[WecomBot] response_url active reply failed for {stream_id}: {e}")

    def _load_image_base64(self, img_path_or_url: str):
        """Load a local/remote image, ensure JPG/PNG within 10MB, return (base64, md5)."""
        local_path = img_path_or_url
        if local_path.startswith("file://"):
            local_path = local_path[7:]

        # Temp files we create here (downloads/conversions/compressions) must be
        # cleaned up afterwards; the caller's original local file must not be.
        temp_files = []
        try:
            if local_path.startswith(("http://", "https://")):
                try:
                    resp = requests.get(local_path, timeout=30)
                    resp.raise_for_status()
                    tmp_path = f"/tmp/wecom_cb_img_{uuid.uuid4().hex[:8]}"
                    with open(tmp_path, "wb") as f:
                        f.write(resp.content)
                    temp_files.append(tmp_path)
                    local_path = tmp_path
                except Exception as e:
                    logger.error(f"[WecomBot] Failed to download image for callback reply: {e}")
                    return None

            if not os.path.exists(local_path):
                logger.error(f"[WecomBot] Image file not found: {local_path}")
                return None

            formatted = self._ensure_image_format(local_path)
            if not formatted:
                return None
            if formatted != local_path:
                temp_files.append(formatted)
            local_path = formatted

            # Unlike the long-connection path (which uploads and sends only a tiny
            # media_id), the callback reply embeds the whole image as base64 inside
            # an AES-encrypted body that is returned on EVERY poll. Empirically a
            # ~1.5MB image (base64 ~2.1MB, encrypted ~2.8MB) makes WeCom reject the
            # finish packet and poll forever, so cap well below that.
            callback_max_size = 512 * 1024
            if os.path.getsize(local_path) > callback_max_size:
                compressed = self._compress_image(local_path, callback_max_size)
                if compressed:
                    temp_files.append(compressed)
                    local_path = compressed
                else:
                    logger.warning("[WecomBot] callback image compress failed; sending original (may be rejected)")

            try:
                with open(local_path, "rb") as f:
                    raw = f.read()
                return base64.b64encode(raw).decode("utf-8"), hashlib.md5(raw).hexdigest()
            except Exception as e:
                logger.error(f"[WecomBot] Failed to read image for callback reply: {e}")
                return None
        finally:
            for path in temp_files:
                try:
                    os.remove(path)
                except OSError:
                    pass

    # ------------------------------------------------------------------
    # Send reply
    # ------------------------------------------------------------------

    def send(self, reply: Reply, context: Context):
        if self.mode == "webhook":
            self._callback_send(reply, context)
            return

        msg = context.get("msg")
        is_group = context.get("isgroup", False)
        receiver = context.get("receiver", "")

        # Determine req_id for responding or use send_msg for scheduled push
        req_id = getattr(msg, "req_id", None) if msg else None

        if reply.type == ReplyType.TEXT:
            self._send_text(reply.content, receiver, is_group, req_id)
        elif reply.type in (ReplyType.IMAGE_URL, ReplyType.IMAGE):
            self._send_image(reply.content, receiver, is_group, req_id)
        elif reply.type == ReplyType.FILE:
            if hasattr(reply, "text_content") and reply.text_content:
                self._send_text(reply.text_content, receiver, is_group, req_id)
                time.sleep(0.3)
            self._send_file(reply.content, receiver, is_group, req_id)
        elif reply.type == ReplyType.VIDEO or reply.type == ReplyType.VIDEO_URL:
            self._send_file(reply.content, receiver, is_group, req_id, media_type="video")
        elif reply.type == ReplyType.VOICE:
            self._send_voice(reply.content, receiver, is_group, req_id)
        else:
            logger.warning(f"[WecomBot] Unsupported reply type: {reply.type}, falling back to text")
            self._send_text(str(reply.content), receiver, is_group, req_id)

    # ------------------------------------------------------------------
    # Respond message (via websocket)
    # ------------------------------------------------------------------

    def _send_text(self, content: str, receiver: str, is_group: bool, req_id: str = None):
        """Send text/markdown reply. Reuses stream state if available (streaming mode)."""
        if req_id:
            state = self._stream_states.pop(req_id, None)
            if state:
                final_content = state["committed"] if state["committed"] else content
                stream_id = state["stream_id"]
            else:
                final_content = content
                stream_id = uuid.uuid4().hex[:16]

            # Brief pause so the server finishes processing the last intermediate chunk
            # before receiving the finish packet
            time.sleep(0.15)

            self._ws_send({
                "cmd": "aibot_respond_msg",
                "headers": {"req_id": req_id},
                "body": {
                    "msgtype": "stream",
                    "stream": {
                        "id": stream_id,
                        "finish": True,
                        "content": final_content,
                    },
                },
            })
        else:
            self._active_send_markdown(content, receiver, is_group)

    def _send_image(self, img_path_or_url: str, receiver: str, is_group: bool, req_id: str = None):
        """Send image reply. Converts to JPG/PNG and compresses if >2MB."""
        local_path = img_path_or_url
        if local_path.startswith("file://"):
            local_path = local_path[7:]

        if local_path.startswith(("http://", "https://")):
            try:
                resp = requests.get(local_path, timeout=30)
                resp.raise_for_status()
                ct = resp.headers.get("Content-Type", "")
                if "jpeg" in ct or "jpg" in ct:
                    ext = ".jpg"
                elif "webp" in ct:
                    ext = ".webp"
                elif "gif" in ct:
                    ext = ".gif"
                else:
                    ext = ".png"
                tmp_path = f"/tmp/wecom_img_{uuid.uuid4().hex[:8]}{ext}"
                with open(tmp_path, "wb") as f:
                    f.write(resp.content)
                logger.info(f"[WecomBot] Image downloaded: size={len(resp.content)}, "
                            f"content-type={ct}, path={tmp_path}")
                local_path = tmp_path
            except Exception as e:
                logger.error(f"[WecomBot] Failed to download image for sending: {e}")
                self._send_text("[Image send failed]", receiver, is_group, req_id)
                return

        if not os.path.exists(local_path):
            logger.error(f"[WecomBot] Image file not found: {local_path}")
            return

        max_image_size = 2 * 1024 * 1024  # 2MB limit for image upload
        local_path = self._ensure_image_format(local_path)
        if not local_path:
            self._send_text("[Image format conversion failed]", receiver, is_group, req_id)
            return

        if os.path.getsize(local_path) > max_image_size:
            local_path = self._compress_image(local_path, max_image_size)
            if not local_path:
                self._send_text("[Image too large]", receiver, is_group, req_id)
                return

        file_size = os.path.getsize(local_path)
        logger.info(f"[WecomBot] Uploading image: path={local_path}, size={file_size} bytes")
        media_id = self._upload_media(local_path, "image")
        if not media_id:
            logger.error("[WecomBot] Failed to upload image")
            self._send_text("[Image upload failed]", receiver, is_group, req_id)
            return

        if req_id:
            self._ws_send({
                "cmd": "aibot_respond_msg",
                "headers": {"req_id": req_id},
                "body": {
                    "msgtype": "image",
                    "image": {"media_id": media_id},
                },
            })
        else:
            self._ws_send({
                "cmd": "aibot_send_msg",
                "headers": {"req_id": self._gen_req_id()},
                "body": {
                    "chatid": receiver,
                    "chat_type": 2 if is_group else 1,
                    "msgtype": "image",
                    "image": {"media_id": media_id},
                },
            })

    @staticmethod
    def _ensure_image_format(file_path: str) -> str:
        """Ensure image is JPG or PNG (the only formats wecom supports). Convert if needed."""
        try:
            from PIL import Image
            img = Image.open(file_path)
            fmt = (img.format or "").upper()
            if fmt in ("JPEG", "PNG"):
                # Already a supported format, but make sure the filename extension matches
                ext = os.path.splitext(file_path)[1].lower()
                if fmt == "JPEG" and ext in (".jpg", ".jpeg"):
                    return file_path
                if fmt == "PNG" and ext == ".png":
                    return file_path
                # Extension doesn't match — rename/copy with correct extension
                correct_ext = ".jpg" if fmt == "JPEG" else ".png"
                out_path = f"/tmp/wecom_fmt_{uuid.uuid4().hex[:8]}{correct_ext}"
                img.save(out_path, fmt)
                logger.info(f"[WecomBot] Image renamed: {file_path} -> {out_path} ({fmt})")
                return out_path

            # Unsupported format (WebP, GIF, BMP, etc.) — convert to PNG
            if img.mode == "RGBA":
                out_path = f"/tmp/wecom_fmt_{uuid.uuid4().hex[:8]}.png"
                img.save(out_path, "PNG")
            else:
                out_path = f"/tmp/wecom_fmt_{uuid.uuid4().hex[:8]}.jpg"
                img.convert("RGB").save(out_path, "JPEG", quality=90)
            logger.info(f"[WecomBot] Image converted from {fmt} -> {out_path}")
            return out_path
        except Exception as e:
            logger.error(f"[WecomBot] Image format check failed: {e}")
            return file_path

    @staticmethod
    def _compress_image(file_path: str, max_bytes: int) -> str:
        """Compress image to fit within max_bytes. Returns new path or empty string."""
        try:
            from PIL import Image
            img = Image.open(file_path)
            if img.mode == "RGBA":
                img = img.convert("RGB")

            out_path = f"/tmp/wecom_compressed_{uuid.uuid4().hex[:8]}.jpg"
            quality = 85
            while quality >= 30:
                img.save(out_path, "JPEG", quality=quality, optimize=True)
                if os.path.getsize(out_path) <= max_bytes:
                    logger.info(f"[WecomBot] Image compressed: quality={quality}, "
                                f"size={os.path.getsize(out_path)} bytes")
                    return out_path
                quality -= 10

            # Still too large — resize
            ratio = (max_bytes / os.path.getsize(out_path)) ** 0.5
            new_size = (int(img.width * ratio), int(img.height * ratio))
            img = img.resize(new_size, Image.LANCZOS)
            img.save(out_path, "JPEG", quality=70, optimize=True)
            if os.path.getsize(out_path) <= max_bytes:
                logger.info(f"[WecomBot] Image compressed with resize: {new_size}, "
                            f"size={os.path.getsize(out_path)} bytes")
                return out_path

            logger.error(f"[WecomBot] Cannot compress image below {max_bytes} bytes")
            return ""
        except Exception as e:
            logger.error(f"[WecomBot] Image compression failed: {e}")
            return ""

    def _send_file(self, file_path: str, receiver: str, is_group: bool,
                   req_id: str = None, media_type: str = "file"):
        """Send file/video reply by uploading media first."""
        local_path = file_path
        if local_path.startswith("file://"):
            local_path = local_path[7:]

        if local_path.startswith(("http://", "https://")):
            try:
                resp = requests.get(local_path, timeout=60)
                resp.raise_for_status()
                ext = os.path.splitext(local_path)[1] or ".bin"
                tmp_path = f"/tmp/wecom_file_{uuid.uuid4().hex[:8]}{ext}"
                with open(tmp_path, "wb") as f:
                    f.write(resp.content)
                local_path = tmp_path
            except Exception as e:
                logger.error(f"[WecomBot] Failed to download file for sending: {e}")
                return

        if not os.path.exists(local_path):
            logger.error(f"[WecomBot] File not found: {local_path}")
            return

        media_id = self._upload_media(local_path, media_type)
        if not media_id:
            logger.error(f"[WecomBot] Failed to upload {media_type}")
            return

        if req_id:
            self._ws_send({
                "cmd": "aibot_respond_msg",
                "headers": {"req_id": req_id},
                "body": {
                    "msgtype": media_type,
                    media_type: {"media_id": media_id},
                },
            })
        else:
            self._ws_send({
                "cmd": "aibot_send_msg",
                "headers": {"req_id": self._gen_req_id()},
                "body": {
                    "chatid": receiver,
                    "chat_type": 2 if is_group else 1,
                    "msgtype": media_type,
                    media_type: {"media_id": media_id},
                },
            })

    def _send_voice(self, voice_path: str, receiver: str, is_group: bool, req_id: str = None):
        """Send native voice reply. WeCom voice media must be amr."""
        local_path = voice_path
        if local_path.startswith("file://"):
            local_path = local_path[7:]

        if local_path.startswith(("http://", "https://")):
            try:
                resp = requests.get(local_path, timeout=60)
                resp.raise_for_status()
                ext = os.path.splitext(local_path)[1] or ".mp3"
                tmp_path = f"/tmp/wecom_voice_{uuid.uuid4().hex[:8]}{ext}"
                with open(tmp_path, "wb") as f:
                    f.write(resp.content)
                local_path = tmp_path
            except Exception as e:
                logger.error(f"[WecomBot] Failed to download voice for sending: {e}")
                return

        if not os.path.exists(local_path):
            logger.error(f"[WecomBot] Voice file not found: {local_path}")
            return

        amr_path = local_path
        if not local_path.lower().endswith(".amr"):
            try:
                from voice.audio_convert import any_to_amr
                amr_path = os.path.splitext(local_path)[0] + ".amr"
                any_to_amr(local_path, amr_path)
            except Exception as e:
                logger.error(f"[WecomBot] Failed to convert voice to amr: {e}")
                return

        media_id = self._upload_media(amr_path, "voice")
        if not media_id:
            logger.error("[WecomBot] Failed to upload voice media")
            return

        if req_id:
            self._ws_send({
                "cmd": "aibot_respond_msg",
                "headers": {"req_id": req_id},
                "body": {
                    "msgtype": "voice",
                    "voice": {"media_id": media_id},
                },
            })
        else:
            self._ws_send({
                "cmd": "aibot_send_msg",
                "headers": {"req_id": self._gen_req_id()},
                "body": {
                    "chatid": receiver,
                    "chat_type": 2 if is_group else 1,
                    "msgtype": "voice",
                    "voice": {"media_id": media_id},
                },
            })

    def _active_send_markdown(self, content: str, receiver: str, is_group: bool):
        """Proactively send markdown message (for scheduled tasks, no req_id)."""
        self._ws_send({
            "cmd": "aibot_send_msg",
            "headers": {"req_id": self._gen_req_id()},
            "body": {
                "chatid": receiver,
                "chat_type": 2 if is_group else 1,
                "msgtype": "markdown",
                "markdown": {"content": content},
            },
        })

    # ------------------------------------------------------------------
    # Media upload (chunked)
    # ------------------------------------------------------------------

    def _upload_media(self, file_path: str, media_type: str = "file") -> str:
        """
        Upload a local file to wecom bot via chunked upload protocol.
        Returns media_id on success, empty string on failure.
        """
        if not os.path.exists(file_path):
            logger.error(f"[WecomBot] Upload file not found: {file_path}")
            return ""

        file_size = os.path.getsize(file_path)
        if file_size < 5:
            logger.error(f"[WecomBot] File too small: {file_size} bytes")
            return ""

        filename = os.path.basename(file_path)
        total_chunks = math.ceil(file_size / MEDIA_CHUNK_SIZE)
        if total_chunks > 100:
            logger.error(f"[WecomBot] Too many chunks: {total_chunks} > 100")
            return ""

        file_md5 = hashlib.md5()
        with open(file_path, "rb") as f:
            for block in iter(lambda: f.read(8192), b""):
                file_md5.update(block)
        md5_hex = file_md5.hexdigest()

        # 1. Init upload
        init_resp = self._send_and_wait({
            "cmd": "aibot_upload_media_init",
            "headers": {"req_id": self._gen_req_id()},
            "body": {
                "type": media_type,
                "filename": filename,
                "total_size": file_size,
                "total_chunks": total_chunks,
                "md5": md5_hex,
            },
        }, timeout=15)

        if init_resp.get("errcode") != 0:
            logger.error(f"[WecomBot] Upload init failed: {init_resp}")
            return ""

        upload_id = init_resp.get("body", {}).get("upload_id")
        if not upload_id:
            logger.error("[WecomBot] Failed to get upload_id")
            return ""

        # 2. Upload chunks
        with open(file_path, "rb") as f:
            for idx in range(total_chunks):
                chunk = f.read(MEDIA_CHUNK_SIZE)
                b64_data = base64.b64encode(chunk).decode("utf-8")
                chunk_resp = self._send_and_wait({
                    "cmd": "aibot_upload_media_chunk",
                    "headers": {"req_id": self._gen_req_id()},
                    "body": {
                        "upload_id": upload_id,
                        "chunk_index": idx,
                        "base64_data": b64_data,
                    },
                }, timeout=30)
                if chunk_resp.get("errcode") != 0:
                    logger.error(f"[WecomBot] Chunk {idx} upload failed: {chunk_resp}")
                    return ""

        # 3. Finish upload
        finish_resp = self._send_and_wait({
            "cmd": "aibot_upload_media_finish",
            "headers": {"req_id": self._gen_req_id()},
            "body": {"upload_id": upload_id},
        }, timeout=30)

        if finish_resp.get("errcode") != 0:
            logger.error(f"[WecomBot] Upload finish failed: {finish_resp}")
            return ""

        media_id = finish_resp.get("body", {}).get("media_id", "")
        if media_id:
            logger.info(f"[WecomBot] Media uploaded: media_id={media_id}")
        else:
            logger.error("[WecomBot] Failed to get media_id from finish response")
        return media_id


class WecomBotCallbackController:
    """HTTP controller for wecom bot callback (webhook) mode.

    - GET  : URL verification (echo the decrypted echostr).
    - POST : encrypted message / stream-refresh / event callbacks; returns an
             encrypted passive reply (or "success" for an empty reply).
    """

    @staticmethod
    def _channel() -> "WecomBotChannel":
        return WecomBotChannel()

    def GET(self):
        channel = self._channel()
        params = web.input(msg_signature="", timestamp="", nonce="", echostr="")
        if not channel._crypt:
            return "wecom bot callback not ready"
        ret, echo = channel._crypt.verify_url(
            params.msg_signature, params.timestamp, params.nonce, params.echostr
        )
        if ret != 0:
            logger.error(f"[WecomBot] URL verify failed: ret={ret}")
            return "verify fail"
        if isinstance(echo, bytes):
            echo = echo.decode("utf-8")
        return echo

    def POST(self):
        channel = self._channel()
        if not channel._crypt:
            return "success"

        params = web.input(msg_signature="", timestamp="", nonce="")
        body = web.data()
        ret, plain = channel._crypt.decrypt_msg(
            body, params.msg_signature, params.timestamp, params.nonce
        )
        if ret != 0:
            logger.error(f"[WecomBot] callback decrypt failed: ret={ret}")
            return "success"

        try:
            data = json.loads(plain)
        except Exception as e:
            logger.error(f"[WecomBot] callback json parse failed: {e}")
            return "success"

        msgtype = data.get("msgtype", "")
        # Stream polls arrive ~1/s; logging each is noisy, so only log non-poll
        # callbacks here (poll completion is logged in the stream-poll handler).
        if msgtype != "stream":
            logger.debug(f"[WecomBot] callback received msgtype={msgtype}")

        try:
            if msgtype == "stream":
                reply = channel._callback_handle_stream_poll(data)
            elif msgtype == "event":
                event_type = data.get("event", {}).get("eventtype", "")
                logger.info(f"[WecomBot] callback event: {event_type}")
                reply = None
            elif msgtype in ("text", "image", "voice", "file", "video", "mixed"):
                reply = channel._callback_handle_message(data)
            else:
                logger.warning(f"[WecomBot] unsupported callback msgtype: {msgtype}")
                reply = None
        except Exception as e:
            logger.error(f"[WecomBot] callback handling error: {e}", exc_info=True)
            reply = None

        if not reply:
            # Empty reply package is acceptable.
            return "success"

        plain_reply = json.dumps(reply, ensure_ascii=False)
        ret, enc = channel._crypt.encrypt_msg(plain_reply, params.nonce, params.timestamp)
        if ret != 0:
            logger.error(f"[WecomBot] callback encrypt failed: ret={ret}")
            return "success"
        web.header("Content-Type", "application/json; charset=utf-8")
        return json.dumps(enc, ensure_ascii=False)
