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.
@@ -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
+ )