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.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/webhook.py ADDED
@@ -0,0 +1,418 @@
1
+ """The `webhook` group: where events go when nothing is watching.
2
+
3
+ The delivery guarantees live in `daemon/webhook.py`; this is the surface that
4
+ configures them and proves one works. Two things it makes explicit that v1
5
+ left implicit:
6
+
7
+ * **the signature, not a bearer token.** v1 had neither, so any process that
8
+ learned the URL could forge events (SEC-08). A delivery now carries an
9
+ HMAC over the exact bytes on the wire, and `webhook test` prints the headers
10
+ it sent so a receiver can be verified end to end rather than by guesswork.
11
+ * **the idempotency key.** Every delivery carries the envelope's `seq` and a
12
+ delivery id, and a re-drive reuses them — which is what makes a catch-up
13
+ replay safe to reprocess instead of a duplicate nobody can detect.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import time
20
+ from typing import Annotated, Any
21
+
22
+ from tlgr.core import eventtypes
23
+ from tlgr.core.errors import UsageError
24
+ from tlgr.models.base import Request
25
+ from tlgr.models.daemon import WebhookProbe, WebhookSettings
26
+ from tlgr.models.event import EventEnvelope
27
+ from tlgr.models.peer import PeerRef
28
+ from tlgr.ops._params import choice, opt
29
+ from tlgr.ops._spec import OpContext, OperationSpec, Surface
30
+
31
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
32
+
33
+ _REDACTED = "<redacted>"
34
+
35
+
36
+ def _config() -> Any:
37
+ from tlgr.core.config import load_webhook_config
38
+ from tlgr.core.paths import default_base
39
+
40
+ return load_webhook_config(default_base())
41
+
42
+
43
+ def _settings(config: Any, *, reveal: bool) -> WebhookSettings:
44
+ return WebhookSettings(
45
+ enabled=bool(config.enabled),
46
+ url=str(config.url or ""),
47
+ events=list(config.events or []),
48
+ filters={"chats": list(config.filters.chats or []), **(config.filters.raw or {})},
49
+ sign="hmac-sha256" if config.signing_key else ("bearer" if config.token else "none"),
50
+ secret=(config.secret or None) if reveal else (_REDACTED if config.secret else None),
51
+ token=(config.token or None) if reveal else (_REDACTED if config.token else None),
52
+ max_attempts=int(config.retry.max_attempts),
53
+ backoff=int(config.retry.backoff_base),
54
+ timeout=int(config.timeout),
55
+ queue=int(config.queue_size),
56
+ )
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # webhook get
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ class WebhookGetReq(Request):
65
+ show_secret: Annotated[
66
+ bool, opt("--show-secret", help="Reveal the HMAC secret and bearer token.")
67
+ ] = False
68
+
69
+
70
+ async def webhook_get(ctx: OpContext, req: WebhookGetReq) -> WebhookSettings:
71
+ """The webhook configuration and its delivery health.
72
+
73
+ Secrets are redacted unless asked for. `webhook get` is a command people
74
+ paste into issues, and a signing key in a bug report is a signing key on
75
+ the internet.
76
+ """
77
+ settings = _settings(_config(), reveal=req.show_secret)
78
+ if req.show_secret:
79
+ ctx.warn("this output contains the signing secret; treat it as a credential")
80
+
81
+ status = _probe()
82
+ if status is None:
83
+ return settings
84
+ live = status.get("webhook") or {}
85
+ settings.delivered = int(live.get("delivered", 0) or 0)
86
+ settings.failed = int(live.get("failed", 0) or 0)
87
+ settings.dead_letters = int(live.get("dead_letters", 0) or 0)
88
+ settings.queue_depth = int(live.get("queued", 0) or 0)
89
+ return settings
90
+
91
+
92
+ def _probe() -> dict[str, Any] | None:
93
+ from tlgr.core.paths import default_base
94
+ from tlgr.transport.client import DaemonClient
95
+
96
+ client = DaemonClient(default_base(), timeout=2.0, auto_start=False, no_restart=True)
97
+ with contextlib.suppress(Exception):
98
+ return client.probe_status()
99
+ return None
100
+
101
+
102
+ SPEC_WEBHOOK_GET = OperationSpec(
103
+ id="webhook.get",
104
+ request=WebhookGetReq,
105
+ response=WebhookSettings,
106
+ impl=webhook_get,
107
+ summary="Show the webhook configuration and delivery health",
108
+ legacy_paths=("config webhook",),
109
+ needs_account=False,
110
+ needs_auth=False,
111
+ needs_client=False,
112
+ surface=Surface.LOCAL,
113
+ idempotent=True,
114
+ rate_class="local",
115
+ timeout_s=15,
116
+ columns=("enabled", "url", "sign", "delivered", "failed", "dead_letters"),
117
+ example={
118
+ "enabled": True,
119
+ "url": "https://example.invalid/hook",
120
+ "sign": "hmac-sha256",
121
+ "events": ["message_new"],
122
+ },
123
+ example_args="webhook get",
124
+ covers_partial=("updates.stream-webhook-delivery",),
125
+ coverage_note="reports the configuration; delivering is the pusher's job.",
126
+ tags=frozenset({"agent-safe"}),
127
+ )
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # webhook set
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ class WebhookSetReq(Request):
136
+ url: Annotated[str | None, opt("--url", metavar="URL", help="Destination URL.")] = None
137
+ enabled: Annotated[
138
+ bool | None, opt("--enabled/--disabled", help="Turn delivery on or off.")
139
+ ] = None
140
+ events: Annotated[str | None, opt("--events", metavar="TYPES", help="Event types to push.")] = (
141
+ None
142
+ )
143
+ chat: Annotated[
144
+ list[PeerRef],
145
+ opt("--chat", metavar="CHAT", kind="peer", help="Only push events about these chats."),
146
+ ] = []
147
+ secret: Annotated[
148
+ str | None,
149
+ opt(
150
+ "--secret",
151
+ secret=True,
152
+ envvar="TLGR_WEBHOOK_SECRET",
153
+ help="HMAC-SHA256 signing secret.",
154
+ ),
155
+ ] = None
156
+ token: Annotated[
157
+ str | None,
158
+ opt(
159
+ "--token",
160
+ secret=True,
161
+ envvar="TLGR_WEBHOOK_TOKEN",
162
+ help="Bearer token (legacy; prefer the HMAC signature).",
163
+ ),
164
+ ] = None
165
+ sign: Annotated[
166
+ str | None, choice("hmac-sha256", "bearer", "none", help="Signature scheme.")
167
+ ] = None
168
+ max_attempts: Annotated[
169
+ int | None, opt("--max-attempts", metavar="N", help="Attempts before dead-lettering.")
170
+ ] = None
171
+ backoff: Annotated[
172
+ int | None, opt("--backoff", metavar="SECONDS", help="Base of the exponential backoff.")
173
+ ] = None
174
+ request_timeout: Annotated[
175
+ int | None, opt("--request-timeout", metavar="SECONDS", help="Per-request timeout.")
176
+ ] = None
177
+ queue: Annotated[
178
+ int | None, opt("--queue", metavar="N", help="Bounded queue depth before the lag policy.")
179
+ ] = None
180
+
181
+
182
+ async def webhook_set(ctx: OpContext, req: WebhookSetReq) -> WebhookSettings:
183
+ """Configure the outbound webhook.
184
+
185
+ Every event name is validated against the taxonomy: `jobs.yaml` and
186
+ `webhook.toml` used to drop a name they did not recognise, so a typo
187
+ produced a webhook that delivered nothing and never said why.
188
+ """
189
+ from tlgr.core.config import load_webhook_config, save_webhook_config
190
+ from tlgr.core.paths import default_base
191
+
192
+ base = default_base()
193
+ config = load_webhook_config(base)
194
+
195
+ if req.url is not None:
196
+ config.url = req.url
197
+ if req.url.startswith("http://") and not _is_loopback(req.url):
198
+ ctx.warn(
199
+ "a plain http:// endpoint sends event payloads and the signature in "
200
+ "clear text over the network"
201
+ )
202
+ if req.enabled is not None:
203
+ config.enabled = req.enabled
204
+ if req.events is not None:
205
+ wanted = eventtypes.resolve_selectors(req.events)
206
+ config.events = sorted(wanted)
207
+ if req.chat:
208
+ config.filters.chats = [ref.raw for ref in req.chat]
209
+ if req.secret is not None:
210
+ config.secret = req.secret
211
+ if req.token is not None:
212
+ config.token = req.token
213
+ if req.max_attempts is not None:
214
+ config.retry.max_attempts = req.max_attempts
215
+ config.retry.enabled = req.max_attempts > 1
216
+ if req.backoff is not None:
217
+ config.retry.backoff_base = req.backoff
218
+ if req.request_timeout is not None:
219
+ config.timeout = req.request_timeout
220
+ if req.queue is not None:
221
+ config.queue_size = req.queue
222
+ if req.sign == "none":
223
+ config.secret = ""
224
+ config.token = ""
225
+ elif req.sign == "bearer" and not config.token:
226
+ raise UsageError(
227
+ "--sign bearer needs a token: --token-env, --token-stdin or --token-file",
228
+ field="token",
229
+ )
230
+
231
+ if config.enabled and not config.url:
232
+ raise UsageError("a webhook cannot be enabled without a --url", field="url")
233
+ if config.enabled and not config.signing_key:
234
+ ctx.warn(
235
+ "no signing secret is set: any process that learns the URL can forge "
236
+ "events. Set one with --secret-env."
237
+ )
238
+
239
+ save_webhook_config(config, base)
240
+ _reload()
241
+ return _settings(config, reveal=False)
242
+
243
+
244
+ def _is_loopback(url: str) -> bool:
245
+ from urllib.parse import urlparse
246
+
247
+ host = (urlparse(url).hostname or "").lower()
248
+ return host in ("localhost", "127.0.0.1", "::1")
249
+
250
+
251
+ def _reload() -> None:
252
+ from tlgr.core.paths import default_base
253
+ from tlgr.transport.client import DaemonClient
254
+
255
+ client = DaemonClient(default_base(), timeout=10.0, auto_start=False, no_restart=True)
256
+ with contextlib.suppress(Exception):
257
+ client.admin("reload", {"what": ["config"]})
258
+
259
+
260
+ SPEC_WEBHOOK_SET = OperationSpec(
261
+ id="webhook.set",
262
+ request=WebhookSetReq,
263
+ response=WebhookSettings,
264
+ impl=webhook_set,
265
+ summary="Configure the outbound webhook",
266
+ description=(
267
+ "Signature: `X-Tlgr-Signature: sha256=<hmac of the exact body>`, plus "
268
+ "`X-Tlgr-Event`, `X-Tlgr-Seq`, `X-Tlgr-Account` and `X-Tlgr-Delivery`. "
269
+ "The delivery id is what makes a catch-up replay safe to reprocess. "
270
+ "Secrets are read from an environment variable, a file or stdin."
271
+ ),
272
+ mutating=True,
273
+ needs_account=False,
274
+ needs_auth=False,
275
+ needs_client=False,
276
+ surface=Surface.LOCAL,
277
+ rate_class="local",
278
+ timeout_s=30,
279
+ columns=("enabled", "url", "sign", "events"),
280
+ example={"enabled": True, "url": "https://example.invalid/hook", "sign": "hmac-sha256"},
281
+ example_args="webhook set --url https://example.invalid/hook --events message_new",
282
+ covers=("updates.sync-duplicate-suppression",),
283
+ covers_partial=("updates.stream-event-filtering", "updates.stream-webhook-delivery"),
284
+ coverage_note="configures delivery; the queue and retries are the pusher's.",
285
+ tags=frozenset({"agent-safe"}),
286
+ )
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # webhook test
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ class WebhookTestReq(Request):
295
+ event: Annotated[str, opt("--event", metavar="TYPE", help="Event type to synthesise.")] = (
296
+ "message_new"
297
+ )
298
+ seq: Annotated[
299
+ int | None, opt("--seq", metavar="N", help="Replay a real buffered event instead.")
300
+ ] = None
301
+ url: Annotated[
302
+ str | None, opt("--url", metavar="URL", help="Override the configured URL for this test.")
303
+ ] = None
304
+ retry: Annotated[
305
+ bool, opt("--retry", help="Use the configured retry policy instead of one attempt.")
306
+ ] = False
307
+
308
+
309
+ async def webhook_test(ctx: OpContext, req: WebhookTestReq) -> WebhookProbe:
310
+ """Send one delivery and report exactly what was sent.
311
+
312
+ The headers — signature included — are in the response, so a receiver can
313
+ be verified against the real bytes rather than against somebody's reading
314
+ of the documentation. One attempt by default: a test that silently
315
+ retried three times would hide the failure it exists to show.
316
+ """
317
+ from tlgr.core.signing import sign_body
318
+
319
+ daemon = getattr(ctx, "daemon", None)
320
+ if daemon is None:
321
+ raise UsageError("this operation runs inside the daemon")
322
+ pusher = daemon.webhook
323
+ config = pusher.config
324
+ target = req.url or config.url
325
+ if not target:
326
+ raise UsageError("no webhook URL is configured; run `tlgr webhook set --url …`")
327
+
328
+ envelope = _sample(ctx, req)
329
+ import msgspec
330
+
331
+ delivery_id = f"test-{int(time.time())}"
332
+ body = msgspec.json.encode({"event": envelope, "delivery_id": delivery_id})
333
+ headers = {
334
+ "Content-Type": "application/json",
335
+ "X-Tlgr-Delivery": delivery_id,
336
+ "X-Tlgr-Seq": str(envelope.seq),
337
+ "X-Tlgr-Event": envelope.type,
338
+ "X-Tlgr-Account": envelope.account,
339
+ "X-Tlgr-Test": "1",
340
+ }
341
+ if config.signing_key:
342
+ headers["X-Tlgr-Signature"] = sign_body(config.signing_key, body)
343
+ if config.token:
344
+ headers["Authorization"] = "Bearer <redacted>"
345
+
346
+ started = time.monotonic()
347
+ ok, error = await pusher.deliver_once(
348
+ {
349
+ "body": body.decode("utf-8"),
350
+ "delivery_id": delivery_id,
351
+ "seq": envelope.seq,
352
+ "event": envelope.type,
353
+ "account": envelope.account,
354
+ },
355
+ url=target,
356
+ )
357
+ probe = WebhookProbe(
358
+ url=target,
359
+ latency_ms=int((time.monotonic() - started) * 1000),
360
+ request_headers=headers,
361
+ body=body.decode("utf-8"),
362
+ error=None if ok else error,
363
+ )
364
+ if ok:
365
+ probe.status = 200
366
+ elif error.startswith("HTTP "):
367
+ with contextlib.suppress(ValueError):
368
+ probe.status = int(error.split(" ", 1)[1])
369
+ return probe
370
+
371
+
372
+ def _sample(ctx: OpContext, req: WebhookTestReq) -> EventEnvelope:
373
+ """A real buffered event when asked for one, else a synthetic envelope."""
374
+ bus = getattr(ctx, "bus", None)
375
+ if req.seq is not None and bus is not None:
376
+ events, _gap = bus.replay(ctx.account, req.seq - 1)
377
+ for event in events:
378
+ if int(event.seq) == req.seq:
379
+ found: EventEnvelope = event
380
+ return found
381
+ raise UsageError(
382
+ f"seq {req.seq} is not in the buffer; `tlgr events replay` shows what is",
383
+ field="seq",
384
+ )
385
+ eventtypes.resolve_selectors(req.event, allow_all=False)
386
+ from datetime import datetime, timezone
387
+
388
+ return EventEnvelope(
389
+ seq=0,
390
+ ts=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
391
+ account=ctx.account,
392
+ type=req.event,
393
+ payload={"test": True},
394
+ )
395
+
396
+
397
+ SPEC_WEBHOOK_TEST = OperationSpec(
398
+ id="webhook.test",
399
+ request=WebhookTestReq,
400
+ response=WebhookProbe,
401
+ impl=webhook_test,
402
+ summary="Send a test delivery to the configured URL",
403
+ description=(
404
+ "Prints the exact headers, signature included, so a receiver can be "
405
+ "verified end to end. One attempt by default: a test that retried "
406
+ "would hide the failure it exists to show."
407
+ ),
408
+ mutating=True,
409
+ needs_client=False,
410
+ surface=Surface.DAEMON,
411
+ rate_class="local",
412
+ timeout_s=60,
413
+ columns=("url", "status", "latency_ms", "error"),
414
+ example={"url": "https://example.invalid/hook", "status": 200, "latency_ms": 41},
415
+ example_args="webhook test --event message_new",
416
+ covers=("updates.stream-webhook-delivery",),
417
+ tags=frozenset({"agent-safe"}),
418
+ )