"""
飞书通道接入

支持两种事件接收模式:
1. webhook模式: 通过HTTP服务器接收事件(需要公网IP)
2. websocket模式: 通过长连接接收事件(本地开发友好)

通过配置项 feishu_event_mode 选择模式: "webhook" 或 "websocket"

@author Saboteur7
@Date 2023/11/19
"""

import importlib.util
import json
import logging
import os
import ssl
import threading
# -*- coding=utf-8 -*-
import uuid

import requests
import web

from bridge.context import Context
from bridge.context import ContextType
from bridge.reply import Reply, ReplyType
from channel.chat_channel import ChatChannel, check_prefix
from channel.feishu.feishu_message import FeishuMessage
from common import utils
from common.expired_dict import ExpiredDict
from common.log import logger
from common.singleton import singleton
from config import conf

# Suppress verbose logs from Lark SDK
logging.getLogger("Lark").setLevel(logging.WARNING)

URL_VERIFICATION = "url_verification"

# Lazy-check for lark_oapi SDK availability without importing it at module level.
# The full `import lark_oapi` pulls in 10k+ files and takes 4-10s, so we defer
# the actual import to _startup_websocket() where it is needed.
LARK_SDK_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
lark = None  # will be populated on first use via _ensure_lark_imported()


def _ensure_lark_imported():
    """Import lark_oapi on first use (takes 4-10s due to 10k+ source files)."""
    global lark
    if lark is None:
        import lark_oapi as _lark
        lark = _lark
    return lark


def _print_qr_to_terminal(qr_url: str):
    """Render a QR code as ASCII art and emit it via logger.

    走 logger 而非 print 是为了避免 nohup/cow 后台启动场景下 stdout 块缓冲导致
    二维码滞后输出（看起来像出现了两次）。logger 的 StreamHandler 是行缓冲，
    既能在前台终端看到，也能进 run.log。
    """
    qr_lines = []
    try:
        import qrcode as qr_lib
        import io
        qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L, box_size=1, border=1)
        qr.add_data(qr_url)
        qr.make(fit=True)
        buf = io.StringIO()
        qr.print_ascii(out=buf, invert=True)
        qr_lines = buf.getvalue().splitlines()
    except ImportError:
        qr_lines = ["(未安装 qrcode 包，无法渲染 ASCII 二维码：pip install qrcode)"]
    except Exception as e:
        qr_lines = [f"(渲染二维码失败：{e})"]

    header = "=" * 60
    banner = [
        "",
        header,
        "  飞书一键创建应用：请使用 飞书 App 扫描下方二维码",
        "  （二维码 10 分钟内有效，仅供一次扫描）",
        header,
    ]
    footer = [
        f"  或点击链接创建: {qr_url}",
        "  等待扫码...",
        "",
    ]
    full = banner + qr_lines + footer
    logger.info("[FeiShu] One-click 飞书应用创建二维码（请用飞书 App 扫码）：\n" + "\n".join(full))


def _persist_feishu_credentials(app_id: str, app_secret: str) -> bool:
    """Write feishu_app_id / feishu_app_secret + ensure feishu in channel_type into config.json.

    Returns True on success, False on failure (e.g. config.json missing or unwritable).
    """
    try:
        config_path = os.path.join(
            os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
            "config.json",
        )
        if os.path.exists(config_path):
            with open(config_path, "r", encoding="utf-8") as f:
                file_cfg = json.load(f)
        else:
            file_cfg = {}

        file_cfg["feishu_app_id"] = app_id
        file_cfg["feishu_app_secret"] = app_secret

        # 保证 channel_type 中包含 feishu（用户可能纯通过 CLI 启动单通道）
        ch_type = file_cfg.get("channel_type", conf().get("channel_type", "")) or ""
        existing = [s.strip() for s in ch_type.split(",") if s.strip()]
        if "feishu" not in existing:
            existing.append("feishu")
            file_cfg["channel_type"] = ",".join(existing)

        with open(config_path, "w", encoding="utf-8") as f:
            json.dump(file_cfg, f, indent=4, ensure_ascii=False)

        # 同步到内存中的 conf()，让本次启动直接生效
        conf()["feishu_app_id"] = app_id
        conf()["feishu_app_secret"] = app_secret
        if "channel_type" in file_cfg:
            conf()["channel_type"] = file_cfg["channel_type"]

        try:
            os.chmod(config_path, 0o600)
        except Exception:
            pass
        return True
    except Exception as e:
        logger.error(f"[FeiShu] Failed to persist credentials to config.json: {e}")
        return False


def _register_via_qr_in_terminal() -> bool:
    """CLI-side one-click app creation via lark_oapi.register_app.

    Blocks the calling thread (typically the channel startup thread) until the user
    finishes scanning, the QR code expires, or registration is cancelled.

    Returns True if credentials were obtained AND persisted; False otherwise.
    The caller should fall back to the original "missing credentials" error in that case.
    """
    if not LARK_SDK_AVAILABLE:
        logger.error(
            "[FeiShu] 缺少 feishu_app_id / feishu_app_secret。"
            "未安装 lark-oapi SDK，无法在终端发起扫码创建。"
            "请执行 pip install -U 'lark-oapi>=1.5.5' 后重试，或手动在 config.json 中填入凭据。"
        )
        return False

    try:
        lark_mod = _ensure_lark_imported()
    except Exception as e:
        logger.error(f"[FeiShu] Import lark_oapi failed: {e}")
        return False

    # register_app 是 lark-oapi 1.5.5 才引入的能力，旧版本调用会得到难以理解的
    # AttributeError。提前显式检查，给出明确的升级提示。
    if not hasattr(lark_mod, "register_app"):
        try:
            from importlib.metadata import version as _pkg_version
            installed = _pkg_version("lark-oapi")
        except Exception:
            installed = "unknown"
        logger.error(
            f"[FeiShu] 当前 lark-oapi 版本 ({installed}) 不支持一键创建应用，需要 >= 1.5.5。"
            "请执行 pip install -U 'lark-oapi>=1.5.5' 后重试，或手动在 config.json 中填入凭据。"
        )
        return False

    logger.info("[FeiShu] 检测到尚未配置 feishu_app_id / feishu_app_secret，"
                "正在向飞书申请一键创建应用...")

    def _on_qr(info):
        url = info.get("url", "")
        if url:
            _print_qr_to_terminal(url)

    def _on_status(info):
        # 过滤 polling 心跳（每 5 秒一次），保留 slow_down / domain_switched 等
        status = info.get("status")
        if status == "polling":
            return
        logger.info(f"[FeiShu] register_app status: {info}")

    try:
        result = lark_mod.register_app(
            on_qr_code=_on_qr,
            on_status_change=_on_status,
            source="cowagent",
        )
    except Exception as e:
        err_cls = e.__class__.__name__
        if "Expired" in err_cls:
            logger.error("[FeiShu] 二维码已过期，请重启程序后重试。")
        elif "Denied" in err_cls:
            logger.error("[FeiShu] 已取消授权。")
        else:
            logger.error(f"[FeiShu] 一键创建失败：{e}")
        return False

    app_id = result.get("client_id", "")
    app_secret = result.get("client_secret", "")
    if not app_id or not app_secret:
        logger.error("[FeiShu] 创建结果缺少 app_id/app_secret，无法继续。")
        return False

    if not _persist_feishu_credentials(app_id, app_secret):
        logger.error(
            "[FeiShu] 应用创建成功但写入 config.json 失败，请手动复制以下值到配置文件：\n"
            f"        feishu_app_id     = {app_id}\n"
            f"        feishu_app_secret = {app_secret}"
        )
        return False

    logger.info(f"[FeiShu] 应用创建成功，凭据已写入 config.json (app_id={app_id})。")
    return True


@singleton
class FeiShuChanel(ChatChannel):
    feishu_app_id = conf().get('feishu_app_id')
    feishu_app_secret = conf().get('feishu_app_secret')
    feishu_token = conf().get('feishu_token')
    feishu_event_mode = conf().get('feishu_event_mode', 'websocket')  # webhook 或 websocket
    # 覆盖父类默认值 [ReplyType.VOICE, ReplyType.IMAGE]。
    # 飞书原生支持发送音频（opus 格式，通过文件上传接口）和图片，
    # 所有回复类型均已处理，置为空列表以启用语音和图片回复。
    NOT_SUPPORT_REPLYTYPE = []

    def __init__(self):
        super().__init__()
        # 历史消息id暂存，用于幂等控制
        self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
        self._http_server = None
        self._ws_client = None
        self._ws_thread = None
        self._bot_open_id = None  # cached bot open_id for @-mention matching
        logger.debug("[FeiShu] app_id={}, app_secret={}, verification_token={}, event_mode={}".format(
            self.feishu_app_id, self.feishu_app_secret, self.feishu_token, self.feishu_event_mode))
        # 无需群校验和前缀
        conf()["group_name_white_list"] = ["ALL_GROUP"]
        conf()["single_chat_prefix"] = [""]

        # 验证配置
        if self.feishu_event_mode == 'websocket' and not LARK_SDK_AVAILABLE:
            logger.error("[FeiShu] websocket mode requires lark_oapi. Please install: pip install lark-oapi")
            raise Exception("lark_oapi not installed")

    def startup(self):
        self.feishu_app_id = conf().get('feishu_app_id')
        self.feishu_app_secret = conf().get('feishu_app_secret')
        self.feishu_token = conf().get('feishu_token')
        self.feishu_event_mode = conf().get('feishu_event_mode', 'websocket')

        # 命令行启动场景：缺少凭据时尝试通过 lark.register_app 在终端弹二维码
        # 引导用户扫码创建应用。Web 控制台启动同样会走到这里，但控制台用户通常
        # 已经通过 /api/feishu/register 完成了创建并写回 config.json。
        if not self.feishu_app_id or not self.feishu_app_secret:
            if _register_via_qr_in_terminal():
                self.feishu_app_id = conf().get('feishu_app_id')
                self.feishu_app_secret = conf().get('feishu_app_secret')
            else:
                err = "[FeiShu] feishu_app_id 与 feishu_app_secret 缺失，无法启动通道"
                logger.error(err)
                self.report_startup_error(err)
                return

        self._fetch_bot_open_id()
        if self.feishu_event_mode == 'websocket':
            self._startup_websocket()
        else:
            self._startup_webhook()

    def _fetch_bot_open_id(self):
        """Fetch the bot's own open_id via API so we can match @-mentions without feishu_bot_name."""
        try:
            access_token = self.fetch_access_token()
            if not access_token:
                logger.warning("[FeiShu] Cannot fetch bot info: no access_token")
                return
            headers = {"Authorization": "Bearer " + access_token}
            resp = requests.get("https://open.feishu.cn/open-apis/bot/v3/info/", headers=headers, timeout=5)
            if resp.status_code == 200:
                data = resp.json()
                if data.get("code") == 0:
                    self._bot_open_id = data.get("bot", {}).get("open_id")
                    logger.info(f"[FeiShu] Bot open_id fetched: {self._bot_open_id}")
                else:
                    logger.warning(f"[FeiShu] Fetch bot info failed: code={data.get('code')}, msg={data.get('msg')}")
        except Exception as e:
            logger.warning(f"[FeiShu] Fetch bot open_id error: {e}")

    def stop(self):
        import ctypes
        logger.info("[FeiShu] stop() called")
        ws_client = self._ws_client
        self._ws_client = None
        ws_thread = self._ws_thread
        self._ws_thread = None
        # Interrupt the ws thread first so its blocking start() unblocks
        if ws_thread and ws_thread.is_alive():
            try:
                tid = ws_thread.ident
                if tid:
                    res = ctypes.pythonapi.PyThreadState_SetAsyncExc(
                        ctypes.c_ulong(tid), ctypes.py_object(SystemExit)
                    )
                    if res == 1:
                        logger.info("[FeiShu] Interrupted ws thread via ctypes")
                    elif res > 1:
                        ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_ulong(tid), None)
            except Exception as e:
                logger.warning(f"[FeiShu] Error interrupting ws thread: {e}")
        # lark.ws.Client has no stop() method; thread interruption above is sufficient
        if self._http_server:
            try:
                self._http_server.stop()
                logger.info("[FeiShu] HTTP server stopped")
            except Exception as e:
                logger.warning(f"[FeiShu] Error stopping HTTP server: {e}")
            self._http_server = None
        logger.info("[FeiShu] stop() completed")

    def _startup_webhook(self):
        """启动HTTP服务器接收事件(webhook模式)"""
        logger.debug("[FeiShu] Starting in webhook mode...")
        urls = (
            '/', 'channel.feishu.feishu_channel.FeishuController'
        )
        app = web.application(urls, globals(), autoreload=False)
        port = conf().get("feishu_port", 9891)
        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
        try:
            server.start()
        except (KeyboardInterrupt, SystemExit):
            server.stop()

    def _startup_websocket(self):
        """启动长连接接收事件(websocket模式)"""
        _ensure_lark_imported()
        logger.debug("[FeiShu] Starting in websocket mode...")

        # 创建事件处理器
        def handle_message_event(data: lark.im.v1.P2ImMessageReceiveV1) -> None:
            """处理接收消息事件 v2.0"""
            try:
                event_dict = json.loads(lark.JSON.marshal(data))
                event = event_dict.get("event", {})
                msg = event.get("message", {})

                # Skip group messages that don't @-mention the bot (reduce log noise)
                if msg.get("chat_type") == "group" and not msg.get("mentions") and msg.get("message_type") == "text":
                    return

                logger.debug(f"[FeiShu] websocket receive event: {lark.JSON.marshal(data, indent=2)}")

                # 处理消息
                self._handle_message_event(event)

            except Exception as e:
                logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True)

        # 构建事件分发器
        event_handler = lark.EventDispatcherHandler.builder("", "") \
            .register_p2_im_message_receive_v1(handle_message_event) \
            .build()

        def start_client_with_retry():
            """Run ws client in this thread with its own event loop to avoid conflicts."""
            import asyncio
            import ssl as ssl_module
            original_create_default_context = ssl_module.create_default_context

            def create_unverified_context(*args, **kwargs):
                context = original_create_default_context(*args, **kwargs)
                context.check_hostname = False
                context.verify_mode = ssl.CERT_NONE
                return context

            # lark_oapi.ws.client captures the event loop at module-import time as a module-
            # level global variable.  When a previous ws thread is force-killed via ctypes its
            # loop may still be marked as "running", which causes the next ws_client.start()
            # call (in this new thread) to raise "This event loop is already running".
            # Fix: replace the module-level loop with a brand-new, idle loop before starting.
            loop = asyncio.new_event_loop()
            asyncio.set_event_loop(loop)
            try:
                import lark_oapi.ws.client as _lark_ws_client_mod
                _lark_ws_client_mod.loop = loop
            except Exception:
                pass

            startup_error = None
            for attempt in range(2):
                try:
                    if attempt == 1:
                        logger.warning("[FeiShu] Retrying with SSL verification disabled...")
                        ssl_module.create_default_context = create_unverified_context
                        ssl_module._create_unverified_context = create_unverified_context

                    ws_client = lark.ws.Client(
                        self.feishu_app_id,
                        self.feishu_app_secret,
                        event_handler=event_handler,
                        log_level=lark.LogLevel.WARNING
                    )
                    self._ws_client = ws_client
                    logger.debug("[FeiShu] Websocket client starting...")
                    ws_client.start()
                    break

                except (SystemExit, KeyboardInterrupt):
                    logger.info("[FeiShu] Websocket thread received stop signal")
                    break
                except Exception as e:
                    error_msg = str(e)
                    is_ssl_error = ("CERTIFICATE_VERIFY_FAILED" in error_msg
                                    or "certificate verify failed" in error_msg.lower())
                    if is_ssl_error and attempt == 0:
                        logger.warning(f"[FeiShu] SSL error: {error_msg}, retrying...")
                        continue
                    logger.error(f"[FeiShu] Websocket client error: {e}", exc_info=True)
                    startup_error = error_msg
                    ssl_module.create_default_context = original_create_default_context
                    break
            if startup_error:
                self.report_startup_error(startup_error)
            try:
                loop.close()
            except Exception:
                pass
            logger.info("[FeiShu] Websocket thread exited")

        ws_thread = threading.Thread(target=start_client_with_retry, daemon=True)
        self._ws_thread = ws_thread
        ws_thread.start()
        logger.info("[FeiShu] ✅ Websocket thread started, ready to receive messages")
        ws_thread.join()

    def _is_mention_bot(self, mentions: list) -> bool:
        """Check whether any mention in the list refers to this bot.

        Priority:
        1. Match by open_id (obtained from /bot/v3/info at startup, no config needed)
        2. Fallback to feishu_bot_name config for backward compatibility
        3. If neither is available, assume the first mention is the bot (Feishu only
           delivers group messages that @-mention the bot, so this is usually correct)
        """
        if self._bot_open_id:
            return any(
                m.get("id", {}).get("open_id") == self._bot_open_id
                for m in mentions
            )
        bot_name = conf().get("feishu_bot_name")
        if bot_name:
            return any(m.get("name") == bot_name for m in mentions)
        # Feishu event subscription only delivers messages that @-mention the bot,
        # so reaching here means the bot was indeed mentioned.
        return True

    def _handle_message_event(self, event: dict):
        """
        处理消息事件的核心逻辑
        webhook和websocket模式共用此方法
        """
        if not event.get("message") or not event.get("sender"):
            logger.warning(f"[FeiShu] invalid message, event={event}")
            return

        msg = event.get("message")

        # 幂等判断
        msg_id = msg.get("message_id")
        if self.receivedMsgs.get(msg_id):
            logger.warning(f"[FeiShu] repeat msg filtered, msg_id={msg_id}")
            return
        self.receivedMsgs[msg_id] = True

        # Filter out stale messages from before channel startup (offline backlog)
        import time as _time
        create_time_ms = msg.get("create_time")
        if create_time_ms:
            msg_age_s = _time.time() - int(create_time_ms) / 1000
            if msg_age_s > 60:
                logger.warning(f"[FeiShu] stale msg filtered (age={msg_age_s:.0f}s), msg_id={msg_id}")
                return

        is_group = False
        chat_type = msg.get("chat_type")

        if chat_type == "group":
            if not msg.get("mentions") and msg.get("message_type") == "text":
                # 群聊中未@不响应
                return
            if msg.get("mentions") and msg.get("message_type") == "text":
                if not self._is_mention_bot(msg.get("mentions")):
                    return
            # 群聊
            is_group = True
            receive_id_type = "chat_id"
        elif chat_type == "p2p":
            receive_id_type = "open_id"
        else:
            logger.warning("[FeiShu] message ignore")
            return

        # 构造飞书消息对象
        feishu_msg = FeishuMessage(event, is_group=is_group, access_token=self.fetch_access_token())
        if not feishu_msg:
            return

        # 处理文件缓存逻辑
        from channel.file_cache import get_file_cache
        file_cache = get_file_cache()

        # 获取 session_id（用于缓存关联）
        if is_group:
            if conf().get("group_shared_session", True):
                session_id = msg.get("chat_id")  # 群共享会话
            else:
                session_id = feishu_msg.from_user_id + "_" + msg.get("chat_id")
        else:
            session_id = feishu_msg.from_user_id

        # 如果是单张图片消息，缓存起来
        if feishu_msg.ctype == ContextType.IMAGE:
            if hasattr(feishu_msg, 'image_path') and feishu_msg.image_path:
                file_cache.add(session_id, feishu_msg.image_path, file_type='image')
                logger.info(f"[FeiShu] Image cached for session {session_id}, waiting for user query...")
            # 单张图片不直接处理，等待用户提问
            return

        # 如果是文件消息，触发实际下载并缓存，等待用户后续提问时一并带上。
        # 与 wecom_bot 行为对齐：发文件后静默缓存（飞书客户端会显示"已读"），
        # 用户下一条文本消息会自动 attach 上文件路径给 agent。
        if feishu_msg.ctype == ContextType.FILE:
            try:
                feishu_msg.prepare()
                # prepare 通过 _prepared 标记保证幂等，重复调用安全
                if not os.path.exists(feishu_msg.content):
                    raise FileNotFoundError(feishu_msg.content)
            except Exception as e:
                logger.warning(f"[FeiShu] prepare file failed: {e}")
                # 文件下载失败时主动通知用户，避免静默丢失
                try:
                    err_reply = Reply(ReplyType.TEXT, f"⚠️ 文件下载失败，请重新发送：{e}")
                    self._send(err_reply, self._compose_context(
                        ContextType.TEXT, "",
                        isgroup=is_group, msg=feishu_msg,
                        receive_id_type=receive_id_type, no_need_at=True,
                    ))
                except Exception:
                    pass
                return
            file_cache.add(session_id, feishu_msg.content, file_type='file')
            logger.info(f"[FeiShu] File cached for session {session_id}: {feishu_msg.content}")
            return

        # 如果是文本消息，检查是否有缓存的文件
        if feishu_msg.ctype == ContextType.TEXT:
            cached_files = file_cache.get(session_id)
            if cached_files:
                # 将缓存的文件附加到文本消息中
                file_refs = []
                for file_info in cached_files:
                    file_path = file_info['path']
                    file_type = file_info['type']
                    if file_type == 'image':
                        file_refs.append(f"[图片: {file_path}]")
                    elif file_type == 'video':
                        file_refs.append(f"[视频: {file_path}]")
                    else:
                        file_refs.append(f"[文件: {file_path}]")

                feishu_msg.content = feishu_msg.content + "\n" + "\n".join(file_refs)
                logger.info(f"[FeiShu] Attached {len(cached_files)} cached file(s) to user query")
                # 清除缓存
                file_cache.clear(session_id)

        context = self._compose_context(
            feishu_msg.ctype,
            feishu_msg.content,
            isgroup=is_group,
            msg=feishu_msg,
            receive_id_type=receive_id_type,
            no_need_at=True
        )
        if context:
            # 流式回复模式：向 context 注入 on_event 回调，agent 每产出一段文字时会调用它。
            # 回调内部先发送一条占位消息获取 message_id，之后通过 PATCH 接口原地更新内容，
            # 实现打字机效果。回调结束时设置 context["feishu_streamed"]=True，
            # 让 send() 跳过重复发送，避免最终完整回复再被重复投递一次。
            # 默认开启流式打字机回复。需机器人开通 cardkit:card:write 权限且飞书客户端 7.20+，
            # 任意环节失败会自动降级为非流式文本回复。
            if conf().get("feishu_stream_reply", True):
                context["on_event"] = self._make_feishu_stream_callback(context, feishu_msg.access_token)
            self.produce(context)
        logger.debug(f"[FeiShu] query={feishu_msg.content}, type={feishu_msg.ctype}")

    def send(self, reply: Reply, context: Context):
        # 如果文本回复已通过流式传输发送，则跳过重复发送
        if reply.type == ReplyType.TEXT and context.get("feishu_streamed"):
            logger.debug("[FeiShu] streaming already delivered text reply, skipping send()")
            return
        msg = context.get("msg")
        is_group = context["isgroup"]
        if msg:
            access_token = msg.access_token
        else:
            access_token = self.fetch_access_token()
        headers = {
            "Authorization": "Bearer " + access_token,
            "Content-Type": "application/json",
        }
        msg_type = "text"
        logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...")
        reply_content = reply.content
        content_key = "text"
        if reply.type == ReplyType.IMAGE_URL:
            # 图片上传
            reply_content = self._upload_image_url(reply.content, access_token)
            if not reply_content:
                logger.warning("[FeiShu] upload image failed")
                return
            msg_type = "image"
            content_key = "image_key"
        elif reply.type == ReplyType.FILE:
            # 如果有附加的文本内容，先发送文本
            if hasattr(reply, 'text_content') and reply.text_content:
                logger.info(f"[FeiShu] Sending text before file: {reply.text_content[:50]}...")
                text_reply = Reply(ReplyType.TEXT, reply.text_content)
                self._send(text_reply, context)
                import time
                time.sleep(0.3)  # 短暂延迟，确保文本先到达

            # 判断是否为视频文件
            file_path = reply.content
            if file_path.startswith("file://"):
                file_path = file_path[7:]

            is_video = file_path.lower().endswith(('.mp4', '.avi', '.mov', '.wmv', '.flv'))

            if is_video:
                # 视频上传（包含duration信息）
                upload_data = self._upload_video_url(reply.content, access_token)
                if not upload_data or not upload_data.get('file_key'):
                    logger.warning("[FeiShu] upload video failed")
                    return

                # 视频使用 media 类型（根据官方文档）
                # 错误码 230055 说明：上传 mp4 时必须使用 msg_type="media"
                msg_type = "media"
                reply_content = upload_data  # 完整的上传响应数据（包含file_key和duration）
                logger.info(
                    f"[FeiShu] Sending video: file_key={upload_data.get('file_key')}, duration={upload_data.get('duration')}ms")
                content_key = None  # 直接序列化整个对象
            else:
                # 其他文件使用 file 类型
                file_key = self._upload_file_url(reply.content, access_token)
                if not file_key:
                    logger.warning("[FeiShu] upload file failed")
                    return
                reply_content = file_key
                msg_type = "file"
                content_key = "file_key"

        elif reply.type == ReplyType.VOICE:
            # 语音回复：上传音频文件到飞书，然后发送 audio 类型消息
            file_key = self._upload_audio(reply.content, access_token)
            if not file_key:
                logger.warning("[FeiShu] upload audio failed")
                return
            reply_content = file_key
            msg_type = "audio"
            content_key = "file_key"

        # Check if we can reply to an existing message (need msg_id)
        can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id

        # Build content JSON
        content_json = json.dumps(reply_content, ensure_ascii=False) if content_key is None else json.dumps({content_key: reply_content}, ensure_ascii=False)
        logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")

        if can_reply:
            # 群聊中回复已有消息
            url = f"https://open.feishu.cn/open-apis/im/v1/messages/{msg.msg_id}/reply"
            data = {
                "msg_type": msg_type,
                "content": content_json
            }
            res = requests.post(url=url, headers=headers, json=data, timeout=(5, 10))
        else:
            # 发送新消息（私聊或群聊中无msg_id的情况，如定时任务）
            url = "https://open.feishu.cn/open-apis/im/v1/messages"
            params = {"receive_id_type": context.get("receive_id_type") or "open_id"}
            data = {
                "receive_id": context.get("receiver"),
                "msg_type": msg_type,
                "content": content_json
            }
            res = requests.post(url=url, headers=headers, params=params, json=data, timeout=(5, 10))
        res = res.json()
        if res.get("code") == 0:
            logger.info(f"[FeiShu] send message success")
        else:
            logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")

    def _make_feishu_stream_callback(self, context, access_token):
        """
        基于飞书官方"流式更新卡片"API 实现打字机回复。

        流程：
        1. message_update 首次到达 → POST /cardkit/v1/cards 创建带 streaming_mode 的卡片实体，
           随后用 POST /im/v1/messages（或 reply）以 card_id 把卡片发出去
        2. 后续 message_update → PUT /cardkit/v1/cards/{id}/elements/{eid}/content
           传入"当前轮"的全量文本，飞书平台自动计算增量并以打字机效果上屏
           （流式模式下不受 10 QPS 限制）
        3. message_end（一轮 LLM 输出结束，且本轮触发了工具调用）→ 把 current 累计到 committed
           并加入分隔符；下一轮 message_update 又从空白开始，避免多轮内容串到一起
        4. agent_end → 用 final_response 强制覆盖卡片，再 PATCH /cardkit/v1/cards/{id}/settings
           关闭 streaming_mode，标记 context["feishu_streamed"]=True 让 chat_channel 跳过普通 send()

        前提条件：
        - 机器人已开通 cardkit:card:write 权限
        - 飞书客户端 7.20+

        失败降级：
        - 创建卡片实体失败（缺权限、网络等）→ 不设置 feishu_streamed 标记，让 chat_channel
          走普通文本回复路径，用户收到完整回复但无打字机效果，并打 warning 日志
        """
        # 共享状态（受 lock 保护）
        # 多轮 agent 模式下，每个"中间过场消息"会作为一张独立卡片发送。
        # current_text 只承载当前正在流式渲染的那张卡片的内容；message_end / agent_end
        # 时会把它定型并 reset。
        current_text = [""]                # 当前卡片正在累加的 LLM 输出
        card_id = [None]                   # 当前流式卡片的实体 ID（每段独立）
        message_id = [None]                # 当前卡片发送后的消息 ID（仅日志用）
        # 占位发送是同步进行的，但用一个 in-flight 标记防止并发的多条 message_update
        # 事件各自触发一次创建+发送，导致发出多张卡片。
        init_in_flight = [False]
        # 一旦初始化失败就长期标记为 disabled，本次回复不再尝试任何流式调用
        disabled = [False]
        # True after agent_cancelled: agent_end stops rewriting the card
        # with stale final_response and just finalizes current content.
        cancelled = [False]
        lock = threading.Lock()

        # ---- 异步推送队列 ----------------------------------------------------
        # 同步 requests.put 单次 100~300ms，会阻塞 LLM stream 线程读下一个 chunk。
        # 把推送丢给独立 worker 线程消费 queue，回调本身只做内存追加，立即返回。
        # 队列里只放"最新累积文本"的快照；worker 用 deduplication 避免重复推同一个
        # 内容（高频 chunk 场景下队列会堆积，只推最后一个就够了）。
        import queue as _queue
        push_queue: "_queue.Queue[str | None]" = _queue.Queue()

        def _push_worker():
            while True:
                snapshot = push_queue.get()
                if snapshot is None:
                    push_queue.task_done()
                    return
                # 合并队列中已堆积的快照：只推最后一个，省 PUT 次数同时降低延迟
                merged_count = 1
                stop = False
                while True:
                    try:
                        nxt = push_queue.get_nowait()
                    except _queue.Empty:
                        break
                    merged_count += 1
                    if nxt is None:
                        stop = True
                        break
                    snapshot = nxt
                try:
                    _stream_update_text(snapshot)
                finally:
                    for _ in range(merged_count):
                        push_queue.task_done()
                if stop:
                    return

        push_thread = threading.Thread(target=_push_worker, daemon=True, name="feishu-stream-push")
        push_thread.start()

        def _drain_push_queue():
            """等当前队列里所有 PUT 都完成。message_end/agent_end 在做最终定型前必须 drain，
            否则 worker 里堆积的旧快照可能在 final_text PUT 之后到达，把最终内容覆盖掉。"""
            try:
                push_queue.join()
            except Exception:
                pass

        msg = context.get("msg")
        is_group = context.get("isgroup", False)
        receiver = context.get("receiver")
        receive_id_type = context.get("receive_id_type", "open_id")
        # 客户端打字机渲染参数（飞书 App 侧实际"出字"速度）：
        #   - print_freq_ms：每次刷新的间隔
        #   - print_step：每次刷新出多少个字符
        # 当前 40ms × 4 字 ≈ 100 字/秒，接近 ChatGPT/DeepSeek 网页端的节奏。
        print_freq_ms = 40
        print_step = 4
        print_strategy = "fast"

        headers = {
            "Authorization": "Bearer " + access_token,
            "Content-Type": "application/json; charset=utf-8",
        }
        # 卡片中富文本组件的 element_id，后续所有 PUT 流式更新都打到这个组件
        ELEMENT_ID = "stream_md"
        # 操作序号，每次 PUT 必须严格递增（飞书要求）
        sequence = [0]

        def _next_sequence():
            sequence[0] += 1
            return sequence[0]

        def _build_card_json():
            """卡片 JSON 2.0 结构 + streaming_mode + 单 markdown 组件"""
            return json.dumps({
                "schema": "2.0",
                "config": {
                    "streaming_mode": True,
                    "summary": {"content": "[正在生成回复...]"},
                    "streaming_config": {
                        "print_frequency_ms": {"default": print_freq_ms},
                        "print_step": {"default": print_step},
                        "print_strategy": print_strategy,
                    },
                },
                "body": {
                    "elements": [
                        {
                            "tag": "markdown",
                            "content": "...",
                            "element_id": ELEMENT_ID,
                        }
                    ],
                },
                # 注意：JSON 2.0 不支持自定义 fallback 字段（传入会报错）。
                # 客户端 < 7.20 时，飞书会自动展示"请升级客户端"占位，无需配置。
            }, ensure_ascii=False)

        def _create_and_send_card():
            """同步执行：创建卡片实体 → 发送消息。任意一步失败则 disabled=True 触发降级"""
            try:
                # 步骤 1: 创建卡片实体
                create_url = "https://open.feishu.cn/open-apis/cardkit/v1/cards"
                create_body = {"type": "card_json", "data": _build_card_json()}
                res = requests.post(
                    create_url, headers=headers, json=create_body, timeout=(5, 10)
                )
                res_json = res.json()
                if res_json.get("code") != 0:
                    logger.warning(
                        f"[FeiShu] Stream: create card failed "
                        f"(code={res_json.get('code')}, msg={res_json.get('msg')}). "
                        f"本次回复已自动降级为普通文本回复（一次性返回完整内容）。"
                        f"如需开启流式打字机效果与完整 Markdown 渲染，请到飞书开放平台 "
                        f"https://open.feishu.cn/app 给机器人开通 cardkit:card:write 权限"
                        f"（创建与更新卡片）并重新发布版本，同时确保飞书客户端 >= 7.20。"
                    )
                    with lock:
                        disabled[0] = True
                    return
                cid = res_json["data"]["card_id"]
                with lock:
                    card_id[0] = cid

                # 步骤 2: 通过 card_id 发送消息（群聊优先用 reply，单聊直接 send）
                content_payload = json.dumps(
                    {"type": "card", "data": {"card_id": cid}}, ensure_ascii=False
                )
                can_reply = is_group and msg and hasattr(msg, "msg_id") and msg.msg_id
                if can_reply:
                    send_url = (
                        f"https://open.feishu.cn/open-apis/im/v1/messages/"
                        f"{msg.msg_id}/reply"
                    )
                    send_body = {"msg_type": "interactive", "content": content_payload}
                    send_res = requests.post(
                        send_url, headers=headers, json=send_body, timeout=(5, 10)
                    )
                else:
                    send_url = "https://open.feishu.cn/open-apis/im/v1/messages"
                    params = {"receive_id_type": receive_id_type}
                    send_body = {
                        "receive_id": receiver,
                        "msg_type": "interactive",
                        "content": content_payload,
                    }
                    send_res = requests.post(
                        send_url, headers=headers, params=params, json=send_body,
                        timeout=(5, 10),
                    )
                send_json = send_res.json()
                if send_json.get("code") != 0:
                    logger.warning(
                        f"[FeiShu] Stream: send card failed: {send_json}. 降级为普通文本。"
                    )
                    with lock:
                        disabled[0] = True
                    return
                mid = send_json["data"]["message_id"]
                with lock:
                    message_id[0] = mid
                logger.info(
                    f"[FeiShu] Stream: card created and sent, "
                    f"card_id={cid}, message_id={mid}"
                )
            except Exception as e:
                logger.warning(
                    f"[FeiShu] Stream: create/send card exception: {e}. 降级为普通文本。"
                )
                with lock:
                    disabled[0] = True
            finally:
                with lock:
                    init_in_flight[0] = False

        def _stream_update_text(full_text):
            """PUT 流式更新文本组件。content 必须是当前组件的全量文本。"""
            with lock:
                cid = card_id[0]
            if not cid:
                return
            url = (
                f"https://open.feishu.cn/open-apis/cardkit/v1/cards/"
                f"{cid}/elements/{ELEMENT_ID}/content"
            )
            body = {
                "content": full_text,
                "sequence": _next_sequence(),
            }
            try:
                res = requests.put(url, headers=headers, json=body, timeout=(5, 10))
                res_json = res.json()
                if res_json.get("code") != 0:
                    logger.warning(
                        f"[FeiShu] Stream: update text failed: {res_json}"
                    )
            except Exception as e:
                logger.warning(f"[FeiShu] Stream: update text exception: {e}")

        def _close_streaming_mode(final_text: str = ""):
            """关闭流式模式（卡片转入"普通"状态，可被转发）。

            同时通过整卡更新接口把 summary 改成最终内容的预览，否则飞书会话列表
            会一直显示创建卡片时的占位摘要（"[正在生成回复...]"）。
            """
            with lock:
                cid = card_id[0]
            if not cid:
                return

            # 1) 通过整卡更新接口把 streaming_mode 关掉，并改写 summary
            #    （settings 接口的 config 不接受 summary 字段，会报 code=2200）
            preview_src = (final_text or "").strip().replace("\n", " ")
            preview = preview_src[:30] if preview_src else ""
            full_card = {
                "schema": "2.0",
                "config": {
                    "streaming_mode": False,
                    "summary": {"content": preview or " "},
                },
                "body": {
                    "elements": [
                        {
                            "tag": "markdown",
                            "content": final_text or " ",
                            "element_id": ELEMENT_ID,
                        }
                    ],
                },
            }
            put_url = f"https://open.feishu.cn/open-apis/cardkit/v1/cards/{cid}"
            put_body = {
                "card": {"type": "card_json", "data": json.dumps(full_card, ensure_ascii=False)},
                "sequence": _next_sequence(),
            }
            try:
                res = requests.put(put_url, headers=headers, json=put_body, timeout=(5, 10))
                res_json = res.json()
                if res_json.get("code") != 0:
                    logger.warning(
                        f"[FeiShu] Stream: finalize card (close+summary) failed: {res_json}"
                    )
            except Exception as e:
                logger.warning(
                    f"[FeiShu] Stream: finalize card exception: {e}"
                )

        def on_event(event: dict):
            event_type = event.get("type")
            data = event.get("data", {})

            # 一旦降级，本次回复不再做任何流式操作
            with lock:
                if disabled[0]:
                    return

            if event_type == "message_update":
                delta = data.get("delta", "")
                if not delta:
                    return

                # 第一段：判断是否需要初始化（创建卡片 + 发送）
                need_init = False
                with lock:
                    if card_id[0] is None and not init_in_flight[0]:
                        init_in_flight[0] = True
                        need_init = True

                if need_init:
                    _create_and_send_card()
                    # 初始化失败已标记 disabled，下次循环直接 return
                    with lock:
                        if disabled[0]:
                            return

                # 第二段：累加文本，把快照丢给 push worker 异步推送。
                # 这里不能直接 requests.put，否则会阻塞 LLM stream 线程读下一个 chunk
                # （实测 DeepSeek 高频小 chunk 场景每个 PUT ~150ms，累积起来非常卡）。
                snapshot = ""
                should_push = False
                with lock:
                    current_text[0] += delta
                    if card_id[0]:
                        snapshot = current_text[0]
                        should_push = True

                if should_push:
                    push_queue.put(snapshot)

            elif event_type == "message_end":
                # 一轮 LLM 输出结束。如果本轮触发了工具调用，说明当前轮的文本是
                # "中间过场消息"（如"来看看！"），应该作为独立卡片定型，然后为下一轮
                # 重新创建一张新卡片。这样最终用户看到的是：
                #   [卡片1: 中间过场1]
                #   [卡片2: 中间过场2]
                #   ...
                #   [卡片N: 最终回复]
                # 与 wecom_bot 的多消息流式体验对齐。
                tool_calls = data.get("tool_calls", []) or []
                if not tool_calls:
                    # 没有工具调用：本轮即最终回复，留给 agent_end 统一处理。
                    return

                with lock:
                    text_to_finalize = current_text[0].rstrip()
                    current_text[0] = ""

                if not text_to_finalize:
                    return

                # 等异步队列里堆积的快照都推完，避免它们晚于 final 文本到达把内容覆盖掉
                _drain_push_queue()
                # 用最终文本覆盖当前卡片并关闭流式模式（凝固成普通卡片，
                # 同时把会话列表的 summary 改成预览，不再显示"正在生成回复..."）
                _stream_update_text(text_to_finalize)
                _close_streaming_mode(text_to_finalize)

                # 重置卡片状态，下一段 message_update 会触发新卡片的创建
                with lock:
                    card_id[0] = None
                    message_id[0] = None
                    sequence[0] = 0

            elif event_type == "agent_cancelled":
                # Lock channel into "no-rewrite" mode: the subsequent
                # agent_end's final_response is from the last *completed*
                # turn (the user already saw it), so rewriting the card
                # would duplicate it visually.
                with lock:
                    cancelled[0] = True

            elif event_type == "agent_end":
                # 最终回复：用 final_response 覆盖当前流式卡片，然后关闭流式模式。
                final_response = data.get("final_response", "")
                # 标记 streamed 让 chat_channel 跳过 send()
                context["feishu_streamed"] = True

                with lock:
                    was_cancelled = cancelled[0]
                    has_card = card_id[0] is not None
                    init_busy = init_in_flight[0]
                    pending_text = current_text[0]

                if was_cancelled:
                    # Cancelled path: finalize the in-flight card with
                    # partial output (or a short marker if empty); drop
                    # stale final_response to avoid duplicating last turn.
                    if has_card:
                        _drain_push_queue()
                        partial = (pending_text or "").rstrip()
                        final_text = partial or "_(已中止)_"
                        _stream_update_text(final_text)
                        _close_streaming_mode(final_text)
                    push_queue.put(None)
                    return

                if not final_response:
                    return
                final_text = str(final_response)

                # 罕见情况：agent_end 触发时还没创建过卡片（极快返回 / 没有
                # message_update），主动创建一张承载 final_text。
                if not has_card and not init_busy:
                    with lock:
                        init_in_flight[0] = True
                    _create_and_send_card()
                    with lock:
                        if disabled[0]:
                            return

                _drain_push_queue()
                _stream_update_text(final_text)
                _close_streaming_mode(final_text)
                # 通知 push worker 退出（本次回复彻底结束）
                push_queue.put(None)

        return on_event

    def fetch_access_token(self) -> str:
        url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/"
        headers = {
            "Content-Type": "application/json"
        }
        req_body = {
            "app_id": self.feishu_app_id,
            "app_secret": self.feishu_app_secret
        }
        data = bytes(json.dumps(req_body), encoding='utf8')
        response = requests.post(url=url, data=data, headers=headers)
        if response.status_code == 200:
            res = response.json()
            if res.get("code") != 0:
                logger.error(f"[FeiShu] get tenant_access_token error, code={res.get('code')}, msg={res.get('msg')}")
                return ""
            else:
                return res.get("tenant_access_token")
        else:
            logger.error(f"[FeiShu] fetch token error, res={response}")

    def _upload_image_url(self, img_url, access_token):
        logger.debug(f"[FeiShu] start process image, img_url={img_url}")

        # Check if it's a local file path (file:// protocol)
        if img_url.startswith("file://"):
            local_path = img_url[7:]  # Remove "file://" prefix
            logger.info(f"[FeiShu] uploading local file: {local_path}")

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

            # Upload directly from local file
            upload_url = "https://open.feishu.cn/open-apis/im/v1/images"
            data = {'image_type': 'message'}
            headers = {'Authorization': f'Bearer {access_token}'}

            with open(local_path, "rb") as file:
                upload_response = requests.post(upload_url, files={"image": file}, data=data, headers=headers)
                logger.info(f"[FeiShu] upload file, res={upload_response.content}")

                response_data = upload_response.json()
                if response_data.get("code") == 0:
                    return response_data.get("data").get("image_key")
                else:
                    logger.error(f"[FeiShu] upload failed: {response_data}")
                    return None

        # Original logic for HTTP URLs
        response = requests.get(img_url)
        suffix = utils.get_path_suffix(img_url)
        temp_name = str(uuid.uuid4()) + "." + suffix
        if response.status_code == 200:
            # 将图片内容保存为临时文件
            with open(temp_name, "wb") as file:
                file.write(response.content)

        # upload
        upload_url = "https://open.feishu.cn/open-apis/im/v1/images"
        data = {
            'image_type': 'message'
        }
        headers = {
            'Authorization': f'Bearer {access_token}',
        }
        with open(temp_name, "rb") as file:
            upload_response = requests.post(upload_url, files={"image": file}, data=data, headers=headers)
            logger.info(f"[FeiShu] upload file, res={upload_response.content}")
            os.remove(temp_name)
            return upload_response.json().get("data").get("image_key")

    def _get_video_duration(self, file_path: str) -> int:
        """
        获取视频时长（毫秒）
        
        Args:
            file_path: 视频文件路径
        
        Returns:
            视频时长（毫秒），如果获取失败返回0
        """
        try:
            import subprocess

            # 使用 ffprobe 获取视频时长
            cmd = [
                'ffprobe',
                '-v', 'error',
                '-show_entries', 'format=duration',
                '-of', 'default=noprint_wrappers=1:nokey=1',
                file_path
            ]

            result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
            if result.returncode == 0:
                duration_seconds = float(result.stdout.strip())
                duration_ms = int(duration_seconds * 1000)
                logger.info(f"[FeiShu] Video duration: {duration_seconds:.2f}s ({duration_ms}ms)")
                return duration_ms
            else:
                logger.warning(f"[FeiShu] Failed to get video duration via ffprobe: {result.stderr}")
                return 0
        except FileNotFoundError:
            logger.warning("[FeiShu] ffprobe not found, video duration will be 0. Install ffmpeg to fix this.")
            return 0
        except Exception as e:
            logger.warning(f"[FeiShu] Failed to get video duration: {e}")
            return 0

    def _upload_video_url(self, video_url, access_token):
        """
        Upload video to Feishu and return video info (file_key and duration)
        Supports:
        - file:// URLs for local files
        - http(s):// URLs (download then upload)
        
        Returns:
            dict with 'file_key' and 'duration' (milliseconds), or None if failed
        """
        local_path = None
        temp_file = None

        try:
            # For file:// URLs (local files), upload directly
            if video_url.startswith("file://"):
                local_path = video_url[7:]  # Remove file:// prefix
                if not os.path.exists(local_path):
                    logger.error(f"[FeiShu] local video file not found: {local_path}")
                    return None
            else:
                # For HTTP URLs, download first
                logger.info(f"[FeiShu] Downloading video from URL: {video_url}")
                response = requests.get(video_url, timeout=(5, 60))
                if response.status_code != 200:
                    logger.error(f"[FeiShu] download video failed, status={response.status_code}")
                    return None

                # Save to temp file
                import uuid
                file_name = os.path.basename(video_url) or "video.mp4"
                temp_file = str(uuid.uuid4()) + "_" + file_name

                with open(temp_file, "wb") as file:
                    file.write(response.content)

                logger.info(f"[FeiShu] Video downloaded, size={len(response.content)} bytes")
                local_path = temp_file

            # Get video duration
            duration = self._get_video_duration(local_path)

            # Upload to Feishu
            file_name = os.path.basename(local_path)
            file_ext = os.path.splitext(file_name)[1].lower()
            file_type_map = {'.mp4': 'mp4'}
            file_type = file_type_map.get(file_ext, 'mp4')

            upload_url = "https://open.feishu.cn/open-apis/im/v1/files"
            data = {
                'file_type': file_type,
                'file_name': file_name
            }
            # Add duration only if available (required for video/audio)
            if duration:
                data['duration'] = duration  # Must be int, not string

            headers = {'Authorization': f'Bearer {access_token}'}

            logger.info(f"[FeiShu] Uploading video: file_name={file_name}, duration={duration}ms")

            with open(local_path, "rb") as file:
                upload_response = requests.post(
                    upload_url,
                    files={"file": file},
                    data=data,
                    headers=headers,
                    timeout=(5, 60)
                )
                logger.info(
                    f"[FeiShu] upload video response, status={upload_response.status_code}, res={upload_response.content}")

                response_data = upload_response.json()
                if response_data.get("code") == 0:
                    # Add duration to the response data (API doesn't return it)
                    upload_data = response_data.get("data")
                    upload_data['duration'] = duration  # Add our calculated duration
                    logger.info(
                        f"[FeiShu] Upload complete: file_key={upload_data.get('file_key')}, duration={duration}ms")
                    return upload_data
                else:
                    logger.error(f"[FeiShu] upload video failed: {response_data}")
                    return None

        except Exception as e:
            logger.error(f"[FeiShu] upload video exception: {e}")
            return None

        finally:
            # Clean up temp file
            if temp_file and os.path.exists(temp_file):
                try:
                    os.remove(temp_file)
                except Exception as e:
                    logger.warning(f"[FeiShu] Failed to remove temp file {temp_file}: {e}")

    def _upload_audio(self, audio_path, access_token):
        """
        Upload a local audio file to Feishu and return file_key.
        audio_path is a plain local file path (no file:// prefix).
        Feishu audio messages only support opus format; non-opus files are converted first.
        """
        logger.debug(f"[FeiShu] start upload audio, path={audio_path}")

        if not os.path.exists(audio_path):
            logger.error(f"[FeiShu] audio file not found: {audio_path}")
            return None

        # Feishu only plays audio messages in opus format.
        # Convert if the TTS engine produced a different format (e.g. mp3 from OpenAI TTS).
        upload_path = audio_path
        if not audio_path.lower().endswith('.opus'):
            opus_path = os.path.splitext(audio_path)[0] + '.opus'
            try:
                from pydub import AudioSegment
                audio = AudioSegment.from_file(audio_path)
                audio.export(opus_path, format='opus')
                upload_path = opus_path
                logger.info(f"[FeiShu] Converted audio to opus: {opus_path}")
            except Exception as e:
                logger.warning(f"[FeiShu] Failed to convert audio to opus, uploading original: {e}")
                upload_path = audio_path

        file_name = os.path.splitext(os.path.basename(upload_path))[0] + '.opus'
        upload_url = "https://open.feishu.cn/open-apis/im/v1/files"
        data = {'file_type': 'opus', 'file_name': file_name}
        headers = {'Authorization': f'Bearer {access_token}'}

        try:
            with open(upload_path, "rb") as f:
                upload_response = requests.post(
                    upload_url,
                    files={"file": f},
                    data=data,
                    headers=headers,
                    timeout=(5, 30)
                )
                logger.info(
                    f"[FeiShu] upload audio response, status={upload_response.status_code}, res={upload_response.content}")
                response_data = upload_response.json()
                if response_data.get("code") == 0:
                    return response_data.get("data").get("file_key")
                else:
                    logger.error(f"[FeiShu] upload audio failed: {response_data}")
                    return None
        except Exception as e:
            logger.error(f"[FeiShu] upload audio exception: {e}")
            return None
        finally:
            # 无论上传成功与否都清理转换产生的临时 opus 文件，避免失败路径下磁盘堆积。
            if upload_path != audio_path and os.path.exists(upload_path):
                try:
                    os.remove(upload_path)
                except Exception as e:
                    logger.warning(f"[FeiShu] Failed to remove temp opus file {upload_path}: {e}")

    def _upload_file_url(self, file_url, access_token):
        """
        Upload file to Feishu
        Supports both local files (file://) and HTTP URLs
        """
        logger.debug(f"[FeiShu] start process file, file_url={file_url}")

        # Check if it's a local file path (file:// protocol)
        if file_url.startswith("file://"):
            local_path = file_url[7:]  # Remove "file://" prefix
            logger.info(f"[FeiShu] uploading local file: {local_path}")

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

            # Get file info
            file_name = os.path.basename(local_path)
            file_ext = os.path.splitext(file_name)[1].lower()

            # Determine file type for Feishu API
            # Feishu supports: opus, mp4, pdf, doc, xls, ppt, stream (other types)
            file_type_map = {
                '.opus': 'opus',
                '.mp4': 'mp4',
                '.pdf': 'pdf',
                '.doc': 'doc', '.docx': 'doc',
                '.xls': 'xls', '.xlsx': 'xls',
                '.ppt': 'ppt', '.pptx': 'ppt',
            }
            file_type = file_type_map.get(file_ext, 'stream')  # Default to stream for other types

            # Upload file to Feishu
            upload_url = "https://open.feishu.cn/open-apis/im/v1/files"
            data = {'file_type': file_type, 'file_name': file_name}
            headers = {'Authorization': f'Bearer {access_token}'}

            try:
                with open(local_path, "rb") as file:
                    upload_response = requests.post(
                        upload_url,
                        files={"file": file},
                        data=data,
                        headers=headers,
                        timeout=(5, 30)  # 5s connect, 30s read timeout
                    )
                    logger.info(
                        f"[FeiShu] upload file response, status={upload_response.status_code}, res={upload_response.content}")

                    response_data = upload_response.json()
                    if response_data.get("code") == 0:
                        return response_data.get("data").get("file_key")
                    else:
                        logger.error(f"[FeiShu] upload file failed: {response_data}")
                        return None
            except Exception as e:
                logger.error(f"[FeiShu] upload file exception: {e}")
                return None

        # For HTTP URLs, download first then upload
        try:
            response = requests.get(file_url, timeout=(5, 30))
            if response.status_code != 200:
                logger.error(f"[FeiShu] download file failed, status={response.status_code}")
                return None

            # Save to temp file
            import uuid
            file_name = os.path.basename(file_url)
            temp_name = str(uuid.uuid4()) + "_" + file_name

            with open(temp_name, "wb") as file:
                file.write(response.content)

            # Upload
            file_ext = os.path.splitext(file_name)[1].lower()
            file_type_map = {
                '.opus': 'opus', '.mp4': 'mp4', '.pdf': 'pdf',
                '.doc': 'doc', '.docx': 'doc',
                '.xls': 'xls', '.xlsx': 'xls',
                '.ppt': 'ppt', '.pptx': 'ppt',
            }
            file_type = file_type_map.get(file_ext, 'stream')

            upload_url = "https://open.feishu.cn/open-apis/im/v1/files"
            data = {'file_type': file_type, 'file_name': file_name}
            headers = {'Authorization': f'Bearer {access_token}'}

            with open(temp_name, "rb") as file:
                upload_response = requests.post(upload_url, files={"file": file}, data=data, headers=headers)
                logger.info(f"[FeiShu] upload file, res={upload_response.content}")

                response_data = upload_response.json()
                os.remove(temp_name)  # Clean up temp file

                if response_data.get("code") == 0:
                    return response_data.get("data").get("file_key")
                else:
                    logger.error(f"[FeiShu] upload file failed: {response_data}")
                    return None
        except Exception as e:
            logger.error(f"[FeiShu] upload file from URL exception: {e}")
            return None

    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"]

        # Set session_id based on chat type
        if cmsg.is_group:
            # Group chat: check if group_shared_session is enabled
            if conf().get("group_shared_session", True):
                # All users in the group share the same session context
                context["session_id"] = cmsg.other_user_id  # group_id
            else:
                # Each user has their own session within the group
                # This ensures:
                # - Same user in different groups have separate conversation histories
                # - Same user in private chat and group chat have separate histories
                context["session_id"] = f"{cmsg.from_user_id}:{cmsg.other_user_id}"
        else:
            # Private chat: use user_id only
            context["session_id"] = cmsg.from_user_id

        context["receiver"] = cmsg.other_user_id

        if ctype == ContextType.TEXT:
            # 1.文本请求
            # 图片生成处理
            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()
            # Text input opts into voice replies only when the always-on toggle is set.
            if "desire_rtype" not in context and conf().get("always_reply_voice"):
                context["desire_rtype"] = ReplyType.VOICE

        elif context.type == ContextType.VOICE:
            # 2.语音请求: voice input replies with voice if either
            # voice_reply_voice (mirror reply) or always_reply_voice is on.
            if "desire_rtype" not in context and (
                conf().get("voice_reply_voice") or conf().get("always_reply_voice")
            ):
                context["desire_rtype"] = ReplyType.VOICE

        return context


class FeishuController:
    """
    HTTP服务器控制器，用于webhook模式
    """
    # 类常量
    FAILED_MSG = '{"success": false}'
    SUCCESS_MSG = '{"success": true}'
    MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"

    def GET(self):
        return "Feishu service start success!"

    def POST(self):
        try:
            channel = FeiShuChanel()

            request = json.loads(web.data().decode("utf-8"))
            logger.debug(f"[FeiShu] receive request: {request}")

            # 1.事件订阅回调验证
            if request.get("type") == URL_VERIFICATION:
                varify_res = {"challenge": request.get("challenge")}
                return json.dumps(varify_res)

            # 2.消息接收处理
            # token 校验
            header = request.get("header")
            if not header or header.get("token") != channel.feishu_token:
                return self.FAILED_MSG

            # 处理消息事件
            event = request.get("event")
            if header.get("event_type") == self.MESSAGE_RECEIVE_TYPE and event:
                channel._handle_message_event(event)

            return self.SUCCESS_MSG

        except Exception as e:
            logger.error(e)
            return self.FAILED_MSG
