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/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
|
+
)
|