From 04c913fdf7f5cc678c0fdc0f80c4e76f8083c2c4 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 2 Jul 2026 17:05:50 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_014w1Z6Nxy6XTRgkFXqPr9Zh --- backend/src/module/notification/base.py | 39 +++++++++++++++++++ backend/src/module/notification/manager.py | 2 +- .../module/notification/providers/__init__.py | 8 ++-- .../src/module/notification/providers/bark.py | 8 ++-- .../module/notification/providers/discord.py | 11 +++--- .../module/notification/providers/gotify.py | 9 +++-- .../module/notification/providers/webhook.py | 8 ++-- .../module/notification/providers/wecom.py | 16 ++------ backend/src/test/test_notification.py | 18 ++++----- 9 files changed, 76 insertions(+), 43 deletions(-) diff --git a/backend/src/module/notification/base.py b/backend/src/module/notification/base.py index c2ebf8bf..249f34df 100644 --- a/backend/src/module/notification/base.py +++ b/backend/src/module/notification/base.py @@ -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/.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) diff --git a/backend/src/module/notification/manager.py b/backend/src/module/notification/manager.py index 94994b12..f223a0e9 100644 --- a/backend/src/module/notification/manager.py +++ b/backend/src/module/notification/manager.py @@ -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__) diff --git a/backend/src/module/notification/providers/__init__.py b/backend/src/module/notification/providers/__init__.py index 6dd3b6e8..3944a3c8 100644 --- a/backend/src/module/notification/providers/__init__.py +++ b/backend/src/module/notification/providers/__init__.py @@ -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 diff --git a/backend/src/module/notification/providers/bark.py b/backend/src/module/notification/providers/bark.py index 4f8534f8..005d25f0 100644 --- a/backend/src/module/notification/providers/bark.py +++ b/backend/src/module/notification/providers/bark.py @@ -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: diff --git a/backend/src/module/notification/providers/discord.py b/backend/src/module/notification/providers/discord.py index 8ce4b654..649c6099 100644 --- a/backend/src/module/notification/providers/discord.py +++ b/backend/src/module/notification/providers/discord.py @@ -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: diff --git a/backend/src/module/notification/providers/gotify.py b/backend/src/module/notification/providers/gotify.py index b03c8233..b5317269 100644 --- a/backend/src/module/notification/providers/gotify.py +++ b/backend/src/module/notification/providers/gotify.py @@ -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: diff --git a/backend/src/module/notification/providers/webhook.py b/backend/src/module/notification/providers/webhook.py index 467d506e..e5d1713b 100644 --- a/backend/src/module/notification/providers/webhook.py +++ b/backend/src/module/notification/providers/webhook.py @@ -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: diff --git a/backend/src/module/notification/providers/wecom.py b/backend/src/module/notification/providers/wecom.py index 80166f81..dd46b757 100644 --- a/backend/src/module/notification/providers/wecom.py +++ b/backend/src/module/notification/providers/wecom.py @@ -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) diff --git a/backend/src/test/test_notification.py b/backend/src/test/test_notification.py index a9e005b1..b2766be3 100644 --- a/backend/src/test/test_notification.py +++ b/backend/src/test/test_notification.py @@ -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)