fix(notification): send JSON bodies as JSON, gate poster URLs on a public base_url

- Discord/Webhook/Gotify/Bark POST real JSON via RequestURL.post_json (was form-encoded and always rejected)
- Route through a distinct _post_json to avoid the form-encoding post_json shadowed on RequestContent
- Poster fields use settings.notification.base_url when set, otherwise omitted (local paths were never reachable)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh
This commit is contained in:
Estrella Pan
2026-07-02 17:05:50 +02:00
parent dc786a29e3
commit 04c913fdf7
9 changed files with 76 additions and 43 deletions

View File

@@ -2,8 +2,10 @@
from abc import ABC, abstractmethod
from module.conf import settings
from module.models.bangumi import Notification
from module.network import RequestContent
from module.network.request_url import RequestURL
class NotificationProvider(RequestContent, ABC):
@@ -49,3 +51,40 @@ class NotificationProvider(RequestContent, ABC):
f"季度: 第{notify.season}季\n"
f"更新集数: 第{notify.episode}集"
)
def _poster_url(self, notify: Notification) -> str | None:
"""Build a public poster URL from a locally-stored poster path.
``Notification.poster_path`` is a local relative path (e.g.
``posters/<hash>.jpg``), not reachable by external services. It can
only be exposed to providers when a public ``base_url`` is
configured; otherwise callers must omit the poster field entirely.
Args:
notify: The notification data.
Returns:
The public poster URL, or None if it cannot be resolved.
"""
if not notify.poster_path or notify.poster_path == "https://mikanani.me":
return None
base_url = settings.notification.base_url
if not base_url:
return None
return f"{base_url.rstrip('/')}/{notify.poster_path}"
async def _post_json(self, url: str, json_data: dict):
"""POST a JSON payload via ``RequestURL.post_json`` (httpx ``json=``).
Called explicitly on ``RequestURL`` rather than ``self.post_json``
because ``RequestContent`` (a parent of this class) defines its own
unrelated, form-encoded ``post_json`` that would otherwise shadow it.
Args:
url: The target URL.
json_data: The JSON-serializable payload.
Returns:
The httpx response.
"""
return await RequestURL.post_json(self, url, json_data)

View File

@@ -9,8 +9,8 @@ from module.database import Database
from module.models.bangumi import Notification
if TYPE_CHECKING:
from module.notification.base import NotificationProvider
from module.models.config import NotificationProvider as ProviderConfig
from module.notification.base import NotificationProvider
logger = logging.getLogger(__name__)

View File

@@ -2,14 +2,14 @@
from typing import TYPE_CHECKING
from module.notification.providers.telegram import TelegramProvider
from module.notification.providers.discord import DiscordProvider
from module.notification.providers.bark import BarkProvider
from module.notification.providers.server_chan import ServerChanProvider
from module.notification.providers.wecom import WecomProvider
from module.notification.providers.discord import DiscordProvider
from module.notification.providers.gotify import GotifyProvider
from module.notification.providers.pushover import PushoverProvider
from module.notification.providers.server_chan import ServerChanProvider
from module.notification.providers.telegram import TelegramProvider
from module.notification.providers.webhook import WebhookProvider
from module.notification.providers.wecom import WecomProvider
if TYPE_CHECKING:
from module.notification.base import NotificationProvider

View File

@@ -30,11 +30,13 @@ class BarkProvider(NotificationProvider):
data = {
"title": notification.official_title,
"body": text,
"icon": notification.poster_path or "",
"device_key": self.device_key,
}
poster_url = self._poster_url(notification)
if poster_url:
data["icon"] = poster_url
resp = await self.post_data(self.notification_url, data)
resp = await self._post_json(self.notification_url, data)
logger.debug("Bark notification: %s", resp.status_code)
return resp.status_code == 200
@@ -46,7 +48,7 @@ class BarkProvider(NotificationProvider):
"device_key": self.device_key,
}
try:
resp = await self.post_data(self.notification_url, data)
resp = await self._post_json(self.notification_url, data)
if resp.status_code == 200:
return True, "Bark test notification sent successfully"
else:

View File

@@ -30,15 +30,16 @@ class DiscordProvider(NotificationProvider):
"color": 0x00BFFF, # Deep Sky Blue
}
# Add poster as thumbnail if available
if notification.poster_path and notification.poster_path != "https://mikanani.me":
embed["thumbnail"] = {"url": notification.poster_path}
# Add poster as thumbnail if a public poster URL is available
poster_url = self._poster_url(notification)
if poster_url:
embed["thumbnail"] = {"url": poster_url}
data = {
"embeds": [embed],
}
resp = await self.post_data(self.webhook_url, data)
resp = await self._post_json(self.webhook_url, data)
logger.debug("Discord notification: %s", resp.status_code)
return resp.status_code in (200, 204)
@@ -52,7 +53,7 @@ class DiscordProvider(NotificationProvider):
data = {"embeds": [embed]}
try:
resp = await self.post_data(self.webhook_url, data)
resp = await self._post_json(self.webhook_url, data)
if resp.status_code in (200, 204):
return True, "Discord test message sent successfully"
else:

View File

@@ -30,9 +30,10 @@ class GotifyProvider(NotificationProvider):
"client::display": {"contentType": "text/markdown"},
}
if notification.poster_path and notification.poster_path != "https://mikanani.me":
poster_url = self._poster_url(notification)
if poster_url:
extras["client::notification"] = {
"bigImageUrl": notification.poster_path,
"bigImageUrl": poster_url,
}
data = {
@@ -42,7 +43,7 @@ class GotifyProvider(NotificationProvider):
"extras": extras,
}
resp = await self.post_data(self.notification_url, data)
resp = await self._post_json(self.notification_url, data)
logger.debug("Gotify notification: %s", resp.status_code)
return resp.status_code == 200
@@ -54,7 +55,7 @@ class GotifyProvider(NotificationProvider):
"priority": 5,
}
try:
resp = await self.post_data(self.notification_url, data)
resp = await self._post_json(self.notification_url, data)
if resp.status_code == 200:
return True, "Gotify test message sent successfully"
else:

View File

@@ -49,7 +49,7 @@ class WebhookProvider(NotificationProvider):
"{{title}}": notification.official_title,
"{{season}}": str(notification.season),
"{{episode}}": str(notification.episode),
"{{poster_url}}": notification.poster_path or "",
"{{poster_url}}": self._poster_url(notification) or "",
}
for pattern, value in replacements.items():
@@ -66,14 +66,14 @@ class WebhookProvider(NotificationProvider):
"title": notification.official_title,
"season": notification.season,
"episode": notification.episode,
"poster_url": notification.poster_path or "",
"poster_url": self._poster_url(notification) or "",
}
async def send(self, notification: Notification) -> bool:
"""Send notification via generic webhook."""
data = self._render_template(notification)
resp = await self.post_data(self.url, data)
resp = await self._post_json(self.url, data)
logger.debug("Webhook notification: %s", resp.status_code)
# Accept any 2xx status code as success
return 200 <= resp.status_code < 300
@@ -89,7 +89,7 @@ class WebhookProvider(NotificationProvider):
data = self._render_template(test_notification)
try:
resp = await self.post_data(self.url, data)
resp = await self._post_json(self.url, data)
if 200 <= resp.status_code < 300:
return True, "Webhook test request sent successfully"
else:

View File

@@ -11,12 +11,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Default fallback image for when no poster is available
DEFAULT_POSTER = (
"https://article.biliimg.com/bfs/article/"
"d8bcd0408bf32594fd82f27de7d2c685829d1b2e.png"
)
class WecomProvider(NotificationProvider):
"""WeChat Work (企业微信) notification provider using news message format."""
@@ -32,18 +26,15 @@ class WecomProvider(NotificationProvider):
title = f"【番剧更新】{notification.official_title}"
msg = self._format_message(notification)
# Use default poster if none available or if it's just the base Mikan URL
picurl = notification.poster_path
if not picurl or picurl == "https://mikanani.me":
picurl = DEFAULT_POSTER
data = {
"key": self.token,
"type": "news",
"title": title,
"msg": msg,
"picurl": picurl,
}
poster_url = self._poster_url(notification)
if poster_url:
data["picurl"] = poster_url
resp = await self.post_data(self.notification_url, data)
logger.debug("Wecom notification: %s", resp.status_code)
@@ -56,7 +47,6 @@ class WecomProvider(NotificationProvider):
"type": "news",
"title": "AutoBangumi 通知测试",
"msg": "通知测试成功!\nNotification test successful!",
"picurl": DEFAULT_POSTER,
}
try:
resp = await self.post_data(self.notification_url, data)

View File

@@ -1,23 +1,23 @@
"""Tests for notification: provider registry, manager, and provider implementations."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from module.models import Notification
from module.models.config import NotificationProvider as ProviderConfig
from module.notification import PROVIDER_REGISTRY, NotificationManager
from module.notification.providers import (
TelegramProvider,
DiscordProvider,
BarkProvider,
ServerChanProvider,
WecomProvider,
DiscordProvider,
GotifyProvider,
PushoverProvider,
ServerChanProvider,
TelegramProvider,
WebhookProvider,
WecomProvider,
)
# ---------------------------------------------------------------------------
# Provider Registry
# ---------------------------------------------------------------------------
@@ -237,7 +237,7 @@ class TestDiscordProvider:
official_title="Test Anime", season=1, episode=5, poster_path="https://example.com/poster.jpg"
)
with patch.object(provider, "post_data", new_callable=AsyncMock) as mock_post:
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
mock_post.return_value = MagicMock(status_code=204)
result = await provider.send(notify)
@@ -256,7 +256,7 @@ class TestBarkProvider:
"""Sends push notification."""
notify = Notification(official_title="Test Anime", season=1, episode=5)
with patch.object(provider, "post_data", new_callable=AsyncMock) as mock_post:
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
mock_post.return_value = MagicMock(status_code=200)
result = await provider.send(notify)
@@ -288,7 +288,7 @@ class TestWebhookProvider:
"""Sends custom payload."""
notify = Notification(official_title="Test Anime", season=1, episode=5)
with patch.object(provider, "post_data", new_callable=AsyncMock) as mock_post:
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
mock_post.return_value = MagicMock(status_code=200)
result = await provider.send(notify)