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/job.py ADDED
@@ -0,0 +1,799 @@
1
+ """The `job` group: the gateway's rules, as data rather than as an editor.
2
+
3
+ v1's `job add` opened `$EDITOR` on `jobs.yaml`. That is a perfectly good way
4
+ for a person to write a rule and a completely useless one for an agent, which
5
+ is the caller this whole product is for — so the flag form is the primary path
6
+ and `--edit` keeps the old behaviour.
7
+
8
+ The other change is what a job can hear. v1 jobs only ever saw `NewMessage`,
9
+ because the engine registered Telethon's high-level handlers directly. A job
10
+ now declares `events:` from the same taxonomy `watch` and `webhook set` use,
11
+ and subscribes to the same bus — so "which events exist" has one answer across
12
+ the three places that ask.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import json
19
+ import os
20
+ from collections.abc import AsyncIterator
21
+ from pathlib import Path
22
+ from typing import Annotated, Any
23
+
24
+ from tlgr.core import eventtypes
25
+ from tlgr.core.errors import EXIT_EMPTY, NotFoundError, UsageError
26
+ from tlgr.core.pagination import PageKind, build_page
27
+ from tlgr.models.base import Request
28
+ from tlgr.models.daemon import Job, JobState, JobTestFrame
29
+ from tlgr.models.page import Page
30
+ from tlgr.models.peer import PeerRef
31
+ from tlgr.ops._params import arg, opt
32
+ from tlgr.ops._spec import OpContext, OperationSpec, Surface
33
+
34
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # jobs.yaml
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ def _jobs_path() -> Path:
43
+ from tlgr.core.paths import TlgrPaths
44
+
45
+ return TlgrPaths().jobs
46
+
47
+
48
+ def _load_raw() -> dict[str, Any]:
49
+ """Read `jobs.yaml` as plain data, so a rewrite keeps what it did not touch.
50
+
51
+ Round-tripping through `GatewayConfig` would silently drop every filter and
52
+ processor the parser does not model, which is how an "add a job" command
53
+ quietly deletes the four already there.
54
+ """
55
+ import yaml
56
+
57
+ path = _jobs_path()
58
+ if not path.exists():
59
+ return {"jobs": []}
60
+ try:
61
+ loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
62
+ except yaml.YAMLError as exc:
63
+ raise UsageError(f"{path} is not valid YAML: {exc}") from exc
64
+ if not isinstance(loaded, dict):
65
+ raise UsageError(f"{path} must be a mapping with a `jobs:` list")
66
+ loaded.setdefault("jobs", [])
67
+ if not isinstance(loaded["jobs"], list):
68
+ raise UsageError(f"{path}: `jobs` must be a list")
69
+ return loaded
70
+
71
+
72
+ def _save_raw(document: dict[str, Any]) -> None:
73
+ import yaml
74
+
75
+ from tlgr.core.paths import write_private
76
+
77
+ write_private(
78
+ _jobs_path(),
79
+ yaml.safe_dump(document, default_flow_style=False, sort_keys=False, allow_unicode=True),
80
+ )
81
+
82
+
83
+ def _find(document: dict[str, Any], name: str) -> dict[str, Any]:
84
+ for entry in document["jobs"]:
85
+ if isinstance(entry, dict) and entry.get("name") == name:
86
+ return entry
87
+ raise NotFoundError(f"no job named {name!r}. Run: tlgr job list")
88
+
89
+
90
+ def _runner(ctx: OpContext) -> Any:
91
+ daemon = getattr(ctx, "daemon", None)
92
+ if daemon is None:
93
+ raise UsageError("this operation runs inside the daemon")
94
+ return daemon
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # job list / get
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ class JobListReq(Request):
103
+ enabled_only: Annotated[bool, opt("--enabled-only", help="Hide disabled jobs.")] = False
104
+
105
+
106
+ async def job_list(ctx: OpContext, req: JobListReq) -> Page[JobState]:
107
+ """Every configured job, with what the running engine has done with it."""
108
+ daemon = _runner(ctx)
109
+ live = {row.get("name"): row for row in daemon.list_jobs()}
110
+ document = _load_raw()
111
+ rows: list[JobState] = []
112
+ for entry in document["jobs"]:
113
+ if not isinstance(entry, dict):
114
+ continue
115
+ name = str(entry.get("name", ""))
116
+ running = live.get(name, {})
117
+ enabled = bool(entry.get("enabled", True))
118
+ if req.enabled_only and not enabled:
119
+ continue
120
+ account = str(entry.get("account", ""))
121
+ if ctx.account and ctx.account != "all" and account and account != ctx.account:
122
+ continue
123
+ rows.append(
124
+ JobState(
125
+ name=name,
126
+ account=account,
127
+ enabled=enabled,
128
+ running=bool(running.get("running")),
129
+ events=[str(e) for e in (entry.get("events") or ["new_message"])],
130
+ matched=int(running.get("matched") or 0),
131
+ skipped=int(running.get("skipped") or 0),
132
+ errors=int(running.get("errors") or 0),
133
+ )
134
+ )
135
+ return build_page(
136
+ rows,
137
+ op="job.list",
138
+ kind=PageKind.LOCAL,
139
+ has_more=False,
140
+ total=len(rows),
141
+ )
142
+
143
+
144
+ SPEC_JOB_LIST = OperationSpec(
145
+ id="job.list",
146
+ request=JobListReq,
147
+ response=Page[JobState],
148
+ impl=job_list,
149
+ summary="List gateway jobs and their state",
150
+ description=(
151
+ "Configured *and* running are different facts: a job can be enabled "
152
+ "in `jobs.yaml` and not running because its account will not connect."
153
+ ),
154
+ legacy_paths=("job list",),
155
+ paginated=PageKind.LOCAL,
156
+ needs_account=False,
157
+ needs_client=False,
158
+ surface=Surface.DAEMON,
159
+ idempotent=True,
160
+ rate_class="local",
161
+ timeout_s=30,
162
+ columns=("name", "account", "enabled", "running", "matched", "errors"),
163
+ example={
164
+ "items": [
165
+ {
166
+ "name": "archive",
167
+ "account": "work",
168
+ "enabled": True,
169
+ "running": True,
170
+ "events": ["new_message"],
171
+ }
172
+ ],
173
+ "has_more": False,
174
+ },
175
+ example_args="job list",
176
+ covers_partial=("updates.stream-event-filtering",),
177
+ coverage_note="lists the rules; proving one fires is `job test`.",
178
+ tags=frozenset({"agent-safe"}),
179
+ )
180
+
181
+
182
+ class JobGetReq(Request):
183
+ name: Annotated[str, arg(0, metavar="NAME")]
184
+ explain: Annotated[
185
+ bool, opt("--explain", help="Annotate each filter with the registry entry it resolves to.")
186
+ ] = False
187
+
188
+
189
+ async def job_get(ctx: OpContext, req: JobGetReq) -> JobState:
190
+ """One job's resolved pipeline: filters, processors, actions."""
191
+ entry = _find(_load_raw(), req.name)
192
+ state = JobState(
193
+ name=req.name,
194
+ account=str(entry.get("account", "")),
195
+ enabled=bool(entry.get("enabled", True)),
196
+ events=[str(e) for e in (entry.get("events") or ["new_message"])],
197
+ filters=entry.get("filters") or {},
198
+ processors=[str(p) for p in (entry.get("processors") or [])],
199
+ actions=[a for a in (entry.get("actions") or []) if isinstance(a, dict)],
200
+ )
201
+ if req.explain:
202
+ state.filters = {
203
+ key: {"value": value, "resolves_to": _explain_filter(key)}
204
+ for key, value in (state.filters or {}).items()
205
+ }
206
+ return state
207
+
208
+
209
+ def _explain_filter(name: str) -> str:
210
+ """Which filter implementation a key resolves to, or that it resolves to none.
211
+
212
+ "This job never fires" is almost always a filter name nobody registered,
213
+ and the pipeline's silence about it is the reason it takes an hour to find.
214
+ """
215
+ with contextlib.suppress(Exception):
216
+ from tlgr.filters import get_filter
217
+
218
+ found = get_filter(name)
219
+ if found is not None:
220
+ return getattr(found, "__name__", str(found))
221
+ return "UNKNOWN — no filter is registered under this name; the job will never match"
222
+
223
+
224
+ SPEC_JOB_GET = OperationSpec(
225
+ id="job.get",
226
+ request=JobGetReq,
227
+ response=JobState,
228
+ impl=job_get,
229
+ summary="Show one job's resolved pipeline (filters, processors, actions)",
230
+ needs_account=False,
231
+ needs_auth=False,
232
+ needs_client=False,
233
+ surface=Surface.LOCAL,
234
+ idempotent=True,
235
+ rate_class="local",
236
+ timeout_s=15,
237
+ example={
238
+ "name": "archive",
239
+ "account": "work",
240
+ "enabled": True,
241
+ "events": ["new_message"],
242
+ "actions": [{"forward": {"to": "@archive"}}],
243
+ },
244
+ example_args="job get archive --explain",
245
+ covers_partial=("updates.stream-event-filtering",),
246
+ coverage_note="shows the rule; evaluating it against real events is `job test`.",
247
+ empty_exit=EXIT_EMPTY,
248
+ tags=frozenset({"agent-safe"}),
249
+ )
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # job add
254
+ # ---------------------------------------------------------------------------
255
+
256
+
257
+ def _pairs(values: list[str], what: str) -> dict[str, Any]:
258
+ out: dict[str, Any] = {}
259
+ for value in values:
260
+ key, sep, raw = value.partition("=")
261
+ if not sep:
262
+ raise UsageError(f"--{what} wants key=value, got {value!r}", field=what)
263
+ out[key.strip()] = _coerce(raw.strip())
264
+ return out
265
+
266
+
267
+ def _coerce(raw: str) -> Any:
268
+ lowered = raw.lower()
269
+ if lowered in ("true", "yes"):
270
+ return True
271
+ if lowered in ("false", "no"):
272
+ return False
273
+ with contextlib.suppress(ValueError):
274
+ return int(raw)
275
+ if raw.startswith(("[", "{")):
276
+ with contextlib.suppress(json.JSONDecodeError):
277
+ return json.loads(raw)
278
+ return raw
279
+
280
+
281
+ def _action(spec: str) -> dict[str, Any]:
282
+ """`reply:hello` or `forward:to=@archive` → one action entry."""
283
+ name, sep, rest = spec.partition(":")
284
+ name = name.strip()
285
+ if not name:
286
+ raise UsageError(f"--action wants NAME[:CONFIG], got {spec!r}", field="action")
287
+ if not sep or not rest:
288
+ return {name: {}}
289
+ if "=" in rest:
290
+ return {name: _pairs([part for part in rest.split(",") if part], "action")}
291
+ return {name: rest}
292
+
293
+
294
+ class JobAddReq(Request):
295
+ name: Annotated[str | None, opt("--name", metavar="NAME", help="Job name.")] = None
296
+ from_file: Annotated[
297
+ str | None,
298
+ opt("--from-file", metavar="PATH", help="Read one job (or a jobs list) from YAML/JSON."),
299
+ ] = None
300
+ job_account: Annotated[
301
+ str | None, opt("--for-account", metavar="ALIAS", help="Account the job runs on.")
302
+ ] = None
303
+ events: Annotated[
304
+ str, opt("--events", metavar="TYPES", help="Event types the job subscribes to.")
305
+ ] = "new_message"
306
+ filter: Annotated[
307
+ list[str], opt("--filter", metavar="KEY=VALUE", help="Filter entry (repeatable).")
308
+ ] = []
309
+ action: Annotated[
310
+ list[str],
311
+ opt("--action", metavar="SPEC", help="Action entry, e.g. 'reply:hello' (repeatable)."),
312
+ ] = []
313
+ processor: Annotated[
314
+ list[str], opt("--processor", metavar="NAME", help="Processor entry (repeatable).")
315
+ ] = []
316
+ enabled: Annotated[bool, opt("--enabled/--disabled", help="Initial state.")] = True
317
+ edit: Annotated[
318
+ bool, opt("--edit", help="Open jobs.yaml in $EDITOR instead (the v1 behaviour).")
319
+ ] = False
320
+
321
+
322
+ async def job_add(ctx: OpContext, req: JobAddReq) -> Job:
323
+ """Add a job from flags, a file, or an editor.
324
+
325
+ The event names are validated here rather than at load time: `jobs.yaml`
326
+ parsing drops an event it does not recognise, so a typo used to produce a
327
+ job that simply never fired and never said why.
328
+ """
329
+ if req.edit:
330
+ _open_editor()
331
+ return Job(name=req.name or "", enabled=True, already=True)
332
+
333
+ document = _load_raw()
334
+ entries = _entries_from(req)
335
+ added: list[str] = []
336
+ for entry in entries:
337
+ name = str(entry.get("name", ""))
338
+ if not name:
339
+ raise UsageError("a job needs a name (--name, or `name:` in the file)", field="name")
340
+ if any(isinstance(e, dict) and e.get("name") == name for e in document["jobs"]):
341
+ raise UsageError(f"a job named {name!r} already exists; remove it first", field="name")
342
+ eventtypes.resolve_selectors(entry.get("events") or ["new_message"])
343
+ document["jobs"].append(entry)
344
+ added.append(name)
345
+
346
+ _save_raw(document)
347
+ first = entries[0]
348
+ return Job(
349
+ name=str(first.get("name", "")),
350
+ account=str(first.get("account", "")),
351
+ enabled=bool(first.get("enabled", True)),
352
+ events=[str(e) for e in (first.get("events") or [])],
353
+ added=added,
354
+ )
355
+
356
+
357
+ def _entries_from(req: JobAddReq) -> list[dict[str, Any]]:
358
+ if req.from_file:
359
+ return _entries_from_file(req.from_file)
360
+ entry: dict[str, Any] = {
361
+ "name": req.name,
362
+ "events": [e.strip() for e in req.events.split(",") if e.strip()],
363
+ "enabled": req.enabled,
364
+ }
365
+ if req.job_account:
366
+ entry["account"] = req.job_account
367
+ if req.filter:
368
+ entry["filters"] = _pairs(req.filter, "filter")
369
+ if req.processor:
370
+ entry["processors"] = list(req.processor)
371
+ if req.action:
372
+ entry["actions"] = [_action(spec) for spec in req.action]
373
+ if not entry.get("actions"):
374
+ raise UsageError("a job with no actions would do nothing; pass --action", field="action")
375
+ return [entry]
376
+
377
+
378
+ def _entries_from_file(source: str) -> list[dict[str, Any]]:
379
+ import sys
380
+
381
+ import yaml
382
+
383
+ if source == "-":
384
+ if sys.stdin is None or sys.stdin.isatty():
385
+ raise UsageError("--from-file - was given but stdin is a terminal", field="from_file")
386
+ text = sys.stdin.read()
387
+ else:
388
+ try:
389
+ text = Path(source).read_text(encoding="utf-8")
390
+ except OSError as exc:
391
+ raise UsageError(f"{source}: {exc.strerror or exc}", field="from_file") from exc
392
+ try:
393
+ loaded = yaml.safe_load(text)
394
+ except yaml.YAMLError as exc:
395
+ raise UsageError(f"{source} is not valid YAML or JSON: {exc}", field="from_file") from exc
396
+ if isinstance(loaded, dict) and isinstance(loaded.get("jobs"), list):
397
+ return [entry for entry in loaded["jobs"] if isinstance(entry, dict)]
398
+ if isinstance(loaded, dict):
399
+ return [loaded]
400
+ if isinstance(loaded, list):
401
+ return [entry for entry in loaded if isinstance(entry, dict)]
402
+ raise UsageError(f"{source} does not contain a job", field="from_file")
403
+
404
+
405
+ def _open_editor() -> None:
406
+ path = _jobs_path()
407
+ if not path.exists():
408
+ from tlgr.core.paths import write_private
409
+
410
+ write_private(
411
+ path,
412
+ "# Gateway jobs. See `tlgr job add --help` for the non-interactive form.\njobs: []\n",
413
+ )
414
+ os.execlp(os.environ.get("EDITOR", "vi"), os.environ.get("EDITOR", "vi"), str(path))
415
+
416
+
417
+ SPEC_JOB_ADD = OperationSpec(
418
+ id="job.add",
419
+ request=JobAddReq,
420
+ response=Job,
421
+ impl=job_add,
422
+ summary="Add a gateway job",
423
+ description=(
424
+ "v1 only opened `$EDITOR`, which no agent can drive. The flags are "
425
+ "the agent path, `--from-file -` takes YAML or JSON on stdin, and "
426
+ "`--edit` keeps the old behaviour."
427
+ ),
428
+ legacy_paths=("job add",),
429
+ mutating=True,
430
+ needs_account=False,
431
+ needs_auth=False,
432
+ needs_client=False,
433
+ surface=Surface.LOCAL,
434
+ rate_class="local",
435
+ timeout_s=30,
436
+ example={"name": "archive", "account": "work", "enabled": True, "added": ["archive"]},
437
+ example_args="job add --name archive --action 'forward:to=@archive'",
438
+ covers_partial=("updates.stream-event-filtering",),
439
+ coverage_note="writes the rule; the filter vocabulary belongs to the gateway.",
440
+ tags=frozenset({"agent-safe"}),
441
+ )
442
+
443
+
444
+ # ---------------------------------------------------------------------------
445
+ # enable / disable / remove / reload
446
+ # ---------------------------------------------------------------------------
447
+
448
+
449
+ async def _set_enabled(ctx: OpContext, name: str, enabled: bool) -> Job:
450
+ document = _load_raw()
451
+ entry = _find(document, name)
452
+ if bool(entry.get("enabled", True)) == enabled:
453
+ ctx.mark_already()
454
+ return Job(name=name, enabled=enabled, already=True)
455
+ entry["enabled"] = enabled
456
+ _save_raw(document)
457
+ daemon = getattr(ctx, "daemon", None)
458
+ if daemon is not None:
459
+ with contextlib.suppress(Exception):
460
+ await (daemon.enable_job(name) if enabled else daemon.disable_job(name))
461
+ return Job(name=name, enabled=enabled, reloaded=daemon is not None)
462
+
463
+
464
+ class JobNameReq(Request):
465
+ name: Annotated[str, arg(0, metavar="NAME")]
466
+
467
+
468
+ async def job_enable(ctx: OpContext, req: JobNameReq) -> Job:
469
+ """Enable a disabled job, in `jobs.yaml` and in the running engine."""
470
+ return await _set_enabled(ctx, req.name, True)
471
+
472
+
473
+ SPEC_JOB_ENABLE = OperationSpec(
474
+ id="job.enable",
475
+ request=JobNameReq,
476
+ response=Job,
477
+ impl=job_enable,
478
+ summary="Enable a disabled job",
479
+ legacy_paths=("job enable",),
480
+ mutating=True,
481
+ idempotent=True,
482
+ needs_account=False,
483
+ needs_client=False,
484
+ surface=Surface.DAEMON,
485
+ rate_class="local",
486
+ timeout_s=30,
487
+ example={"name": "archive", "enabled": True},
488
+ example_args="job enable archive",
489
+ covers_partial=("updates.stream-event-filtering",),
490
+ coverage_note="toggles a rule; the filtering itself is the gateway's.",
491
+ tags=frozenset({"agent-safe"}),
492
+ )
493
+
494
+
495
+ async def job_disable(ctx: OpContext, req: JobNameReq) -> Job:
496
+ """Disable a job without removing it."""
497
+ return await _set_enabled(ctx, req.name, False)
498
+
499
+
500
+ SPEC_JOB_DISABLE = OperationSpec(
501
+ id="job.disable",
502
+ request=JobNameReq,
503
+ response=Job,
504
+ impl=job_disable,
505
+ summary="Disable a job without removing it",
506
+ legacy_paths=("job disable",),
507
+ mutating=True,
508
+ idempotent=True,
509
+ needs_account=False,
510
+ needs_client=False,
511
+ surface=Surface.DAEMON,
512
+ rate_class="local",
513
+ timeout_s=30,
514
+ example={"name": "archive", "enabled": False},
515
+ example_args="job disable archive",
516
+ covers_partial=("updates.stream-event-filtering",),
517
+ coverage_note="toggles a rule; the filtering itself is the gateway's.",
518
+ tags=frozenset({"agent-safe"}),
519
+ )
520
+
521
+
522
+ async def job_remove(ctx: OpContext, req: JobNameReq) -> Job:
523
+ """Remove a job from `jobs.yaml` and stop it."""
524
+ document = _load_raw()
525
+ _find(document, req.name)
526
+ document["jobs"] = [
527
+ entry
528
+ for entry in document["jobs"]
529
+ if not (isinstance(entry, dict) and entry.get("name") == req.name)
530
+ ]
531
+ _save_raw(document)
532
+ daemon = getattr(ctx, "daemon", None)
533
+ if daemon is not None:
534
+ with contextlib.suppress(Exception):
535
+ await daemon.remove_job(req.name)
536
+ return Job(name=req.name, enabled=False, removed=True)
537
+
538
+
539
+ SPEC_JOB_REMOVE = OperationSpec(
540
+ id="job.remove",
541
+ request=JobNameReq,
542
+ response=Job,
543
+ impl=job_remove,
544
+ summary="Remove a job",
545
+ legacy_paths=("job remove",),
546
+ mutating=True,
547
+ destructive=True,
548
+ needs_account=False,
549
+ needs_client=False,
550
+ surface=Surface.DAEMON,
551
+ rate_class="local",
552
+ timeout_s=30,
553
+ example={"name": "archive", "enabled": False, "removed": True},
554
+ example_args="job remove archive",
555
+ covers_partial=("updates.stream-event-filtering",),
556
+ coverage_note="deletes a rule; the filtering itself is the gateway's.",
557
+ tags=frozenset({"agent-safe"}),
558
+ )
559
+
560
+
561
+ class JobReloadReq(Request):
562
+ validate_only: Annotated[
563
+ bool, opt("--validate-only", help="Parse and report without swapping the pipeline.")
564
+ ] = False
565
+
566
+
567
+ async def job_reload(ctx: OpContext, req: JobReloadReq) -> Job:
568
+ """Re-read `jobs.yaml` and swap the running pipeline.
569
+
570
+ `--validate-only` parses and reports without swapping, which is the check
571
+ to run before a reload rather than after one: a config with a typo would
572
+ otherwise take effect as "that job is gone".
573
+ """
574
+ from tlgr.gateway.config import load_gateway_configs
575
+
576
+ daemon = _runner(ctx)
577
+ base = getattr(getattr(daemon, "paths", None), "base", None)
578
+ configs = load_gateway_configs(base)
579
+ problems: list[str] = []
580
+ for config in configs:
581
+ if not config.name:
582
+ problems.append("a job has no `name`")
583
+ if not config.actions:
584
+ problems.append(f"job {config.name!r} has no actions and would do nothing")
585
+ for action in config.actions:
586
+ from tlgr.actions import get_action
587
+
588
+ if get_action(action.name) is None:
589
+ problems.append(f"job {config.name!r} uses unknown action {action.name!r}")
590
+
591
+ if req.validate_only or problems:
592
+ return Job(name="", enabled=True, loaded=len(configs), errors=problems)
593
+
594
+ result = await daemon.reload_jobs()
595
+ return Job(
596
+ name="",
597
+ enabled=True,
598
+ reloaded=True,
599
+ loaded=len(configs),
600
+ added=sorted(result.get("added", [])),
601
+ removed_names=sorted(result.get("removed", [])),
602
+ changed=sorted(result.get("updated", [])),
603
+ )
604
+
605
+
606
+ SPEC_JOB_RELOAD = OperationSpec(
607
+ id="job.reload",
608
+ request=JobReloadReq,
609
+ response=Job,
610
+ impl=job_reload,
611
+ summary="Hot-reload jobs.yaml without restarting the daemon",
612
+ legacy_paths=("job reload",),
613
+ mutating=True,
614
+ needs_account=False,
615
+ needs_client=False,
616
+ surface=Surface.DAEMON,
617
+ rate_class="local",
618
+ timeout_s=60,
619
+ example={"name": "", "enabled": True, "reloaded": True, "loaded": 3, "added": ["archive"]},
620
+ example_args="job reload --validate-only",
621
+ covers_partial=("updates.stream-webhook-delivery",),
622
+ coverage_note="reloads the consumers; delivery is the webhook pusher's.",
623
+ tags=frozenset({"agent-safe"}),
624
+ )
625
+
626
+
627
+ # ---------------------------------------------------------------------------
628
+ # job test
629
+ # ---------------------------------------------------------------------------
630
+
631
+
632
+ class JobTestReq(Request):
633
+ name: Annotated[str, arg(0, metavar="NAME")]
634
+ event: Annotated[
635
+ str | None,
636
+ opt("--event", metavar="TYPE", help="Synthesise an event of this type instead."),
637
+ ] = None
638
+ since: Annotated[
639
+ int | None, opt("--since", metavar="SEQ", help="Replay buffered events from this seq.")
640
+ ] = None
641
+ chat: Annotated[
642
+ PeerRef | None, opt("--chat", metavar="CHAT", kind="peer", help="Restrict the replay.")
643
+ ] = None
644
+ from_file: Annotated[
645
+ str | None, opt("--from-file", metavar="PATH", help="Feed envelopes from NDJSON.")
646
+ ] = None
647
+ run_actions: Annotated[
648
+ bool,
649
+ opt("--run-actions", help="Actually execute the actions instead of reporting them."),
650
+ ] = False
651
+
652
+
653
+ async def job_test(ctx: OpContext, req: JobTestReq) -> AsyncIterator[dict[str, Any]]:
654
+ """Feed events through one job's filters and report what it decided.
655
+
656
+ `filter_trace` is the whole point. "The job never fires" is the commonest
657
+ complaint about a rule engine and the hardest to diagnose, because a
658
+ pipeline that silently drops an event looks exactly like an event that
659
+ never arrived. Every filter node is named here, with the reason it passed
660
+ or rejected.
661
+ """
662
+ entry = _find(_load_raw(), req.name)
663
+ events = await _test_events(ctx, req)
664
+ if not events:
665
+ yield {
666
+ "type": "note",
667
+ "message": (
668
+ "no events matched the selection; pass --event <type> to synthesise one, "
669
+ "or --since <seq> to replay the buffer"
670
+ ),
671
+ }
672
+ return
673
+
674
+ wanted = eventtypes.resolve_selectors(entry.get("events") or ["new_message"])
675
+ filters = entry.get("filters") or {}
676
+ actions = [a for a in (entry.get("actions") or []) if isinstance(a, dict)]
677
+
678
+ for index, event in enumerate(events, start=1):
679
+ subscribed = event.get("type") in wanted
680
+ trace, matched = _evaluate(filters, event)
681
+ if not subscribed:
682
+ trace.insert(0, f"events: {event.get('type')} is not subscribed — rejected")
683
+ matched = False
684
+ frame = JobTestFrame(
685
+ seq=int(event.get("seq") or index),
686
+ event=str(event.get("type", "")),
687
+ matched=matched,
688
+ filter_trace=trace,
689
+ actions=[
690
+ {"name": name, "would_do": config, "result": "not run (dry run)"}
691
+ for action in actions
692
+ for name, config in action.items()
693
+ ]
694
+ if matched
695
+ else [],
696
+ )
697
+ from tlgr.models.base import to_builtins
698
+
699
+ body = to_builtins(frame)
700
+ yield {"type": "job-test", **(body if isinstance(body, dict) else {})}
701
+
702
+ if req.run_actions:
703
+ yield {
704
+ "type": "note",
705
+ "message": (
706
+ "--run-actions is refused here: executing a rule's actions against real "
707
+ "chats is `job enable` plus a live event, not a test"
708
+ ),
709
+ }
710
+
711
+
712
+ async def _test_events(ctx: OpContext, req: JobTestReq) -> list[dict[str, Any]]:
713
+ from tlgr.models.base import to_builtins
714
+
715
+ if req.from_file:
716
+ return _events_from_file(req.from_file)
717
+ if req.event:
718
+ eventtypes.resolve_selectors(req.event, allow_all=False)
719
+ return [{"type": req.event, "seq": 0, "payload": {}, "account": ctx.account}]
720
+
721
+ bus = getattr(ctx, "bus", None)
722
+ if bus is None:
723
+ return []
724
+ replayed, _gap = bus.replay(ctx.account, req.since if req.since is not None else 0)
725
+ limit = int(getattr(ctx, "limit", None) or 20)
726
+ out: list[dict[str, Any]] = []
727
+ for event in replayed[:limit]:
728
+ body = to_builtins(event)
729
+ if isinstance(body, dict):
730
+ out.append(body)
731
+ return out
732
+
733
+
734
+ def _events_from_file(source: str) -> list[dict[str, Any]]:
735
+ import sys
736
+
737
+ text = sys.stdin.read() if source == "-" else Path(source).read_text(encoding="utf-8")
738
+ out: list[dict[str, Any]] = []
739
+ for line in text.splitlines():
740
+ line = line.strip()
741
+ if not line:
742
+ continue
743
+ try:
744
+ loaded = json.loads(line)
745
+ except json.JSONDecodeError as exc:
746
+ raise UsageError(f"{source}: not NDJSON: {exc}", field="from_file") from exc
747
+ if isinstance(loaded, dict):
748
+ out.append(loaded)
749
+ return out
750
+
751
+
752
+ def _evaluate(filters: dict[str, Any], event: dict[str, Any]) -> tuple[list[str], bool]:
753
+ """Every filter key, and why it passed or rejected. Never a bare boolean."""
754
+ if not filters:
755
+ return ["(no filters — everything matches)"], True
756
+ trace: list[str] = []
757
+ matched = True
758
+ payload = event.get("payload") or {}
759
+ for key, expected in filters.items():
760
+ actual = event.get(key, payload.get(key))
761
+ ok = _compare(actual, expected)
762
+ trace.append(
763
+ f"{key}: {actual!r} {'==' if ok else '!='} {expected!r} — "
764
+ f"{'passed' if ok else 'rejected'}"
765
+ )
766
+ matched = matched and ok
767
+ return trace, matched
768
+
769
+
770
+ def _compare(actual: Any, expected: Any) -> bool:
771
+ if isinstance(expected, list):
772
+ return actual in expected
773
+ if isinstance(expected, str) and isinstance(actual, str):
774
+ return expected.lower() in actual.lower()
775
+ return bool(actual == expected)
776
+
777
+
778
+ SPEC_JOB_TEST = OperationSpec(
779
+ id="job.test",
780
+ request=JobTestReq,
781
+ response=None,
782
+ impl=job_test,
783
+ summary="Dry-run a job's filters against real or synthetic events",
784
+ description=(
785
+ "`filter_trace` names every filter node and says why it passed or "
786
+ "rejected, which is the missing piece when a job silently never "
787
+ "fires. Actions are reported, never executed."
788
+ ),
789
+ stream=True,
790
+ needs_account=False,
791
+ needs_client=False,
792
+ surface=Surface.DAEMON,
793
+ rate_class="local",
794
+ timeout_s=120,
795
+ example={"type": "job-test", "event": "message_new", "matched": True},
796
+ example_args="job test archive --event message_new",
797
+ covers=("updates.stream-event-filtering",),
798
+ tags=frozenset({"agent-safe", "frames", "live-stream"}),
799
+ )