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
|
@@ -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."
|
tlgr/filters/__init__.py
ADDED
|
@@ -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
|
tlgr/filters/compose.py
ADDED
|
@@ -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"
|
tlgr/filters/content.py
ADDED
|
@@ -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"
|
tlgr/filters/context.py
ADDED
|
@@ -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}"
|
tlgr/filters/message.py
ADDED
|
@@ -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"
|