mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-10-31 12:45:41 +08:00
- apprise:urls 含令牌,改为 secret_field;secret_field 支持 default_factory,可声明列表 / 字典秘密字段
- run-command:stderr 改为自建管道边读边丢,只保留最后 2000 字节;完成仍只看 proc.wait(),结束后给读取 0.5 秒再关闭管道
- run-command:{files} 按原始路径列表展开,含换行的路径不再拆成两个参数
- 通知:插件渠道 extra 里的已声明字段与内置渠道一样展开 $VAR
- slack:只在 HTTP 400 invalid_blocks 时退回纯文字重试,其它失败不重发
- onebot:先发文字,再单独发海报;结果只看文字,去掉重试
- strm-writer:media_root 为 "/" 时正确映射完整路径
129 lines
4.8 KiB
Python
129 lines
4.8 KiB
Python
"""OneBot v11 通知渠道。
|
||
|
||
POST JSON 到 ``{url}/send_private_msg`` 或 ``{url}/send_group_msg``,消息为消息段
|
||
数组。先发文字;文字成功且开启海报、宿主给出海报地址时,再单独发一条图片消息。
|
||
HTTP 2xx 且返回 ``status == "ok"`` 或 ``retcode == 0`` 才算成功,发送结果只看文字:
|
||
OneBot 实现取不到海报时只丢图片,文字已经送达,不重发。
|
||
协议文档:https://github.com/botuniverse/onebot-11
|
||
"""
|
||
|
||
from collections.abc import Callable
|
||
from contextlib import suppress
|
||
from typing import Any, Literal
|
||
|
||
import httpx2
|
||
from pydantic import BaseModel, Field
|
||
|
||
from ab_sdk import Plugin, points, provider, secret_field
|
||
from ab_sdk.notify import NotificationMessage, NotifierFactory, NotifierSettings
|
||
|
||
|
||
class Options(BaseModel):
|
||
url: str = Field(
|
||
"",
|
||
title="API 地址",
|
||
description="OneBot v11 HTTP API 地址,如 http://napcat:3000",
|
||
)
|
||
access_token: str = secret_field(
|
||
title="Access Token", description="可选:以 Authorization: Bearer 发送"
|
||
)
|
||
message_type: Literal["private", "group"] = Field("private", title="消息类型")
|
||
target: int = Field(0, title="目标", description="私聊填 QQ 号,群聊填群号")
|
||
send_poster: bool = Field(
|
||
True,
|
||
title="发送海报",
|
||
description="需在通知设置里配置 base_url,宿主才会给出可访问的海报地址",
|
||
)
|
||
|
||
|
||
class Channel:
|
||
def __init__(
|
||
self,
|
||
options: Options,
|
||
settings: NotifierSettings,
|
||
client_factory: Callable[[], httpx2.AsyncClient],
|
||
) -> None:
|
||
self.options = options
|
||
self.settings = settings
|
||
self.client_factory = client_factory
|
||
|
||
async def _post(self, message: NotificationMessage) -> tuple[bool, str]:
|
||
opts = self.options
|
||
if not opts.url or not opts.target:
|
||
return False, "未填写 API 地址或目标"
|
||
# 单集通知的正文已含番剧名;系统事件的标题与正文分开,拼在一起发
|
||
text = (
|
||
message.body
|
||
if message.kind == "episode"
|
||
else f"{message.title}\n{message.body}"
|
||
)
|
||
try:
|
||
async with self.client_factory() as client:
|
||
ok, detail = await self._send(
|
||
client, [{"type": "text", "data": {"text": text}}]
|
||
)
|
||
# SDK 只给出公开海报 URL(没有本地路径),由 OneBot 实现自行下载;
|
||
# 图片失败(含请求异常)不影响已送达的文字
|
||
if ok and opts.send_poster and message.poster_url:
|
||
image = {"type": "image", "data": {"file": message.poster_url}}
|
||
with suppress(httpx2.HTTPError):
|
||
await self._send(client, [image])
|
||
except httpx2.HTTPError as e:
|
||
return False, f"请求失败:{e}"
|
||
return ok, detail
|
||
|
||
async def _send(
|
||
self, client: httpx2.AsyncClient, segments: list[dict[str, Any]]
|
||
) -> tuple[bool, str]:
|
||
opts = self.options
|
||
id_key = "group_id" if opts.message_type == "group" else "user_id"
|
||
headers = {}
|
||
if opts.access_token:
|
||
headers["Authorization"] = f"Bearer {opts.access_token}"
|
||
resp = await client.post(
|
||
f"{opts.url.rstrip('/')}/send_{opts.message_type}_msg",
|
||
json={id_key: opts.target, "message": segments},
|
||
headers=headers,
|
||
)
|
||
try:
|
||
body = resp.json()
|
||
except ValueError:
|
||
body = None
|
||
if (
|
||
resp.is_success
|
||
and isinstance(body, dict)
|
||
and (body.get("status") == "ok" or body.get("retcode") == 0)
|
||
):
|
||
return True, "ok"
|
||
detail = (
|
||
(body.get("wording") or body.get("msg")) if isinstance(body, dict) else None
|
||
)
|
||
if not resp.is_success:
|
||
# 反向代理的错误页不是 JSON,先报 HTTP 状态
|
||
detail = f"HTTP {resp.status_code}" + (f",{detail}" if detail else "")
|
||
return False, f"OneBot 返回错误:{detail or '响应不是预期的 JSON'}"
|
||
|
||
async def send(self, message: NotificationMessage) -> bool:
|
||
ok, _ = await self._post(message)
|
||
return ok
|
||
|
||
async def test(self) -> tuple[bool, str]:
|
||
return await self._post(
|
||
NotificationMessage(
|
||
kind="event", title="AutoBangumi 通知测试", body="通知测试成功!"
|
||
)
|
||
)
|
||
|
||
|
||
class OneBotNotifier(Plugin[Options]):
|
||
config_model = Options
|
||
|
||
# 测试可替换为带 MockTransport 的客户端
|
||
client_factory: Callable[[], httpx2.AsyncClient] = staticmethod(
|
||
lambda: httpx2.AsyncClient(timeout=10)
|
||
)
|
||
|
||
@provider(points.NOTIFIER, id="onebot")
|
||
def channel(self) -> NotifierFactory:
|
||
return lambda settings: Channel(self.config, settings, self.client_factory)
|