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
|