tlgr-cli 2.0.1__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.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/core/errors.py
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
"""Error types, stable exit codes, and the one Telethon-exception table.
|
|
2
|
+
|
|
3
|
+
Everything that turns a raised exception into something a caller can act on
|
|
4
|
+
happens here: the machine name (`code`), the process exit status, the HTTP
|
|
5
|
+
status the daemon answers with, whether a retry is worth attempting, and the
|
|
6
|
+
hint a human reads. v1 spread that decision across four modules and collapsed
|
|
7
|
+
most of it to IPC_ERROR/exit 12 on the way out (COR-06); one table cannot
|
|
8
|
+
disagree with itself.
|
|
9
|
+
|
|
10
|
+
**Why the table is keyed by class *name*.** ARCHITECTURE §2.2 requires that
|
|
11
|
+
`cli/` never import Telethon — `tlgr --help` has to stay fast and work on a
|
|
12
|
+
machine with no Telethon installed — while §7.1 requires that this module be
|
|
13
|
+
the only place Telethon exception classes are named. Both hold only if the
|
|
14
|
+
names are strings: `classify()` walks `type(exc).__mro__` and looks each class
|
|
15
|
+
name up, so nothing is imported to classify anything.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import msgspec
|
|
27
|
+
|
|
28
|
+
from tlgr.models.error import ErrorBody
|
|
29
|
+
|
|
30
|
+
# Stable exit codes for automation/agent consumption.
|
|
31
|
+
EXIT_SUCCESS = 0
|
|
32
|
+
EXIT_GENERIC = 1
|
|
33
|
+
EXIT_USAGE = 2
|
|
34
|
+
EXIT_EMPTY = 3
|
|
35
|
+
EXIT_AUTH = 4
|
|
36
|
+
EXIT_NOT_FOUND = 5
|
|
37
|
+
EXIT_PERMISSION = 6
|
|
38
|
+
EXIT_RATE_LIMITED = 7
|
|
39
|
+
EXIT_RETRYABLE = 8
|
|
40
|
+
EXIT_SPAM_FLAGGED = 9
|
|
41
|
+
EXIT_CONFIG = 10
|
|
42
|
+
EXIT_DAEMON = 11
|
|
43
|
+
EXIT_IPC = 12
|
|
44
|
+
EXIT_INDETERMINATE = 13
|
|
45
|
+
EXIT_CANCELLED = 130
|
|
46
|
+
|
|
47
|
+
#: `MessageNotModifiedError` is not a failure: the world already looks the way
|
|
48
|
+
#: the caller asked for. `classify()` returns this code with `exit_code == 0`,
|
|
49
|
+
#: and the dispatcher turns it into `ok: true` with `meta.already = true`.
|
|
50
|
+
NOT_MODIFIED = "NOT_MODIFIED"
|
|
51
|
+
|
|
52
|
+
EXIT_CODE_MAP: dict[str, dict[str, Any]] = {
|
|
53
|
+
"SUCCESS": {"code": EXIT_SUCCESS, "description": "Success"},
|
|
54
|
+
"GENERIC": {"code": EXIT_GENERIC, "description": "Generic failure"},
|
|
55
|
+
"USAGE": {"code": EXIT_USAGE, "description": "Usage or parse error"},
|
|
56
|
+
"EMPTY": {"code": EXIT_EMPTY, "description": "Empty results"},
|
|
57
|
+
"AUTH_ERROR": {"code": EXIT_AUTH, "description": "Authentication required"},
|
|
58
|
+
"AUTH_PASSWORD_REQUIRED": {
|
|
59
|
+
"code": EXIT_AUTH,
|
|
60
|
+
"description": "Two-factor password required to complete sign-in",
|
|
61
|
+
},
|
|
62
|
+
"SESSION_ERROR": {"code": EXIT_AUTH, "description": "Session error (re-auth needed)"},
|
|
63
|
+
"CHAT_NOT_FOUND": {"code": EXIT_NOT_FOUND, "description": "Chat or entity not found"},
|
|
64
|
+
"NOT_FOUND": {
|
|
65
|
+
"code": EXIT_NOT_FOUND,
|
|
66
|
+
"description": "Chat, user, message or account not found",
|
|
67
|
+
},
|
|
68
|
+
"ACCOUNT_NOT_FOUND": {"code": EXIT_NOT_FOUND, "description": "Account alias is not registered"},
|
|
69
|
+
"ACCOUNT_REQUIRED": {"code": EXIT_USAGE, "description": "No account given and none inferable"},
|
|
70
|
+
"PERMISSION_DENIED": {"code": EXIT_PERMISSION, "description": "Permission denied"},
|
|
71
|
+
"RATE_LIMITED": {"code": EXIT_RATE_LIMITED, "description": "Rate limited (retry later)"},
|
|
72
|
+
"RETRYABLE": {"code": EXIT_RETRYABLE, "description": "Transient/retryable error"},
|
|
73
|
+
"PEER_FLOOD": {
|
|
74
|
+
"code": EXIT_SPAM_FLAGGED,
|
|
75
|
+
"description": "Account spam-flagged for messaging strangers (PeerFlood) — stop sending",
|
|
76
|
+
},
|
|
77
|
+
"ACCOUNT_FROZEN": {
|
|
78
|
+
"code": EXIT_SPAM_FLAGGED,
|
|
79
|
+
"description": "Account frozen/restricted by Telegram — stop sending",
|
|
80
|
+
},
|
|
81
|
+
"CONFIG_ERROR": {"code": EXIT_CONFIG, "description": "Configuration error"},
|
|
82
|
+
"DAEMON_ERROR": {"code": EXIT_DAEMON, "description": "Daemon error"},
|
|
83
|
+
"DAEMON_NOT_RUNNING": {"code": EXIT_DAEMON, "description": "Daemon is not running"},
|
|
84
|
+
"DAEMON_VERSION_MISMATCH": {
|
|
85
|
+
"code": EXIT_DAEMON,
|
|
86
|
+
"description": "Daemon speaks a different protocol version than this CLI",
|
|
87
|
+
},
|
|
88
|
+
"IPC_ERROR": {"code": EXIT_IPC, "description": "IPC communication error"},
|
|
89
|
+
"NOT_SUPPORTED": {
|
|
90
|
+
"code": EXIT_INDETERMINATE,
|
|
91
|
+
"description": (
|
|
92
|
+
"tlgr cannot perform this — the API layer or Telethon build lacks it, "
|
|
93
|
+
"not a failure of the request"
|
|
94
|
+
),
|
|
95
|
+
},
|
|
96
|
+
"INDETERMINATE": {
|
|
97
|
+
"code": EXIT_INDETERMINATE,
|
|
98
|
+
"description": (
|
|
99
|
+
"Question could not be answered authoritatively — treat as unknown, never as a negative"
|
|
100
|
+
),
|
|
101
|
+
},
|
|
102
|
+
"CANCELLED": {"code": EXIT_CANCELLED, "description": "Interrupted (SIGINT)"},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# The exception tree
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TlgrError(Exception):
|
|
112
|
+
"""Base error for all tlgr errors."""
|
|
113
|
+
|
|
114
|
+
code: str = "TLGR_ERROR"
|
|
115
|
+
exit_code: int = EXIT_GENERIC
|
|
116
|
+
hint: str = ""
|
|
117
|
+
http: int = 500
|
|
118
|
+
retryable: bool = False
|
|
119
|
+
|
|
120
|
+
def __init__(self, message: str, code: str | None = None):
|
|
121
|
+
super().__init__(message)
|
|
122
|
+
if code:
|
|
123
|
+
self.code = code
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class UsageError(TlgrError):
|
|
127
|
+
code = "USAGE"
|
|
128
|
+
exit_code = EXIT_USAGE
|
|
129
|
+
http = 400
|
|
130
|
+
|
|
131
|
+
def __init__(self, message: str, code: str | None = None, field: str | None = None):
|
|
132
|
+
super().__init__(message, code)
|
|
133
|
+
self.field = field
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class AuthenticationError(TlgrError):
|
|
137
|
+
code = "AUTH_ERROR"
|
|
138
|
+
exit_code = EXIT_AUTH
|
|
139
|
+
http = 401
|
|
140
|
+
hint = "Run: tlgr account add <phone>"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class AuthPasswordRequiredError(AuthenticationError):
|
|
144
|
+
code = "AUTH_PASSWORD_REQUIRED"
|
|
145
|
+
hint = "Supply the 2FA password with --password-env TLGR_2FA_PASSWORD"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class SessionError(TlgrError):
|
|
149
|
+
code = "SESSION_ERROR"
|
|
150
|
+
exit_code = EXIT_AUTH
|
|
151
|
+
http = 401
|
|
152
|
+
hint = "Session expired. Run: tlgr account add <phone>"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ConfigurationError(TlgrError):
|
|
156
|
+
code = "CONFIG_ERROR"
|
|
157
|
+
exit_code = EXIT_CONFIG
|
|
158
|
+
http = 400
|
|
159
|
+
hint = "Run: tlgr config init"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class NotFoundError(TlgrError):
|
|
163
|
+
code = "NOT_FOUND"
|
|
164
|
+
exit_code = EXIT_NOT_FOUND
|
|
165
|
+
http = 404
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class ChatNotFoundError(NotFoundError):
|
|
169
|
+
code = "CHAT_NOT_FOUND"
|
|
170
|
+
hint = "Run: tlgr chat list to find available chats"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class AccountNotFoundError(NotFoundError):
|
|
174
|
+
code = "ACCOUNT_NOT_FOUND"
|
|
175
|
+
hint = "Run: tlgr account list to see registered aliases"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class AccountRequiredError(TlgrError):
|
|
179
|
+
"""No account was given and the daemon refuses to pick one for you.
|
|
180
|
+
|
|
181
|
+
v1 used "whichever alias came first out of a set" (COR-02), which meant a
|
|
182
|
+
two-account user could send from the wrong identity without any signal.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
code = "ACCOUNT_REQUIRED"
|
|
186
|
+
exit_code = EXIT_USAGE
|
|
187
|
+
http = 400
|
|
188
|
+
hint = "Pass -a <alias>, set TLGR_ACCOUNT, or set [accounts] default in config.toml"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class PermissionError_(TlgrError):
|
|
192
|
+
code = "PERMISSION_DENIED"
|
|
193
|
+
exit_code = EXIT_PERMISSION
|
|
194
|
+
http = 403
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class RateLimitError(TlgrError):
|
|
198
|
+
code = "RATE_LIMITED"
|
|
199
|
+
exit_code = EXIT_RATE_LIMITED
|
|
200
|
+
http = 429
|
|
201
|
+
retryable = True
|
|
202
|
+
|
|
203
|
+
def __init__(self, message: str, wait_seconds: int = 0):
|
|
204
|
+
super().__init__(message, code="RATE_LIMITED")
|
|
205
|
+
self.wait_seconds = wait_seconds
|
|
206
|
+
if wait_seconds:
|
|
207
|
+
self.hint = f"Rate limited. Retry after {wait_seconds}s"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class SpamFlagError(TlgrError):
|
|
211
|
+
"""Telegram has restricted this account from messaging strangers.
|
|
212
|
+
|
|
213
|
+
Distinct from RateLimitError: a FloodWait clears after `wait_seconds`,
|
|
214
|
+
whereas PeerFlood/FROZEN is an account-level spam flag with no advertised
|
|
215
|
+
expiry. Callers running outreach must stop ALL outgoing traffic for the
|
|
216
|
+
account rather than back off and retry.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
code = "PEER_FLOOD"
|
|
220
|
+
exit_code = EXIT_SPAM_FLAGGED
|
|
221
|
+
http = 403
|
|
222
|
+
hint = "Account is spam-flagged. Stop sending from it and let it rest."
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class AccountFrozenError(SpamFlagError):
|
|
226
|
+
code = "ACCOUNT_FROZEN"
|
|
227
|
+
hint = "Account is frozen by Telegram. Appeal before sending anything else."
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class RetryableError(TlgrError):
|
|
231
|
+
code = "RETRYABLE"
|
|
232
|
+
exit_code = EXIT_RETRYABLE
|
|
233
|
+
http = 503
|
|
234
|
+
retryable = True
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class IndeterminateError(TlgrError):
|
|
238
|
+
"""The answer could not be established — and must never be reported as "no".
|
|
239
|
+
|
|
240
|
+
Exit 13 exists because a truncated scan, a flood mid-harvest or an RPC
|
|
241
|
+
failure during a *negative* proof are all "we do not know", and a caller
|
|
242
|
+
that reads them as "no" acts on a fact nobody established.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
code = "INDETERMINATE"
|
|
246
|
+
exit_code = EXIT_INDETERMINATE
|
|
247
|
+
http = 200
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class NotSupportedError(TlgrError):
|
|
251
|
+
"""tlgr cannot do this, and no retry will change that.
|
|
252
|
+
|
|
253
|
+
Shares exit 13 with INDETERMINATE because both mean "do not read this as
|
|
254
|
+
a no about the world": a feature Telethon's layer does not carry has not
|
|
255
|
+
been refused by Telegram, it was never asked. The code is distinct so an
|
|
256
|
+
agent can tell "unavailable in this build" from "could not establish".
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
code = "NOT_SUPPORTED"
|
|
260
|
+
exit_code = EXIT_INDETERMINATE
|
|
261
|
+
http = 501
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class DaemonError(TlgrError):
|
|
265
|
+
code = "DAEMON_ERROR"
|
|
266
|
+
exit_code = EXIT_DAEMON
|
|
267
|
+
http = 500
|
|
268
|
+
hint = "Run: tlgr daemon start"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class DaemonNotRunningError(DaemonError):
|
|
272
|
+
code = "DAEMON_NOT_RUNNING"
|
|
273
|
+
exit_code = EXIT_DAEMON
|
|
274
|
+
hint = "Daemon is not running. Start it with: tlgr daemon start"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class DaemonVersionMismatchError(DaemonError):
|
|
278
|
+
code = "DAEMON_VERSION_MISMATCH"
|
|
279
|
+
http = 409
|
|
280
|
+
hint = "Run: tlgr daemon restart to pick up the new protocol"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class IPCError(TlgrError):
|
|
284
|
+
code = "IPC_ERROR"
|
|
285
|
+
exit_code = EXIT_IPC
|
|
286
|
+
http = 500
|
|
287
|
+
retryable = True
|
|
288
|
+
hint = "Check daemon status with: tlgr daemon status"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class CancelledError(TlgrError):
|
|
292
|
+
code = "CANCELLED"
|
|
293
|
+
exit_code = EXIT_CANCELLED
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# The mapping table (ARCHITECTURE §7.2)
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True, slots=True)
|
|
302
|
+
class ErrorRule:
|
|
303
|
+
"""One row of the §7.2 table."""
|
|
304
|
+
|
|
305
|
+
code: str
|
|
306
|
+
exit_code: int
|
|
307
|
+
http: int
|
|
308
|
+
retryable: bool = False
|
|
309
|
+
hint: str = ""
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
_RATE = ErrorRule("RATE_LIMITED", EXIT_RATE_LIMITED, 429, True)
|
|
313
|
+
_SESSION = ErrorRule(
|
|
314
|
+
"SESSION_ERROR", EXIT_AUTH, 401, False, "Re-authenticate: tlgr account add <phone>"
|
|
315
|
+
)
|
|
316
|
+
_AUTH = ErrorRule("AUTH_ERROR", EXIT_AUTH, 401)
|
|
317
|
+
_NOT_FOUND = ErrorRule("NOT_FOUND", EXIT_NOT_FOUND, 404)
|
|
318
|
+
_DENIED = ErrorRule("PERMISSION_DENIED", EXIT_PERMISSION, 403)
|
|
319
|
+
_PREMIUM = ErrorRule("PERMISSION_DENIED", EXIT_PERMISSION, 403, False, "Requires Telegram Premium")
|
|
320
|
+
_PAYMENT = ErrorRule(
|
|
321
|
+
"PERMISSION_DENIED", EXIT_PERMISSION, 403, False, "Not enough Stars/balance for this operation"
|
|
322
|
+
)
|
|
323
|
+
_USAGE = ErrorRule("USAGE", EXIT_USAGE, 400)
|
|
324
|
+
_RETRY = ErrorRule("RETRYABLE", EXIT_RETRYABLE, 503, True)
|
|
325
|
+
|
|
326
|
+
#: Telethon (and internal) exception class name -> rule. Keyed by name so that
|
|
327
|
+
#: importing this module never imports Telethon; see the module docstring.
|
|
328
|
+
ERROR_MAP: dict[str, ErrorRule] = {
|
|
329
|
+
# --- waits -------------------------------------------------------------
|
|
330
|
+
"FloodWaitError": _RATE,
|
|
331
|
+
"SlowModeWaitError": _RATE,
|
|
332
|
+
"FloodPremiumWaitError": _RATE,
|
|
333
|
+
"FloodTestPhoneWaitError": _RATE,
|
|
334
|
+
"TakeoutInitDelayError": _RATE,
|
|
335
|
+
"PreviousChatImportActiveWaitXminError": _RATE,
|
|
336
|
+
"TwoFaConfirmWaitError": _RATE,
|
|
337
|
+
"_2faConfirmWaitError": _RATE,
|
|
338
|
+
# --- account-level stop signs -----------------------------------------
|
|
339
|
+
"PeerFloodError": ErrorRule(
|
|
340
|
+
"PEER_FLOOD",
|
|
341
|
+
EXIT_SPAM_FLAGGED,
|
|
342
|
+
403,
|
|
343
|
+
False,
|
|
344
|
+
"Account is spam-flagged. Stop sending from it and let it rest.",
|
|
345
|
+
),
|
|
346
|
+
"FrozenMethodInvalidError": ErrorRule(
|
|
347
|
+
"ACCOUNT_FROZEN", EXIT_SPAM_FLAGGED, 403, False, "Account is frozen; appeal before retrying"
|
|
348
|
+
),
|
|
349
|
+
# --- session / auth ----------------------------------------------------
|
|
350
|
+
"AuthKeyUnregisteredError": _SESSION,
|
|
351
|
+
"AuthKeyInvalidError": _SESSION,
|
|
352
|
+
"AuthKeyPermEmptyError": _SESSION,
|
|
353
|
+
"SessionRevokedError": _SESSION,
|
|
354
|
+
"SessionExpiredError": _SESSION,
|
|
355
|
+
"AuthKeyDuplicatedError": _SESSION,
|
|
356
|
+
"UserDeactivatedError": _SESSION,
|
|
357
|
+
"UserDeactivatedBanError": _SESSION,
|
|
358
|
+
"AuthKeyNotFound": _SESSION,
|
|
359
|
+
"SessionPasswordNeededError": ErrorRule(
|
|
360
|
+
"AUTH_PASSWORD_REQUIRED",
|
|
361
|
+
EXIT_AUTH,
|
|
362
|
+
401,
|
|
363
|
+
False,
|
|
364
|
+
"Supply the 2FA password with --password-env TLGR_2FA_PASSWORD",
|
|
365
|
+
),
|
|
366
|
+
"PhoneCodeInvalidError": _AUTH,
|
|
367
|
+
"PhoneCodeExpiredError": _AUTH,
|
|
368
|
+
"PhoneNumberInvalidError": _AUTH,
|
|
369
|
+
"PhoneNumberBannedError": _AUTH,
|
|
370
|
+
"PhoneNumberUnoccupiedError": _AUTH,
|
|
371
|
+
"PasswordHashInvalidError": _AUTH,
|
|
372
|
+
"UpdateAppToLoginError": ErrorRule(
|
|
373
|
+
"AUTH_ERROR", EXIT_AUTH, 401, False, "This login method is retired; use QR login instead"
|
|
374
|
+
),
|
|
375
|
+
# --- not found ---------------------------------------------------------
|
|
376
|
+
"UsernameNotOccupiedError": _NOT_FOUND,
|
|
377
|
+
"UsernameInvalidError": _NOT_FOUND,
|
|
378
|
+
"PeerIdInvalidError": _NOT_FOUND,
|
|
379
|
+
"ChannelInvalidError": _NOT_FOUND,
|
|
380
|
+
"ChatIdInvalidError": _NOT_FOUND,
|
|
381
|
+
"UserIdInvalidError": _NOT_FOUND,
|
|
382
|
+
"MessageIdInvalidError": _NOT_FOUND,
|
|
383
|
+
"MsgIdInvalidError": _NOT_FOUND,
|
|
384
|
+
"InviteHashExpiredError": _NOT_FOUND,
|
|
385
|
+
"InviteHashInvalidError": _NOT_FOUND,
|
|
386
|
+
"StickersetInvalidError": _NOT_FOUND,
|
|
387
|
+
# --- permission --------------------------------------------------------
|
|
388
|
+
"ChatAdminRequiredError": _DENIED,
|
|
389
|
+
"ChatWriteForbiddenError": _DENIED,
|
|
390
|
+
"ChatSendMediaForbiddenError": _DENIED,
|
|
391
|
+
"ChatSendStickersForbiddenError": _DENIED,
|
|
392
|
+
"ChatSendGifsForbiddenError": _DENIED,
|
|
393
|
+
"ChatSendGameForbiddenError": _DENIED,
|
|
394
|
+
"ChatSendInlineForbiddenError": _DENIED,
|
|
395
|
+
"ChatSendPollForbiddenError": _DENIED,
|
|
396
|
+
"ChatSendPhotosForbiddenError": _DENIED,
|
|
397
|
+
"ChatSendVideosForbiddenError": _DENIED,
|
|
398
|
+
"ChatSendAudiosForbiddenError": _DENIED,
|
|
399
|
+
"ChatSendVoicesForbiddenError": _DENIED,
|
|
400
|
+
"ChatSendRoundvideosForbiddenError": _DENIED,
|
|
401
|
+
"ChatSendDocsForbiddenError": _DENIED,
|
|
402
|
+
"ChatSendPlainForbiddenError": _DENIED,
|
|
403
|
+
"ChannelPrivateError": _DENIED,
|
|
404
|
+
"UserPrivacyRestrictedError": _DENIED,
|
|
405
|
+
"UserIsBlockedError": _DENIED,
|
|
406
|
+
"UserBannedInChannelError": _DENIED,
|
|
407
|
+
"UserNotParticipantError": _DENIED,
|
|
408
|
+
"MessageDeleteForbiddenError": _DENIED,
|
|
409
|
+
"MessageAuthorRequiredError": _DENIED,
|
|
410
|
+
"MessageEditTimeExpiredError": _DENIED,
|
|
411
|
+
"RightForbiddenError": _DENIED,
|
|
412
|
+
"ChatForwardsRestrictedError": _DENIED,
|
|
413
|
+
"TopicClosedError": _DENIED,
|
|
414
|
+
"BroadcastForbiddenError": _DENIED,
|
|
415
|
+
"ForbiddenError": _DENIED,
|
|
416
|
+
"PremiumAccountRequiredError": _PREMIUM,
|
|
417
|
+
"PrivacyPremiumRequiredError": _PREMIUM,
|
|
418
|
+
"BoostsRequiredError": _PREMIUM,
|
|
419
|
+
"BalanceTooLowError": _PAYMENT,
|
|
420
|
+
"AllowPaymentRequiredError": _PAYMENT,
|
|
421
|
+
"StarsFormAmountMismatchError": _PAYMENT,
|
|
422
|
+
"FormExpiredError": _PAYMENT,
|
|
423
|
+
# --- not an error ------------------------------------------------------
|
|
424
|
+
"MessageNotModifiedError": ErrorRule(NOT_MODIFIED, EXIT_SUCCESS, 200),
|
|
425
|
+
# --- usage -------------------------------------------------------------
|
|
426
|
+
"MessageEmptyError": _USAGE,
|
|
427
|
+
"MessageTooLongError": _USAGE,
|
|
428
|
+
"MediaEmptyError": _USAGE,
|
|
429
|
+
"MediaInvalidError": _USAGE,
|
|
430
|
+
"PhotoInvalidDimensionsError": _USAGE,
|
|
431
|
+
"ContactIdInvalidError": _USAGE,
|
|
432
|
+
"UserAlreadyParticipantError": _USAGE,
|
|
433
|
+
"UsersTooMuchError": _USAGE,
|
|
434
|
+
"BotMethodInvalidError": _USAGE,
|
|
435
|
+
"BannedRightsInvalidError": _USAGE,
|
|
436
|
+
"ScheduleDateInvalidError": _USAGE,
|
|
437
|
+
"BadRequestError": _USAGE,
|
|
438
|
+
"ValidationError": _USAGE,
|
|
439
|
+
"UsageError": _USAGE,
|
|
440
|
+
# --- transient ---------------------------------------------------------
|
|
441
|
+
"FileReferenceExpiredError": _RETRY,
|
|
442
|
+
"FileReferenceInvalidError": _RETRY,
|
|
443
|
+
"FilerefUpgradeNeededError": _RETRY,
|
|
444
|
+
"ServerError": _RETRY,
|
|
445
|
+
"RpcCallFailError": _RETRY,
|
|
446
|
+
"RpcMcgetFailError": _RETRY,
|
|
447
|
+
"InterdcCallErrorError": _RETRY,
|
|
448
|
+
"TimedOutError": _RETRY,
|
|
449
|
+
"PersistentTimestampOutdatedError": _RETRY,
|
|
450
|
+
"TimeoutError": _RETRY,
|
|
451
|
+
"ConnectionError": _RETRY,
|
|
452
|
+
"OSError": _RETRY,
|
|
453
|
+
# --- everything else ---------------------------------------------------
|
|
454
|
+
"RPCError": ErrorRule("GENERIC", EXIT_GENERIC, 500),
|
|
455
|
+
"KeyboardInterrupt": ErrorRule("CANCELLED", EXIT_CANCELLED, 500),
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
#: RPC message patterns that outrank the class name. Telethon has no generated
|
|
459
|
+
#: class for every string in the 780-entry error DB, and `FROZEN_*` in
|
|
460
|
+
#: particular arrives as a bare RPCError.
|
|
461
|
+
_MESSAGE_RULES: tuple[tuple[re.Pattern[str], ErrorRule], ...] = (
|
|
462
|
+
(
|
|
463
|
+
# Anywhere in the message, not only at the start: Telethon renders an
|
|
464
|
+
# unknown RPC error as "RPCError None: FROZEN_METHOD_INVALID (caused
|
|
465
|
+
# by …)", so anchoring this pattern made it match only the bare code.
|
|
466
|
+
re.compile(r"\bFROZEN_[A-Z_]+"),
|
|
467
|
+
ErrorRule("ACCOUNT_FROZEN", EXIT_SPAM_FLAGGED, 403, False, "Account is frozen by Telegram"),
|
|
468
|
+
),
|
|
469
|
+
(re.compile(r"^FLOOD_WAIT_\d+$"), _RATE),
|
|
470
|
+
(re.compile(r"^SLOWMODE_WAIT_\d+$"), _RATE),
|
|
471
|
+
(re.compile(r"Cannot send requests while disconnected"), _RETRY),
|
|
472
|
+
(re.compile(r"Could not find the input entity"), _NOT_FOUND),
|
|
473
|
+
(re.compile(r"^AUTH_KEY_(UNREGISTERED|INVALID|DUPLICATED|PERM_EMPTY)$"), _SESSION),
|
|
474
|
+
# PR-12. The settings surface names things by slug, id and code, and
|
|
475
|
+
# Telethon has no generated class for any of these — without a rule they
|
|
476
|
+
# all arrive as exit 1, which tells a caller nothing about whether to
|
|
477
|
+
# retry, to fix the name, or to give up.
|
|
478
|
+
(
|
|
479
|
+
re.compile(
|
|
480
|
+
r"\b(GIFT_SLUG_INVALID|SLUG_INVALID|THEME_INVALID|LANG_PACK_INVALID"
|
|
481
|
+
r"|COLLECTION_ID_INVALID|AUCTION_INVALID|BUSINESS_LINK_INVALID"
|
|
482
|
+
r"|STARGIFT_INVALID|SHORTCUT_INVALID|RINGTONE_INVALID)\b"
|
|
483
|
+
),
|
|
484
|
+
_NOT_FOUND,
|
|
485
|
+
),
|
|
486
|
+
# Limits the server enforces and names. They are usage errors, not
|
|
487
|
+
# failures: the fix is to pass fewer of something.
|
|
488
|
+
(
|
|
489
|
+
re.compile(
|
|
490
|
+
r"\b(CHATLINKS_TOO_MUCH|QUICK_REPLIES_TOO_MUCH|REPLY_MESSAGES_TOO_MUCH"
|
|
491
|
+
r"|USERNAMES_ACTIVE_TOO_MUCH|BIRTHDAY_INVALID|TTL_DAYS_INVALID)\b"
|
|
492
|
+
),
|
|
493
|
+
_USAGE,
|
|
494
|
+
),
|
|
495
|
+
# `USERNAME_PURCHASE_AVAILABLE` means the name is free *on Fragment*,
|
|
496
|
+
# which is neither "taken" nor an error tlgr can retry past.
|
|
497
|
+
(re.compile(r"\bUSERNAME_PURCHASE_AVAILABLE\b"), _USAGE),
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
_GENERIC = ErrorRule("GENERIC", EXIT_GENERIC, 500)
|
|
501
|
+
|
|
502
|
+
#: Trailing numbers are parameters, not part of the name: FLOOD_WAIT_42 and
|
|
503
|
+
#: FLOOD_WAIT_3 are the same error with a different wait.
|
|
504
|
+
_SUFFIX_RE = re.compile(r"_(\d+)$")
|
|
505
|
+
|
|
506
|
+
#: msgspec ends a validation message with " - at $.chat.kind"; that suffix is
|
|
507
|
+
#: what turns a USAGE error into an actionable one (`error.field`).
|
|
508
|
+
_FIELD_RE = re.compile(r"\s-\s+at\s+`?\$\.?([^`]+)`?\s*$")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def strip_numeric_suffix(rpc_message: str) -> tuple[str, int | None]:
|
|
512
|
+
"""Split `FLOOD_WAIT_42` into `("FLOOD_WAIT_X", 42)`."""
|
|
513
|
+
m = _SUFFIX_RE.search(rpc_message)
|
|
514
|
+
if not m:
|
|
515
|
+
return rpc_message, None
|
|
516
|
+
return rpc_message[: m.start()] + "_X", int(m.group(1))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def rule_for(exc: BaseException) -> ErrorRule:
|
|
520
|
+
"""Find the §7.2 row for *exc*, most specific class first.
|
|
521
|
+
|
|
522
|
+
Walks the MRO by name so that a Telethon subclass we have never heard of
|
|
523
|
+
still lands on its base's row (every `*ForbiddenError` under
|
|
524
|
+
`ForbiddenError` becomes PERMISSION_DENIED rather than GENERIC).
|
|
525
|
+
"""
|
|
526
|
+
if isinstance(exc, TlgrError):
|
|
527
|
+
return ErrorRule(exc.code, exc.exit_code, exc.http, exc.retryable, exc.hint)
|
|
528
|
+
|
|
529
|
+
message = str(exc)
|
|
530
|
+
for pattern, message_rule in _MESSAGE_RULES:
|
|
531
|
+
if pattern.search(message):
|
|
532
|
+
return message_rule
|
|
533
|
+
|
|
534
|
+
for klass in type(exc).__mro__:
|
|
535
|
+
found = ERROR_MAP.get(klass.__name__)
|
|
536
|
+
if found is not None:
|
|
537
|
+
return found
|
|
538
|
+
return _GENERIC
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def classify(exc: BaseException, *, account: str | None = None, request_id: str = "") -> ErrorBody:
|
|
542
|
+
"""Turn any exception into the wire error shape.
|
|
543
|
+
|
|
544
|
+
This is the single funnel: the daemon calls it before answering, the
|
|
545
|
+
legacy IPC handler calls it during migration, and the CLI calls it for
|
|
546
|
+
anything raised locally, so an unmigrated command gets the right exit code
|
|
547
|
+
on day one (COR-06).
|
|
548
|
+
"""
|
|
549
|
+
rule = rule_for(exc)
|
|
550
|
+
body = ErrorBody(
|
|
551
|
+
code=rule.code,
|
|
552
|
+
message=str(exc) or type(exc).__name__,
|
|
553
|
+
exit_code=rule.exit_code,
|
|
554
|
+
retryable=rule.retryable,
|
|
555
|
+
hint=rule.hint or getattr(exc, "hint", "") or None,
|
|
556
|
+
account=account,
|
|
557
|
+
request_id=request_id or None,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
# FloodWaitError carries the wait as an attribute; a bare RPCError carries
|
|
561
|
+
# it in the message (FLOOD_WAIT_42). Both must reach the caller, because
|
|
562
|
+
# "retry later" without "how much later" is not actionable.
|
|
563
|
+
seconds = getattr(exc, "seconds", None)
|
|
564
|
+
if not isinstance(seconds, int):
|
|
565
|
+
seconds = getattr(exc, "wait_seconds", None) or None
|
|
566
|
+
if rule.code == "RATE_LIMITED":
|
|
567
|
+
if not isinstance(seconds, int):
|
|
568
|
+
_, parsed = strip_numeric_suffix(str(exc).strip())
|
|
569
|
+
seconds = parsed
|
|
570
|
+
if isinstance(seconds, int):
|
|
571
|
+
body.wait_seconds = seconds
|
|
572
|
+
if not body.hint:
|
|
573
|
+
body.hint = f"Retry after {seconds}s, or raise --flood-wait-max."
|
|
574
|
+
|
|
575
|
+
if rule.code == "USAGE":
|
|
576
|
+
field = getattr(exc, "field", None)
|
|
577
|
+
if not field:
|
|
578
|
+
m = _FIELD_RE.search(str(exc))
|
|
579
|
+
if m:
|
|
580
|
+
field = m.group(1).strip()
|
|
581
|
+
body.field = field or None
|
|
582
|
+
|
|
583
|
+
rpc_code = getattr(exc, "code", None)
|
|
584
|
+
rpc_message = getattr(exc, "message", None)
|
|
585
|
+
if isinstance(rpc_code, int) and isinstance(rpc_message, str):
|
|
586
|
+
rpc: dict[str, Any] = {"code": rpc_code, "message": rpc_message}
|
|
587
|
+
method = getattr(exc, "request", None)
|
|
588
|
+
if method is not None:
|
|
589
|
+
rpc["method"] = type(method).__name__
|
|
590
|
+
body.rpc = rpc
|
|
591
|
+
|
|
592
|
+
reason = getattr(exc, "reason", None)
|
|
593
|
+
if isinstance(reason, str) and reason:
|
|
594
|
+
body.reason = reason
|
|
595
|
+
|
|
596
|
+
return body
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
#: The codes that mean "this session will never work again without a human".
|
|
600
|
+
#: Distinguished from a transport failure because the supervisor must stop
|
|
601
|
+
#: reconnecting rather than back off — retrying an unregistered auth key is
|
|
602
|
+
#: how v1 span forever while reporting "degraded".
|
|
603
|
+
FATAL_AUTH_CODES = frozenset({"SESSION_ERROR", "AUTH_ERROR", "AUTH_PASSWORD_REQUIRED"})
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def is_fatal_auth(exc: BaseException) -> bool:
|
|
607
|
+
"""True when *exc* means the account needs a human to log in again."""
|
|
608
|
+
return rule_for(exc).code in FATAL_AUTH_CODES
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def http_status_for(exc: BaseException) -> int:
|
|
612
|
+
"""The HTTP status the daemon answers *exc* with."""
|
|
613
|
+
return rule_for(exc).http
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def is_not_an_error(body: ErrorBody) -> bool:
|
|
617
|
+
"""True for MESSAGE_NOT_MODIFIED, which is success wearing an exception."""
|
|
618
|
+
return body.code == NOT_MODIFIED
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# ---------------------------------------------------------------------------
|
|
622
|
+
# Rendering
|
|
623
|
+
# ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def exit_code_for(error: BaseException) -> int:
|
|
627
|
+
"""Return the stable exit code for an error."""
|
|
628
|
+
if isinstance(error, TlgrError):
|
|
629
|
+
return error.exit_code
|
|
630
|
+
return EXIT_GENERIC
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def error_body_dict(body: ErrorBody) -> dict[str, Any]:
|
|
634
|
+
"""ErrorBody as JSON, plus v1's `error` key aliasing `message`.
|
|
635
|
+
|
|
636
|
+
Keeping the alias is what lets `--results-only` emit exactly the object v1
|
|
637
|
+
printed (`error`/`code`/`exit_code`) while the envelope carries the full
|
|
638
|
+
modern body (§12.4).
|
|
639
|
+
"""
|
|
640
|
+
data: dict[str, Any] = msgspec.to_builtins(body)
|
|
641
|
+
data["error"] = body.message
|
|
642
|
+
return data
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def error_envelope(
|
|
646
|
+
exc: BaseException, *, op: str = "", account: str | None = None, request_id: str = ""
|
|
647
|
+
) -> dict[str, Any]:
|
|
648
|
+
"""The v2 failure envelope: `{"ok": false, "op": …, "error": {…}}`."""
|
|
649
|
+
body = classify(exc, account=account, request_id=request_id)
|
|
650
|
+
envelope: dict[str, Any] = {"ok": False, "error": error_body_dict(body)}
|
|
651
|
+
if op:
|
|
652
|
+
envelope["op"] = op
|
|
653
|
+
if account:
|
|
654
|
+
envelope["account"] = account
|
|
655
|
+
return envelope
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def format_error_json(error: BaseException) -> dict[str, Any]:
|
|
659
|
+
"""v1's flat error object — kept verbatim as a compatibility contract.
|
|
660
|
+
|
|
661
|
+
This is the shape `--results-only` still prints, so every v1 consumer
|
|
662
|
+
that reads `error`/`code`/`exit_code` off stdout keeps working. New code
|
|
663
|
+
wants `error_envelope()`.
|
|
664
|
+
"""
|
|
665
|
+
code = getattr(error, "code", "UNKNOWN_ERROR")
|
|
666
|
+
result: dict[str, Any] = {
|
|
667
|
+
"error": str(error),
|
|
668
|
+
"code": code,
|
|
669
|
+
"exit_code": exit_code_for(error),
|
|
670
|
+
}
|
|
671
|
+
if isinstance(error, RateLimitError) and error.wait_seconds:
|
|
672
|
+
result["wait_seconds"] = error.wait_seconds
|
|
673
|
+
return result
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def emit_error(error: BaseException, use_json: bool = False) -> None:
|
|
677
|
+
"""Emit an error to stderr (human) and optionally stdout (JSON)."""
|
|
678
|
+
if use_json:
|
|
679
|
+
json.dump(format_error_json(error), sys.stdout)
|
|
680
|
+
sys.stdout.write("\n")
|
|
681
|
+
sys.stdout.flush()
|
|
682
|
+
|
|
683
|
+
hint = getattr(error, "hint", "")
|
|
684
|
+
if hint:
|
|
685
|
+
print(f"Error: {error}\n {hint}", file=sys.stderr)
|
|
686
|
+
else:
|
|
687
|
+
print(f"Error: {error}", file=sys.stderr)
|