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,90 @@
1
+ # Catalog ids that are knowingly not covered, and why.
2
+ #
3
+ # Until PR-12 this file was a backlog: a waiver was a promise with a PR number
4
+ # on it, so `tlgr agent parity` could report "uncovered, waived until PR-9"
5
+ # instead of silently shrinking the denominator. Every one of those promises
6
+ # has now been kept, and what is left is a different thing entirely — nine ids
7
+ # that this build genuinely cannot cover, each naming the reason.
8
+ #
9
+ # **There are no blanket waivers.** No domain is waived, and no id is waived
10
+ # because "another PR owns it": that sentence has no meaning once every PR has
11
+ # landed. `kind` names which of the four permanent reasons applies:
12
+ #
13
+ # layer-gap the method arrived in an MTProto layer newer than the one
14
+ # Telethon 1.44 speaks; the command is registered and exits
15
+ # 13 (NOT_SUPPORTED) with the method named
16
+ # absent-method the method exists in the layer but Telethon 1.44 ships no
17
+ # request class for it; the flag that needs it exits 13
18
+ # prohibited tlgr will not do it (spending money, deanonymising)
19
+ # not-applicable there is nothing for a CLI to do
20
+ #
21
+ # The gate in tests/test_parity.py enforces all of that: it refuses a domain
22
+ # waiver, refuses an unknown `kind`, refuses a waiver for an id that is in
23
+ # fact covered, and refuses a waiver whose reason does not name a method.
24
+ #
25
+ # Regenerate the catalog index with: python tools/prune_catalog.py
26
+
27
+ [meta]
28
+ catalog_version = "2026-09-02"
29
+ # The plan's last PR. Kept as a record of when the backlog emptied.
30
+ final_pr = 12
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Layer 229: `ephemeral.*` and the rich-message keyboard.
34
+ #
35
+ # Telethon 1.44 speaks layer 227 and has no TL classes for these, so tlgr has
36
+ # no request to send. Each command is registered and refuses with exit 13
37
+ # naming the method, because a command that is absent teaches an agent
38
+ # nothing while a command that refuses teaches it exactly what is missing.
39
+ # ---------------------------------------------------------------------------
40
+
41
+ [[id]]
42
+ id = "bots.ephemeral-command-send"
43
+ kind = "layer-gap"
44
+ reason = "`ephemeral.sendMessage` is a layer-229 method; `bot command send --ephemeral` is registered and exits 13."
45
+
46
+ [[id]]
47
+ id = "bots.ephemeral-callback-press"
48
+ kind = "layer-gap"
49
+ reason = "`ephemeral.getCallbackAnswer` is a layer-229 method; `bot press --ephemeral` is registered and exits 13."
50
+
51
+ [[id]]
52
+ id = "bots.ephemeral-message-send"
53
+ kind = "layer-gap"
54
+ reason = "`ephemeral.sendMessage` is a layer-229 method; the command is registered and exits 13."
55
+
56
+ [[id]]
57
+ id = "bots.ephemeral-report"
58
+ kind = "layer-gap"
59
+ reason = "`ephemeral.report` is a layer-229 method; the command is registered and exits 13."
60
+
61
+ [[id]]
62
+ id = "bots.welcome-messages-view"
63
+ kind = "layer-gap"
64
+ reason = "`ephemeral.getWelcomeMessages` is a layer-229 method; `chat welcome list` is registered and exits 13."
65
+
66
+ [[id]]
67
+ id = "bots.welcome-messages-manage"
68
+ kind = "layer-gap"
69
+ reason = "`ephemeral.sendMessage` / `ephemeral.editMessage` are layer-229 methods; `chat welcome set` is registered and exits 13."
70
+
71
+ [[id]]
72
+ id = "bots.rich-message-buttons"
73
+ kind = "layer-gap"
74
+ reason = "The rich-message keyboard constructors arrived in layer 229; tlgr refuses rather than guessing at a constructor id."
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Methods this Telethon has no request class for. The layer has them; the
78
+ # pinned library does not, so the *flag* that needs one refuses while the rest
79
+ # of its command still works.
80
+ # ---------------------------------------------------------------------------
81
+
82
+ [[id]]
83
+ id = "bots.chat-join-webview"
84
+ kind = "absent-method"
85
+ reason = "`messages.requestChatJoinWebView` is absent from Telethon 1.44; `webapp open --join-query-id` is registered and exits 13."
86
+
87
+ [[id]]
88
+ id = "gift.can-send"
89
+ kind = "absent-method"
90
+ reason = "`payments.canSendStarGift` is absent from Telethon 1.44; `gift catalog --until` is registered and exits 13, and the rest of the catalogue still reads."
@@ -0,0 +1,42 @@
1
+ """Registry-based message and event filtering.
2
+
3
+ Every filter is a plain function registered via ``@register_filter``.
4
+ Filters receive an :class:`~tlgr.gateway.event.Event` and a config value
5
+ (whatever was in the YAML) and return ``(matched: bool, reason: str)``.
6
+
7
+ Modules in this package auto-register their filters on import.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ from tlgr.gateway.event import Event
16
+
17
+ FilterFunc = Callable[[Event, Any], tuple[bool, str]]
18
+
19
+ _REGISTRY: dict[str, FilterFunc] = {}
20
+
21
+
22
+ def register_filter(name: str):
23
+ """Decorator that registers a filter function under *name*."""
24
+
25
+ def decorator(func: FilterFunc) -> FilterFunc:
26
+ _REGISTRY[name] = func
27
+ return func
28
+
29
+ return decorator
30
+
31
+
32
+ def get_filter(name: str) -> FilterFunc | None:
33
+ return _REGISTRY.get(name)
34
+
35
+
36
+ def list_filters() -> list[str]:
37
+ return list(_REGISTRY.keys())
38
+
39
+
40
+ # Import built-in filter modules so they self-register.
41
+ from tlgr.filters import content, context, message, temporal, user # noqa: E402, F401
42
+ from tlgr.filters.compose import evaluate, parse_filter_config # noqa: E402, F401
@@ -0,0 +1,121 @@
1
+ """Filter composition engine — AND / OR / NOT trees.
2
+
3
+ YAML filter blocks are parsed into a tree of :class:`FilterNode` objects
4
+ and evaluated recursively against an :class:`~tlgr.gateway.event.Event`.
5
+
6
+ Top-level keys are AND'd. ``any_of`` gives OR, ``none_of`` gives NOT.
7
+ Both ``any_of`` and ``none_of`` accept a list of filter-sets that can
8
+ themselves contain ``any_of`` / ``none_of``, enabling arbitrary nesting.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from typing import Any
16
+
17
+ from tlgr.gateway.event import Event
18
+
19
+
20
+ class Op(Enum):
21
+ AND = "and"
22
+ OR = "or"
23
+ NOT = "not"
24
+ LEAF = "leaf"
25
+
26
+
27
+ @dataclass
28
+ class FilterNode:
29
+ op: Op
30
+ children: list[FilterNode] = field(default_factory=list)
31
+ filter_name: str = ""
32
+ filter_value: Any = None
33
+
34
+ def __repr__(self) -> str:
35
+ if self.op is Op.LEAF:
36
+ return f"Leaf({self.filter_name}={self.filter_value!r})"
37
+ return f"{self.op.value.upper()}({self.children})"
38
+
39
+
40
+ _COMPOSITION_KEYS = {"any_of", "none_of"}
41
+
42
+
43
+ def parse_filter_config(raw: dict[str, Any] | None) -> FilterNode | None:
44
+ """Turn a YAML filter block into a :class:`FilterNode` tree."""
45
+ if not raw:
46
+ return None
47
+
48
+ and_children: list[FilterNode] = []
49
+
50
+ for key, value in raw.items():
51
+ if key == "any_of":
52
+ or_children = _parse_child_list(value)
53
+ if or_children:
54
+ and_children.append(FilterNode(op=Op.OR, children=or_children))
55
+ elif key == "none_of":
56
+ or_children = _parse_child_list(value)
57
+ if or_children:
58
+ inner = FilterNode(op=Op.OR, children=or_children)
59
+ and_children.append(FilterNode(op=Op.NOT, children=[inner]))
60
+ else:
61
+ and_children.append(FilterNode(op=Op.LEAF, filter_name=key, filter_value=value))
62
+
63
+ if not and_children:
64
+ return None
65
+ if len(and_children) == 1:
66
+ return and_children[0]
67
+ return FilterNode(op=Op.AND, children=and_children)
68
+
69
+
70
+ def _parse_child_list(items: list[dict[str, Any]] | Any) -> list[FilterNode]:
71
+ """Parse a list of filter-set dicts (used inside ``any_of`` / ``none_of``)."""
72
+ if not isinstance(items, list):
73
+ return []
74
+ nodes: list[FilterNode] = []
75
+ for item in items:
76
+ if isinstance(item, dict):
77
+ child = parse_filter_config(item)
78
+ if child is not None:
79
+ nodes.append(child)
80
+ return nodes
81
+
82
+
83
+ def evaluate(node: FilterNode | None, event: Event) -> tuple[bool, str]:
84
+ """Recursively evaluate *node* against *event*.
85
+
86
+ Returns ``(passed, reason)`` just like individual filters.
87
+ """
88
+ if node is None:
89
+ return True, "no filters"
90
+
91
+ if node.op is Op.LEAF:
92
+ from tlgr.filters import get_filter
93
+
94
+ func = get_filter(node.filter_name)
95
+ if func is None:
96
+ return False, f"unknown filter: {node.filter_name}"
97
+ return func(event, node.filter_value)
98
+
99
+ if node.op is Op.AND:
100
+ for child in node.children:
101
+ ok, reason = evaluate(child, event)
102
+ if not ok:
103
+ return False, reason
104
+ return True, "all passed"
105
+
106
+ if node.op is Op.OR:
107
+ reasons: list[str] = []
108
+ for child in node.children:
109
+ ok, reason = evaluate(child, event)
110
+ if ok:
111
+ return True, reason
112
+ reasons.append(reason)
113
+ return False, f"none matched: {'; '.join(reasons)}"
114
+
115
+ if node.op is Op.NOT:
116
+ ok, reason = evaluate(node.children[0], event)
117
+ if ok:
118
+ return False, f"excluded: {reason}"
119
+ return True, "not-match passed"
120
+
121
+ return False, "invalid node"
@@ -0,0 +1,85 @@
1
+ """Content-based filters — keyword matching, regex, links."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from tlgr.filters import register_filter
9
+ from tlgr.gateway.event import Event
10
+
11
+
12
+ def _text(event: Event) -> str:
13
+ if event.source == "telegram":
14
+ msg = event.raw.message
15
+ return (msg.text or getattr(msg, "message", "") or "").lower()
16
+ raw = event.raw
17
+ if isinstance(raw, dict):
18
+ return str(raw.get("text", "")).lower()
19
+ return ""
20
+
21
+
22
+ @register_filter("contains")
23
+ def filter_contains(event: Event, value: Any) -> tuple[bool, str]:
24
+ """All keywords must appear (AND). Value: list[str]."""
25
+ text = _text(event)
26
+ keywords = value if isinstance(value, list) else [value]
27
+ for kw in keywords:
28
+ if str(kw).lower() not in text:
29
+ return False, f"missing keyword: {kw}"
30
+ return True, "all keywords found"
31
+
32
+
33
+ @register_filter("contains_any")
34
+ def filter_contains_any(event: Event, value: Any) -> tuple[bool, str]:
35
+ """At least one keyword must appear (OR). Value: list[str]."""
36
+ text = _text(event)
37
+ keywords = value if isinstance(value, list) else [value]
38
+ if any(str(kw).lower() in text for kw in keywords):
39
+ return True, "keyword matched"
40
+ return False, "none of keywords found"
41
+
42
+
43
+ @register_filter("excludes")
44
+ def filter_excludes(event: Event, value: Any) -> tuple[bool, str]:
45
+ """No listed keyword may appear. Value: list[str]."""
46
+ text = _text(event)
47
+ keywords = value if isinstance(value, list) else [value]
48
+ for kw in keywords:
49
+ if str(kw).lower() in text:
50
+ return False, f"excluded keyword: {kw}"
51
+ return True, "no excluded keywords"
52
+
53
+
54
+ @register_filter("regex")
55
+ def filter_regex(event: Event, value: Any) -> tuple[bool, str]:
56
+ """Text must match the regex pattern. Value: str."""
57
+ if event.source == "telegram":
58
+ msg = event.raw.message
59
+ text = msg.text or getattr(msg, "message", "") or ""
60
+ elif isinstance(event.raw, dict):
61
+ text = str(event.raw.get("text", ""))
62
+ else:
63
+ text = ""
64
+ if re.search(str(value), text, re.IGNORECASE):
65
+ return True, "regex matched"
66
+ return False, "regex not matched"
67
+
68
+
69
+ @register_filter("has_links")
70
+ def filter_has_links(event: Event, value: Any) -> tuple[bool, str]:
71
+ """Message must (or must not) contain URL entities. Value: bool."""
72
+ if event.source != "telegram":
73
+ return False, "has_links requires telegram source"
74
+ msg = event.raw.message
75
+ has = bool(
76
+ msg.entities
77
+ and any(
78
+ hasattr(e, "url")
79
+ or e.__class__.__name__ in ("MessageEntityUrl", "MessageEntityTextUrl")
80
+ for e in msg.entities
81
+ )
82
+ )
83
+ if has == bool(value):
84
+ return True, "links filter matched"
85
+ return False, "links filter"
@@ -0,0 +1,114 @@
1
+ """Event-context filters — chat type, direction, sender metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from tlgr.filters import register_filter
9
+ from tlgr.gateway.event import Event
10
+
11
+
12
+ def _tg_event(event: Event):
13
+ """Return the raw Telethon event, or *None* if the source is not Telegram."""
14
+ if event.source != "telegram":
15
+ return None
16
+ return event.raw
17
+
18
+
19
+ @register_filter("chat_type")
20
+ def filter_chat_type(event: Event, value: Any) -> tuple[bool, str]:
21
+ """Match chat type: private, group, supergroup, channel, bot."""
22
+ tg = _tg_event(event)
23
+ if tg is None:
24
+ return False, "chat_type requires telegram source"
25
+
26
+ expected = value if isinstance(value, list) else [value]
27
+
28
+ if tg.is_private:
29
+ actual = "private"
30
+ elif tg.is_group:
31
+ actual = "group"
32
+ elif tg.is_channel:
33
+ actual = "channel"
34
+ else:
35
+ actual = "unknown"
36
+
37
+ # Telethon doesn't expose supergroup directly on the event; check the chat entity.
38
+ if actual == "group" and hasattr(tg, "chat") and hasattr(tg.chat, "megagroup"):
39
+ if tg.chat.megagroup:
40
+ actual = "supergroup"
41
+
42
+ if actual in expected:
43
+ return True, f"chat_type={actual}"
44
+ return False, f"chat_type {actual} not in {expected}"
45
+
46
+
47
+ @register_filter("chat_id")
48
+ def filter_chat_id(event: Event, value: Any) -> tuple[bool, str]:
49
+ """Match by chat ID or @username. Value may be a single ref or a list."""
50
+ tg = _tg_event(event)
51
+ if tg is None:
52
+ return False, "chat_id requires telegram source"
53
+
54
+ refs = value if isinstance(value, list) else [value]
55
+ chat_id = tg.chat_id
56
+
57
+ for ref in refs:
58
+ try:
59
+ if int(ref) == chat_id:
60
+ return True, f"chat_id matched {ref}"
61
+ except (ValueError, TypeError):
62
+ pass
63
+ # @username matching requires entity resolution at setup time;
64
+ # the gateway pre-resolves these into the filter value.
65
+ if isinstance(ref, str) and ref.startswith("@"):
66
+ continue
67
+
68
+ return False, f"chat_id {chat_id} not in {refs}"
69
+
70
+
71
+ @register_filter("chat_title")
72
+ def filter_chat_title(event: Event, value: Any) -> tuple[bool, str]:
73
+ """Regex match against the chat title."""
74
+ tg = _tg_event(event)
75
+ if tg is None:
76
+ return False, "chat_title requires telegram source"
77
+ title = getattr(tg.chat, "title", "") or ""
78
+ if re.search(str(value), title, re.IGNORECASE):
79
+ return True, "chat_title matched"
80
+ return False, f"chat_title '{title}' does not match '{value}'"
81
+
82
+
83
+ @register_filter("is_incoming")
84
+ def filter_is_incoming(event: Event, value: Any) -> tuple[bool, str]:
85
+ tg = _tg_event(event)
86
+ if tg is None:
87
+ return False, "is_incoming requires telegram source"
88
+ incoming = not tg.message.out
89
+ if incoming == bool(value):
90
+ return True, "direction matched"
91
+ return False, f"is_incoming={incoming}, expected {value}"
92
+
93
+
94
+ @register_filter("sender_is_bot")
95
+ def filter_sender_is_bot(event: Event, value: Any) -> tuple[bool, str]:
96
+ tg = _tg_event(event)
97
+ if tg is None:
98
+ return False, "sender_is_bot requires telegram source"
99
+ sender = tg.message.sender
100
+ is_bot = getattr(sender, "bot", False) if sender else False
101
+ if is_bot == bool(value):
102
+ return True, "bot check matched"
103
+ return False, f"sender_is_bot={is_bot}, expected {value}"
104
+
105
+
106
+ @register_filter("sender_is_self")
107
+ def filter_sender_is_self(event: Event, value: Any) -> tuple[bool, str]:
108
+ tg = _tg_event(event)
109
+ if tg is None:
110
+ return False, "sender_is_self requires telegram source"
111
+ is_self = tg.message.out
112
+ if is_self == bool(value):
113
+ return True, "self check matched"
114
+ return False, f"sender_is_self={is_self}, expected {value}"
@@ -0,0 +1,161 @@
1
+ """Message-attribute filters — media type, reply, forward status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+ from tlgr.filters import register_filter
9
+ from tlgr.gateway.event import Event
10
+
11
+
12
+ class MessageType(Enum):
13
+ TEXT = "text"
14
+ PHOTO = "photo"
15
+ VIDEO = "video"
16
+ DOCUMENT = "document"
17
+ STICKER = "sticker"
18
+ VOICE = "voice"
19
+ VIDEO_NOTE = "video_note"
20
+ AUDIO = "audio"
21
+ POLL = "poll"
22
+ LOCATION = "location"
23
+ LIVE_LOCATION = "live_location"
24
+ CONTACT = "contact"
25
+ GAME = "game"
26
+ INVOICE = "invoice"
27
+ DICE = "dice"
28
+ GIF = "gif"
29
+ WEBPAGE = "webpage"
30
+ UNSUPPORTED = "unsupported"
31
+
32
+
33
+ def detect_message_type(message) -> MessageType:
34
+ from telethon.tl.types import (
35
+ DocumentAttributeAnimated,
36
+ DocumentAttributeAudio,
37
+ DocumentAttributeSticker,
38
+ DocumentAttributeVideo,
39
+ MessageMediaContact,
40
+ MessageMediaDice,
41
+ MessageMediaDocument,
42
+ MessageMediaGame,
43
+ MessageMediaGeo,
44
+ MessageMediaGeoLive,
45
+ MessageMediaInvoice,
46
+ MessageMediaPhoto,
47
+ MessageMediaPoll,
48
+ MessageMediaWebPage,
49
+ )
50
+
51
+ if not message.media:
52
+ return MessageType.TEXT
53
+ media = message.media
54
+ if isinstance(media, MessageMediaPhoto):
55
+ return MessageType.PHOTO
56
+ if isinstance(media, MessageMediaPoll):
57
+ return MessageType.POLL
58
+ if isinstance(media, MessageMediaGeoLive):
59
+ return MessageType.LIVE_LOCATION
60
+ if isinstance(media, MessageMediaGeo):
61
+ return MessageType.LOCATION
62
+ if isinstance(media, MessageMediaContact):
63
+ return MessageType.CONTACT
64
+ if isinstance(media, MessageMediaGame):
65
+ return MessageType.GAME
66
+ if isinstance(media, MessageMediaInvoice):
67
+ return MessageType.INVOICE
68
+ if isinstance(media, MessageMediaDice):
69
+ return MessageType.DICE
70
+ if isinstance(media, MessageMediaWebPage):
71
+ return MessageType.WEBPAGE
72
+ if isinstance(media, MessageMediaDocument):
73
+ doc = media.document
74
+ if doc is None:
75
+ return MessageType.UNSUPPORTED
76
+ is_animated = False
77
+ for attr in doc.attributes:
78
+ if isinstance(attr, DocumentAttributeAnimated):
79
+ is_animated = True
80
+ if isinstance(attr, DocumentAttributeSticker):
81
+ return MessageType.STICKER
82
+ if isinstance(attr, DocumentAttributeVideo):
83
+ if attr.round_message:
84
+ return MessageType.VIDEO_NOTE
85
+ if is_animated:
86
+ return MessageType.GIF
87
+ return MessageType.VIDEO
88
+ if isinstance(attr, DocumentAttributeAudio):
89
+ if attr.voice:
90
+ return MessageType.VOICE
91
+ return MessageType.AUDIO
92
+ if is_animated or (hasattr(doc, "mime_type") and doc.mime_type == "image/gif"):
93
+ return MessageType.GIF
94
+ return MessageType.DOCUMENT
95
+ return MessageType.UNSUPPORTED
96
+
97
+
98
+ def is_forwardable(message) -> tuple[bool, str]:
99
+ """Quick sanity check used by the forward action."""
100
+ if message.action is not None:
101
+ return False, f"Service message: {type(message.action).__name__}"
102
+ if not message.text and not message.media:
103
+ return False, "Empty message"
104
+ if message.media:
105
+ if hasattr(message.media, "ttl_seconds") and message.media.ttl_seconds:
106
+ return False, "Self-destructing message"
107
+ return True, ""
108
+
109
+
110
+ @register_filter("types")
111
+ def filter_types(event: Event, value: Any) -> tuple[bool, str]:
112
+ """Message type must be in *value*. Value: list[str]."""
113
+ if event.source != "telegram":
114
+ return False, "types requires telegram source"
115
+ mt = detect_message_type(event.raw.message)
116
+ allowed = value if isinstance(value, list) else [value]
117
+ if mt.value in allowed:
118
+ return True, f"type={mt.value}"
119
+ return False, f"type {mt.value} not in {allowed}"
120
+
121
+
122
+ @register_filter("exclude_types")
123
+ def filter_exclude_types(event: Event, value: Any) -> tuple[bool, str]:
124
+ """Message type must NOT be in *value*. Value: list[str]."""
125
+ if event.source != "telegram":
126
+ return False, "exclude_types requires telegram source"
127
+ mt = detect_message_type(event.raw.message)
128
+ excluded = value if isinstance(value, list) else [value]
129
+ if mt.value in excluded:
130
+ return False, f"type {mt.value} excluded"
131
+ return True, f"type {mt.value} allowed"
132
+
133
+
134
+ @register_filter("has_media")
135
+ def filter_has_media(event: Event, value: Any) -> tuple[bool, str]:
136
+ if event.source != "telegram":
137
+ return False, "has_media requires telegram source"
138
+ has = event.raw.message.media is not None
139
+ if has == bool(value):
140
+ return True, "media filter matched"
141
+ return False, "media filter"
142
+
143
+
144
+ @register_filter("is_reply")
145
+ def filter_is_reply(event: Event, value: Any) -> tuple[bool, str]:
146
+ if event.source != "telegram":
147
+ return False, "is_reply requires telegram source"
148
+ is_reply = event.raw.message.reply_to is not None
149
+ if is_reply == bool(value):
150
+ return True, "reply filter matched"
151
+ return False, "reply filter"
152
+
153
+
154
+ @register_filter("is_forward")
155
+ def filter_is_forward(event: Event, value: Any) -> tuple[bool, str]:
156
+ if event.source != "telegram":
157
+ return False, "is_forward requires telegram source"
158
+ is_fwd = event.raw.message.forward is not None
159
+ if is_fwd == bool(value):
160
+ return True, "forward filter matched"
161
+ return False, "forward filter"