diff --git a/app/foundation/text.py b/app/foundation/text.py index 5aef5b8bd..b86fd0a0c 100644 --- a/app/foundation/text.py +++ b/app/foundation/text.py @@ -26,9 +26,21 @@ def render_notification(title: Optional[str], blocks: List[Dict[str, Optional[st paragraphs = [] if title and text_format == "rich_markdown": paragraphs.append(f"## {_escape_notification_text(title, text_format)}") + in_section = False for block in blocks: lines = [] block_title, block_text = block.get("title"), block.get("text") + if text_format == "rich_markdown" and block_title: + heading = _escape_notification_text(block_title, text_format) + if not block_text: + paragraphs.append(f"### {heading}") + in_section = True + continue + if in_section: + # 分节之后的标题/正文对是列表项,硬换行让续行仍缩进在当前条目内。 + body = _escape_notification_text(block_text, text_format).replace("\n", " \n ") + paragraphs.append(f"- **{heading}** \n {body}") + continue if block_title: lines.append(f"{bold}{_escape_notification_text(block_title, text_format)}{bold}") if block_text: diff --git a/app/modules/telegram/module.py b/app/modules/telegram/module.py index be6cc4f84..0fe48b7a3 100644 --- a/app/modules/telegram/module.py +++ b/app/modules/telegram/module.py @@ -1,7 +1,7 @@ -import html import json import re from typing import Any, List, Optional, Tuple, Union +from urllib.parse import quote from app.application.messaging.channel.admin import ( matches_channel_admin, @@ -463,8 +463,9 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]): if not rich_text: return message.rich_message if message.link: - link = html.escape(message.link, quote=True) - rich_text = f'{rich_text}\n\n查看详情' + # 输入仍是 Markdown,HTML 标签会被 richify 当作普通文本转义。 + link = quote(message.link, safe=":/?#&=%") + rich_text = f'{rich_text}\n\n[查看详情](<{link}>)' return rich_text def post_message(self, message: Message, **kwargs) -> None: diff --git a/app/schemas/message.py b/app/schemas/message.py index 7f5ddcdf8..bfff03761 100644 --- a/app/schemas/message.py +++ b/app/schemas/message.py @@ -229,7 +229,7 @@ class IncomingMessage(BaseModel): @_dataclass class NotificationBlock: - """一段与渠道无关的通知内容,标题和正文均是未插入格式标记的普通文本。""" + """通知段落,内容均为普通文本;单独标题开启分节,节中的标题/正文对表示条目。""" title: Optional[str] = None text: Optional[str] = None diff --git a/docs/notification-formatting.md b/docs/notification-formatting.md index 1c8b8a0b5..a4d166ea9 100644 --- a/docs/notification-formatting.md +++ b/docs/notification-formatting.md @@ -4,6 +4,8 @@ `text` 保留完整纯文本结果;`rich_content` 使用 `NotificationContent`,只包含标题和有序 `NotificationBlock` 列表,每段的 `title`、`text` 都是普通文本。插件不枚举渠道、不插入 Markdown 或 HTML 标签,也不判断渠道权限。 +独立标题表示分节;分节之后的标题和正文对表示条目。Telegram 转为原生分节标题与缩进列表, +同一条目内的续行保留缩进。详情入口使用 Markdown 链接交给真实转换器,避免 HTML 标签被原样展示。 宿主通过 `ChannelCapabilityManager.get_notification_text_format()` 查询普通通知的投递能力, 按 `ChannelCapabilities.notification_text_format` 选择语法和转义方式。未声明该能力的渠道 @@ -31,10 +33,11 @@ WebAgent 能渲染 Markdown,但通知中心仍记录普通文本;ClawBot 当 Telegram 原有的 `rich_message` 用于完整 Rich Markdown 正文,未提供 `rich_content` 时 沿用原来的行为。渠道 API 错误不因一次临时网络错误自动重发另一份通知。 -站点自动签到只负责统计、结果分组和重试标记,结构化内容包括本次全部站点。 -V3 插件版本为 3.0.0,显式声明 `system_version >=3.1.6`,提供默认开启的 +站点自动签到只负责统计、结果分组和重试标记;同一状态内按完整原因归并,原因只展示一次, +对应名单保留全部站点。不同原因在缩短显示之前区分,避免截断后相同的前缀被误合并。 +V3 插件显式声明 `system_version >=3.1.6`,提供默认开启的 “优化消息通知样式”开关;关闭后不传结构化内容,所有渠道恢复原有通知样式。 -原 V2 版本 2.9.13 保持源码及版本不变,在市场索引声明 `v3: true` 和 `system_version <=3.1.5`, +V2 兼容实现保持独立版本,在市场索引声明 `v3: true` 和 `system_version <=3.1.5`, 保留低于 3.1.6 的 V3 主程序选择 V2 兼容实现的安装路径。 具体说明见插件仓 `plugins.v3/autosignin/README.md`。 diff --git a/tests/test_notification_channel_texts.py b/tests/test_notification_channel_texts.py index 5080b130c..d12ad33bd 100644 --- a/tests/test_notification_channel_texts.py +++ b/tests/test_notification_channel_texts.py @@ -290,16 +290,55 @@ def test_channel_notification_format_obeys_capabilities_and_instance_mode(monkey def test_telegram_channel_summary_includes_detail_link_without_changing_legacy_rich_message(): - """专用摘要能访问完整结果,原有 Rich Message 不被添加内容。""" + """详情链接经过真实 Markdown 转换仍是链接,原有 Rich Message 不被添加内容。""" + from app.modules.telegram.telegram import Telegram + message = _message() message.link = 'https://example.com/?a=1&b="value"' rich = TelegramModule._notification_rich_message(message) - assert '查看详情' in rich + converted = "".join(chunk["html"] for chunk in Telegram._build_rich_message_chunks(rich)) + assert '查看详情' in converted + assert "<a href" not in converted message.rich_content = None message.rich_message = "原有完整 Rich Message" assert TelegramModule._notification_rich_message(message) == message.rich_message +def test_telegram_notification_sections_have_native_list_indentation(): + """真实转换产物应包含分节标题和缩进列表,而非全是同级普通段落。""" + from app.modules.telegram.telegram import Telegram + + message = Message(rich_content=NotificationContent(title="签到结果", blocks=[ + NotificationBlock(title="统计", text="本次 47"), + NotificationBlock(title="失败 · 9"), + NotificationBlock(title="Cookie已失效", text="站点一、站点二"), + NotificationBlock(title="网络失败", text="站点三"), + NotificationBlock(title="成功 · 28"), + NotificationBlock(title="签到成功", text="站点四、站点五"), + ])) + body = "".join(chunk["html"] for chunk in Telegram._build_rich_message_chunks( + TelegramModule._notification_rich_message(message))) + assert "

失败 · 9

" in body + assert "

成功 · 28

" in body + assert body.count("
  • ") == 3 + assert "Cookie已失效
    站点一、站点二" in body + assert "签到成功
    站点四、站点五" in body + + +def test_telegram_long_section_list_splits_without_losing_items(): + """原因很多时由真实转换器拆分长列表,各组和对应名单仍完整保留。""" + from app.modules.telegram.telegram import Telegram + + blocks = [NotificationBlock(title="访问失败 · 150")] + blocks.extend(NotificationBlock(title=f"失败原因{index}:" + "网络连接失败" * 20, + text=f"完整站点{index:03d}") for index in range(150)) + message = Message(rich_content=NotificationContent(title="签到结果", blocks=blocks)) + chunks = Telegram._build_rich_message_chunks(TelegramModule._notification_rich_message(message)) + assert len(chunks) > 1 + body = "".join(chunk["html"] for chunk in chunks) + assert all(body.count(f"完整站点{index:03d}") == 1 for index in range(150)) + + def test_host_renders_raw_data_safely_for_every_declared_format(): """同一份结构化数据按能力生成语法,站点文本不能注入格式、链接或提醒。""" from app.schemas.notification import NotificationTextFormat