mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 07:56:52 +08:00
feat(telegram): support rich Agent messages
This commit is contained in:
@@ -486,6 +486,14 @@ class StreamingHandler:
|
||||
except (ValueError, KeyError):
|
||||
return False
|
||||
|
||||
def _get_rich_message(self, text: str) -> Optional[str]:
|
||||
"""
|
||||
为 Telegram 流式消息返回 Rich Markdown,其他渠道继续使用原有格式。
|
||||
"""
|
||||
if self._channel == NotificationChannel.Telegram.value:
|
||||
return text
|
||||
return None
|
||||
|
||||
async def _flush_loop(self):
|
||||
"""
|
||||
定时刷新循环,定期将缓冲区内容发送/编辑到用户
|
||||
@@ -557,6 +565,7 @@ class StreamingHandler:
|
||||
original_chat_id=self._original_chat_id,
|
||||
title=self._title,
|
||||
text=current_text,
|
||||
rich_message=self._get_rich_message(current_text),
|
||||
save_history=False,
|
||||
),
|
||||
)
|
||||
@@ -603,6 +612,7 @@ class StreamingHandler:
|
||||
original_chat_id=self._original_chat_id,
|
||||
title=self._title,
|
||||
text=current_text,
|
||||
rich_message=self._get_rich_message(current_text),
|
||||
save_history=False,
|
||||
),
|
||||
)
|
||||
@@ -623,6 +633,11 @@ class StreamingHandler:
|
||||
except (ValueError, KeyError):
|
||||
return
|
||||
|
||||
metadata = dict(self._message_response.metadata or {})
|
||||
rich_message = self._get_rich_message(current_text)
|
||||
if rich_message:
|
||||
# 通用编辑接口不增加渠道专属参数,通过元数据交给 Telegram 模块消费。
|
||||
metadata["telegram_rich_message"] = rich_message
|
||||
success = await run_in_threadpool(
|
||||
chain.edit_message,
|
||||
channel=channel_enum,
|
||||
@@ -631,7 +646,7 @@ class StreamingHandler:
|
||||
chat_id=self._message_response.chat_id,
|
||||
text=current_text,
|
||||
title=self._title,
|
||||
metadata=self._message_response.metadata,
|
||||
metadata=metadata,
|
||||
)
|
||||
if success:
|
||||
with self._lock:
|
||||
|
||||
@@ -2395,6 +2395,12 @@ class MoviePilotAgent:
|
||||
发送 Agent 消息;后台任务不绑定原渠道,交由通知链广播。
|
||||
"""
|
||||
broadcast = self.is_background
|
||||
rich_message = (
|
||||
message
|
||||
if not broadcast
|
||||
and self.channel == NotificationChannel.Telegram.value
|
||||
else None
|
||||
)
|
||||
self._save_assistant_display_message_once(message)
|
||||
await AgentChain().async_post_message(
|
||||
Message(
|
||||
@@ -2407,6 +2413,7 @@ class MoviePilotAgent:
|
||||
original_chat_id=None if broadcast else self.original_chat_id,
|
||||
title=title,
|
||||
text=message,
|
||||
rich_message=rich_message,
|
||||
save_history=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -105,6 +105,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
|
||||
- Channel-aware formatting: Follow the capability rules below for Markdown, plain text, buttons, and voice replies.
|
||||
{button_choice_spec}
|
||||
- Voice replies: {voice_reply_spec}
|
||||
{rich_message_spec}
|
||||
- If the current channel supports image sending and an image would materially help, you may use the `send_message` tool with `image_url` to send it.
|
||||
- If the current channel supports file sending and you need to return a local image or file for the user to download, use `send_local_file`.
|
||||
</communication_runtime>
|
||||
|
||||
@@ -137,6 +137,7 @@ class PromptManager:
|
||||
if caps:
|
||||
markdown_spec = self._generate_formatting_instructions(caps)
|
||||
button_choice_spec = self._generate_button_choice_instructions(msg_channel)
|
||||
rich_message_spec = self._generate_rich_message_instructions(msg_channel)
|
||||
|
||||
# MoviePilot系统信息
|
||||
moviepilot_info = self._get_moviepilot_info()
|
||||
@@ -148,6 +149,7 @@ class PromptManager:
|
||||
moviepilot_info=moviepilot_info,
|
||||
voice_reply_spec=voice_reply_spec,
|
||||
button_choice_spec=button_choice_spec,
|
||||
rich_message_spec=rich_message_spec,
|
||||
)
|
||||
|
||||
return base_prompt
|
||||
@@ -352,6 +354,22 @@ class PromptManager:
|
||||
"content as a text fallback and still completes the reply."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _generate_rich_message_instructions(
|
||||
channel: NotificationChannel = None,
|
||||
) -> str:
|
||||
"""根据渠道生成 Telegram Rich Message 回复提示。"""
|
||||
if channel != NotificationChannel.Telegram:
|
||||
return ""
|
||||
return (
|
||||
"- Telegram final replies: Prefer the `send_message` tool with its "
|
||||
"`rich_message` argument. Put the complete reply in that argument using "
|
||||
"GitHub-style Markdown; headings, lists, tables, blockquotes, code blocks, "
|
||||
"and links are converted to Telegram Rich Message blocks. Do not also set "
|
||||
"`message`, `title`, or `image_url` for the same reply. Use a normal plain "
|
||||
"reply only when the response is very short and has no useful structure."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _generate_button_choice_instructions(
|
||||
channel: NotificationChannel = None,
|
||||
|
||||
@@ -26,12 +26,25 @@ class SendMessageInput(BaseModel):
|
||||
None,
|
||||
description="Optional image URL to send together with the message on channels that support images (such as Telegram and Slack)",
|
||||
)
|
||||
rich_message: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional complete Telegram Rich Message body written in GitHub-style "
|
||||
"Markdown. Telegram renders this instead of message, title, and image_url; "
|
||||
"other channels use it as the plain-text fallback when message is empty."
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_payload(self) -> "SendMessageInput":
|
||||
"""校验消息内容和可选格式参数。"""
|
||||
if not self.message and not self.title and not self.image_url:
|
||||
raise ValueError("message、title、image_url 至少需要提供一个")
|
||||
if (
|
||||
not self.message
|
||||
and not self.title
|
||||
and not self.image_url
|
||||
and not self.rich_message
|
||||
):
|
||||
raise ValueError("message、title、image_url、rich_message 至少需要提供一个")
|
||||
return self
|
||||
|
||||
|
||||
@@ -50,15 +63,18 @@ class SendMessageTool(MoviePilotTool):
|
||||
description: str = (
|
||||
"Send notification message to the user through configured notification channels "
|
||||
"(Telegram, Slack, WeChat, etc.). Supports optional image_url on channels that can "
|
||||
"send images. This is a terminal response tool: after it sends the user-facing "
|
||||
"message, do not send another final text reply with the same content."
|
||||
"send images. On Telegram, prefer the optional rich_message parameter for structured "
|
||||
"final replies; write its complete content in GitHub-style Markdown. This is a "
|
||||
"terminal response tool: after it sends the user-facing message, do not send another "
|
||||
"final text reply with the same content."
|
||||
)
|
||||
args_schema: Type[BaseModel] = SendMessageInput
|
||||
require_admin: bool = True
|
||||
|
||||
def get_tool_message(self, **kwargs) -> Optional[str]:
|
||||
"""根据消息参数生成友好的提示消息"""
|
||||
message = kwargs.get("message", "") or ""
|
||||
rich_message = kwargs.get("rich_message") or ""
|
||||
message = kwargs.get("message", "") or rich_message
|
||||
title = kwargs.get("title") or ""
|
||||
image_url = kwargs.get("image_url")
|
||||
|
||||
@@ -66,6 +82,8 @@ class SendMessageTool(MoviePilotTool):
|
||||
if len(message) > 50:
|
||||
message = message[:50] + "..."
|
||||
|
||||
if rich_message:
|
||||
return f"发送富文本消息: {message}"
|
||||
if title and image_url:
|
||||
return f"发送图文消息: [{title}] {message}"
|
||||
if title:
|
||||
@@ -79,15 +97,16 @@ class SendMessageTool(MoviePilotTool):
|
||||
message: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
image_url: Optional[str] = None,
|
||||
rich_message: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""发送消息到当前会话渠道。"""
|
||||
title = title or ("图片" if image_url and not message else "")
|
||||
text = message or ""
|
||||
title = title or ("图片" if image_url and not message and not rich_message else "")
|
||||
text = message or rich_message or ""
|
||||
|
||||
logger.info(
|
||||
f"执行工具: {self.name}, 参数: title={title}, message={text}, "
|
||||
f"image_url={image_url}"
|
||||
f"image_url={image_url}, rich_message={bool(rich_message)}"
|
||||
)
|
||||
try:
|
||||
await self.send_message(
|
||||
@@ -100,6 +119,7 @@ class SendMessageTool(MoviePilotTool):
|
||||
title=title,
|
||||
text=text,
|
||||
image=image_url,
|
||||
rich_message=rich_message,
|
||||
save_history=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -515,6 +515,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]):
|
||||
original_chat_id=message.original_chat_id,
|
||||
disable_web_page_preview=message.disable_web_page_preview,
|
||||
parse_mode=message.parse_mode,
|
||||
rich_message=message.rich_message,
|
||||
)
|
||||
|
||||
def post_medias_message(
|
||||
@@ -616,7 +617,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]):
|
||||
:param text: 新的消息内容
|
||||
:param title: 消息标题
|
||||
:param buttons: 新的按钮列表
|
||||
:param metadata: 其他元信息
|
||||
:param metadata: 其他元信息;telegram_rich_message 用于流式富文本编辑
|
||||
:param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML
|
||||
:return: 编辑是否成功
|
||||
"""
|
||||
@@ -634,6 +635,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]):
|
||||
title=title,
|
||||
buttons=buttons,
|
||||
parse_mode=parse_mode,
|
||||
rich_message=(metadata or {}).get("telegram_rich_message"),
|
||||
)
|
||||
if result:
|
||||
return True
|
||||
@@ -739,6 +741,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]):
|
||||
original_chat_id=original_chat_id,
|
||||
disable_web_page_preview=message.disable_web_page_preview,
|
||||
parse_mode=message.parse_mode,
|
||||
rich_message=message.rich_message,
|
||||
private_delivery=message.private_delivery,
|
||||
)
|
||||
if result and result.get("success"):
|
||||
|
||||
@@ -19,12 +19,14 @@ from telebot.types import ( # noqa: E402
|
||||
InlineKeyboardMarkup,
|
||||
InlineKeyboardButton,
|
||||
InputMediaPhoto,
|
||||
InputRichMessage as TelebotInputRichMessage,
|
||||
ReplyParameters,
|
||||
)
|
||||
try:
|
||||
from telebot.types import ForceReply # noqa: E402
|
||||
except ImportError:
|
||||
ForceReply = None
|
||||
from telegramify_markdown import standardize, telegramify # noqa: E402
|
||||
from telegramify_markdown import richify, split_rich, standardize, telegramify # noqa: E402
|
||||
try:
|
||||
from telegramify_markdown import entities_to_markdownv2 # noqa: E402
|
||||
except ImportError:
|
||||
@@ -615,6 +617,7 @@ class Telegram:
|
||||
disable_web_page_preview: Optional[bool] = None,
|
||||
stop_typing: bool = False,
|
||||
parse_mode: Optional[str] = None,
|
||||
rich_message: Optional[str] = None,
|
||||
private_delivery: bool = False,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
@@ -631,6 +634,7 @@ class Telegram:
|
||||
:param disable_web_page_preview: 是否禁用链接预览
|
||||
:param stop_typing: 发送完成后是否立即停止 typing
|
||||
:param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML
|
||||
:param rich_message: 完整的 Telegram Rich Markdown 正文,设置后替代普通图文内容
|
||||
:param private_delivery: 是否绕过最近会话映射,直接以用户 ID 作为私聊目标
|
||||
:return: 包含 message_id, chat_id, success 的字典
|
||||
"""
|
||||
@@ -644,8 +648,8 @@ class Telegram:
|
||||
original_chat_id,
|
||||
private_delivery=private_delivery,
|
||||
)
|
||||
if not title and not text:
|
||||
logger.warn("标题和内容不能同时为空")
|
||||
if not title and not text and not rich_message:
|
||||
logger.warn("标题、内容和富文本内容不能同时为空")
|
||||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||||
return {"success": False}
|
||||
|
||||
@@ -670,6 +674,45 @@ class Telegram:
|
||||
elif force_reply and ForceReply:
|
||||
reply_markup = self._create_force_reply_markup()
|
||||
|
||||
if rich_message:
|
||||
if original_message_id and original_chat_id and not force_reply:
|
||||
result = self.__edit_rich_message(
|
||||
chat_id=original_chat_id,
|
||||
message_id=original_message_id,
|
||||
rich_message=rich_message,
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||||
return {
|
||||
"success": bool(result),
|
||||
"message_id": original_message_id,
|
||||
"chat_id": original_chat_id,
|
||||
}
|
||||
|
||||
target_chat_id = (
|
||||
original_chat_id
|
||||
if force_reply and original_chat_id
|
||||
else chat_id
|
||||
)
|
||||
sent = self.__send_rich_message(
|
||||
chat_id=target_chat_id,
|
||||
rich_message=rich_message,
|
||||
reply_markup=reply_markup,
|
||||
reply_to_message_id=(
|
||||
original_message_id if force_reply else None
|
||||
),
|
||||
)
|
||||
self._stop_typing_if_needed(chat_id, stop_typing)
|
||||
if sent and hasattr(sent, "message_id"):
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": sent.message_id,
|
||||
"chat_id": sent.chat.id if hasattr(sent, "chat") else chat_id,
|
||||
}
|
||||
if sent:
|
||||
return {"success": True}
|
||||
return {"success": False}
|
||||
|
||||
# 判断是编辑消息还是发送新消息
|
||||
if original_message_id and original_chat_id:
|
||||
if force_reply and reply_markup and not buttons:
|
||||
@@ -1165,6 +1208,7 @@ class Telegram:
|
||||
buttons: Optional[List[List[dict]]] = None,
|
||||
stop_typing: bool = False,
|
||||
parse_mode: Optional[str] = None,
|
||||
rich_message: Optional[str] = None,
|
||||
) -> Optional[bool]:
|
||||
"""
|
||||
编辑Telegram消息(公开方法)
|
||||
@@ -1175,6 +1219,7 @@ class Telegram:
|
||||
:param buttons: 新的按钮列表
|
||||
:param stop_typing: 编辑完成后是否立即停止 typing
|
||||
:param parse_mode: Telegram 消息格式类型,默认 MarkdownV2,可传 HTML
|
||||
:param rich_message: 完整的 Telegram Rich Markdown 正文,设置后替代普通文本
|
||||
:return: 编辑是否成功
|
||||
"""
|
||||
if not self._bot:
|
||||
@@ -1182,6 +1227,17 @@ class Telegram:
|
||||
|
||||
parse_mode = self._normalize_parse_mode(parse_mode)
|
||||
try:
|
||||
if rich_message:
|
||||
reply_markup = (
|
||||
self._create_inline_keyboard(buttons) if buttons else None
|
||||
)
|
||||
return self.__edit_rich_message(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
rich_message=rich_message,
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
|
||||
# 组合标题和文本
|
||||
if title:
|
||||
bold_title = self._format_title(title, parse_mode)
|
||||
@@ -1351,6 +1407,94 @@ class Telegram:
|
||||
logger.error(f"编辑消息失败:{str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _build_rich_message_chunks(
|
||||
rich_message: str,
|
||||
) -> List[TelebotInputRichMessage]:
|
||||
"""
|
||||
将 GitHub 风格 Markdown 转换并拆分为 Telegram Rich Message。
|
||||
|
||||
:param rich_message: 完整的 Rich Markdown 正文
|
||||
:return: 满足 Telegram 字节数和块数量限制的消息片段
|
||||
"""
|
||||
converted = richify(rich_message, mode="html")
|
||||
return [
|
||||
TelebotInputRichMessage(**chunk.to_dict())
|
||||
for chunk in split_rich(converted)
|
||||
]
|
||||
|
||||
def __edit_rich_message(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
message_id: Union[str, int],
|
||||
rich_message: str,
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
编辑 Telegram Rich Message。
|
||||
|
||||
:param chat_id: 聊天 ID
|
||||
:param message_id: 原消息 ID
|
||||
:param rich_message: 完整的 Rich Markdown 正文
|
||||
:param reply_markup: 内联键盘
|
||||
:return: 编辑是否成功
|
||||
"""
|
||||
chunks = self._build_rich_message_chunks(rich_message)
|
||||
if len(chunks) != 1:
|
||||
logger.warning("Telegram Rich Message 超出单条限制,无法编辑原消息")
|
||||
return False
|
||||
try:
|
||||
self._bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=int(message_id),
|
||||
text=None,
|
||||
rich_message=chunks[0],
|
||||
reply_markup=reply_markup,
|
||||
)
|
||||
return True
|
||||
except Exception as err:
|
||||
if self.__is_message_not_modified_error(err):
|
||||
logger.debug(f"Telegram消息内容未变化,跳过编辑:{str(err)}")
|
||||
return True
|
||||
logger.error(f"编辑 Telegram Rich Message 失败:{str(err)}")
|
||||
return False
|
||||
|
||||
@retry(RetryException, logger=logger)
|
||||
def __send_rich_message(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
rich_message: str,
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||
reply_to_message_id: Optional[Union[str, int]] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
发送 Telegram Rich Message,超限内容自动拆分为多条。
|
||||
|
||||
:param chat_id: 目标聊天 ID
|
||||
:param rich_message: 完整的 Rich Markdown 正文
|
||||
:param reply_markup: 首条消息携带的键盘
|
||||
:param reply_to_message_id: 首条消息回复的原消息 ID
|
||||
:return: 最后一条已发送的 Telegram 消息
|
||||
"""
|
||||
chunks = self._build_rich_message_chunks(rich_message)
|
||||
reply_parameters = (
|
||||
ReplyParameters(message_id=int(reply_to_message_id))
|
||||
if reply_to_message_id is not None
|
||||
else None
|
||||
)
|
||||
sent = None
|
||||
try:
|
||||
for index, chunk in enumerate(chunks):
|
||||
sent = self._bot.send_rich_message(
|
||||
chat_id=chat_id,
|
||||
rich_message=chunk,
|
||||
reply_markup=reply_markup if index == 0 else None,
|
||||
reply_parameters=reply_parameters if index == 0 else None,
|
||||
)
|
||||
return sent
|
||||
except Exception as err:
|
||||
raise RetryException("发送 Telegram Rich Message 失败") from err
|
||||
|
||||
def __send_request(
|
||||
self,
|
||||
userid: Optional[str] = None,
|
||||
|
||||
@@ -271,6 +271,8 @@ class Message(BaseModel):
|
||||
disable_web_page_preview: Optional[bool] = None
|
||||
# 消息文本格式;Telegram 支持 MarkdownV2、HTML、plain,飞书直发支持 plain
|
||||
parse_mode: Optional[str] = None
|
||||
# Telegram Rich Message 完整 Markdown 正文;其他渠道可使用 text 作为回退
|
||||
rich_message: Optional[str] = None
|
||||
# 是否写入消息历史
|
||||
save_history: bool = True
|
||||
|
||||
|
||||
Reference in New Issue
Block a user