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/filters/temporal.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Time-based filters — date ranges and time-of-day."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from tlgr.filters import register_filter
|
|
10
|
+
from tlgr.gateway.event import Event
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_date(date_str: str) -> datetime:
|
|
14
|
+
date_str = date_str.strip()
|
|
15
|
+
rel = re.match(r"^(\d+)([dwmh])$", date_str.lower())
|
|
16
|
+
if rel:
|
|
17
|
+
val, unit = int(rel.group(1)), rel.group(2)
|
|
18
|
+
now = datetime.now(timezone.utc)
|
|
19
|
+
deltas = {
|
|
20
|
+
"h": timedelta(hours=val),
|
|
21
|
+
"d": timedelta(days=val),
|
|
22
|
+
"w": timedelta(weeks=val),
|
|
23
|
+
"m": timedelta(days=val * 30),
|
|
24
|
+
}
|
|
25
|
+
return now - deltas[unit]
|
|
26
|
+
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
|
|
27
|
+
try:
|
|
28
|
+
return datetime.strptime(date_str, fmt).replace(tzinfo=timezone.utc)
|
|
29
|
+
except ValueError:
|
|
30
|
+
continue
|
|
31
|
+
raise ValueError(f"Invalid date: {date_str}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _msg_date(event: Event) -> datetime | None:
|
|
35
|
+
if event.source == "telegram":
|
|
36
|
+
return event.raw.message.date
|
|
37
|
+
return event.timestamp
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@register_filter("after")
|
|
41
|
+
def filter_after(event: Event, value: Any) -> tuple[bool, str]:
|
|
42
|
+
"""Message date must be at or after *value*. Value: date string."""
|
|
43
|
+
msg_date = _msg_date(event)
|
|
44
|
+
if msg_date is None:
|
|
45
|
+
return True, "no date"
|
|
46
|
+
cutoff = _parse_date(str(value))
|
|
47
|
+
a = cutoff.replace(tzinfo=timezone.utc) if cutoff.tzinfo is None else cutoff
|
|
48
|
+
d = msg_date.replace(tzinfo=timezone.utc) if msg_date.tzinfo is None else msg_date
|
|
49
|
+
if d >= a:
|
|
50
|
+
return True, "after cutoff"
|
|
51
|
+
return False, "before cutoff"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@register_filter("before")
|
|
55
|
+
def filter_before(event: Event, value: Any) -> tuple[bool, str]:
|
|
56
|
+
"""Message date must be at or before *value*. Value: date string."""
|
|
57
|
+
msg_date = _msg_date(event)
|
|
58
|
+
if msg_date is None:
|
|
59
|
+
return True, "no date"
|
|
60
|
+
cutoff = _parse_date(str(value))
|
|
61
|
+
b = cutoff.replace(tzinfo=timezone.utc) if cutoff.tzinfo is None else cutoff
|
|
62
|
+
d = msg_date.replace(tzinfo=timezone.utc) if msg_date.tzinfo is None else msg_date
|
|
63
|
+
if d <= b:
|
|
64
|
+
return True, "before cutoff"
|
|
65
|
+
return False, "after cutoff"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@register_filter("time_of_day")
|
|
69
|
+
def filter_time_of_day(event: Event, value: Any) -> tuple[bool, str]:
|
|
70
|
+
"""Message time must fall within a range. Value: ``"HH:MM-HH:MM"``."""
|
|
71
|
+
msg_date = _msg_date(event)
|
|
72
|
+
if msg_date is None:
|
|
73
|
+
return True, "no date"
|
|
74
|
+
d = msg_date.replace(tzinfo=timezone.utc) if msg_date.tzinfo is None else msg_date
|
|
75
|
+
current = d.strftime("%H:%M")
|
|
76
|
+
|
|
77
|
+
if isinstance(value, str) and "-" in value:
|
|
78
|
+
start, end = value.split("-", 1)
|
|
79
|
+
start, end = start.strip(), end.strip()
|
|
80
|
+
if start <= end:
|
|
81
|
+
ok = start <= current <= end
|
|
82
|
+
else:
|
|
83
|
+
ok = current >= start or current <= end
|
|
84
|
+
if ok:
|
|
85
|
+
return True, "time_of_day matched"
|
|
86
|
+
return False, f"time {current} not in {value}"
|
|
87
|
+
return False, "time_of_day expects 'HH:MM-HH:MM'"
|
tlgr/filters/user.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Sender/user filters — allow-list and block-list by user ID."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from tlgr.filters import register_filter
|
|
8
|
+
from tlgr.gateway.event import Event
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _sender_id(event: Event) -> int | None:
|
|
12
|
+
if event.source == "telegram":
|
|
13
|
+
return event.raw.message.sender_id
|
|
14
|
+
if isinstance(event.raw, dict):
|
|
15
|
+
return event.raw.get("sender_id")
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@register_filter("from_users")
|
|
20
|
+
def filter_from_users(event: Event, value: Any) -> tuple[bool, str]:
|
|
21
|
+
"""Sender must be in the given list of user IDs. Value: list[int]."""
|
|
22
|
+
sid = _sender_id(event)
|
|
23
|
+
allowed = value if isinstance(value, list) else [value]
|
|
24
|
+
if sid in allowed:
|
|
25
|
+
return True, "sender allowed"
|
|
26
|
+
return False, "sender not allowed"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@register_filter("exclude_users")
|
|
30
|
+
def filter_exclude_users(event: Event, value: Any) -> tuple[bool, str]:
|
|
31
|
+
"""Sender must NOT be in the given list of user IDs. Value: list[int]."""
|
|
32
|
+
sid = _sender_id(event)
|
|
33
|
+
excluded = value if isinstance(value, list) else [value]
|
|
34
|
+
if sid in excluded:
|
|
35
|
+
return False, "sender excluded"
|
|
36
|
+
return True, "sender allowed"
|
tlgr/gateway/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Gateway — generic event-driven pipeline engine."""
|
tlgr/gateway/config.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""YAML-based job configuration for the Gateway pipeline.
|
|
2
|
+
|
|
3
|
+
Parses ``~/.tlgr/jobs.yaml`` into :class:`GatewayConfig` objects that the
|
|
4
|
+
:class:`~tlgr.gateway.engine.Gateway` consumes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from tlgr.core.config import CONFIG_DIR
|
|
14
|
+
from tlgr.core.errors import ConfigurationError
|
|
15
|
+
from tlgr.filters.compose import FilterNode, parse_filter_config
|
|
16
|
+
from tlgr.processors import ProcessorChain, create_chain_from_list
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ActionConfig:
|
|
21
|
+
"""A single action in a job's action list."""
|
|
22
|
+
|
|
23
|
+
name: str = ""
|
|
24
|
+
config: Any = None
|
|
25
|
+
filters: FilterNode | None = None
|
|
26
|
+
processors: ProcessorChain | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ALL_EVENT_TYPES = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"new_message",
|
|
32
|
+
"message_edited",
|
|
33
|
+
"message_deleted",
|
|
34
|
+
"chat_action",
|
|
35
|
+
"user_joined",
|
|
36
|
+
"message_read",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class GatewayConfig:
|
|
43
|
+
"""Parsed configuration for one Gateway job."""
|
|
44
|
+
|
|
45
|
+
name: str = ""
|
|
46
|
+
account: str = ""
|
|
47
|
+
enabled: bool = True
|
|
48
|
+
events: list[str] = field(default_factory=lambda: ["new_message"])
|
|
49
|
+
filters: FilterNode | None = None
|
|
50
|
+
processors: ProcessorChain | None = None
|
|
51
|
+
actions: list[ActionConfig] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_action(raw: dict[str, Any]) -> ActionConfig:
|
|
55
|
+
"""Parse a concise action entry.
|
|
56
|
+
|
|
57
|
+
Concise syntax: the action name is the dict key, the value is its config.
|
|
58
|
+
|
|
59
|
+
Examples::
|
|
60
|
+
|
|
61
|
+
{"reply": "hello"} -> ActionConfig(name="reply", config="hello")
|
|
62
|
+
{"forward": {"to": "@chan"}} -> ActionConfig(name="forward", config={"to": "@chan"})
|
|
63
|
+
"""
|
|
64
|
+
for key, value in raw.items():
|
|
65
|
+
ac = ActionConfig(name=key)
|
|
66
|
+
|
|
67
|
+
if isinstance(value, str):
|
|
68
|
+
ac.config = value
|
|
69
|
+
elif isinstance(value, dict):
|
|
70
|
+
ac.config = {k: v for k, v in value.items() if k not in ("filters", "processors")}
|
|
71
|
+
if len(ac.config) == 1 and "text" in ac.config:
|
|
72
|
+
ac.config = ac.config["text"]
|
|
73
|
+
ac.filters = parse_filter_config(value.get("filters"))
|
|
74
|
+
procs = value.get("processors")
|
|
75
|
+
if procs:
|
|
76
|
+
ac.processors = create_chain_from_list(procs) if isinstance(procs, list) else None
|
|
77
|
+
else:
|
|
78
|
+
ac.config = value
|
|
79
|
+
|
|
80
|
+
return ac
|
|
81
|
+
|
|
82
|
+
return ActionConfig()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_job(raw: dict[str, Any]) -> GatewayConfig:
|
|
86
|
+
cfg = GatewayConfig(
|
|
87
|
+
name=raw.get("name", ""),
|
|
88
|
+
account=raw.get("account", ""),
|
|
89
|
+
enabled=raw.get("enabled", True),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
raw_events = raw.get("events")
|
|
93
|
+
if raw_events and isinstance(raw_events, list):
|
|
94
|
+
cfg.events = [str(e) for e in raw_events if str(e) in ALL_EVENT_TYPES]
|
|
95
|
+
elif raw_events and isinstance(raw_events, str):
|
|
96
|
+
cfg.events = [raw_events] if raw_events in ALL_EVENT_TYPES else ["new_message"]
|
|
97
|
+
|
|
98
|
+
cfg.filters = parse_filter_config(raw.get("filters"))
|
|
99
|
+
|
|
100
|
+
procs = raw.get("processors")
|
|
101
|
+
if procs and isinstance(procs, list):
|
|
102
|
+
cfg.processors = create_chain_from_list(procs)
|
|
103
|
+
|
|
104
|
+
actions_raw = raw.get("actions", [])
|
|
105
|
+
for action_raw in actions_raw:
|
|
106
|
+
if isinstance(action_raw, dict):
|
|
107
|
+
cfg.actions.append(_parse_action(action_raw))
|
|
108
|
+
|
|
109
|
+
return cfg
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_gateway_configs(base: Path | None = None) -> list[GatewayConfig]:
|
|
113
|
+
"""Load all gateway jobs from ``jobs.yaml``."""
|
|
114
|
+
base = base or CONFIG_DIR
|
|
115
|
+
jobs_path = base / "jobs.yaml"
|
|
116
|
+
if not jobs_path.exists():
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
import yaml
|
|
121
|
+
except ModuleNotFoundError as e:
|
|
122
|
+
raise ConfigurationError(
|
|
123
|
+
"PyYAML is required for jobs.yaml support. Install with: pip install pyyaml"
|
|
124
|
+
) from e
|
|
125
|
+
|
|
126
|
+
with open(jobs_path) as f:
|
|
127
|
+
data = yaml.safe_load(f)
|
|
128
|
+
|
|
129
|
+
if not data or "jobs" not in data:
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
return [_parse_job(j) for j in data["jobs"] if isinstance(j, dict)]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def save_gateway_configs(configs: list[GatewayConfig], base: Path | None = None) -> None:
|
|
136
|
+
"""Serialize gateway configs back to ``jobs.yaml`` (minimal round-trip)."""
|
|
137
|
+
base = base or CONFIG_DIR
|
|
138
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
jobs_path = base / "jobs.yaml"
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
import yaml
|
|
143
|
+
except ModuleNotFoundError as e:
|
|
144
|
+
raise ConfigurationError(
|
|
145
|
+
"PyYAML is required for jobs.yaml support. Install with: pip install pyyaml"
|
|
146
|
+
) from e
|
|
147
|
+
|
|
148
|
+
jobs_list: list[dict[str, Any]] = []
|
|
149
|
+
for cfg in configs:
|
|
150
|
+
d: dict[str, Any] = {"name": cfg.name}
|
|
151
|
+
if cfg.account:
|
|
152
|
+
d["account"] = cfg.account
|
|
153
|
+
if not cfg.enabled:
|
|
154
|
+
d["enabled"] = False
|
|
155
|
+
# filters and processors are not round-tripped here;
|
|
156
|
+
# users edit the YAML directly.
|
|
157
|
+
jobs_list.append(d)
|
|
158
|
+
|
|
159
|
+
with open(jobs_path, "w") as f:
|
|
160
|
+
yaml.dump({"jobs": jobs_list}, f, default_flow_style=False, sort_keys=False)
|
|
161
|
+
jobs_path.chmod(0o600)
|
tlgr/gateway/engine.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Gateway engine — generic event-driven pipeline.
|
|
2
|
+
|
|
3
|
+
event -> filters -> processors -> actions
|
|
4
|
+
|
|
5
|
+
Where the events come from changed in v2. A job used to register its own
|
|
6
|
+
Telethon handlers, so a rule whose action posted to a slow endpoint ran
|
|
7
|
+
*inside* the update loop and, with `sequential_updates=True`, made every
|
|
8
|
+
account deaf until it returned (ROB-02). A job now subscribes to the daemon's
|
|
9
|
+
event bus, which runs handlers on bounded worker lanes keyed by chat: per-chat
|
|
10
|
+
order is preserved, the update loop is never blocked, and a job that falls
|
|
11
|
+
behind is bounded rather than unbounded.
|
|
12
|
+
|
|
13
|
+
The pipeline itself is unchanged, and filters still read the raw Telethon
|
|
14
|
+
event, which is why the bus carries it beside the normalised envelope. The
|
|
15
|
+
full move to model-based filters belongs to the updates group (PR-4).
|
|
16
|
+
|
|
17
|
+
Without a bus — a unit test, or a daemon that has not started one — the job
|
|
18
|
+
falls back to registering Telethon handlers exactly as v1 did.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import logging
|
|
25
|
+
|
|
26
|
+
from telethon import events
|
|
27
|
+
|
|
28
|
+
from tlgr.actions import get_action
|
|
29
|
+
from tlgr.filters.compose import evaluate
|
|
30
|
+
from tlgr.gateway.config import ActionConfig, GatewayConfig
|
|
31
|
+
from tlgr.gateway.event import Event
|
|
32
|
+
from tlgr.jobs.base import BaseJob
|
|
33
|
+
from tlgr.jobs.client import JobClient
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger("tlgr.gateway")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _GatewayJobConfig:
|
|
39
|
+
"""Minimal shim so Gateway can sit on top of BaseJob.
|
|
40
|
+
|
|
41
|
+
BaseJob expects a config object with ``.name``, ``.type``, and
|
|
42
|
+
``.enabled`` attributes.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, gw: GatewayConfig) -> None:
|
|
46
|
+
self.name = gw.name
|
|
47
|
+
self.type = "gateway"
|
|
48
|
+
self.enabled = gw.enabled
|
|
49
|
+
self.account = gw.account
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
#: v1's job event names → the bus taxonomy, and back for the pipeline, which
|
|
53
|
+
#: still labels envelopes with v1's names. The expansion is the taxonomy's own
|
|
54
|
+
#: alias table (`core.eventtypes.ALIASES`), so a job and a `watch` accept the
|
|
55
|
+
#: same words; a job may also name any v2 type directly.
|
|
56
|
+
def _bus_types(names: list[str]) -> set[str]:
|
|
57
|
+
from tlgr.core import eventtypes
|
|
58
|
+
|
|
59
|
+
wanted: set[str] = set()
|
|
60
|
+
for name in names:
|
|
61
|
+
wanted.update(eventtypes.ALIASES.get(name, (name,)))
|
|
62
|
+
return wanted
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_V1_TYPE_MAP = {
|
|
66
|
+
"message_new": "new_message",
|
|
67
|
+
"message_edited": "message_edited",
|
|
68
|
+
"message_deleted": "message_deleted",
|
|
69
|
+
"message_service": "chat_action",
|
|
70
|
+
"user_status": "user_joined",
|
|
71
|
+
"read_inbox": "message_read",
|
|
72
|
+
"read_outbox": "message_read",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_EVENT_TYPE_MAP = {
|
|
76
|
+
"new_message": (events.NewMessage, {}),
|
|
77
|
+
"message_edited": (events.MessageEdited, {}),
|
|
78
|
+
"message_deleted": (events.MessageDeleted, {}),
|
|
79
|
+
"chat_action": (events.ChatAction, {}),
|
|
80
|
+
"user_joined": (events.UserUpdate, {}),
|
|
81
|
+
"message_read": (events.MessageRead, {}),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Gateway(BaseJob):
|
|
86
|
+
"""Generic pipeline job: filters -> processors -> actions."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
config: GatewayConfig,
|
|
91
|
+
client: JobClient,
|
|
92
|
+
webhook=None,
|
|
93
|
+
bus=None,
|
|
94
|
+
) -> None:
|
|
95
|
+
self._gw = config
|
|
96
|
+
shim = _GatewayJobConfig(config)
|
|
97
|
+
super().__init__(shim, client, webhook) # type: ignore[arg-type]
|
|
98
|
+
self._handlers: list = []
|
|
99
|
+
self._bus = bus
|
|
100
|
+
self._bus_handler = None
|
|
101
|
+
self._stats: dict[str, int] = {"matched": 0, "skipped": 0, "errors": 0}
|
|
102
|
+
|
|
103
|
+
async def setup(self) -> None:
|
|
104
|
+
log.info(
|
|
105
|
+
"[%s] events=%s filters=%s actions=%s",
|
|
106
|
+
self.name,
|
|
107
|
+
self._gw.events,
|
|
108
|
+
"yes" if self._gw.filters else "none",
|
|
109
|
+
[a.name for a in self._gw.actions],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async def run(self) -> None:
|
|
113
|
+
if self._bus is not None:
|
|
114
|
+
await self._run_on_bus()
|
|
115
|
+
return
|
|
116
|
+
await self._run_on_client()
|
|
117
|
+
|
|
118
|
+
async def _run_on_bus(self) -> None:
|
|
119
|
+
"""Subscribe to the daemon's bus instead of the update loop (ROB-02)."""
|
|
120
|
+
wanted = _bus_types(list(self._gw.events))
|
|
121
|
+
account = self._gw.account
|
|
122
|
+
|
|
123
|
+
async def on_event(envelope, raw) -> None:
|
|
124
|
+
if envelope.type not in wanted:
|
|
125
|
+
return
|
|
126
|
+
if account and envelope.account != account:
|
|
127
|
+
return
|
|
128
|
+
if raw is None:
|
|
129
|
+
# A self-origin echo or a synthesised event: the filters read
|
|
130
|
+
# the raw Telethon object, so there is nothing to evaluate.
|
|
131
|
+
return
|
|
132
|
+
await self._handle(raw, _V1_TYPE_MAP.get(envelope.type, envelope.type))
|
|
133
|
+
|
|
134
|
+
self._bus_handler = on_event
|
|
135
|
+
self._bus.add_handler(on_event)
|
|
136
|
+
try:
|
|
137
|
+
await asyncio.Future()
|
|
138
|
+
except asyncio.CancelledError:
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
async def _run_on_client(self) -> None:
|
|
142
|
+
for event_type_name in self._gw.events:
|
|
143
|
+
mapping = _EVENT_TYPE_MAP.get(event_type_name)
|
|
144
|
+
if not mapping:
|
|
145
|
+
log.warning("[%s] unknown event type: %s", self.name, event_type_name)
|
|
146
|
+
continue
|
|
147
|
+
event_cls, kwargs = mapping
|
|
148
|
+
if event_type_name == "new_message":
|
|
149
|
+
kwargs = {"incoming": True}
|
|
150
|
+
|
|
151
|
+
et = event_type_name
|
|
152
|
+
|
|
153
|
+
@self.client.client.on(event_cls(**kwargs))
|
|
154
|
+
async def handler(tg_event, _et=et):
|
|
155
|
+
await self._handle(tg_event, _et)
|
|
156
|
+
|
|
157
|
+
self._handlers.append(handler)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
await asyncio.Future()
|
|
161
|
+
except asyncio.CancelledError:
|
|
162
|
+
raise
|
|
163
|
+
|
|
164
|
+
async def teardown(self) -> None:
|
|
165
|
+
if self._bus is not None and self._bus_handler is not None:
|
|
166
|
+
self._bus.remove_handler(self._bus_handler)
|
|
167
|
+
self._bus_handler = None
|
|
168
|
+
for h in self._handlers:
|
|
169
|
+
self.client.client.remove_event_handler(h)
|
|
170
|
+
self._handlers.clear()
|
|
171
|
+
log.info(
|
|
172
|
+
"[%s] stopped — matched=%d skipped=%d errors=%d",
|
|
173
|
+
self.name,
|
|
174
|
+
self._stats["matched"],
|
|
175
|
+
self._stats["skipped"],
|
|
176
|
+
self._stats["errors"],
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
async def _handle(self, tg_event, event_type: str = "new_message") -> None:
|
|
180
|
+
envelope = Event(
|
|
181
|
+
source="telegram",
|
|
182
|
+
raw=tg_event,
|
|
183
|
+
account=self._gw.account,
|
|
184
|
+
event_type=event_type,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
ok, reason = evaluate(self._gw.filters, envelope)
|
|
188
|
+
if not ok:
|
|
189
|
+
self._stats["skipped"] += 1
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
self._stats["matched"] += 1
|
|
193
|
+
|
|
194
|
+
for action_cfg in self._gw.actions:
|
|
195
|
+
await self._run_action(action_cfg, envelope)
|
|
196
|
+
|
|
197
|
+
async def _run_action(self, ac: ActionConfig, envelope: Event) -> None:
|
|
198
|
+
if ac.filters:
|
|
199
|
+
ok, reason = evaluate(ac.filters, envelope)
|
|
200
|
+
if not ok:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
func = get_action(ac.name)
|
|
204
|
+
if func is None:
|
|
205
|
+
log.warning("[%s] unknown action: %s", self.name, ac.name)
|
|
206
|
+
self._stats["errors"] += 1
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
chain = ac.processors or self._gw.processors
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
await func(envelope, ac.config, self.client, chain)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
log.warning("[%s] action '%s' failed: %s", self.name, ac.name, e)
|
|
215
|
+
self._stats["errors"] += 1
|
tlgr/gateway/event.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Thin event envelope for the Gateway pipeline.
|
|
2
|
+
|
|
3
|
+
The envelope carries a source discriminator and the raw event payload.
|
|
4
|
+
Filters extract what they need from ``raw`` directly, keeping the envelope
|
|
5
|
+
protocol-agnostic so it can wrap Telethon events, inbound webhook payloads,
|
|
6
|
+
or any future event source.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class Event:
|
|
18
|
+
source: str
|
|
19
|
+
raw: Any
|
|
20
|
+
account: str = ""
|
|
21
|
+
event_type: str = "new_message"
|
|
22
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
tlgr/jobs/__init__.py
ADDED
|
File without changes
|
tlgr/jobs/base.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Base class for background jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from tlgr.daemon.webhook import WebhookPusher
|
|
11
|
+
from tlgr.jobs.client import JobClient
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger("tlgr.jobs")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseJob(ABC):
|
|
17
|
+
"""Base class that all background jobs inherit from."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
config: Any,
|
|
22
|
+
client: JobClient,
|
|
23
|
+
webhook: WebhookPusher | None = None,
|
|
24
|
+
):
|
|
25
|
+
self.config = config
|
|
26
|
+
self.client = client
|
|
27
|
+
self.webhook = webhook
|
|
28
|
+
self._task: asyncio.Task | None = None
|
|
29
|
+
self.enabled = config.enabled
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def name(self) -> str:
|
|
33
|
+
return self.config.name
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def job_type(self) -> str:
|
|
37
|
+
return self.config.type
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
async def setup(self) -> None:
|
|
41
|
+
"""Resolve chat IDs and prepare the job. Called once before start."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
async def run(self) -> None:
|
|
45
|
+
"""Run the job (register event handlers, etc.). Should run indefinitely."""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
async def teardown(self) -> None:
|
|
49
|
+
"""Clean up resources."""
|
|
50
|
+
|
|
51
|
+
def start(self) -> asyncio.Task:
|
|
52
|
+
self._task = asyncio.create_task(self._run_wrapper(), name=f"job:{self.name}")
|
|
53
|
+
return self._task
|
|
54
|
+
|
|
55
|
+
async def _run_wrapper(self) -> None:
|
|
56
|
+
try:
|
|
57
|
+
await self.setup()
|
|
58
|
+
log.info("Job '%s' started", self.name)
|
|
59
|
+
await self.run()
|
|
60
|
+
except asyncio.CancelledError:
|
|
61
|
+
log.info("Job '%s' cancelled", self.name)
|
|
62
|
+
except Exception:
|
|
63
|
+
log.exception("Job '%s' crashed", self.name)
|
|
64
|
+
finally:
|
|
65
|
+
await self.teardown()
|
|
66
|
+
|
|
67
|
+
async def stop(self) -> None:
|
|
68
|
+
if self._task and not self._task.done():
|
|
69
|
+
self._task.cancel()
|
|
70
|
+
try:
|
|
71
|
+
await self._task
|
|
72
|
+
except asyncio.CancelledError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def status(self) -> dict[str, Any]:
|
|
76
|
+
return {
|
|
77
|
+
"name": self.name,
|
|
78
|
+
"type": self.job_type,
|
|
79
|
+
"enabled": self.enabled,
|
|
80
|
+
"running": self._task is not None and not self._task.done(),
|
|
81
|
+
}
|
tlgr/jobs/client.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The narrow client view a background job is handed.
|
|
2
|
+
|
|
3
|
+
v1 passed the job engine a `ClientWrapper` — a 460-line object that owned a
|
|
4
|
+
Telethon client, logged in, logged out, serialised messages and answered the
|
|
5
|
+
v1 IPC routes. PR-12 deleted it: the daemon owns the connection now, and a
|
|
6
|
+
job has no business logging anything in or out.
|
|
7
|
+
|
|
8
|
+
What a job genuinely needs is two things, and this Protocol is exactly those
|
|
9
|
+
two: the raw Telethon client to attach handlers to and to send with, and a
|
|
10
|
+
resolver so a YAML file can name a destination as `@channel` rather than as a
|
|
11
|
+
marked id. Anything that satisfies them is a job client; the daemon's session
|
|
12
|
+
supplies one (`daemon/session.py`), and a test can supply one in four lines.
|
|
13
|
+
|
|
14
|
+
A Protocol rather than a base class because `jobs/` must not import
|
|
15
|
+
`daemon/`: the layering lint in `tests/test_layering.py` is what keeps the
|
|
16
|
+
job engine testable without a socket.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any, Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
__all__ = ["JobClient"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@runtime_checkable
|
|
27
|
+
class JobClient(Protocol):
|
|
28
|
+
"""What a background job may do with the account it runs on."""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def client(self) -> Any:
|
|
32
|
+
"""The connected Telethon client, owned by whoever supplied it."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
async def resolve_chat(self, chat_ref: str) -> int:
|
|
36
|
+
"""`@channel`, a marked id or a t.me link → the marked chat id."""
|
|
37
|
+
...
|