Files
archived-MoviePilot/app/modules/feishu/__init__.py
jxxghp b2a18f9ae4 feat(message-processing-status): unified processing status indicator for Telegram, Slack, Discord, Feishu
- Add ChannelCapability.PROCESSING_STATUS and capability detection for supported channels
- Implement mark_message_processing_started/finished in Telegram, Slack, Discord, Feishu modules
  - Telegram: manage typing lifecycle with max duration and explicit stop
  - Slack: add/remove reaction as processing indicator
  - Discord: start/stop typing indicator with async task management
  - Feishu: add/remove reaction for processing status
- Refactor message chain to invoke processing status hooks for supported channels
- Ensure processing status is properly finished on sync and async message handling paths
- Add tests for processing status lifecycle and capability detection across channels
2026-05-15 12:45:41 +08:00

439 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Any, List, Optional, Tuple, Union
from app.core.context import Context, MediaInfo
from app.log import logger
from app.modules import _ModuleBase, _MessageBase
from app.modules.feishu.feishu import Feishu
from app.schemas import CommingMessage, MessageChannel, MessageResponse, Notification
from app.schemas.types import ModuleType
class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
def init_module(self) -> None:
self.stop()
super().init_service(service_name=Feishu.__name__.lower(), service_type=Feishu)
self._channel = MessageChannel.Feishu
@staticmethod
def get_name() -> str:
return "飞书"
@staticmethod
def get_type() -> ModuleType:
return ModuleType.Notification
@staticmethod
def get_subtype() -> MessageChannel:
return MessageChannel.Feishu
@staticmethod
def get_priority() -> int:
return 2
def stop(self):
for client in self.get_instances().values():
if hasattr(client, "stop"):
try:
client.stop()
except Exception as err:
logger.error(f"停止飞书模块实例失败:{err}")
def test(self) -> Optional[Tuple[bool, str]]:
if not self.get_instances():
return None
for name, client in self.get_instances().items():
state = client.get_state()
if not state:
return False, f"飞书 {name} 未就绪"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
"""通知模块通过系统通知配置控制实例化,这里不额外设置环境开关。"""
return None
@staticmethod
def _resolve_message_target(
message: Notification,
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""优先使用 open_id其次回退 user_id 或 chat_id。"""
userid = str(message.userid).strip() if message.userid else None
chat_id = None
receive_id_type = "open_id" if userid else None
targets = message.targets or {}
if not userid and targets:
open_id = str(targets.get("feishu_openid") or "").strip() or None
user_id = str(targets.get("feishu_userid") or "").strip() or None
chat_id = str(targets.get("feishu_chat_id") or "").strip() or None
if open_id:
userid = open_id
receive_id_type = "open_id"
elif user_id:
userid = user_id
receive_id_type = "user_id"
return userid, chat_id, receive_id_type
def message_parser(
self, source: str, body: Any, form: Any, args: Any
) -> Optional[CommingMessage]:
client_config = self.get_config(source)
if not client_config:
return None
client: Feishu = self.get_instance(client_config.name)
if not client:
return None
return client.parse_message(body)
def post_message(self, message: Notification, **kwargs) -> None:
for conf in self.get_configs().values():
if not self.check_message(message, conf.name):
continue
userid, chat_id, receive_id_type = self._resolve_message_target(message)
client: Feishu = self.get_instance(conf.name)
if client:
if message.image and message.file_path:
# 普通文件无法嵌入卡片,先发送图文卡片,再单独发送附件,避免图片被 file_path 分支吞掉。
client.send_notification(
message=message.model_copy(update={"file_path": None, "file_name": None}),
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
original_message_id=str(message.original_message_id) if message.original_message_id else None,
)
client.send_file(
file_path=message.file_path,
userid=userid,
chat_id=chat_id,
file_name=message.file_name,
receive_id_type=receive_id_type,
original_message_id=str(message.original_message_id) if message.original_message_id else None,
)
elif message.file_path:
client.send_file(
file_path=message.file_path,
userid=userid,
chat_id=chat_id,
title=message.title,
text=message.text,
file_name=message.file_name,
receive_id_type=receive_id_type,
original_message_id=str(message.original_message_id) if message.original_message_id else None,
)
elif message.voice_path:
client.send_voice(
voice_path=message.voice_path,
userid=userid,
chat_id=chat_id,
caption=message.voice_caption,
receive_id_type=receive_id_type,
original_message_id=str(message.original_message_id) if message.original_message_id else None,
)
else:
client.send_notification(
message=message,
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
original_message_id=str(message.original_message_id) if message.original_message_id else None,
)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
for conf in self.get_configs().values():
if not self.check_message(message, conf.name):
continue
userid, chat_id, receive_id_type = self._resolve_message_target(message)
client: Feishu = self.get_instance(conf.name)
if client:
client.send_medias_message(
message=message,
medias=medias,
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
)
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:
for conf in self.get_configs().values():
if not self.check_message(message, conf.name):
continue
userid, chat_id, receive_id_type = self._resolve_message_target(message)
client: Feishu = self.get_instance(conf.name)
if client:
client.send_torrents_message(
message=message,
torrents=torrents,
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
)
def edit_message(
self,
channel: MessageChannel,
source: str,
message_id: Union[str, int],
chat_id: Union[str, int],
text: str,
title: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
metadata: Optional[dict] = None,
) -> Optional[bool]:
if channel != self._channel:
return None
for conf in self.get_configs().values():
if source != conf.name:
continue
client: Feishu = self.get_instance(conf.name)
if client and client.edit_message(
message_id=str(message_id),
title=title,
text=text,
buttons=buttons,
metadata=metadata,
):
return True
return False
def send_direct_message(self, message: Notification) -> Optional[MessageResponse]:
for conf in self.get_configs().values():
if not self.check_message(message, conf.name):
continue
userid, chat_id, receive_id_type = self._resolve_message_target(message)
client: Feishu = self.get_instance(conf.name)
if not client:
continue
if message.image and message.file_path:
result = client.send_notification(
message=message.model_copy(update={"file_path": None, "file_name": None}),
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
original_message_id=str(message.original_message_id) if message.original_message_id else None,
)
if result and result.get("success"):
client.send_file(
file_path=message.file_path,
userid=userid,
chat_id=chat_id,
file_name=message.file_name,
receive_id_type=receive_id_type,
original_message_id=str(message.original_message_id) if message.original_message_id else None,
)
elif message.file_path:
result = client.send_file(
file_path=message.file_path,
userid=userid,
chat_id=chat_id,
title=message.title,
text=message.text,
file_name=message.file_name,
receive_id_type=receive_id_type,
original_message_id=str(message.original_message_id) if message.original_message_id else None,
)
elif message.voice_path:
result = client.send_voice(
voice_path=message.voice_path,
userid=userid,
chat_id=chat_id,
caption=message.voice_caption,
receive_id_type=receive_id_type,
original_message_id=str(message.original_message_id) if message.original_message_id else None,
)
else:
result = client.send_notification(
message=message,
userid=userid,
chat_id=chat_id,
receive_id_type=receive_id_type,
original_message_id=str(message.original_message_id) if message.original_message_id else None,
)
if result and result.get("success"):
return MessageResponse(
message_id=result.get("message_id"),
chat_id=result.get("chat_id"),
channel=MessageChannel.Feishu,
source=conf.name,
metadata=result.get("metadata"),
success=True,
)
return None
def download_feishu_image_to_data_url(self, image_ref: str, source: str) -> Optional[str]:
if not image_ref or not image_ref.startswith("feishu://image/"):
return None
client_config = self.get_config(source)
if not client_config:
return None
client = self.get_instance(client_config.name)
if not client:
return None
resource_path = image_ref.replace("feishu://image/", "", 1)
message_id = None
image_key = resource_path
if "/" in resource_path:
message_id, image_key = resource_path.split("/", 1)
message_id = message_id.strip() or None
image_key = image_key.strip()
downloaded = None
if message_id:
downloaded = client.download_message_resource_bytes(
message_id=message_id,
file_key=image_key,
resource_type="image",
)
if not downloaded:
downloaded = client.download_image_bytes(image_key)
if not downloaded:
return None
content, _, content_type = downloaded
mime_type = content_type or "image/jpeg"
import base64
return f"data:{mime_type};base64,{base64.b64encode(content).decode()}"
def download_feishu_file_bytes(self, file_ref: str, source: str) -> Optional[bytes]:
if not file_ref or not file_ref.startswith("feishu://file/"):
return None
client_config = self.get_config(source)
if not client_config:
return None
client = self.get_instance(client_config.name)
if not client:
return None
parts = [
part.strip()
for part in file_ref.replace("feishu://file/", "", 1).split("/")
if part.strip()
]
file_key = ""
downloaded = None
if len(parts) >= 2 and parts[0].startswith("om_"):
message_id, file_key = parts[0], parts[1]
downloaded = client.download_message_resource_bytes(
message_id=message_id,
file_key=file_key,
resource_type="audio",
)
if not downloaded:
downloaded = client.download_message_resource_bytes(
message_id=message_id,
file_key=file_key,
resource_type="file",
)
else:
file_key = parts[0] if parts else ""
if not file_key:
return None
if not downloaded:
downloaded = client.download_file_bytes(file_key)
if not downloaded:
return None
content, _, _ = downloaded
return content
def add_feishu_message_reaction(
self,
message_id: str,
emoji_type: str,
source: str,
) -> Optional[str]:
client_config = self.get_config(source)
if not client_config:
return None
client = self.get_instance(client_config.name)
if not client:
return None
return client.add_message_reaction(message_id=message_id, emoji_type=emoji_type)
def delete_feishu_message_reaction(
self,
message_id: str,
reaction_id: str,
source: str,
) -> Optional[bool]:
client_config = self.get_config(source)
if not client_config:
return False
client = self.get_instance(client_config.name)
if not client:
return False
return client.delete_message_reaction(message_id=message_id, reaction_id=reaction_id)
def mark_message_processing_started(
self,
channel: MessageChannel,
source: str,
userid: Optional[Union[str, int]] = None,
message_id: Optional[Union[str, int]] = None,
chat_id: Optional[Union[str, int]] = None,
text: Optional[str] = None,
) -> Optional[dict]:
"""
使用飞书消息表情标记“正在处理”。
"""
if channel != self._channel:
return None
if not message_id or not text or str(text).startswith("CALLBACK:"):
return None
reaction_id = self.add_feishu_message_reaction(
message_id=str(message_id),
emoji_type=Feishu.PROCESSING_REACTION_EMOJI,
source=source,
)
if not reaction_id:
return None
return {
"channel": channel.value,
"source": source,
"userid": userid,
"message_id": str(message_id),
"chat_id": str(chat_id) if chat_id else None,
"metadata": {
"kind": "reaction",
"reaction_id": str(reaction_id),
"emoji_type": Feishu.PROCESSING_REACTION_EMOJI,
},
}
def mark_message_processing_finished(
self,
channel: MessageChannel,
source: str,
userid: Optional[Union[str, int]] = None,
message_id: Optional[Union[str, int]] = None,
chat_id: Optional[Union[str, int]] = None,
status: Optional[dict] = None,
) -> Optional[bool]:
"""
删除飞书“正在处理”表情。
"""
if channel != self._channel:
return None
metadata = (status or {}).get("metadata") or {}
target_message_id = (status or {}).get("message_id") or message_id
reaction_id = metadata.get("reaction_id")
if not target_message_id or not reaction_id:
return False
return self.delete_feishu_message_reaction(
message_id=str(target_message_id),
reaction_id=str(reaction_id),
source=source,
)
def finalize_message(self, response: MessageResponse) -> bool:
if response.channel != self._channel or not isinstance(response.metadata, dict):
return False
stream_meta = response.metadata.get("feishu_streaming") or {}
card_id = str(stream_meta.get("card_id") or "").strip()
if not card_id:
return False
client_config = self.get_config(response.source)
if not client_config:
return False
client = self.get_instance(client_config.name)
if not client:
return False
sequence = int(stream_meta.get("sequence") or 0) + 1
return client.close_streaming_card(card_id=card_id, sequence=sequence)