mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-24 07:26:50 +00:00
- 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
439 lines
17 KiB
Python
439 lines
17 KiB
Python
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)
|