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
@@ -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"
@@ -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
+ ...