feat(telegram): support rich Agent messages

This commit is contained in:
jxxghp
2026-08-22 10:11:18 +08:00
parent 1c07333909
commit 9273d68aa7
16 changed files with 444 additions and 33 deletions
+16 -1
View File
@@ -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:
+7
View File
@@ -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,
)
)
+1
View File
@@ -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>
+18
View File
@@ -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,
+28 -8
View File
@@ -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,
)
)
+4 -1
View File
@@ -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"):
+147 -3
View File
@@ -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,
+2
View File
@@ -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