Skip to content

Commit 7bf1c75

Browse files
committed
feat: add OneBot v11 notification provider
- Add OneBotProvider for sending notifications via OneBot v11 HTTP API - Support both private and group messages with poster image attachment - Add message_type config field (private/group) - Register OneBotProvider in provider registry - Add frontend UI for OneBot configuration - Add API endpoint support for message_type field - Add unit tests for OneBotProvider
1 parent 717ad11 commit 7bf1c75

8 files changed

Lines changed: 332 additions & 9 deletions

File tree

backend/src/module/api/notification.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ class TestProviderConfigRequest(BaseModel):
3333
user_key: Optional[str] = Field(None, description="User key")
3434
api_token: Optional[str] = Field(None, description="API token")
3535
template: Optional[str] = Field(None, description="Custom template")
36-
url: Optional[str] = Field(None, description="URL for generic webhook")
36+
url: Optional[str] = Field(None, description="URL for generic webhook/onebot")
37+
message_type: Optional[str] = Field(None, description="Message type for onebot: private or group")
3738

3839

3940
class TestResponse(BaseModel):
@@ -105,6 +106,7 @@ async def test_provider_config(request: TestProviderConfigRequest):
105106
api_token=request.api_token or "",
106107
template=request.template,
107108
url=request.url or "",
109+
message_type=request.message_type or "private",
108110
)
109111

110112
success, message = await NotificationManager.test_provider_config(config)

backend/src/module/models/config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ class NotificationProvider(BaseModel):
120120
None, description="Custom template for webhook provider"
121121
)
122122
url_: Optional[str] = Field(
123-
None, alias="url", description="URL for generic webhook provider"
123+
None, alias="url", description="URL for generic webhook/onebot provider"
124+
)
125+
message_type: Optional[str] = Field(
126+
"private", description="Message type for onebot: 'private' or 'group'"
124127
)
125128

126129
@property

backend/src/module/notification/providers/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from module.notification.providers.gotify import GotifyProvider
1111
from module.notification.providers.pushover import PushoverProvider
1212
from module.notification.providers.webhook import WebhookProvider
13+
from module.notification.providers.onebot import OneBotProvider
1314

1415
if TYPE_CHECKING:
1516
from module.notification.base import NotificationProvider
@@ -25,6 +26,7 @@
2526
"gotify": GotifyProvider,
2627
"pushover": PushoverProvider,
2728
"webhook": WebhookProvider,
29+
"onebot": OneBotProvider,
2830
}
2931

3032
__all__ = [
@@ -37,4 +39,5 @@
3739
"GotifyProvider",
3840
"PushoverProvider",
3941
"WebhookProvider",
42+
"OneBotProvider",
4043
]
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""OneBot v11 notification provider.
2+
3+
OneBot v11 is a standard for QQ bot APIs. This provider sends
4+
notifications via the OneBot v11 HTTP API.
5+
6+
Documentation: https://github.com/botuniverse/onebot-11
7+
"""
8+
9+
import json
10+
import logging
11+
from typing import TYPE_CHECKING
12+
13+
from module.models.bangumi import Notification
14+
from module.notification.base import NotificationProvider
15+
16+
if TYPE_CHECKING:
17+
from module.models.config import NotificationProvider as ProviderConfig
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
class OneBotProvider(NotificationProvider):
23+
"""OneBot v11 HTTP API notification provider.
24+
25+
Sends anime update notifications through a OneBot v11-compatible
26+
QQ bot using the HTTP API.
27+
28+
Config fields used:
29+
- url: Base URL of the OneBot HTTP API (e.g. http://localhost:5700)
30+
- token: Optional Authorization access_token
31+
- chat_id: Target user_id (private) or group_id (group)
32+
- message_type: "private" for private messages, "group" for group messages
33+
"""
34+
35+
def __init__(self, config: "ProviderConfig"):
36+
super().__init__()
37+
self.base_url = config.url.rstrip("/")
38+
self.token = config.token or ""
39+
self.chat_id = config.chat_id or ""
40+
self.message_type = config.message_type or "private"
41+
42+
# Build API endpoints
43+
self.private_msg_url = f"{self.base_url}/send_private_msg"
44+
self.group_msg_url = f"{self.base_url}/send_group_msg"
45+
46+
# Build JSON headers (OneBot API expects application/json)
47+
self.json_headers = {
48+
"Content-Type": "application/json",
49+
"Accept": "application/json",
50+
}
51+
if self.token:
52+
self.json_headers["Authorization"] = f"Bearer {self.token}"
53+
54+
async def _post_json(self, url: str, data: dict) -> object:
55+
"""Send a JSON POST request using the shared httpx client.
56+
57+
OneBot API requires proper application/json content type,
58+
which the inherited post_data() does not provide (it sends
59+
form-encoded data). This method uses the underlying httpx
60+
client directly with json= parameter.
61+
62+
Args:
63+
url: The URL to send the request to.
64+
data: The JSON-serializable data to send.
65+
66+
Returns:
67+
The httpx response object, or None on failure.
68+
"""
69+
try:
70+
req = await self._client.post(
71+
url=url,
72+
json=data,
73+
headers=self.json_headers,
74+
)
75+
req.raise_for_status()
76+
return req
77+
except Exception as e:
78+
logger.warning(f"[OneBot] Request failed: {e}")
79+
return None
80+
81+
def _build_payload(
82+
self, text: str, poster_path: str = None
83+
) -> str | list:
84+
"""Build the message payload for OneBot API.
85+
86+
For plain text (no poster), sends a simple string.
87+
When a poster image is available, sends a message segment array
88+
with both image and text.
89+
90+
Args:
91+
text: The text message content.
92+
poster_path: Optional URL to a poster image.
93+
94+
Returns:
95+
A string (plain text) or list (message segments).
96+
"""
97+
if poster_path and poster_path not in ("", "https://mikanani.me"):
98+
return [
99+
{"type": "image", "data": {"file": poster_path}},
100+
{"type": "text", "data": {"text": text}},
101+
]
102+
return text
103+
104+
async def send(self, notification: Notification) -> bool:
105+
"""Send notification via OneBot v11.
106+
107+
Args:
108+
notification: The notification data.
109+
110+
Returns:
111+
True if the message was sent successfully.
112+
"""
113+
text = self._format_message(notification)
114+
message = self._build_payload(text, notification.poster_path)
115+
116+
if self.message_type == "group":
117+
payload = {
118+
"group_id": int(self.chat_id),
119+
"message": message,
120+
}
121+
url = self.group_msg_url
122+
else:
123+
payload = {
124+
"user_id": int(self.chat_id),
125+
"message": message,
126+
}
127+
url = self.private_msg_url
128+
129+
resp = await self._post_json(url, payload)
130+
logger.debug("OneBot notification: %s", resp.status_code if resp else None)
131+
132+
if resp and resp.status_code == 200:
133+
try:
134+
result = resp.json()
135+
if result.get("status") == "ok" or result.get("retcode") == 0:
136+
return True
137+
else:
138+
logger.warning("OneBot API returned error: %s", result)
139+
return False
140+
except (json.JSONDecodeError, AttributeError):
141+
return True
142+
143+
return resp is not None and resp.status_code == 200
144+
145+
async def test(self) -> tuple[bool, str]:
146+
"""Test the OneBot configuration by sending a test message.
147+
148+
Returns:
149+
A tuple of (success, message).
150+
"""
151+
text = "AutoBangumi 通知测试成功!\nNotification test successful!"
152+
153+
if self.message_type == "group":
154+
payload = {
155+
"group_id": int(self.chat_id),
156+
"message": text,
157+
}
158+
url = self.group_msg_url
159+
else:
160+
payload = {
161+
"user_id": int(self.chat_id),
162+
"message": text,
163+
}
164+
url = self.private_msg_url
165+
166+
try:
167+
resp = await self._post_json(url, payload)
168+
if resp and resp.status_code == 200:
169+
try:
170+
result = resp.json()
171+
if result.get("status") == "ok" or result.get("retcode") == 0:
172+
return True, "OneBot test message sent successfully"
173+
else:
174+
error_msg = (
175+
result.get("msg")
176+
or result.get("wording")
177+
or "unknown error"
178+
)
179+
return False, f"OneBot API error: {error_msg}"
180+
except (json.JSONDecodeError, AttributeError):
181+
return True, "OneBot test message sent successfully"
182+
else:
183+
status = resp.status_code if resp else "No response"
184+
return False, f"OneBot API returned status {status}"
185+
except Exception as e:
186+
return False, f"OneBot test failed: {e}"

backend/src/test/test_notification.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
GotifyProvider,
1616
PushoverProvider,
1717
WebhookProvider,
18+
OneBotProvider,
1819
)
1920

2021

@@ -295,6 +296,100 @@ async def test_send(self, provider):
295296
assert result is True
296297

297298

299+
300+
301+
class TestOneBotProvider:
302+
@pytest.fixture
303+
def provider(self):
304+
config = ProviderConfig(
305+
type="onebot",
306+
enabled=True,
307+
url="http://localhost:5700",
308+
token="test_token",
309+
chat_id="123456789",
310+
message_type="private",
311+
)
312+
return OneBotProvider(config)
313+
314+
async def test_send_private_message(self, provider):
315+
"""Sends private message via OneBot API."""
316+
notify = Notification(
317+
official_title="Test Anime", season=1, episode=5, poster_path="https://example.com/poster.jpg"
318+
)
319+
320+
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
321+
mock_response = MagicMock(status_code=200)
322+
mock_response.json.return_value = {"status": "ok", "retcode": 0}
323+
mock_post.return_value = mock_response
324+
result = await provider.send(notify)
325+
326+
assert result is True
327+
call_args = mock_post.call_args[0]
328+
assert "send_private_msg" in call_args[0]
329+
assert "user_id" in call_args[1]
330+
331+
async def test_send_group_message(self, provider):
332+
"""Sends group message via OneBot API."""
333+
provider.message_type = "group"
334+
notify = Notification(
335+
official_title="Test Anime", season=1, episode=5
336+
)
337+
338+
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
339+
mock_response = MagicMock(status_code=200)
340+
mock_response.json.return_value = {"status": "ok", "retcode": 0}
341+
mock_post.return_value = mock_response
342+
result = await provider.send(notify)
343+
344+
assert result is True
345+
call_args = mock_post.call_args[0]
346+
assert "send_group_msg" in call_args[0]
347+
assert "group_id" in call_args[1]
348+
349+
async def test_send_api_error(self, provider):
350+
"""Handles OneBot API error response."""
351+
notify = Notification(official_title="Test Anime", season=1, episode=5)
352+
353+
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
354+
mock_response = MagicMock(status_code=200)
355+
mock_response.json.return_value = {"status": "failed", "retcode": 100, "msg": "bad request"}
356+
mock_post.return_value = mock_response
357+
result = await provider.send(notify)
358+
359+
assert result is False
360+
361+
async def test_send_http_error(self, provider):
362+
"""Handles HTTP error."""
363+
notify = Notification(official_title="Test Anime", season=1, episode=5)
364+
365+
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
366+
mock_post.return_value = MagicMock(status_code=401)
367+
result = await provider.send(notify)
368+
369+
assert result is False
370+
371+
async def test_test_success(self, provider):
372+
"""Test method sends test message successfully."""
373+
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
374+
mock_response = MagicMock(status_code=200)
375+
mock_response.json.return_value = {"status": "ok", "retcode": 0}
376+
mock_post.return_value = mock_response
377+
success, message = await provider.test()
378+
379+
assert success is True
380+
assert "successfully" in message.lower()
381+
382+
async def test_test_failure(self, provider):
383+
"""Test method handles API error."""
384+
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
385+
mock_response = MagicMock(status_code=200)
386+
mock_response.json.return_value = {"status": "failed", "retcode": 100, "wording": "invalid target"}
387+
mock_post.return_value = mock_response
388+
success, message = await provider.test()
389+
390+
assert success is False
391+
assert "invalid target" in message.lower()
392+
298393
# ---------------------------------------------------------------------------
299394
# Config Migration
300395
# ---------------------------------------------------------------------------
@@ -330,3 +425,4 @@ def test_new_config_no_migration(self):
330425

331426
assert len(new_config.providers) == 1
332427
assert new_config.providers[0].type == "discord"
428+

webui/src/api/notification.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { NotificationProviderConfig, NotificationType } from '#/config';
1+
import type { NotificationProviderConfig, NotificationType } from '#/config';
22
import type { TupleToUnion } from '#/utils';
33

44
export interface TestProviderRequest {
@@ -17,6 +17,7 @@ export interface TestProviderConfigRequest {
1717
api_token?: string;
1818
template?: string;
1919
url?: string;
20+
message_type?: string;
2021
}
2122

2223
export interface TestResponse {
@@ -49,3 +50,4 @@ export const apiNotification = {
4950
return { data };
5051
},
5152
};
53+

0 commit comments

Comments
 (0)