Files
opc-backup/code/bark-notify/src/bark_notify/client.py
T

138 lines
5.0 KiB
Python
Raw Normal View History

2026-08-21 23:21:00 +08:00
"""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 bodycamelCase 键)。"""
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