diff --git a/backend/src/module/api/notification.py b/backend/src/module/api/notification.py index 21af55c98..8a39fad01 100644 --- a/backend/src/module/api/notification.py +++ b/backend/src/module/api/notification.py @@ -33,7 +33,8 @@ class TestProviderConfigRequest(BaseModel): user_key: Optional[str] = Field(None, description="User key") api_token: Optional[str] = Field(None, description="API token") template: Optional[str] = Field(None, description="Custom template") - url: Optional[str] = Field(None, description="URL for generic webhook") + url: Optional[str] = Field(None, description="URL for generic webhook/onebot") + message_type: Optional[str] = Field(None, description="Message type for onebot: private or group") class TestResponse(BaseModel): @@ -105,6 +106,7 @@ async def test_provider_config(request: TestProviderConfigRequest): api_token=request.api_token or "", template=request.template, url=request.url or "", + message_type=request.message_type or "private", ) success, message = await NotificationManager.test_provider_config(config) diff --git a/backend/src/module/models/config.py b/backend/src/module/models/config.py index b0f4a59b2..3cf76f5a5 100644 --- a/backend/src/module/models/config.py +++ b/backend/src/module/models/config.py @@ -120,7 +120,10 @@ class NotificationProvider(BaseModel): None, description="Custom template for webhook provider" ) url_: Optional[str] = Field( - None, alias="url", description="URL for generic webhook provider" + None, alias="url", description="URL for generic webhook/onebot provider" + ) + message_type: Optional[str] = Field( + "private", description="Message type for onebot: 'private' or 'group'" ) @property diff --git a/backend/src/module/notification/providers/__init__.py b/backend/src/module/notification/providers/__init__.py index 6dd3b6e85..f37f78fa9 100644 --- a/backend/src/module/notification/providers/__init__.py +++ b/backend/src/module/notification/providers/__init__.py @@ -10,6 +10,7 @@ from module.notification.providers.gotify import GotifyProvider from module.notification.providers.pushover import PushoverProvider from module.notification.providers.webhook import WebhookProvider +from module.notification.providers.onebot import OneBotProvider if TYPE_CHECKING: from module.notification.base import NotificationProvider @@ -25,6 +26,7 @@ "gotify": GotifyProvider, "pushover": PushoverProvider, "webhook": WebhookProvider, + "onebot": OneBotProvider, } __all__ = [ @@ -37,4 +39,5 @@ "GotifyProvider", "PushoverProvider", "WebhookProvider", + "OneBotProvider", ] diff --git a/backend/src/module/notification/providers/onebot.py b/backend/src/module/notification/providers/onebot.py new file mode 100644 index 000000000..63f9fcbdf --- /dev/null +++ b/backend/src/module/notification/providers/onebot.py @@ -0,0 +1,186 @@ +"""OneBot v11 notification provider. + +OneBot v11 is a standard for QQ bot APIs. This provider sends +notifications via the OneBot v11 HTTP API. + +Documentation: https://github.com/botuniverse/onebot-11 +""" + +import json +import logging +from typing import TYPE_CHECKING + +from module.models.bangumi import Notification +from module.notification.base import NotificationProvider + +if TYPE_CHECKING: + from module.models.config import NotificationProvider as ProviderConfig + +logger = logging.getLogger(__name__) + + +class OneBotProvider(NotificationProvider): + """OneBot v11 HTTP API notification provider. + + Sends anime update notifications through a OneBot v11-compatible + QQ bot using the HTTP API. + + Config fields used: + - url: Base URL of the OneBot HTTP API (e.g. http://localhost:5700) + - token: Optional Authorization access_token + - chat_id: Target user_id (private) or group_id (group) + - message_type: "private" for private messages, "group" for group messages + """ + + def __init__(self, config: "ProviderConfig"): + super().__init__() + self.base_url = config.url.rstrip("/") + self.token = config.token or "" + self.chat_id = config.chat_id or "" + self.message_type = config.message_type or "private" + + # Build API endpoints + self.private_msg_url = f"{self.base_url}/send_private_msg" + self.group_msg_url = f"{self.base_url}/send_group_msg" + + # Build JSON headers (OneBot API expects application/json) + self.json_headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if self.token: + self.json_headers["Authorization"] = f"Bearer {self.token}" + + async def _post_json(self, url: str, data: dict) -> object: + """Send a JSON POST request using the shared httpx client. + + OneBot API requires proper application/json content type, + which the inherited post_data() does not provide (it sends + form-encoded data). This method uses the underlying httpx + client directly with json= parameter. + + Args: + url: The URL to send the request to. + data: The JSON-serializable data to send. + + Returns: + The httpx response object, or None on failure. + """ + try: + req = await self._client.post( + url=url, + json=data, + headers=self.json_headers, + ) + req.raise_for_status() + return req + except Exception as e: + logger.warning(f"[OneBot] Request failed: {e}") + return None + + def _build_payload( + self, text: str, poster_path: str = None + ) -> str | list: + """Build the message payload for OneBot API. + + For plain text (no poster), sends a simple string. + When a poster image is available, sends a message segment array + with both image and text. + + Args: + text: The text message content. + poster_path: Optional URL to a poster image. + + Returns: + A string (plain text) or list (message segments). + """ + if poster_path and poster_path not in ("", "https://mikanani.me"): + return [ + {"type": "image", "data": {"file": poster_path}}, + {"type": "text", "data": {"text": text}}, + ] + return text + + async def send(self, notification: Notification) -> bool: + """Send notification via OneBot v11. + + Args: + notification: The notification data. + + Returns: + True if the message was sent successfully. + """ + text = self._format_message(notification) + message = self._build_payload(text, notification.poster_path) + + if self.message_type == "group": + payload = { + "group_id": int(self.chat_id), + "message": message, + } + url = self.group_msg_url + else: + payload = { + "user_id": int(self.chat_id), + "message": message, + } + url = self.private_msg_url + + resp = await self._post_json(url, payload) + logger.debug("OneBot notification: %s", resp.status_code if resp else None) + + if resp and resp.status_code == 200: + try: + result = resp.json() + if result.get("status") == "ok" or result.get("retcode") == 0: + return True + else: + logger.warning("OneBot API returned error: %s", result) + return False + except (json.JSONDecodeError, AttributeError): + return True + + return resp is not None and resp.status_code == 200 + + async def test(self) -> tuple[bool, str]: + """Test the OneBot configuration by sending a test message. + + Returns: + A tuple of (success, message). + """ + text = "AutoBangumi 通知测试成功!\nNotification test successful!" + + if self.message_type == "group": + payload = { + "group_id": int(self.chat_id), + "message": text, + } + url = self.group_msg_url + else: + payload = { + "user_id": int(self.chat_id), + "message": text, + } + url = self.private_msg_url + + try: + resp = await self._post_json(url, payload) + if resp and resp.status_code == 200: + try: + result = resp.json() + if result.get("status") == "ok" or result.get("retcode") == 0: + return True, "OneBot test message sent successfully" + else: + error_msg = ( + result.get("msg") + or result.get("wording") + or "unknown error" + ) + return False, f"OneBot API error: {error_msg}" + except (json.JSONDecodeError, AttributeError): + return True, "OneBot test message sent successfully" + else: + status = resp.status_code if resp else "No response" + return False, f"OneBot API returned status {status}" + except Exception as e: + return False, f"OneBot test failed: {e}" diff --git a/backend/src/test/test_notification.py b/backend/src/test/test_notification.py index a9e005b14..289070bb0 100644 --- a/backend/src/test/test_notification.py +++ b/backend/src/test/test_notification.py @@ -15,6 +15,7 @@ GotifyProvider, PushoverProvider, WebhookProvider, + OneBotProvider, ) @@ -295,6 +296,100 @@ async def test_send(self, provider): assert result is True + + +class TestOneBotProvider: + @pytest.fixture + def provider(self): + config = ProviderConfig( + type="onebot", + enabled=True, + url="http://localhost:5700", + token="test_token", + chat_id="123456789", + message_type="private", + ) + return OneBotProvider(config) + + async def test_send_private_message(self, provider): + """Sends private message via OneBot API.""" + notify = Notification( + official_title="Test Anime", season=1, episode=5, poster_path="https://example.com/poster.jpg" + ) + + with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post: + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"status": "ok", "retcode": 0} + mock_post.return_value = mock_response + result = await provider.send(notify) + + assert result is True + call_args = mock_post.call_args[0] + assert "send_private_msg" in call_args[0] + assert "user_id" in call_args[1] + + async def test_send_group_message(self, provider): + """Sends group message via OneBot API.""" + provider.message_type = "group" + notify = Notification( + official_title="Test Anime", season=1, episode=5 + ) + + with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post: + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"status": "ok", "retcode": 0} + mock_post.return_value = mock_response + result = await provider.send(notify) + + assert result is True + call_args = mock_post.call_args[0] + assert "send_group_msg" in call_args[0] + assert "group_id" in call_args[1] + + async def test_send_api_error(self, provider): + """Handles OneBot API error response.""" + notify = Notification(official_title="Test Anime", season=1, episode=5) + + with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post: + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"status": "failed", "retcode": 100, "msg": "bad request"} + mock_post.return_value = mock_response + result = await provider.send(notify) + + assert result is False + + async def test_send_http_error(self, provider): + """Handles HTTP error.""" + notify = Notification(official_title="Test Anime", season=1, episode=5) + + with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post: + mock_post.return_value = MagicMock(status_code=401) + result = await provider.send(notify) + + assert result is False + + async def test_test_success(self, provider): + """Test method sends test message successfully.""" + with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post: + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"status": "ok", "retcode": 0} + mock_post.return_value = mock_response + success, message = await provider.test() + + assert success is True + assert "successfully" in message.lower() + + async def test_test_failure(self, provider): + """Test method handles API error.""" + with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post: + mock_response = MagicMock(status_code=200) + mock_response.json.return_value = {"status": "failed", "retcode": 100, "wording": "invalid target"} + mock_post.return_value = mock_response + success, message = await provider.test() + + assert success is False + assert "invalid target" in message.lower() + # --------------------------------------------------------------------------- # Config Migration # --------------------------------------------------------------------------- @@ -330,3 +425,4 @@ def test_new_config_no_migration(self): assert len(new_config.providers) == 1 assert new_config.providers[0].type == "discord" + diff --git a/webui/src/api/notification.ts b/webui/src/api/notification.ts index e1d6d5f90..b0fe3b219 100644 --- a/webui/src/api/notification.ts +++ b/webui/src/api/notification.ts @@ -1,4 +1,4 @@ -import type { NotificationProviderConfig, NotificationType } from '#/config'; +import type { NotificationProviderConfig, NotificationType } from '#/config'; import type { TupleToUnion } from '#/utils'; export interface TestProviderRequest { @@ -17,6 +17,7 @@ export interface TestProviderConfigRequest { api_token?: string; template?: string; url?: string; + message_type?: string; } export interface TestResponse { @@ -49,3 +50,4 @@ export const apiNotification = { return { data }; }, }; + diff --git a/webui/src/components/setting/config-notification.vue b/webui/src/components/setting/config-notification.vue index f6adf0717..00e667eb1 100644 --- a/webui/src/components/setting/config-notification.vue +++ b/webui/src/components/setting/config-notification.vue @@ -18,6 +18,7 @@ const providerTypes: { value: TupleToUnion; label: string }[] { value: 'gotify', label: 'Gotify' }, { value: 'pushover', label: 'Pushover' }, { value: 'webhook', label: 'Webhook' }, + { value: 'onebot', label: 'OneBot v11' }, ]; // Provider field configurations @@ -73,6 +74,28 @@ const providerFields: Record< placeholder: '{"title": "{{title}}", "episode": {{episode}}}', }, ], + onebot: [ + { + key: 'url', + label: 'API URL', + placeholder: 'http://localhost:5700', + }, + { + key: 'token', + label: 'Access Token (optional)', + placeholder: 'access token', + }, + { + key: 'chat_id', + label: 'Target ID (QQ/Group)', + placeholder: '123456789', + }, + { + key: 'message_type', + label: 'Message Type', + placeholder: 'private', + }, + ], }; // Dialog state @@ -122,6 +145,7 @@ function getProviderIcon(type: string): string { gotify: 'i-carbon-notification-filled', pushover: 'i-carbon-mobile', webhook: 'i-carbon-webhook', + onebot: 'i-carbon-qq' }; return icons[type] || 'i-carbon-notification'; } @@ -552,3 +576,7 @@ function getFieldsForType(type: string) { background: color-mix(in srgb, var(--color-danger, #ef4444) 10%, transparent); } + + + + diff --git a/webui/types/config.ts b/webui/types/config.ts index 1bf42dc44..666005f08 100644 --- a/webui/types/config.ts +++ b/webui/types/config.ts @@ -1,14 +1,14 @@ -import type { TupleToUnion } from './utils'; +import type { TupleToUnion } from './utils'; -/** 下载方式 */ +/** 涓嬭浇鏂瑰紡 */ export type DownloaderType = ['qbittorrent']; -/** rss parser 语言 */ +/** rss parser 璇█ */ export type RssParserLang = ['zh', 'en', 'jp']; -/** 重命名方式 */ +/** 閲嶅懡鍚嶆柟寮?*/ export type RenameMethod = ['normal', 'pn', 'advance', 'none']; -/** 代理类型 */ +/** 浠g悊绫诲瀷 */ export type ProxyType = ['http', 'https', 'socks5']; -/** 通知类型 */ +/** 閫氱煡绫诲瀷 */ export type NotificationType = [ 'telegram', 'discord', @@ -18,6 +18,7 @@ export type NotificationType = [ 'gotify', 'pushover', 'webhook', + 'onebot', ]; /** OpenAI Model List */ export type OpenAIModel = ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo']; @@ -76,6 +77,7 @@ export interface NotificationProviderConfig { api_token?: string; template?: string; url?: string; + message_type?: string; } export interface Notification { @@ -178,3 +180,4 @@ export const initConfig: Config = { mcp_tokens: [], }, }; +