Feat: Ntfy integration (Draft/Future Use)#8818
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for the ntfy platform adapter, enabling message notifications and streaming. It adds the core adapter, API client, and event handling logic, along with default configurations, i18n resources, and frontend assets. Feedback on the implementation highlights several critical improvements: encoding non-ASCII HTTP headers in send_file to prevent UnicodeEncodeError, prefixing configuration metadata keys with ntfy_ to resolve mismatches, safely handling None values during configuration retrieval, correcting a typo in the default server URL, and offloading synchronous file I/O to a thread pool to prevent blocking the async event loop.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| async def send_file( | ||
| self, | ||
| file_bytes: bytes, | ||
| filename: str, | ||
| message: str | None = None, | ||
| ) -> bool: | ||
| """Uploads a rich attachment asset via PUT binary stream.""" | ||
| headers = { | ||
| **self.headers, | ||
| "X-Title": "AstrBot", | ||
| "X-Tags": "robot", | ||
| "X-Filename": filename, | ||
| } | ||
| if message: | ||
| headers["X-Message"] = message | ||
|
|
There was a problem hiding this comment.
在 send_file 中,X-Filename 和 X-Message 头部直接被赋予了可能包含非 ASCII 字符(如中文)的 filename 和 message。由于 aiohttp 默认使用 latin-1 编码 HTTP 头部,这会导致 UnicodeEncodeError 异常,从而使发送带中文文件名或中文消息的附件时彻底失败。根据 ntfy 官方文档,非 ASCII 头部必须使用 RFC 2047 进行 Base64 编码。建议在此处实现一个本地的 encode_header 辅助函数来处理这些头部。此外,作为新增加的附件处理功能,请确保为其编写相应的单元测试。
async def send_file(
self,
file_bytes: bytes,
filename: str,
message: str | None = None,
) -> bool:
"""Uploads a rich attachment asset via PUT binary stream."""
import base64
def encode_header(val: str) -> str:
try:
val.encode('ascii')
return val
except UnicodeEncodeError:
return f"=?utf-8?B?{base64.b64encode(val.encode('utf-8')).decode('ascii')}?="
headers = {
**self.headers,
"X-Title": "AstrBot",
"X-Tags": "robot",
"X-Filename": encode_header(filename),
}
if message:
headers["X-Message"] = encode_header(message)References
- New functionality, such as handling attachments, should be accompanied by corresponding unit tests.
| "server_url": { | ||
| "description": "ntfy Server URL", | ||
| "type": "string", | ||
| "hint": "ntfy 服务器地址,例如 https://ntfy.sh 或您的自建实例地址。", | ||
| }, | ||
| "topic": { | ||
| "description": "ntfy Topic", | ||
| "type": "string", | ||
| "hint": "用于收发消息的唯一订阅主题名称 (请确保其足够私密)。", | ||
| }, | ||
| "access_token": { | ||
| "description": "Access Token (Optional)", | ||
| "type": "string", | ||
| "hint": "如果您的 ntfy 服务器开启了身份验证,请在此输入 Bearer Token。", | ||
| }, |
There was a problem hiding this comment.
Ntfy 平台的配置元数据键名(server_url、topic、access_token)与默认配置模板及适配器中实际读取的键名(ntfy_server_url、ntfy_topic、ntfy_access_token)不一致。这会导致管理面板(Dashboard)无法正确绑定和保存这些配置项。建议将元数据中的键名统一加上 ntfy_ 前缀。
| "server_url": { | |
| "description": "ntfy Server URL", | |
| "type": "string", | |
| "hint": "ntfy 服务器地址,例如 https://ntfy.sh 或您的自建实例地址。", | |
| }, | |
| "topic": { | |
| "description": "ntfy Topic", | |
| "type": "string", | |
| "hint": "用于收发消息的唯一订阅主题名称 (请确保其足够私密)。", | |
| }, | |
| "access_token": { | |
| "description": "Access Token (Optional)", | |
| "type": "string", | |
| "hint": "如果您的 ntfy 服务器开启了身份验证,请在此输入 Bearer Token。", | |
| }, | |
| "ntfy_server_url": { | |
| "description": "ntfy Server URL", | |
| "type": "string", | |
| "hint": "ntfy 服务器地址,例如 https://ntfy.sh 或您的自建实例地址。", | |
| }, | |
| "ntfy_topic": { | |
| "description": "ntfy Topic", | |
| "type": "string", | |
| "hint": "用于收发消息的唯一订阅主题名称 (请确保其足够私密)。", | |
| }, | |
| "ntfy_access_token": { | |
| "description": "Access Token (Optional)", | |
| "type": "string", | |
| "hint": "如果您的 ntfy 服务器开启了身份验证,请在此输入 Bearer Token。", | |
| }, |
| NTFY_CONFIG_METADATA = { | ||
| "server_url": { | ||
| "description": "ntfy Server URL", | ||
| "type": "string", | ||
| "hint": "ntfy 服务器地址,例如 https://ntfy.sh 或您的自建实例地址。", | ||
| }, | ||
| "topic": { | ||
| "description": "ntfy Topic", | ||
| "type": "string", | ||
| "hint": "用于收发消息的唯一订阅主题名称 (请确保其足够私密)。", | ||
| }, | ||
| "access_token": { | ||
| "description": "Access Token (Optional)", | ||
| "type": "string", | ||
| "hint": "如果您的 ntfy 服务器开启了身份验证,请在此输入 Bearer Token。", | ||
| }, | ||
| } |
There was a problem hiding this comment.
同样地,NtfyPlatformAdapter 内部定义的 NTFY_CONFIG_METADATA 键名也缺少 ntfy_ 前缀,与实际读取的配置键名不符。建议在此处也统一加上 ntfy_ 前缀以保持一致。
| NTFY_CONFIG_METADATA = { | |
| "server_url": { | |
| "description": "ntfy Server URL", | |
| "type": "string", | |
| "hint": "ntfy 服务器地址,例如 https://ntfy.sh 或您的自建实例地址。", | |
| }, | |
| "topic": { | |
| "description": "ntfy Topic", | |
| "type": "string", | |
| "hint": "用于收发消息的唯一订阅主题名称 (请确保其足够私密)。", | |
| }, | |
| "access_token": { | |
| "description": "Access Token (Optional)", | |
| "type": "string", | |
| "hint": "如果您的 ntfy 服务器开启了身份验证,请在此输入 Bearer Token。", | |
| }, | |
| } | |
| NTFY_CONFIG_METADATA = { | |
| "ntfy_server_url": { | |
| "description": "ntfy Server URL", | |
| "type": "string", | |
| "hint": "ntfy 服务器地址,例如 https://ntfy.sh 或您的自建实例地址。", | |
| }, | |
| "ntfy_topic": { | |
| "description": "ntfy Topic", | |
| "type": "string", | |
| "hint": "用于收发消息的唯一订阅主题名称 (请确保其足够私密)。", | |
| }, | |
| "ntfy_access_token": { | |
| "description": "Access Token (Optional)", | |
| "type": "string", | |
| "hint": "如果您的 ntfy 服务器开启了身份验证,请在此输入 Bearer Token。", | |
| }, | |
| } |
| server_url = str( | ||
| platform_config.get("ntfy_server_url", "https://ntfy.sh") | ||
| ).strip() | ||
| topic = str(platform_config.get("ntfy_topic", "")).strip() | ||
| access_token = str(platform_config.get("ntfy_access_token", "")).strip() |
There was a problem hiding this comment.
如果 platform_config 中对应的配置项值为 None(例如在 JSON 配置中显式设为 null),直接使用 str(platform_config.get(..., default)) 会将 None 转换为字符串 "None",从而导致错误的请求 URL 或 Token。建议使用 or 运算符进行安全的空值兜底。
| server_url = str( | |
| platform_config.get("ntfy_server_url", "https://ntfy.sh") | |
| ).strip() | |
| topic = str(platform_config.get("ntfy_topic", "")).strip() | |
| access_token = str(platform_config.get("ntfy_access_token", "")).strip() | |
| server_url = str( | |
| platform_config.get("ntfy_server_url") or "https://ntfy.sh" | |
| ).strip() | |
| topic = str(platform_config.get("ntfy_topic") or "").strip() | |
| access_token = str(platform_config.get("ntfy_access_token") or "").strip() |
| "id": "ntfy", | ||
| "type": "ntfy", | ||
| "enable": True, | ||
| "ntfy_server_url": "https:ntfy.sh", |
There was a problem hiding this comment.
"ntfy_server_url" 的默认值 "https:ntfy.sh" 缺少双斜杠 "//",应修正为 "https://ntfy.sh"。
| "ntfy_server_url": "https:ntfy.sh", | |
| "ntfy_server_url": "https://ntfy.sh", |
| filename = getattr( | ||
| file_component, "name", None | ||
| ) or os.path.basename(file_path) | ||
| with open(file_path, "rb") as f: | ||
| file_bytes = f.read() |
There was a problem hiding this comment.
在异步事件处理函数中,使用原生的 open().read() 进行同步 file I/O 读取会阻塞整个 asyncio 事件循环,导致其他并发任务(如其他用户的消息处理)出现明显的延迟。建议使用 asyncio.to_thread 将文件读取操作放到线程池中执行。
filename = getattr(
file_component, "name", None
) or os.path.basename(file_path)
def read_file():
with open(file_path, "rb") as f:
return f.read()
file_bytes = await asyncio.to_thread(read_file)
The feature is currently not needed for AstrBot
AstrBot目前不需要该功能
This is a draft PR for community feedback and future reference
该PR为草稿PR,主要目的为整合社区反馈和作为未来参考
I can mark the PR as ready if notified
如有需要可以通知我将PR改为正式PR
Summary / 总结
Added Ntfy as a lightweight messaging platform for easy interaction with the AI agent.
加入了更加轻量化的Ntfy作为与Agent交互的平台
Motivation / 改动原因
See Issue 8720 for more details
具体请见Issue 8720
Modifications / 改动点
Screenshots or Test Results / 运行截图或测试结果
Manual Testing / 手动测试
Automated Testing Sequence / 自动测试
Passed / 通过
ruff format .: 438 files left unchangedruff check .: All checks passed!uv run pytest -q tests/test_neo_sync.py: 2 passed in 6.37suv run pytest -q tests/test_neo_skill_tools.py: 1 passed in 3.76suv run pytest -q tests/test_skill_manager_sandbox_cache.py: 3 passed in 0.15suv run pytest -q tests/test_dashboard.py:test_neo_skills_routes: 1 passed in 12.24sFailed / 失败
uv run pytest -q tests/test_computer_skill_sync.py: 2 failed, 3 passed in 4.20s#####Cause / 原因
AssertionError: assert 'skills\skills.zip' == 'skills/skills.zip'
The error is caused by OS configuration / 报错由OS设置导致,与主程序无关
Checklist / 检查清单
😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
/ 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。
🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in
requirements.txtandpyproject.toml./ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到
requirements.txt和pyproject.toml文件相应位置。😮 My changes do not introduce malicious code.
/ 我的更改没有引入恶意代码。