""" 飞书通道接入 支持两种事件接收模式: 1. webhook模式: 通过HTTP服务器接收事件(需要公网IP) 2. websocket模式: 通过长连接接收事件(本地开发友好) 通过配置项 feishu_event_mode 选择模式: "webhook" 或 "websocket" @author Saboteur7 @Date 2023/11/19 """ 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" # 尝试导入飞书SDK,如果未安装则websocket模式不可用 try: import lark_oapi as lark LARK_SDK_AVAILABLE = True except ImportError: LARK_SDK_AVAILABLE = False logger.warning( "[FeiShu] lark_oapi not installed, websocket mode is not available. Install with: pip install lark-oapi") @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 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 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') if self.feishu_event_mode == 'websocket': self._startup_websocket() else: self._startup_webhook() 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模式)""" logger.debug("[FeiShu] Starting in websocket mode...") # 创建事件处理器 def handle_message_event(data: lark.im.v1.P2ImMessageReceiveV1) -> None: """处理接收消息事件 v2.0""" try: logger.debug(f"[FeiShu] websocket receive event: {lark.JSON.marshal(data, indent=2)}") # 转换为标准的event格式 event_dict = json.loads(lark.JSON.marshal(data)) event = event_dict.get("event", {}) # 处理消息 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 _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("mentions")[0].get("name") != conf().get("feishu_bot_name") and msg.get( "message_type") == "text": # 不是@机器人,不响应 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 # 如果是文本消息,检查是否有缓存的文件 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: self.produce(context) logger.debug(f"[FeiShu] query={feishu_msg.content}, type={feishu_msg.ctype}") def send(self, reply: Reply, context: Context): 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" # 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) if content_key is None else json.dumps({content_key: reply_content}) 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 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_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() elif context.type == ContextType.VOICE: # 2.语音请求 if "desire_rtype" not in context and conf().get("voice_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