138 lines
5.0 KiB
Python
138 lines
5.0 KiB
Python
|
|
"""Bark 推送客户端:基于标准 Bark 服务器 HTTP API,零第三方依赖。"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import urllib.error
|
|||
|
|
import urllib.parse
|
|||
|
|
import urllib.request
|
|||
|
|
from typing import Any, Dict, Optional, Union
|
|||
|
|
|
|||
|
|
from .types import Level, Notification, Sound
|
|||
|
|
|
|||
|
|
DEFAULT_BASE_URL = os.environ.get("BARK_BASE_URL", "http://47.108.226.213:10086")
|
|||
|
|
DEFAULT_DEVICE_KEY = os.environ.get("BARK_DEVICE_KEY", "pvd3kWwHNFjKXNjiC2sMe7")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class BarkError(RuntimeError):
|
|||
|
|
"""Bark 服务器返回失败或网络异常。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _value_of(value: Union[Level, Sound, str, None]) -> Optional[str]:
|
|||
|
|
"""枚举成员取其值,普通字符串原样返回。"""
|
|||
|
|
if isinstance(value, (Level, Sound)):
|
|||
|
|
return value.value
|
|||
|
|
return value
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _normalize_query(params: Dict[str, Any]) -> Dict[str, Any]:
|
|||
|
|
"""把 Python 值转成 GET 查询参数可接受的类型。"""
|
|||
|
|
out: Dict[str, Any] = {}
|
|||
|
|
for key, value in params.items():
|
|||
|
|
if value is None:
|
|||
|
|
continue
|
|||
|
|
if isinstance(value, bool):
|
|||
|
|
out[key] = "true" if value else "false"
|
|||
|
|
else:
|
|||
|
|
out[key] = _value_of(value)
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
class BarkClient:
|
|||
|
|
"""Bark 通知客户端。
|
|||
|
|
|
|||
|
|
支持两种推送方式:
|
|||
|
|
|
|||
|
|
- :meth:`push`:通过 ``POST /push`` 发送,支持全部参数(推荐)。
|
|||
|
|
- :meth:`notify`:通过 ``GET /<key>/<title>/<body>`` 发送,适合快速测试。
|
|||
|
|
|
|||
|
|
地址与密钥默认从环境变量读取,也可在构造时显式传入:
|
|||
|
|
|
|||
|
|
- ``BARK_BASE_URL``:服务器地址,默认 ``http://47.108.226.213:10086``。
|
|||
|
|
- ``BARK_DEVICE_KEY``:设备密钥。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
base_url: Optional[str] = None,
|
|||
|
|
device_key: Optional[str] = None,
|
|||
|
|
timeout: float = 10.0,
|
|||
|
|
) -> None:
|
|||
|
|
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|||
|
|
self.device_key = device_key or DEFAULT_DEVICE_KEY
|
|||
|
|
self.timeout = timeout
|
|||
|
|
|
|||
|
|
def push(self, notification: Notification) -> Dict[str, Any]:
|
|||
|
|
"""通过 ``POST /push`` 推送一条完整通知,返回服务器 JSON 响应。"""
|
|||
|
|
payload = self._payload(notification)
|
|||
|
|
payload["device_key"] = self.device_key
|
|||
|
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|||
|
|
return self._request("/push", data=data)
|
|||
|
|
|
|||
|
|
def notify(self, title: str, body: str = "", **kwargs: Any) -> Dict[str, Any]:
|
|||
|
|
"""GET 快捷推送:``/<key>/<title>/<body>?<params>``。
|
|||
|
|
|
|||
|
|
``kwargs`` 中除 ``title/body`` 外的键会转成查询参数,
|
|||
|
|
布尔值转 ``true/false``,例如 ``level="critical"``、``group="x"``。
|
|||
|
|
"""
|
|||
|
|
encoded_title = urllib.parse.quote(title)
|
|||
|
|
encoded_body = urllib.parse.quote(body)
|
|||
|
|
path = f"/{self.device_key}/{encoded_title}/{encoded_body}"
|
|||
|
|
params = _normalize_query(kwargs)
|
|||
|
|
params.pop("title", None)
|
|||
|
|
params.pop("body", None)
|
|||
|
|
if params:
|
|||
|
|
path += "?" + urllib.parse.urlencode(params)
|
|||
|
|
return self._request(path)
|
|||
|
|
|
|||
|
|
# ---------- 内部工具 ----------
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _payload(notification: Notification) -> Dict[str, Any]:
|
|||
|
|
"""把 Notification 转成 POST /push 的 JSON body(camelCase 键)。"""
|
|||
|
|
payload: Dict[str, Any] = {
|
|||
|
|
"title": notification.title,
|
|||
|
|
"body": notification.body,
|
|||
|
|
"level": _value_of(notification.level) or Level.DEFAULT.value,
|
|||
|
|
}
|
|||
|
|
optional: Dict[str, Any] = {
|
|||
|
|
"subtitle": notification.subtitle,
|
|||
|
|
"sound": _value_of(notification.sound),
|
|||
|
|
"badge": notification.badge,
|
|||
|
|
"group": notification.group,
|
|||
|
|
"icon": notification.icon,
|
|||
|
|
"image": notification.image,
|
|||
|
|
"copy": notification.copy,
|
|||
|
|
"url": notification.url,
|
|||
|
|
"isArchive": notification.is_archive,
|
|||
|
|
"call": notification.call,
|
|||
|
|
"ttl": notification.ttl,
|
|||
|
|
"volume": notification.volume,
|
|||
|
|
}
|
|||
|
|
for key, value in optional.items():
|
|||
|
|
if value is not None:
|
|||
|
|
payload[key] = value
|
|||
|
|
return payload
|
|||
|
|
|
|||
|
|
def _request(self, path: str, data: Optional[bytes] = None) -> Dict[str, Any]:
|
|||
|
|
url = self.base_url + path
|
|||
|
|
req = urllib.request.Request(url, data=data)
|
|||
|
|
if data is not None:
|
|||
|
|
req.add_header("Content-Type", "application/json")
|
|||
|
|
try:
|
|||
|
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|||
|
|
raw = resp.read().decode("utf-8")
|
|||
|
|
except urllib.error.HTTPError as exc:
|
|||
|
|
raw = exc.read().decode("utf-8", errors="replace")
|
|||
|
|
except urllib.error.URLError as exc:
|
|||
|
|
raise BarkError(f"网络错误: {exc}") from exc
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
result = json.loads(raw)
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
result = {"code": -1, "message": raw.strip()}
|
|||
|
|
if result.get("code") != 200:
|
|||
|
|
raise BarkError(f"Bark 返回异常: code={result.get('code')} message={result.get('message')}")
|
|||
|
|
return result
|