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 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}&timestamp={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