Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/src/module/api/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion backend/src/module/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions backend/src/module/notification/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +26,7 @@
"gotify": GotifyProvider,
"pushover": PushoverProvider,
"webhook": WebhookProvider,
"onebot": OneBotProvider,
}

__all__ = [
Expand All @@ -37,4 +39,5 @@
"GotifyProvider",
"PushoverProvider",
"WebhookProvider",
"OneBotProvider",
]
186 changes: 186 additions & 0 deletions backend/src/module/notification/providers/onebot.py
Original file line number Diff line number Diff line change
@@ -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}"
96 changes: 96 additions & 0 deletions backend/src/test/test_notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
GotifyProvider,
PushoverProvider,
WebhookProvider,
OneBotProvider,
)


Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -330,3 +425,4 @@ def test_new_config_no_migration(self):

assert len(new_config.providers) == 1
assert new_config.providers[0].type == "discord"

4 changes: 3 additions & 1 deletion webui/src/api/notification.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { NotificationProviderConfig, NotificationType } from '#/config';
import type { NotificationProviderConfig, NotificationType } from '#/config';
import type { TupleToUnion } from '#/utils';

export interface TestProviderRequest {
Expand All @@ -17,6 +17,7 @@ export interface TestProviderConfigRequest {
api_token?: string;
template?: string;
url?: string;
message_type?: string;
}

export interface TestResponse {
Expand Down Expand Up @@ -49,3 +50,4 @@ export const apiNotification = {
return { data };
},
};

Loading