pynotifyhub 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- notify_hub/__init__.py +75 -0
- notify_hub/_util.py +79 -0
- notify_hub/capabilities.py +32 -0
- notify_hub/channels/__init__.py +19 -0
- notify_hub/channels/base.py +93 -0
- notify_hub/channels/dingtalk.py +82 -0
- notify_hub/channels/discord.py +51 -0
- notify_hub/channels/lark.py +166 -0
- notify_hub/channels/phone/__init__.py +7 -0
- notify_hub/channels/phone/base.py +34 -0
- notify_hub/channels/phone/channel.py +66 -0
- notify_hub/channels/phone/twilio.py +65 -0
- notify_hub/channels/slack.py +110 -0
- notify_hub/channels/telegram.py +125 -0
- notify_hub/channels/wecom.py +106 -0
- notify_hub/config.py +233 -0
- notify_hub/exceptions.py +40 -0
- notify_hub/guard.py +58 -0
- notify_hub/hub.py +345 -0
- notify_hub/levels.py +75 -0
- notify_hub/logging.py +124 -0
- notify_hub/message.py +112 -0
- notify_hub/py.typed +0 -0
- notify_hub/registry.py +65 -0
- notify_hub/results.py +75 -0
- notify_hub/router.py +62 -0
- notify_hub/runtime.py +70 -0
- pynotifyhub-0.1.0.dist-info/METADATA +223 -0
- pynotifyhub-0.1.0.dist-info/RECORD +31 -0
- pynotifyhub-0.1.0.dist-info/WHEEL +4 -0
- pynotifyhub-0.1.0.dist-info/licenses/LICENSE +21 -0
notify_hub/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""notify-hub: unified multi-channel notification library.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
from notify_hub import Hub
|
|
6
|
+
|
|
7
|
+
hub = Hub.from_config("notify-hub.toml")
|
|
8
|
+
hub.notify("db down", level="CRITICAL") # returns immediately
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .capabilities import Capability, DegradeMode, DegradePolicy
|
|
12
|
+
from .channels.base import Channel
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
ChannelSendError,
|
|
15
|
+
ConfigError,
|
|
16
|
+
HubClosedError,
|
|
17
|
+
NotifyHubError,
|
|
18
|
+
UnknownChannelError,
|
|
19
|
+
UnknownLevelError,
|
|
20
|
+
)
|
|
21
|
+
from .guard import Deduplicator, TokenBucket
|
|
22
|
+
from .hub import Hub
|
|
23
|
+
from .levels import CRITICAL, DEBUG, ERROR, INFO, WARNING, Level, LevelRegistry
|
|
24
|
+
from .logging import (
|
|
25
|
+
FileNotificationLogger,
|
|
26
|
+
MultiLogger,
|
|
27
|
+
NotificationLogger,
|
|
28
|
+
NullLogger,
|
|
29
|
+
SendRecord,
|
|
30
|
+
StdlibNotificationLogger,
|
|
31
|
+
)
|
|
32
|
+
from .message import Attachment, AttachmentKind, Message
|
|
33
|
+
from .registry import register_channel
|
|
34
|
+
from .results import ChannelResult, NotificationHandle, NotificationResult
|
|
35
|
+
from .router import Router, RoutingRule
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"CRITICAL",
|
|
41
|
+
"DEBUG",
|
|
42
|
+
"ERROR",
|
|
43
|
+
"INFO",
|
|
44
|
+
"WARNING",
|
|
45
|
+
"Attachment",
|
|
46
|
+
"AttachmentKind",
|
|
47
|
+
"Capability",
|
|
48
|
+
"Channel",
|
|
49
|
+
"ChannelResult",
|
|
50
|
+
"ChannelSendError",
|
|
51
|
+
"ConfigError",
|
|
52
|
+
"Deduplicator",
|
|
53
|
+
"DegradeMode",
|
|
54
|
+
"DegradePolicy",
|
|
55
|
+
"FileNotificationLogger",
|
|
56
|
+
"Hub",
|
|
57
|
+
"HubClosedError",
|
|
58
|
+
"Level",
|
|
59
|
+
"LevelRegistry",
|
|
60
|
+
"Message",
|
|
61
|
+
"MultiLogger",
|
|
62
|
+
"NotificationHandle",
|
|
63
|
+
"NotificationLogger",
|
|
64
|
+
"NotificationResult",
|
|
65
|
+
"NotifyHubError",
|
|
66
|
+
"NullLogger",
|
|
67
|
+
"Router",
|
|
68
|
+
"RoutingRule",
|
|
69
|
+
"SendRecord",
|
|
70
|
+
"StdlibNotificationLogger",
|
|
71
|
+
"TokenBucket",
|
|
72
|
+
"UnknownChannelError",
|
|
73
|
+
"UnknownLevelError",
|
|
74
|
+
"register_channel",
|
|
75
|
+
]
|
notify_hub/_util.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Small internal helpers: fingerprinting, truncation, backoff, TTS cleanup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import random
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
_MD_PATTERNS = (
|
|
10
|
+
re.compile(r"```.*?```", re.DOTALL), # code blocks
|
|
11
|
+
re.compile(r"`([^`]*)`"), # inline code -> keep content
|
|
12
|
+
re.compile(r"!\[[^\]]*\]\([^)]*\)"), # images
|
|
13
|
+
re.compile(r"\[([^\]]*)\]\([^)]*\)"), # links -> keep label
|
|
14
|
+
re.compile(r"https?://\S+"), # bare URLs
|
|
15
|
+
re.compile(r"[*_~#>|]+"), # emphasis / heading / table markers
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fingerprint(level_name: str, title: str | None, body: str) -> str:
|
|
20
|
+
"""Stable 16-hex-char digest used for dedup and log correlation."""
|
|
21
|
+
payload = "\x00".join((level_name, title or "", body))
|
|
22
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def truncate(text: str, limit: int, *, suffix: str = "…") -> str:
|
|
26
|
+
"""Truncate by character count, appending a suffix when cut."""
|
|
27
|
+
if limit <= 0 or len(text) <= limit:
|
|
28
|
+
return text
|
|
29
|
+
return text[: max(limit - len(suffix), 0)] + suffix
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def truncate_utf8_bytes(text: str, limit: int, *, suffix: str = "…") -> str:
|
|
33
|
+
"""Truncate so the UTF-8 encoding fits in ``limit`` bytes (WeCom limits)."""
|
|
34
|
+
raw = text.encode("utf-8")
|
|
35
|
+
if len(raw) <= limit:
|
|
36
|
+
return text
|
|
37
|
+
suffix_len = len(suffix.encode("utf-8"))
|
|
38
|
+
cut = raw[: max(limit - suffix_len, 0)]
|
|
39
|
+
return cut.decode("utf-8", errors="ignore") + suffix
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def backoff_delay(attempt: int, *, base: float = 1.0, cap: float = 30.0) -> float:
|
|
43
|
+
"""Exponential backoff with full jitter: attempt 1 -> ~1s, 2 -> ~2s, 3 -> ~4s."""
|
|
44
|
+
return random.uniform(0, min(cap, base * (2 ** (attempt - 1))))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
48
|
+
# query-string credentials (wecom key=, dingtalk access_token=, signatures)
|
|
49
|
+
(re.compile(r"(?i)\b(key|access_token|sign|secret|token)=[^&\s'\"]+"), r"\1=***"),
|
|
50
|
+
# telegram bot token in the URL path
|
|
51
|
+
(re.compile(r"/bot\d+:[\w-]+"), "/bot***"),
|
|
52
|
+
# slack incoming-webhook path
|
|
53
|
+
(re.compile(r"(hooks\.slack\.com/services/)[\w/]+"), r"\1***"),
|
|
54
|
+
# discord webhook id/token path
|
|
55
|
+
(re.compile(r"(discord(?:app)?\.com/api/webhooks/\d+/)[\w-]+"), r"\1***"),
|
|
56
|
+
# lark/feishu webhook token path
|
|
57
|
+
(re.compile(r"(/open-apis/bot/v2/hook/)[\w-]+"), r"\1***"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def redact_secrets(text: str) -> str:
|
|
62
|
+
"""Mask credentials that may appear in error messages (e.g. httpx errors
|
|
63
|
+
quote the full request URL, which embeds tokens for several channels).
|
|
64
|
+
Applied to every error string before it reaches results and the log."""
|
|
65
|
+
for pattern, replacement in _SECRET_PATTERNS:
|
|
66
|
+
text = pattern.sub(replacement, text)
|
|
67
|
+
return text
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def strip_for_tts(text: str) -> str:
|
|
71
|
+
"""Strip markdown noise and URLs so the text reads naturally over TTS."""
|
|
72
|
+
out = text
|
|
73
|
+
out = _MD_PATTERNS[0].sub(" ", out)
|
|
74
|
+
out = _MD_PATTERNS[1].sub(r"\1", out)
|
|
75
|
+
out = _MD_PATTERNS[2].sub(" ", out)
|
|
76
|
+
out = _MD_PATTERNS[3].sub(r"\1", out)
|
|
77
|
+
out = _MD_PATTERNS[4].sub(" ", out)
|
|
78
|
+
out = _MD_PATTERNS[5].sub(" ", out)
|
|
79
|
+
return re.sub(r"\s+", " ", out).strip()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Channel capability flags and the attachment degrade policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Capability(enum.Flag):
|
|
10
|
+
TEXT = enum.auto()
|
|
11
|
+
MARKDOWN = enum.auto()
|
|
12
|
+
IMAGE = enum.auto()
|
|
13
|
+
FILE = enum.auto()
|
|
14
|
+
RICH_CARD = enum.auto()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DegradeMode(enum.Enum):
|
|
18
|
+
"""What to do when a message part is unsupported by a channel.
|
|
19
|
+
|
|
20
|
+
DROP: silently drop the unsupported part (recorded in the result).
|
|
21
|
+
LINK: if the attachment source is a URL, append the URL to the text.
|
|
22
|
+
FAIL: mark that channel's send as failed (never raises into the host).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
DROP = "drop"
|
|
26
|
+
LINK = "link"
|
|
27
|
+
FAIL = "fail"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class DegradePolicy:
|
|
32
|
+
on_unsupported_attachment: DegradeMode = DegradeMode.DROP
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Built-in channel adapters.
|
|
2
|
+
|
|
3
|
+
Importing this package registers all built-in channel types.
|
|
4
|
+
Adapter modules are added incrementally; each must import cleanly with only
|
|
5
|
+
the library's hard dependencies installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from . import ( # noqa: F401 (self-register on import)
|
|
9
|
+
dingtalk,
|
|
10
|
+
discord,
|
|
11
|
+
lark,
|
|
12
|
+
phone,
|
|
13
|
+
slack,
|
|
14
|
+
telegram,
|
|
15
|
+
wecom,
|
|
16
|
+
)
|
|
17
|
+
from .base import Channel
|
|
18
|
+
|
|
19
|
+
__all__ = ["Channel"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Channel adapter base class.
|
|
2
|
+
|
|
3
|
+
Adapters are async-only and written once; sync-host support lives entirely
|
|
4
|
+
in the runtime layer. Adapters raise ``ChannelSendError`` on failure and the
|
|
5
|
+
hub pipeline handles retry, isolation and logging.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from typing import Any, ClassVar
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ..capabilities import Capability, DegradeMode, DegradePolicy
|
|
16
|
+
from ..exceptions import ChannelSendError
|
|
17
|
+
from ..message import Attachment, AttachmentKind, Message
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ensure_http_ok(resp: httpx.Response, context: str) -> None:
|
|
21
|
+
"""Raise ChannelSendError for HTTP errors; 429/5xx are marked retryable."""
|
|
22
|
+
if resp.status_code == 429 or resp.status_code >= 500:
|
|
23
|
+
raise ChannelSendError(f"{context}: HTTP {resp.status_code}", retryable=True)
|
|
24
|
+
if resp.status_code >= 400:
|
|
25
|
+
raise ChannelSendError(f"{context}: HTTP {resp.status_code} {resp.text[:200]}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def render_text(message: Message, *, bold: str = "**") -> str:
|
|
29
|
+
"""Default rendering: bolded title line followed by the body."""
|
|
30
|
+
if message.title:
|
|
31
|
+
return f"{bold}{message.title}{bold}\n{message.body}"
|
|
32
|
+
return message.body
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Channel(ABC):
|
|
36
|
+
name: ClassVar[str]
|
|
37
|
+
# Class-level default; adapters whose support depends on instance config
|
|
38
|
+
# (e.g. Slack file upload requiring a bot token) override supports().
|
|
39
|
+
capabilities: Capability
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
channel_id: str,
|
|
45
|
+
client: httpx.AsyncClient,
|
|
46
|
+
degrade: DegradePolicy | None = None,
|
|
47
|
+
**config: Any,
|
|
48
|
+
) -> None:
|
|
49
|
+
self.channel_id = channel_id
|
|
50
|
+
self.client = client
|
|
51
|
+
self.degrade_policy = degrade or DegradePolicy()
|
|
52
|
+
self.config = config
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
async def send(self, message: Message) -> None:
|
|
56
|
+
"""Deliver the message. Raise ChannelSendError on failure."""
|
|
57
|
+
|
|
58
|
+
def supports(self, attachment: Attachment) -> bool:
|
|
59
|
+
needed = Capability.IMAGE if attachment.kind is AttachmentKind.IMAGE else Capability.FILE
|
|
60
|
+
return bool(self.capabilities & needed)
|
|
61
|
+
|
|
62
|
+
def degrade(self, message: Message) -> tuple[Message, list[str]]:
|
|
63
|
+
"""Strip unsupported attachments per policy.
|
|
64
|
+
|
|
65
|
+
Returns the (possibly modified) message and human-readable
|
|
66
|
+
descriptions of the parts that were dropped or linked. With
|
|
67
|
+
``DegradeMode.FAIL`` the caller is expected to fail the channel send
|
|
68
|
+
when descriptions are non-empty (see Hub pipeline).
|
|
69
|
+
"""
|
|
70
|
+
if not message.attachments:
|
|
71
|
+
return message, []
|
|
72
|
+
keep: list[Attachment] = []
|
|
73
|
+
dropped: list[str] = []
|
|
74
|
+
extra_links: list[str] = []
|
|
75
|
+
mode = self.degrade_policy.on_unsupported_attachment
|
|
76
|
+
for att in message.attachments:
|
|
77
|
+
if self.supports(att):
|
|
78
|
+
keep.append(att)
|
|
79
|
+
continue
|
|
80
|
+
if mode is DegradeMode.LINK and att.is_url:
|
|
81
|
+
extra_links.append(str(att.source))
|
|
82
|
+
dropped.append(f"linked {att.describe()}")
|
|
83
|
+
else:
|
|
84
|
+
dropped.append(f"dropped {att.describe()}")
|
|
85
|
+
if not dropped:
|
|
86
|
+
return message, []
|
|
87
|
+
body = message.body
|
|
88
|
+
if extra_links:
|
|
89
|
+
body = body + "\n" + "\n".join(extra_links)
|
|
90
|
+
return message.replace_attachments(tuple(keep), body), dropped
|
|
91
|
+
|
|
92
|
+
async def aclose(self) -> None: # noqa: B027 - optional hook, not abstract
|
|
93
|
+
"""Release adapter-owned resources (the shared client is hub-owned)."""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""DingTalk (钉钉) group robot adapter.
|
|
2
|
+
|
|
3
|
+
Signing (when ``secret`` is configured): millisecond timestamp,
|
|
4
|
+
``f"{ts}\\n{secret}"`` is the HMAC-SHA256 *message* with the secret as key;
|
|
5
|
+
the base64 digest is percent-encoded and appended to the query string.
|
|
6
|
+
Robots configured with keyword filtering instead can set
|
|
7
|
+
``keyword_prefix``, which is prepended to every message.
|
|
8
|
+
|
|
9
|
+
The robot webhook only supports text/markdown — attachments always
|
|
10
|
+
degrade. Markdown messages require a ``title`` (shown in the notification
|
|
11
|
+
banner); we use the message title or the level name.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import json
|
|
20
|
+
import time
|
|
21
|
+
import urllib.parse
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from ..capabilities import Capability
|
|
25
|
+
from ..exceptions import ChannelSendError, ConfigError
|
|
26
|
+
from ..message import Message
|
|
27
|
+
from ..registry import register_channel
|
|
28
|
+
from .base import Channel, ensure_http_ok, render_text
|
|
29
|
+
|
|
30
|
+
API_URL = "https://oapi.dingtalk.com/robot/send"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@register_channel
|
|
34
|
+
class DingTalkChannel(Channel):
|
|
35
|
+
name = "dingtalk"
|
|
36
|
+
capabilities = Capability.TEXT | Capability.MARKDOWN
|
|
37
|
+
|
|
38
|
+
def __init__(self, **kw: Any) -> None:
|
|
39
|
+
super().__init__(**kw)
|
|
40
|
+
access_token = self.config.get("access_token")
|
|
41
|
+
if not access_token:
|
|
42
|
+
raise ConfigError("dingtalk channel requires `access_token`")
|
|
43
|
+
self.access_token: str = access_token
|
|
44
|
+
self.secret: str | None = self.config.get("secret")
|
|
45
|
+
self.keyword_prefix: str = self.config.get("keyword_prefix", "")
|
|
46
|
+
|
|
47
|
+
def _signed_url(self, now_ms: int | None = None) -> str:
|
|
48
|
+
url = f"{API_URL}?access_token={self.access_token}"
|
|
49
|
+
if not self.secret:
|
|
50
|
+
return url
|
|
51
|
+
ts = str(now_ms if now_ms is not None else round(time.time() * 1000))
|
|
52
|
+
string_to_sign = f"{ts}\n{self.secret}"
|
|
53
|
+
digest = hmac.new(
|
|
54
|
+
self.secret.encode("utf-8"), string_to_sign.encode("utf-8"), hashlib.sha256
|
|
55
|
+
).digest()
|
|
56
|
+
sign = urllib.parse.quote_plus(base64.b64encode(digest))
|
|
57
|
+
return f"{url}×tamp={ts}&sign={sign}"
|
|
58
|
+
|
|
59
|
+
async def send(self, message: Message) -> None:
|
|
60
|
+
text = render_text(message)
|
|
61
|
+
if self.keyword_prefix:
|
|
62
|
+
text = f"{self.keyword_prefix} {text}"
|
|
63
|
+
payload = {
|
|
64
|
+
"msgtype": "markdown",
|
|
65
|
+
"markdown": {
|
|
66
|
+
"title": message.title or message.level.name,
|
|
67
|
+
"text": text,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
resp = await self.client.post(self._signed_url(), json=payload)
|
|
71
|
+
ensure_http_ok(resp, "dingtalk send")
|
|
72
|
+
try:
|
|
73
|
+
result = resp.json()
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
raise ChannelSendError("dingtalk send: non-JSON response") from None
|
|
76
|
+
errcode = result.get("errcode")
|
|
77
|
+
if errcode != 0:
|
|
78
|
+
# 130101: send too fast (rate limited by DingTalk)
|
|
79
|
+
raise ChannelSendError(
|
|
80
|
+
f"dingtalk send: errcode={errcode} {result.get('errmsg', '')}",
|
|
81
|
+
retryable=errcode == 130101,
|
|
82
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Discord webhook adapter.
|
|
2
|
+
|
|
3
|
+
Plain text goes as JSON ``{"content": ...}``; attachments use
|
|
4
|
+
``multipart/form-data`` with a ``payload_json`` part plus ``files[N]`` parts
|
|
5
|
+
(max 10 files, content limit 2000 chars). ``?wait=true`` makes the API
|
|
6
|
+
return the created message so we get a real success signal.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .._util import truncate
|
|
15
|
+
from ..capabilities import Capability
|
|
16
|
+
from ..exceptions import ConfigError
|
|
17
|
+
from ..message import Message
|
|
18
|
+
from ..registry import register_channel
|
|
19
|
+
from .base import Channel, ensure_http_ok, render_text
|
|
20
|
+
|
|
21
|
+
CONTENT_LIMIT = 2000
|
|
22
|
+
MAX_FILES = 10
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@register_channel
|
|
26
|
+
class DiscordChannel(Channel):
|
|
27
|
+
name = "discord"
|
|
28
|
+
capabilities = Capability.TEXT | Capability.MARKDOWN | Capability.IMAGE | Capability.FILE
|
|
29
|
+
|
|
30
|
+
def __init__(self, **kw: Any) -> None:
|
|
31
|
+
super().__init__(**kw)
|
|
32
|
+
webhook_url = self.config.get("webhook_url")
|
|
33
|
+
if not webhook_url:
|
|
34
|
+
raise ConfigError("discord channel requires `webhook_url`")
|
|
35
|
+
separator = "&" if "?" in webhook_url else "?"
|
|
36
|
+
self.webhook_url = f"{webhook_url}{separator}wait=true"
|
|
37
|
+
|
|
38
|
+
async def send(self, message: Message) -> None:
|
|
39
|
+
content = truncate(render_text(message), CONTENT_LIMIT)
|
|
40
|
+
if not message.attachments:
|
|
41
|
+
resp = await self.client.post(self.webhook_url, json={"content": content})
|
|
42
|
+
ensure_http_ok(resp, "discord webhook")
|
|
43
|
+
return
|
|
44
|
+
files: dict[str, tuple[str | None, bytes | str, str]] = {
|
|
45
|
+
"payload_json": (None, json.dumps({"content": content}), "application/json"),
|
|
46
|
+
}
|
|
47
|
+
for i, att in enumerate(message.attachments[:MAX_FILES]):
|
|
48
|
+
data = await att.read_bytes(self.client)
|
|
49
|
+
files[f"files[{i}]"] = (att.resolved_filename(), data, att.resolved_mime_type())
|
|
50
|
+
resp = await self.client.post(self.webhook_url, files=files)
|
|
51
|
+
ensure_http_ok(resp, "discord webhook")
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Lark / Feishu (飞书) group bot webhook adapter.
|
|
2
|
+
|
|
3
|
+
The webhook URL is given in full, so both ``open.feishu.cn`` and
|
|
4
|
+
``open.larksuite.com`` domains just work.
|
|
5
|
+
|
|
6
|
+
Signing differs from DingTalk: SECOND-precision timestamp,
|
|
7
|
+
``f"{ts}\\n{secret}"`` is the HMAC-SHA256 *key* with an EMPTY message, and
|
|
8
|
+
``sign``/``timestamp`` go in the JSON body, not the query string.
|
|
9
|
+
|
|
10
|
+
Titled messages use ``msg_type: post`` (rich text); plain ones use
|
|
11
|
+
``msg_type: text``. Success is ``{"code": 0}`` (newer) or
|
|
12
|
+
``{"StatusCode": 0}`` (legacy); we accept either.
|
|
13
|
+
|
|
14
|
+
Images: the webhook only accepts an ``image_key``, which can only be
|
|
15
|
+
obtained through the authenticated upload API — so image support requires
|
|
16
|
+
optional ``app_id``/``app_secret`` (a custom app). When configured, the
|
|
17
|
+
adapter fetches a tenant_access_token (cached until near expiry), uploads
|
|
18
|
+
each image to ``/open-apis/im/v1/images`` and sends an image message.
|
|
19
|
+
Without app credentials, images degrade as before.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import hashlib
|
|
26
|
+
import hmac
|
|
27
|
+
import json
|
|
28
|
+
import time
|
|
29
|
+
from typing import Any
|
|
30
|
+
from urllib.parse import urlsplit
|
|
31
|
+
|
|
32
|
+
from ..capabilities import Capability
|
|
33
|
+
from ..exceptions import ChannelSendError, ConfigError
|
|
34
|
+
from ..message import Attachment, AttachmentKind, Message
|
|
35
|
+
from ..registry import register_channel
|
|
36
|
+
from .base import Channel, ensure_http_ok
|
|
37
|
+
|
|
38
|
+
TOKEN_REFRESH_MARGIN = 300 # refresh the tenant token 5 min before expiry
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@register_channel
|
|
42
|
+
class LarkChannel(Channel):
|
|
43
|
+
name = "lark"
|
|
44
|
+
capabilities = Capability.TEXT | Capability.MARKDOWN | Capability.RICH_CARD | Capability.IMAGE
|
|
45
|
+
|
|
46
|
+
def __init__(self, **kw: Any) -> None:
|
|
47
|
+
super().__init__(**kw)
|
|
48
|
+
webhook_url = self.config.get("webhook_url")
|
|
49
|
+
if not webhook_url:
|
|
50
|
+
raise ConfigError("lark channel requires `webhook_url`")
|
|
51
|
+
self.webhook_url: str = webhook_url
|
|
52
|
+
self.secret: str | None = self.config.get("secret")
|
|
53
|
+
self.lang: str = self.config.get("lang", "zh_cn")
|
|
54
|
+
self.app_id: str | None = self.config.get("app_id")
|
|
55
|
+
self.app_secret: str | None = self.config.get("app_secret")
|
|
56
|
+
if bool(self.app_id) != bool(self.app_secret):
|
|
57
|
+
raise ConfigError("lark `app_id` and `app_secret` must be set together")
|
|
58
|
+
# Reuse the webhook's domain (feishu.cn vs larksuite.com) for app APIs.
|
|
59
|
+
split = urlsplit(webhook_url)
|
|
60
|
+
self.api_base = f"{split.scheme}://{split.netloc}"
|
|
61
|
+
self._token: str | None = None
|
|
62
|
+
self._token_deadline = 0.0
|
|
63
|
+
|
|
64
|
+
def supports(self, attachment: Attachment) -> bool:
|
|
65
|
+
if attachment.kind is AttachmentKind.IMAGE:
|
|
66
|
+
return bool(self.app_id) # image upload needs app credentials
|
|
67
|
+
return False # files unsupported via group bot
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------ #
|
|
70
|
+
# Webhook message
|
|
71
|
+
# ------------------------------------------------------------------ #
|
|
72
|
+
|
|
73
|
+
def _signature(self, ts: int) -> str:
|
|
74
|
+
assert self.secret is not None
|
|
75
|
+
key = f"{ts}\n{self.secret}".encode()
|
|
76
|
+
digest = hmac.new(key, b"", hashlib.sha256).digest()
|
|
77
|
+
return base64.b64encode(digest).decode("ascii")
|
|
78
|
+
|
|
79
|
+
def _signed(self, payload: dict[str, Any], now_s: int | None = None) -> dict[str, Any]:
|
|
80
|
+
if self.secret:
|
|
81
|
+
ts = now_s if now_s is not None else int(time.time())
|
|
82
|
+
payload["timestamp"] = str(ts)
|
|
83
|
+
payload["sign"] = self._signature(ts)
|
|
84
|
+
return payload
|
|
85
|
+
|
|
86
|
+
def _payload(self, message: Message, now_s: int | None = None) -> dict[str, Any]:
|
|
87
|
+
payload: dict[str, Any]
|
|
88
|
+
if message.title:
|
|
89
|
+
payload = {
|
|
90
|
+
"msg_type": "post",
|
|
91
|
+
"content": {
|
|
92
|
+
"post": {
|
|
93
|
+
self.lang: {
|
|
94
|
+
"title": message.title,
|
|
95
|
+
"content": [[{"tag": "text", "text": message.body}]],
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
else:
|
|
101
|
+
payload = {"msg_type": "text", "content": {"text": message.body}}
|
|
102
|
+
return self._signed(payload, now_s)
|
|
103
|
+
|
|
104
|
+
async def _post_webhook(self, payload: dict[str, Any], context: str) -> None:
|
|
105
|
+
resp = await self.client.post(self.webhook_url, json=payload)
|
|
106
|
+
ensure_http_ok(resp, context)
|
|
107
|
+
try:
|
|
108
|
+
result = resp.json()
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
raise ChannelSendError(f"{context}: non-JSON response") from None
|
|
111
|
+
code = result.get("code", result.get("StatusCode"))
|
|
112
|
+
if code != 0:
|
|
113
|
+
raise ChannelSendError(
|
|
114
|
+
f"{context}: code={code} {result.get('msg', result.get('StatusMessage', ''))}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------ #
|
|
118
|
+
# App API (tenant token + image upload), only with app credentials
|
|
119
|
+
# ------------------------------------------------------------------ #
|
|
120
|
+
|
|
121
|
+
async def _tenant_token(self) -> str:
|
|
122
|
+
if self._token is not None and time.monotonic() < self._token_deadline:
|
|
123
|
+
return self._token
|
|
124
|
+
resp = await self.client.post(
|
|
125
|
+
f"{self.api_base}/open-apis/auth/v3/tenant_access_token/internal",
|
|
126
|
+
json={"app_id": self.app_id, "app_secret": self.app_secret},
|
|
127
|
+
)
|
|
128
|
+
ensure_http_ok(resp, "lark tenant_access_token")
|
|
129
|
+
result = resp.json()
|
|
130
|
+
if result.get("code") != 0:
|
|
131
|
+
raise ChannelSendError(
|
|
132
|
+
f"lark tenant_access_token: code={result.get('code')} {result.get('msg', '')}"
|
|
133
|
+
)
|
|
134
|
+
token: str = result["tenant_access_token"]
|
|
135
|
+
expire = float(result.get("expire", 3600))
|
|
136
|
+
self._token = token
|
|
137
|
+
self._token_deadline = time.monotonic() + max(expire - TOKEN_REFRESH_MARGIN, 60)
|
|
138
|
+
return token
|
|
139
|
+
|
|
140
|
+
async def _upload_image(self, att: Attachment) -> str:
|
|
141
|
+
data = await att.read_bytes(self.client)
|
|
142
|
+
token = await self._tenant_token()
|
|
143
|
+
resp = await self.client.post(
|
|
144
|
+
f"{self.api_base}/open-apis/im/v1/images",
|
|
145
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
146
|
+
data={"image_type": "message"},
|
|
147
|
+
files={"image": (att.resolved_filename(), data, att.resolved_mime_type())},
|
|
148
|
+
)
|
|
149
|
+
ensure_http_ok(resp, "lark image upload")
|
|
150
|
+
result = resp.json()
|
|
151
|
+
if result.get("code") != 0:
|
|
152
|
+
raise ChannelSendError(
|
|
153
|
+
f"lark image upload: code={result.get('code')} {result.get('msg', '')}"
|
|
154
|
+
)
|
|
155
|
+
key: str = result["data"]["image_key"]
|
|
156
|
+
return key
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------ #
|
|
159
|
+
|
|
160
|
+
async def send(self, message: Message) -> None:
|
|
161
|
+
await self._post_webhook(self._payload(message), "lark send")
|
|
162
|
+
for att in message.attachments:
|
|
163
|
+
if att.kind is AttachmentKind.IMAGE:
|
|
164
|
+
image_key = await self._upload_image(att)
|
|
165
|
+
payload = self._signed({"msg_type": "image", "content": {"image_key": image_key}})
|
|
166
|
+
await self._post_webhook(payload, "lark image send")
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Phone voice-call channel package."""
|
|
2
|
+
|
|
3
|
+
from . import twilio # noqa: F401 (registers the provider)
|
|
4
|
+
from .base import PROVIDERS, VoiceProvider, register_provider
|
|
5
|
+
from .channel import PhoneChannel
|
|
6
|
+
|
|
7
|
+
__all__ = ["PROVIDERS", "PhoneChannel", "VoiceProvider", "register_provider"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Voice call provider abstraction.
|
|
2
|
+
|
|
3
|
+
``PhoneChannel`` dispatches to a ``VoiceProvider``; Twilio ships in v1 and
|
|
4
|
+
new providers (e.g. Vonage) only need to subclass ``VoiceProvider`` and
|
|
5
|
+
register themselves in ``PROVIDERS``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class VoiceProvider(ABC):
|
|
16
|
+
name: str
|
|
17
|
+
|
|
18
|
+
def __init__(self, client: httpx.AsyncClient) -> None:
|
|
19
|
+
self.client = client
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
async def call(self, to: str, text: str) -> None:
|
|
23
|
+
"""Place a call to ``to`` reading ``text`` aloud.
|
|
24
|
+
|
|
25
|
+
Raise ChannelSendError on failure.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
PROVIDERS: dict[str, type[VoiceProvider]] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def register_provider(cls: type[VoiceProvider]) -> type[VoiceProvider]:
|
|
33
|
+
PROVIDERS[cls.name] = cls
|
|
34
|
+
return cls
|