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