push-tools 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.
- push_tools/__init__.py +195 -0
- push_tools/base.py +189 -0
- push_tools/channels/__init__.py +25 -0
- push_tools/channels/pushplus.py +343 -0
- push_tools/channels/qmsg.py +242 -0
- push_tools/channels/serverchan.py +181 -0
- push_tools/channels/telegram.py +217 -0
- push_tools/channels/wechat.py +856 -0
- push_tools/composite.py +179 -0
- push_tools/errors.py +131 -0
- push_tools/factory.py +48 -0
- push_tools/registry.py +338 -0
- push_tools-0.1.0.dist-info/METADATA +656 -0
- push_tools-0.1.0.dist-info/RECORD +17 -0
- push_tools-0.1.0.dist-info/WHEEL +5 -0
- push_tools-0.1.0.dist-info/licenses/LICENSE +21 -0
- push_tools-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""PushPlus push channel.
|
|
2
|
+
|
|
3
|
+
Service website: https://www.pushplus.plus
|
|
4
|
+
API reference (V1.18, 2026-09-14): https://www.pushplus.plus/doc/guide/api.html
|
|
5
|
+
Return codes: https://www.pushplus.plus/doc/guide/code.html
|
|
6
|
+
Rate limits: https://www.pushplus.plus/doc/help/limit.html
|
|
7
|
+
|
|
8
|
+
PushPlus fans messages out to WeChat Official Account, App, webhook robots
|
|
9
|
+
(WeCom / DingTalk / Feishu / Bark / ...), mail, SMS, voice and more through
|
|
10
|
+
two endpoints authenticated by a user token (or a message token):
|
|
11
|
+
|
|
12
|
+
- ``POST /send`` - deliver through one channel (``channel``);
|
|
13
|
+
- ``POST /batchSend`` - deliver through several channels at once
|
|
14
|
+
(``channel`` / ``option`` are comma-separated parallel lists).
|
|
15
|
+
|
|
16
|
+
Important behaviour from the docs
|
|
17
|
+
---------------------------------
|
|
18
|
+
- The API is **asynchronous**: a synchronous ``code == 200`` only means the
|
|
19
|
+
request was accepted. The response ``data`` is a message serial number
|
|
20
|
+
("shortCode"); use it (or ``callbackUrl``) to learn the final delivery
|
|
21
|
+
result. It must never be interpreted as "message delivered".
|
|
22
|
+
- ``template`` defaults to ``html``; supported values are html / txt / json
|
|
23
|
+
/ markdown / cloudMonitor / jenkins / route / pay / form / doc / excel.
|
|
24
|
+
``pushId`` is required for the form / doc / excel templates.
|
|
25
|
+
- ``channel`` defaults to ``wechat``; supported values are wechat / app /
|
|
26
|
+
extension / webhook / clawbot / cmcc / qq / cp / mail / sms / voice. The
|
|
27
|
+
webhook / cp / mail / qq channels additionally need an ``option`` channel
|
|
28
|
+
config code created in the PushPlus console.
|
|
29
|
+
- ``topic`` (group code) and ``to`` (friend tokens) must not be used
|
|
30
|
+
together; topic takes precedence.
|
|
31
|
+
- ``timestamp`` is a millisecond expiry stamp: when the server clock passes
|
|
32
|
+
it, the message is dropped (used to suppress stale notifications).
|
|
33
|
+
- Limits (real-name users): 5 requests/minute, 3 identical messages/hour,
|
|
34
|
+
200 wechat requests/day; title <= 100 chars and content <= 20000 chars
|
|
35
|
+
(VIP: 10s/5 requests, 2000/day, title 200, content 100000). Code 900 means
|
|
36
|
+
the account is blocked and callers should stop sending for the day.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import requests
|
|
42
|
+
|
|
43
|
+
from ..base import PushChannel, PushResult
|
|
44
|
+
from ..errors import AccessFailed, catch_exception
|
|
45
|
+
from ..registry import register_channel
|
|
46
|
+
|
|
47
|
+
# Documented template enum (parameter ``template``).
|
|
48
|
+
_TEMPLATES = frozenset(
|
|
49
|
+
{
|
|
50
|
+
"html",
|
|
51
|
+
"txt",
|
|
52
|
+
"json",
|
|
53
|
+
"markdown",
|
|
54
|
+
"cloudMonitor",
|
|
55
|
+
"jenkins",
|
|
56
|
+
"route",
|
|
57
|
+
"pay",
|
|
58
|
+
"form",
|
|
59
|
+
"doc",
|
|
60
|
+
"excel",
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Documented channel enum (parameter ``channel``).
|
|
65
|
+
_CHANNELS = frozenset(
|
|
66
|
+
{
|
|
67
|
+
"wechat",
|
|
68
|
+
"app",
|
|
69
|
+
"extension",
|
|
70
|
+
"webhook",
|
|
71
|
+
"clawbot",
|
|
72
|
+
"cmcc",
|
|
73
|
+
"qq",
|
|
74
|
+
"cp",
|
|
75
|
+
"mail",
|
|
76
|
+
"sms",
|
|
77
|
+
"voice",
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Templates whose content is a stored object referenced by a pushId code.
|
|
82
|
+
_PUSHID_REQUIRED_TEMPLATES = frozenset({"form", "doc", "excel"})
|
|
83
|
+
|
|
84
|
+
# send()/batch_send() options that actually reach the API; everything else
|
|
85
|
+
# in a fan-out call (e.g. ``qq=...`` for the Qmsg channel) is ignored.
|
|
86
|
+
_DOCUMENTED_OPTIONS = frozenset(
|
|
87
|
+
{
|
|
88
|
+
"title",
|
|
89
|
+
"topic",
|
|
90
|
+
"template",
|
|
91
|
+
"channel",
|
|
92
|
+
"option",
|
|
93
|
+
"callbackUrl",
|
|
94
|
+
"timestamp",
|
|
95
|
+
"to",
|
|
96
|
+
"pre",
|
|
97
|
+
"pushId",
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Human-readable meaning of the documented return codes, used only when the
|
|
102
|
+
# response does not carry a ``msg`` of its own.
|
|
103
|
+
_RETURN_CODES = {
|
|
104
|
+
200: "request accepted",
|
|
105
|
+
302: "not logged in",
|
|
106
|
+
401: "request not authorized (enable the open API feature)",
|
|
107
|
+
403: "request IP not authorized (add it to the open-API whitelist)",
|
|
108
|
+
500: "system error, try again later",
|
|
109
|
+
600: "data error, operation failed",
|
|
110
|
+
805: "permission denied",
|
|
111
|
+
888: "insufficient credits, top-up required",
|
|
112
|
+
900: "account usage restricted (too many requests; stop sending today)",
|
|
113
|
+
903: "invalid user token",
|
|
114
|
+
905: "account has not completed real-name verification",
|
|
115
|
+
999: "server-side validation error (see response body)",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@register_channel("pushplus")
|
|
120
|
+
class PushPlus(PushChannel):
|
|
121
|
+
"""Deliver messages through the PushPlus open API (V1.18).
|
|
122
|
+
|
|
123
|
+
Supported ``send()`` options (documented parameters):
|
|
124
|
+
|
|
125
|
+
- ``title``: message title (optional);
|
|
126
|
+
- ``template``: content template, defaults to ``html``; a message
|
|
127
|
+
starting with ``#`` is sent as ``markdown`` automatically;
|
|
128
|
+
- ``channel``: delivery channel, defaults to ``wechat``;
|
|
129
|
+
- ``topic``: group code for one-to-many delivery (mutually exclusive
|
|
130
|
+
with ``to``);
|
|
131
|
+
- ``to``: comma-separated friend tokens / WeCom user ids (max 10
|
|
132
|
+
real-name, 100 VIP); mutually exclusive with ``topic``;
|
|
133
|
+
- ``option``: per-channel config code for webhook / cp / mail / qq;
|
|
134
|
+
- ``callbackUrl``: webhook receiving the asynchronous delivery
|
|
135
|
+
result;
|
|
136
|
+
- ``timestamp``: millisecond expiry timestamp (int);
|
|
137
|
+
- ``pre``: member-only preprocessing code;
|
|
138
|
+
- ``pushId``: form/doc/excel object code, required for those
|
|
139
|
+
templates.
|
|
140
|
+
|
|
141
|
+
``webhook=...`` is accepted as a deprecated alias of ``option=...`` for
|
|
142
|
+
callers written against older API versions.
|
|
143
|
+
|
|
144
|
+
Example:
|
|
145
|
+
Plain HTML message with a title::
|
|
146
|
+
|
|
147
|
+
pusher = PushPlus("your-pushplus-token")
|
|
148
|
+
pusher.send("hello world.", title="greeting")
|
|
149
|
+
|
|
150
|
+
Markdown body delivered to a group topic::
|
|
151
|
+
|
|
152
|
+
pusher.send("# Hi\\nhello world", title="greeting",
|
|
153
|
+
template="markdown", topic="ops-group")
|
|
154
|
+
|
|
155
|
+
Forward to a pre-configured DingTalk/WeCom webhook robot::
|
|
156
|
+
|
|
157
|
+
pusher.send("deploy finished", title="alert",
|
|
158
|
+
channel="webhook", option="my-dingtalk-code")
|
|
159
|
+
|
|
160
|
+
Fan the same message out to several channels at once::
|
|
161
|
+
|
|
162
|
+
pusher.batch_send("deploy finished",
|
|
163
|
+
channels=["wechat", "webhook"],
|
|
164
|
+
options=[None, "my-dingtalk-code"],
|
|
165
|
+
title="alert")
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
# Single-channel and multi-channel endpoints (HTTPS is supported).
|
|
169
|
+
url = "https://www.pushplus.plus/send"
|
|
170
|
+
batch_url = "https://www.pushplus.plus/batchSend"
|
|
171
|
+
|
|
172
|
+
allowed_options = _DOCUMENTED_OPTIONS | {"webhook"}
|
|
173
|
+
|
|
174
|
+
def __init__(self, token=None, *, timeout=None):
|
|
175
|
+
super().__init__(token, timeout=timeout)
|
|
176
|
+
if not isinstance(token, str) or not token.strip():
|
|
177
|
+
raise ValueError("PushPlus token must be a non-empty user/message token string")
|
|
178
|
+
|
|
179
|
+
@catch_exception
|
|
180
|
+
def send(self, message, **options):
|
|
181
|
+
"""Send one message through a single PushPlus channel.
|
|
182
|
+
|
|
183
|
+
Example:
|
|
184
|
+
>>> pusher.send("hello world.", title="greeting")
|
|
185
|
+
... # doctest: +SKIP
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
payload = self._build_payload(message, **options)
|
|
189
|
+
data = requests.post(self.url, json=payload, timeout=self.timeout).json()
|
|
190
|
+
return self._parse_response(data)
|
|
191
|
+
|
|
192
|
+
@catch_exception
|
|
193
|
+
def batch_send(self, message, channels, *, options=None, **fields):
|
|
194
|
+
"""Send one message through several channels via ``/batchSend``.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
message: Message content (required).
|
|
198
|
+
channels: Channel list, e.g. ``["wechat", "webhook"]`` or a
|
|
199
|
+
comma-separated string ``"wechat,webhook"``.
|
|
200
|
+
options: Channel config codes aligned with ``channels`` - a list
|
|
201
|
+
of strings/``None`` (``None`` means "no option code",
|
|
202
|
+
rendered as an empty entry), or a ready comma-separated
|
|
203
|
+
string. When given as a list its length must match
|
|
204
|
+
``channels``.
|
|
205
|
+
**fields: Other documented fields (``title``, ``template``,
|
|
206
|
+
``topic``, ``to``, ``callbackUrl``, ``timestamp``, ``pre``,
|
|
207
|
+
``pushId``).
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
PushResult whose raw ``data`` is a per-channel result list;
|
|
211
|
+
``None`` after a logged failure.
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
>>> pusher.batch_send("disk 92%", ["wechat", "mail"],
|
|
215
|
+
... options=[None, "163"], title="alert")
|
|
216
|
+
... # doctest: +SKIP
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
channel_field, option_field = self._build_batch_fields(channels, options)
|
|
220
|
+
fields["channel"] = channel_field
|
|
221
|
+
if option_field is not None:
|
|
222
|
+
fields["option"] = option_field
|
|
223
|
+
|
|
224
|
+
payload = self._build_payload(message, **fields)
|
|
225
|
+
data = requests.post(self.batch_url, json=payload, timeout=self.timeout).json()
|
|
226
|
+
return self._parse_response(data)
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------ #
|
|
229
|
+
# Body construction and validation
|
|
230
|
+
# ------------------------------------------------------------------ #
|
|
231
|
+
def _build_payload(self, message, **options):
|
|
232
|
+
"""Assemble the documented JSON request body for either endpoint."""
|
|
233
|
+
|
|
234
|
+
if not isinstance(message, str) or not message.strip():
|
|
235
|
+
raise ValueError("content (message) must be a non-empty string")
|
|
236
|
+
|
|
237
|
+
# Internal hint used only for template auto-selection, never sent.
|
|
238
|
+
options["_content_lead_hash"] = message.lstrip().startswith("#")
|
|
239
|
+
payload: dict = {"token": self.token, "content": message}
|
|
240
|
+
payload.update(self._normalize_options(**options))
|
|
241
|
+
return payload
|
|
242
|
+
|
|
243
|
+
def _normalize_options(self, **options):
|
|
244
|
+
"""Validate documented fields and return the API-shaped dict.
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
ValueError: On unknown enum values, mutually-exclusive
|
|
248
|
+
topic/to usage, a missing pushId for form/doc/excel, or a
|
|
249
|
+
non-integer timestamp.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
# Legacy alias: the field formerly called "webhook" is now "option".
|
|
253
|
+
if options.get("option") is None and options.get("webhook") is not None:
|
|
254
|
+
options["option"] = options["webhook"]
|
|
255
|
+
|
|
256
|
+
# The alias must never be forwarded under its retired name; unset
|
|
257
|
+
# fields are dropped so the server applies its own defaults instead
|
|
258
|
+
# of receiving explicit JSON nulls.
|
|
259
|
+
normalized = {key: value for key, value in self._filter_options(**options).items() if key != "webhook" and value is not None}
|
|
260
|
+
|
|
261
|
+
template = normalized.get("template")
|
|
262
|
+
if template is None:
|
|
263
|
+
# Match the convenience behaviour of the other built-in channels:
|
|
264
|
+
# a leading Markdown heading selects the markdown template.
|
|
265
|
+
template = "markdown" if options.get("_content_lead_hash") else "html"
|
|
266
|
+
elif template not in _TEMPLATES:
|
|
267
|
+
raise ValueError(f"unknown template {template!r}; expected one of {sorted(_TEMPLATES)}")
|
|
268
|
+
normalized["template"] = template
|
|
269
|
+
|
|
270
|
+
channel = normalized.get("channel")
|
|
271
|
+
if channel is not None:
|
|
272
|
+
# batch_send passes an already-joined comma list; single values
|
|
273
|
+
# are validated against the enum in _build_batch_fields/send.
|
|
274
|
+
single_channels = [part for part in str(channel).split(",") if part]
|
|
275
|
+
if "," not in str(channel):
|
|
276
|
+
if channel not in _CHANNELS:
|
|
277
|
+
raise ValueError(f"unknown channel {channel!r}; expected one of {sorted(_CHANNELS)}")
|
|
278
|
+
elif any(part not in _CHANNELS for part in single_channels):
|
|
279
|
+
raise ValueError(f"unknown channel in {channel!r}; expected values from " f"{sorted(_CHANNELS)}")
|
|
280
|
+
|
|
281
|
+
if normalized.get("topic") and normalized.get("to"):
|
|
282
|
+
raise ValueError("topic and to are mutually exclusive; do not set both")
|
|
283
|
+
|
|
284
|
+
if template in _PUSHID_REQUIRED_TEMPLATES and not normalized.get("pushId"):
|
|
285
|
+
raise ValueError(f"pushId is required when template={template!r}")
|
|
286
|
+
|
|
287
|
+
timestamp = normalized.get("timestamp")
|
|
288
|
+
if timestamp is not None:
|
|
289
|
+
# Millisecond stamps for the current era have 13 digits
|
|
290
|
+
# (>= 10**12); a 10-digit value is almost certainly seconds.
|
|
291
|
+
if isinstance(timestamp, bool) or not isinstance(timestamp, int):
|
|
292
|
+
raise ValueError("timestamp must be an int of milliseconds, e.g. 1632993318000")
|
|
293
|
+
if timestamp < 1_000_000_000_000:
|
|
294
|
+
raise ValueError("timestamp looks like seconds; PushPlus expects milliseconds " "(multiply by 1000), e.g. 1632993318000")
|
|
295
|
+
|
|
296
|
+
return normalized
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _build_batch_fields(channels, options):
|
|
300
|
+
"""Serialize the parallel channel / option lists for /batchSend."""
|
|
301
|
+
|
|
302
|
+
if isinstance(channels, str):
|
|
303
|
+
channel_list = [part.strip() for part in channels.split(",") if part.strip()]
|
|
304
|
+
elif isinstance(channels, (list, tuple)):
|
|
305
|
+
channel_list = [str(part).strip() for part in channels if str(part).strip()]
|
|
306
|
+
else:
|
|
307
|
+
raise ValueError("channels must be a list/tuple of names or a comma-separated string")
|
|
308
|
+
if not channel_list:
|
|
309
|
+
raise ValueError("at least one channel is required for batch_send")
|
|
310
|
+
unknown = [name for name in channel_list if name not in _CHANNELS]
|
|
311
|
+
if unknown:
|
|
312
|
+
raise ValueError(f"unknown channel(s) {unknown}; expected values from {sorted(_CHANNELS)}")
|
|
313
|
+
|
|
314
|
+
option_field = None
|
|
315
|
+
if options is not None:
|
|
316
|
+
if isinstance(options, str):
|
|
317
|
+
option_field = options
|
|
318
|
+
elif isinstance(options, (list, tuple)):
|
|
319
|
+
if len(options) != len(channel_list):
|
|
320
|
+
raise ValueError(f"options length {len(options)} does not match channels " f"length {len(channel_list)}")
|
|
321
|
+
# Empty/None entries render as empty config codes, matching the
|
|
322
|
+
# documented ",config1," alignment style.
|
|
323
|
+
option_field = ",".join("" if code is None else str(code) for code in options)
|
|
324
|
+
else:
|
|
325
|
+
raise ValueError("options must be a list/tuple aligned with channels or a string")
|
|
326
|
+
return ",".join(channel_list), option_field
|
|
327
|
+
|
|
328
|
+
def _parse_response(self, data: dict) -> PushResult:
|
|
329
|
+
"""Validate a PushPlus response envelope.
|
|
330
|
+
|
|
331
|
+
``code == 200`` means the server accepted (but has not necessarily
|
|
332
|
+
delivered) the message; any other code is a failure carrying ``msg``.
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
AccessFailed: With the code and server message on failure.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
if data.get("code") == 200:
|
|
339
|
+
return self._succeed(raw=data)
|
|
340
|
+
|
|
341
|
+
code = data.get("code")
|
|
342
|
+
msg = data.get("msg") or _RETURN_CODES.get(code, "")
|
|
343
|
+
raise AccessFailed(f"[{code}] {msg}".strip() if code else (msg or "PushPlus request failed"))
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Qmsg push channel (API v3).
|
|
2
|
+
|
|
3
|
+
Service website: https://qmsg.zendee.cn
|
|
4
|
+
API reference: https://qmsg.zendee.cn/docs
|
|
5
|
+
|
|
6
|
+
Qmsg delivers messages to a QQ account (or a bound QQ group) through a
|
|
7
|
+
simple HTTP API authenticated by an API key embedded in the URL path.
|
|
8
|
+
|
|
9
|
+
API v3 highlights
|
|
10
|
+
-----------------
|
|
11
|
+
- Endpoints live under the ``/v3/`` prefix.
|
|
12
|
+
- Every JSON response carries a boolean ``success`` field; on success the
|
|
13
|
+
``data`` field holds the *message id*, which can later be polled through
|
|
14
|
+
:meth:`Qmsg.query_status`.
|
|
15
|
+
- ``msg`` is required (non-empty, up to :attr:`Qmsg.max_message_length`
|
|
16
|
+
characters); ``group`` optionally targets a bound QQ group number.
|
|
17
|
+
- Rate limit: one submission per API key every 5 seconds (0.5s for hosted
|
|
18
|
+
private bots); daily quota: 500 messages (1000 for private bots).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
import requests
|
|
27
|
+
|
|
28
|
+
from ..base import PushChannel, PushResult
|
|
29
|
+
from ..errors import AccessFailed, catch_exception
|
|
30
|
+
from ..registry import register_channel
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------- #
|
|
33
|
+
# Message delivery status codes returned by /v3/msg/status/{key}
|
|
34
|
+
# ---------------------------------------------------------------------- #
|
|
35
|
+
#: Message submitted, QQ has not returned a delivery receipt yet.
|
|
36
|
+
STATUS_PENDING = 0
|
|
37
|
+
#: Message delivered successfully.
|
|
38
|
+
STATUS_SENT = 1
|
|
39
|
+
#: Message rejected by the platform content-moderation check.
|
|
40
|
+
STATUS_VIOLATION = 2
|
|
41
|
+
#: Message delivery failed.
|
|
42
|
+
STATUS_FAILED = -1
|
|
43
|
+
|
|
44
|
+
#: Human-readable label for every documented status code.
|
|
45
|
+
STATUS_LABELS = {
|
|
46
|
+
STATUS_PENDING: "pending",
|
|
47
|
+
STATUS_SENT: "sent",
|
|
48
|
+
STATUS_VIOLATION: "violation",
|
|
49
|
+
STATUS_FAILED: "failed",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class QmsgStatus:
|
|
55
|
+
"""Immutable delivery-state snapshot returned by :meth:`Qmsg.query_status`.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
msg_id: Message id previously returned by the send API.
|
|
59
|
+
code: Raw status code, one of ``STATUS_PENDING`` (0),
|
|
60
|
+
``STATUS_SENT`` (1), ``STATUS_VIOLATION`` (2) or
|
|
61
|
+
``STATUS_FAILED`` (-1).
|
|
62
|
+
label: Lower-case textual form of :attr:`code` (``"unknown"`` when
|
|
63
|
+
the service returns an undocumented code).
|
|
64
|
+
raw: Full decoded JSON response, kept for debugging.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
>>> status = qmsg.query_status(42) # doctest: +SKIP
|
|
68
|
+
>>> status.code # doctest: +SKIP
|
|
69
|
+
1
|
|
70
|
+
>>> status.label # doctest: +SKIP
|
|
71
|
+
'sent'
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
msg_id: int
|
|
75
|
+
code: int
|
|
76
|
+
label: str
|
|
77
|
+
raw: dict
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@register_channel("qmsg")
|
|
81
|
+
class Qmsg(PushChannel):
|
|
82
|
+
"""Deliver messages through the Qmsg API v3.
|
|
83
|
+
|
|
84
|
+
Supported ``send()`` options:
|
|
85
|
+
|
|
86
|
+
- ``group``: target QQ group number as a string. The group must be
|
|
87
|
+
added and bound in the Qmsg console first; omit it to deliver to
|
|
88
|
+
the QQ account bound to the API key (default single-chat push).
|
|
89
|
+
|
|
90
|
+
Unknown options (e.g. ``title=...`` forwarded by a composite fan-out)
|
|
91
|
+
are ignored, so Qmsg can sit next to other channels in one call.
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
Push to the bound QQ single chat::
|
|
95
|
+
|
|
96
|
+
qmsg = Qmsg("your-api-key")
|
|
97
|
+
result = qmsg.send("backup job finished")
|
|
98
|
+
msg_id = result.raw["data"]
|
|
99
|
+
|
|
100
|
+
Push to a bound QQ group::
|
|
101
|
+
|
|
102
|
+
qmsg.send("deploy finished", group="123456789")
|
|
103
|
+
|
|
104
|
+
Poll the asynchronous delivery status (0/1/2/-1)::
|
|
105
|
+
|
|
106
|
+
snapshot = qmsg.query_status(msg_id)
|
|
107
|
+
if snapshot.code == STATUS_SENT:
|
|
108
|
+
print("delivered")
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
# Base host; every v3 endpoint is derived from it.
|
|
112
|
+
base_url = "https://qmsg.zendee.cn"
|
|
113
|
+
|
|
114
|
+
# API version prefix, kept as an attribute so a future v4 migration only
|
|
115
|
+
# touches this one line.
|
|
116
|
+
api_prefix = "/v3"
|
|
117
|
+
|
|
118
|
+
# Documented maximum message length in characters.
|
|
119
|
+
max_message_length = 1800
|
|
120
|
+
|
|
121
|
+
# Keyword arguments accepted by the send API; everything else in a
|
|
122
|
+
# fan-out call (e.g. ``title=...``) is ignored automatically.
|
|
123
|
+
allowed_options = frozenset({"group"})
|
|
124
|
+
|
|
125
|
+
def __init__(self, token=None, *, timeout=None):
|
|
126
|
+
super().__init__(token, timeout=timeout)
|
|
127
|
+
# POST endpoint accepting query/form parameters.
|
|
128
|
+
self.send_url = f"{self.base_url}{self.api_prefix}/send/{self.token}"
|
|
129
|
+
# POST endpoint accepting an ``application/json`` body.
|
|
130
|
+
self.json_send_url = f"{self.base_url}{self.api_prefix}/jsend/{self.token}"
|
|
131
|
+
# GET endpoint reporting asynchronous message delivery status.
|
|
132
|
+
self.status_url = f"{self.base_url}{self.api_prefix}/msg/status/{self.token}"
|
|
133
|
+
# Message id of the most recent successful send (``None`` before any
|
|
134
|
+
# successful call); lets ``query_status()`` be called without args.
|
|
135
|
+
self.last_msg_id = None
|
|
136
|
+
|
|
137
|
+
@catch_exception
|
|
138
|
+
def send(self, message, **options):
|
|
139
|
+
# Fail fast on the documented content constraints instead of waiting
|
|
140
|
+
# for the remote service to reject the request.
|
|
141
|
+
if not isinstance(message, str) or not message.strip():
|
|
142
|
+
raise ValueError("message must be a non-empty string")
|
|
143
|
+
if len(message) > self.max_message_length:
|
|
144
|
+
raise ValueError(f"message is {len(message)} characters long but the Qmsg limit " f"is {self.max_message_length} characters")
|
|
145
|
+
|
|
146
|
+
# ``msg`` is required; ``group`` is the only documented optional field.
|
|
147
|
+
payload = {"msg": message}
|
|
148
|
+
payload.update(self._filter_options(**options))
|
|
149
|
+
|
|
150
|
+
response = requests.post(self.send_url, data=payload, timeout=self.timeout)
|
|
151
|
+
return self._parse_send_response(response.json())
|
|
152
|
+
|
|
153
|
+
@catch_exception
|
|
154
|
+
def send_json(self, message, group=None):
|
|
155
|
+
"""Send a message against the JSON endpoint ``/v3/jsend/{key}``.
|
|
156
|
+
|
|
157
|
+
Functionally identical to :meth:`send` but posts an
|
|
158
|
+
``application/json`` body, which is convenient when the caller is
|
|
159
|
+
already working with JSON payloads.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
message: Non-empty text body (<= 1800 characters).
|
|
163
|
+
group: Optional bound QQ group number.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
A :class:`PushResult` on success, or ``None`` on failure.
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
>>> qmsg.send_json("hello from JSON") # doctest: +SKIP
|
|
170
|
+
>>> qmsg.send_json("hello group", group="123") # doctest: +SKIP
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
if not isinstance(message, str) or not message.strip():
|
|
174
|
+
raise ValueError("message must be a non-empty string")
|
|
175
|
+
if len(message) > self.max_message_length:
|
|
176
|
+
raise ValueError(f"message is {len(message)} characters long but the Qmsg limit " f"is {self.max_message_length} characters")
|
|
177
|
+
|
|
178
|
+
body: dict = {"msg": message}
|
|
179
|
+
if group is not None:
|
|
180
|
+
body["group"] = str(group)
|
|
181
|
+
|
|
182
|
+
response = requests.post(self.json_send_url, json=body, timeout=self.timeout)
|
|
183
|
+
return self._parse_send_response(response.json())
|
|
184
|
+
|
|
185
|
+
def _parse_send_response(self, data: dict) -> PushResult:
|
|
186
|
+
"""Validate a v3 send response and cache the returned message id.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
AccessFailed: When the response reports ``success: false``.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
# The docs require judging business success via the ``success`` flag
|
|
193
|
+
# (``code`` is only 0/500 and exists for historical reasons).
|
|
194
|
+
if data.get("success") is True:
|
|
195
|
+
# ``data`` is the asynchronous message id used by query_status().
|
|
196
|
+
self.last_msg_id = data.get("data")
|
|
197
|
+
return self._succeed(raw=data)
|
|
198
|
+
raise AccessFailed(data.get("message"))
|
|
199
|
+
|
|
200
|
+
@catch_exception
|
|
201
|
+
def query_status(self, msg_id: "int | None" = None) -> "Optional[QmsgStatus]":
|
|
202
|
+
"""Query the asynchronous delivery status of a previously sent message.
|
|
203
|
+
|
|
204
|
+
See https://qmsg.zendee.cn/docs -> "消息状态".
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
msg_id: Message id returned in ``result.raw["data"]`` by
|
|
208
|
+
:meth:`send`. When omitted, the id of the most recent
|
|
209
|
+
successful send on this instance is reused.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
A :class:`QmsgStatus` snapshot on success, or ``None`` on failure.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
ValueError: When no ``msg_id`` is given and no previous send is
|
|
216
|
+
cached on this instance.
|
|
217
|
+
|
|
218
|
+
Example:
|
|
219
|
+
>>> result = qmsg.send("hello") # doctest: +SKIP
|
|
220
|
+
>>> qmsg.query_status(result.raw["data"]).label # doctest: +SKIP
|
|
221
|
+
'sent'
|
|
222
|
+
>>> qmsg.query_status() # reuse last_msg_id # doctest: +SKIP
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
if msg_id is None:
|
|
226
|
+
msg_id = self.last_msg_id
|
|
227
|
+
if msg_id is None:
|
|
228
|
+
raise ValueError("msg_id is required when no successful send has been made yet")
|
|
229
|
+
|
|
230
|
+
response = requests.get(self.status_url, params={"msgId": msg_id}, timeout=self.timeout)
|
|
231
|
+
data = response.json()
|
|
232
|
+
|
|
233
|
+
if data.get("success") is not True:
|
|
234
|
+
raise AccessFailed(data.get("message"))
|
|
235
|
+
|
|
236
|
+
code = data.get("data")
|
|
237
|
+
return QmsgStatus(
|
|
238
|
+
msg_id=int(msg_id),
|
|
239
|
+
code=code,
|
|
240
|
+
label=STATUS_LABELS.get(code, "unknown"),
|
|
241
|
+
raw=data,
|
|
242
|
+
)
|