matele 0.1.0__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.
- matele/__init__.py +96 -0
- matele/_http.py +39 -0
- matele/callbacks.py +225 -0
- matele/dispatch/__init__.py +19 -0
- matele/dispatch/context.py +226 -0
- matele/dispatch/dispatcher.py +400 -0
- matele/dispatch/filters.py +167 -0
- matele/dispatch/panel.py +119 -0
- matele/dispatch/polling.py +151 -0
- matele/dispatch/router.py +190 -0
- matele/errors.py +103 -0
- matele/fsm/__init__.py +20 -0
- matele/fsm/context.py +54 -0
- matele/fsm/redis.py +114 -0
- matele/fsm/sqlite.py +130 -0
- matele/fsm/state.py +93 -0
- matele/fsm/storage.py +76 -0
- matele/hub.py +217 -0
- matele/max/__init__.py +7 -0
- matele/max/_model.py +122 -0
- matele/max/api.py +516 -0
- matele/max/bot.py +460 -0
- matele/max/certs/README.md +6 -0
- matele/max/certs/russian_trusted_root_ca.pem +33 -0
- matele/max/models.py +1539 -0
- matele/py.typed +0 -0
- matele/telegram/__init__.py +6 -0
- matele/telegram/api.py +255 -0
- matele/telegram/bot.py +628 -0
- matele/testing.py +289 -0
- matele/types.py +253 -0
- matele/webapp.py +127 -0
- matele/webhook.py +267 -0
- matele-0.1.0.dist-info/METADATA +310 -0
- matele-0.1.0.dist-info/RECORD +37 -0
- matele-0.1.0.dist-info/WHEEL +4 -0
- matele-0.1.0.dist-info/licenses/LICENSE +21 -0
matele/__init__.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""matele: one bot, two messengers (MAX and Telegram)."""
|
|
2
|
+
|
|
3
|
+
from matele.callbacks import CallbackData, CallbackError, CallbackRegistry, action, callback, parse
|
|
4
|
+
from matele.dispatch import (
|
|
5
|
+
ChatType,
|
|
6
|
+
Context,
|
|
7
|
+
Dispatcher,
|
|
8
|
+
Filter,
|
|
9
|
+
HasAttachment,
|
|
10
|
+
OnPlatform,
|
|
11
|
+
Regex,
|
|
12
|
+
Router,
|
|
13
|
+
Text,
|
|
14
|
+
as_filter,
|
|
15
|
+
)
|
|
16
|
+
from matele.errors import (
|
|
17
|
+
BotApiError,
|
|
18
|
+
BotForbiddenError,
|
|
19
|
+
BotUnavailableError,
|
|
20
|
+
ConnectionGuard,
|
|
21
|
+
is_unreachable,
|
|
22
|
+
platform_title,
|
|
23
|
+
)
|
|
24
|
+
from matele.fsm import ANY_STATE, FSMContext, MemoryStorage, State, StatesGroup, Storage, StorageKey
|
|
25
|
+
from matele.types import (
|
|
26
|
+
GROUP,
|
|
27
|
+
MAX,
|
|
28
|
+
PRIVATE,
|
|
29
|
+
TELEGRAM,
|
|
30
|
+
Attachment,
|
|
31
|
+
BotClient,
|
|
32
|
+
Button,
|
|
33
|
+
CallbackQuery,
|
|
34
|
+
ChatMemberUpdate,
|
|
35
|
+
ChatStatus,
|
|
36
|
+
Event,
|
|
37
|
+
FileKind,
|
|
38
|
+
FileSource,
|
|
39
|
+
IncomingEvent,
|
|
40
|
+
Keyboard,
|
|
41
|
+
Message,
|
|
42
|
+
User,
|
|
43
|
+
escape,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__version__ = "0.1.0"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"ANY_STATE",
|
|
50
|
+
"GROUP",
|
|
51
|
+
"MAX",
|
|
52
|
+
"PRIVATE",
|
|
53
|
+
"TELEGRAM",
|
|
54
|
+
"Attachment",
|
|
55
|
+
"BotApiError",
|
|
56
|
+
"BotClient",
|
|
57
|
+
"BotForbiddenError",
|
|
58
|
+
"BotUnavailableError",
|
|
59
|
+
"Button",
|
|
60
|
+
"CallbackData",
|
|
61
|
+
"CallbackError",
|
|
62
|
+
"CallbackQuery",
|
|
63
|
+
"CallbackRegistry",
|
|
64
|
+
"ChatMemberUpdate",
|
|
65
|
+
"ChatStatus",
|
|
66
|
+
"ChatType",
|
|
67
|
+
"ConnectionGuard",
|
|
68
|
+
"Context",
|
|
69
|
+
"Dispatcher",
|
|
70
|
+
"Event",
|
|
71
|
+
"FileKind",
|
|
72
|
+
"FileSource",
|
|
73
|
+
"FSMContext",
|
|
74
|
+
"Filter",
|
|
75
|
+
"HasAttachment",
|
|
76
|
+
"IncomingEvent",
|
|
77
|
+
"Keyboard",
|
|
78
|
+
"MemoryStorage",
|
|
79
|
+
"Message",
|
|
80
|
+
"OnPlatform",
|
|
81
|
+
"Regex",
|
|
82
|
+
"Router",
|
|
83
|
+
"State",
|
|
84
|
+
"StatesGroup",
|
|
85
|
+
"Storage",
|
|
86
|
+
"StorageKey",
|
|
87
|
+
"Text",
|
|
88
|
+
"User",
|
|
89
|
+
"action",
|
|
90
|
+
"as_filter",
|
|
91
|
+
"callback",
|
|
92
|
+
"escape",
|
|
93
|
+
"is_unreachable",
|
|
94
|
+
"parse",
|
|
95
|
+
"platform_title",
|
|
96
|
+
]
|
matele/_http.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""HTTP helpers shared by the transports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
# Files people send are downloaded into memory, so their size is capped:
|
|
8
|
+
# without a cap one huge file would take the whole process down.
|
|
9
|
+
DEFAULT_MAX_DOWNLOAD = 50 * 1024 * 1024
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_connection_failure(exc: Exception) -> bool:
|
|
13
|
+
"""Does this failure say the API can't be reached (refused, reset or
|
|
14
|
+
timed-out connection, DNS, proxy)? A timeout while reading, writing or
|
|
15
|
+
waiting for a free connection doesn't: the API was reached, this one
|
|
16
|
+
call was slow or the client is busy."""
|
|
17
|
+
return isinstance(exc, httpx.TransportError) and not isinstance(
|
|
18
|
+
exc, httpx.ReadTimeout | httpx.WriteTimeout | httpx.PoolTimeout
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TooLargeError(Exception):
|
|
23
|
+
"""The body is bigger than the limit."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def read_limited(resp: httpx.Response, limit: int | None) -> bytes:
|
|
27
|
+
"""Reads a streamed response, giving up as soon as it is over `limit`
|
|
28
|
+
bytes (None: no limit). Raises TooLargeError."""
|
|
29
|
+
declared = resp.headers.get("content-length", "")
|
|
30
|
+
if limit is not None and declared.isdigit() and int(declared) > limit:
|
|
31
|
+
raise TooLargeError
|
|
32
|
+
chunks: list[bytes] = []
|
|
33
|
+
size = 0
|
|
34
|
+
async for chunk in resp.aiter_bytes():
|
|
35
|
+
size += len(chunk)
|
|
36
|
+
if limit is not None and size > limit:
|
|
37
|
+
raise TooLargeError
|
|
38
|
+
chunks.append(chunk)
|
|
39
|
+
return b"".join(chunks)
|
matele/callbacks.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Typed callback data: every button payload is a small frozen dataclass
|
|
2
|
+
with a fixed prefix, packed to "prefix:field:field…" and parsed back by
|
|
3
|
+
the dispatcher, so handlers never split strings.
|
|
4
|
+
|
|
5
|
+
@callback("order:set")
|
|
6
|
+
class SetStatus(CallbackData):
|
|
7
|
+
order_id: int
|
|
8
|
+
status: Literal["paid", "shipped"]
|
|
9
|
+
note: str = ""
|
|
10
|
+
|
|
11
|
+
Button("✅ Оплачен", SetStatus(42, "paid").pack())
|
|
12
|
+
|
|
13
|
+
Fields may be int, str, bool, datetime.date or a Literal of strings.
|
|
14
|
+
Trailing fields equal to their defaults are left out when packing and get
|
|
15
|
+
their defaults back when parsing, so a field with a default can be added
|
|
16
|
+
to an existing payload without breaking buttons already sent. Only the
|
|
17
|
+
last field may be a str containing ":".
|
|
18
|
+
|
|
19
|
+
Payloads without fields are `action("menu:home")` instances.
|
|
20
|
+
|
|
21
|
+
Keep payloads short (ids, not names): Telegram allows 64 bytes.
|
|
22
|
+
|
|
23
|
+
Prefixes are unique within a registry: declaring the same one twice is an
|
|
24
|
+
error at import time, so two features can never fight over a button.
|
|
25
|
+
`callback()` and `parse()` use the default registry unless given another
|
|
26
|
+
(tests and plugins may want their own).
|
|
27
|
+
|
|
28
|
+
Button data comes back from the person's client, and a modified client
|
|
29
|
+
can send any string: `order:view:43` for an order that was never shown
|
|
30
|
+
to them. Give the bots a `callback_secret` and every payload is sent
|
|
31
|
+
with a signature (`sign()`); data that doesn't carry a valid one reaches
|
|
32
|
+
the handlers as an outdated button, not as a payload. A person can then
|
|
33
|
+
only press buttons the bot really made, though still any of them they
|
|
34
|
+
have seen (in a group, other people's too): who may do what is for the
|
|
35
|
+
handler to check.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import base64
|
|
41
|
+
import dataclasses
|
|
42
|
+
import datetime as dt
|
|
43
|
+
import hashlib
|
|
44
|
+
import hmac
|
|
45
|
+
import typing
|
|
46
|
+
from collections.abc import Callable
|
|
47
|
+
from typing import Any, ClassVar, Literal, Self, TypeVar, cast, dataclass_transform
|
|
48
|
+
|
|
49
|
+
SEP = ":"
|
|
50
|
+
|
|
51
|
+
T = TypeVar("T")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
SIGN_SEP = "#"
|
|
55
|
+
SIGNATURE_LENGTH = 10 # base64 characters: 60 bits of an HMAC-SHA256
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _signature(data: str, secret: str) -> str:
|
|
59
|
+
digest = hmac.new(secret.encode(), data.encode(), hashlib.sha256).digest()
|
|
60
|
+
return base64.urlsafe_b64encode(digest).decode()[:SIGNATURE_LENGTH]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sign(data: str, secret: str) -> str:
|
|
64
|
+
"""`data` with a signature only the holder of `secret` can make."""
|
|
65
|
+
return f"{data}{SIGN_SEP}{_signature(data, secret)}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def unsign(signed: str, secret: str) -> str | None:
|
|
69
|
+
"""The data `sign()` was given; None if the signature is missing or wrong."""
|
|
70
|
+
data, sep, given = signed.rpartition(SIGN_SEP)
|
|
71
|
+
if not sep or not hmac.compare_digest(given.encode(), _signature(data, secret).encode()):
|
|
72
|
+
return None
|
|
73
|
+
return data
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CallbackError(ValueError):
|
|
77
|
+
"""Payload that doesn't parse: a stale button from an older version, or forged."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CallbackData:
|
|
81
|
+
prefix: ClassVar[str]
|
|
82
|
+
_fields: ClassVar[tuple[tuple[str, Any, Any], ...]] # (name, type, default or MISSING)
|
|
83
|
+
|
|
84
|
+
def pack(self) -> str:
|
|
85
|
+
values = [_dump(getattr(self, name)) for name, _, _ in self._fields]
|
|
86
|
+
for i, value in enumerate(values[:-1]):
|
|
87
|
+
if SEP in value:
|
|
88
|
+
raise ValueError(f"{type(self).__name__}.{self._fields[i][0]}: only the last field may contain {SEP!r}")
|
|
89
|
+
# drop trailing defaults, so optional fields don't widen old payloads
|
|
90
|
+
n = len(values)
|
|
91
|
+
while (
|
|
92
|
+
n
|
|
93
|
+
and self._fields[n - 1][2] is not dataclasses.MISSING
|
|
94
|
+
and getattr(self, self._fields[n - 1][0]) == self._fields[n - 1][2]
|
|
95
|
+
):
|
|
96
|
+
n -= 1
|
|
97
|
+
return SEP.join([self.prefix, *values[:n]])
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def unpack(cls, data: str) -> Self:
|
|
101
|
+
if data == cls.prefix:
|
|
102
|
+
raw: list[str] = []
|
|
103
|
+
elif data.startswith(cls.prefix + SEP):
|
|
104
|
+
rest = data[len(cls.prefix) + 1 :]
|
|
105
|
+
raw = rest.split(SEP, max(len(cls._fields) - 1, 0)) if cls._fields else [rest]
|
|
106
|
+
else:
|
|
107
|
+
raise CallbackError(f"not a {cls.__name__}: {data!r}")
|
|
108
|
+
if len(raw) > len(cls._fields):
|
|
109
|
+
raise CallbackError(f"too many fields for {cls.__name__}: {data!r}")
|
|
110
|
+
kwargs = {}
|
|
111
|
+
for i, (name, tp, default) in enumerate(cls._fields):
|
|
112
|
+
if i < len(raw):
|
|
113
|
+
kwargs[name] = _load(raw[i], tp)
|
|
114
|
+
elif default is dataclasses.MISSING:
|
|
115
|
+
raise CallbackError(f"{cls.__name__}.{name} missing: {data!r}")
|
|
116
|
+
return cast(Callable[..., Self], cls)(**kwargs)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class CallbackRegistry:
|
|
120
|
+
"""Prefix -> payload class. The longest registered prefix wins."""
|
|
121
|
+
|
|
122
|
+
def __init__(self) -> None:
|
|
123
|
+
self._classes: dict[str, type[CallbackData]] = {}
|
|
124
|
+
|
|
125
|
+
def check_free(self, prefix: str) -> None:
|
|
126
|
+
existing = self._classes.get(prefix)
|
|
127
|
+
if existing is not None:
|
|
128
|
+
raise ValueError(f"callback prefix {prefix!r} is already used by {existing.__qualname__}")
|
|
129
|
+
|
|
130
|
+
def register(self, cls: type[CallbackData]) -> None:
|
|
131
|
+
self.check_free(cls.prefix)
|
|
132
|
+
self._classes[cls.prefix] = cls
|
|
133
|
+
|
|
134
|
+
def parse(self, data: str) -> CallbackData:
|
|
135
|
+
parts = data.split(SEP)
|
|
136
|
+
for n in range(len(parts), 0, -1):
|
|
137
|
+
cls = self._classes.get(SEP.join(parts[:n]))
|
|
138
|
+
if cls is not None:
|
|
139
|
+
return cls.unpack(data)
|
|
140
|
+
raise CallbackError(f"unknown callback {data!r}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
default_registry = CallbackRegistry()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass_transform(frozen_default=True)
|
|
147
|
+
def callback(prefix: str, *, registry: CallbackRegistry | None = None) -> Callable[[type[T]], type[T]]:
|
|
148
|
+
"""Class decorator: makes a frozen dataclass and registers its prefix."""
|
|
149
|
+
if not prefix or prefix.startswith(SEP) or prefix.endswith(SEP):
|
|
150
|
+
raise ValueError(f"bad callback prefix {prefix!r}")
|
|
151
|
+
target = registry if registry is not None else default_registry
|
|
152
|
+
|
|
153
|
+
def decorate(cls: type[T]) -> type[T]:
|
|
154
|
+
target.check_free(prefix)
|
|
155
|
+
base = dataclasses.dataclass(frozen=True)(cls)
|
|
156
|
+
bases: tuple[type, ...] = (base,) if issubclass(base, CallbackData) else (base, CallbackData)
|
|
157
|
+
dc = cast(Callable[..., type[CallbackData]], type)(
|
|
158
|
+
cls.__name__,
|
|
159
|
+
bases,
|
|
160
|
+
{"__module__": cls.__module__, "__qualname__": cls.__qualname__, "__doc__": cls.__doc__},
|
|
161
|
+
)
|
|
162
|
+
hints = typing.get_type_hints(cls)
|
|
163
|
+
dc.prefix = prefix
|
|
164
|
+
dc._fields = tuple((f.name, hints[f.name], f.default) for f in dataclasses.fields(cast(Any, dc)))
|
|
165
|
+
for name, tp, _ in dc._fields:
|
|
166
|
+
_check_type(dc, name, tp)
|
|
167
|
+
target.register(dc)
|
|
168
|
+
return cast(type[T], dc)
|
|
169
|
+
|
|
170
|
+
return decorate
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def action(prefix: str, *, registry: CallbackRegistry | None = None) -> CallbackData:
|
|
174
|
+
"""A payload without fields, ready to put on a button."""
|
|
175
|
+
cls = callback(prefix, registry=registry)(type(_class_name(prefix), (), {"__module__": __name__}))
|
|
176
|
+
return cast(type[CallbackData], cls)()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def parse(data: str, *, registry: CallbackRegistry | None = None) -> CallbackData:
|
|
180
|
+
"""The payload for raw callback data; the longest registered prefix wins."""
|
|
181
|
+
return (registry if registry is not None else default_registry).parse(data)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def as_class(target: type[CallbackData] | CallbackData) -> type[CallbackData]:
|
|
185
|
+
return target if isinstance(target, type) else type(target)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ----------------------------------------------------------------------
|
|
189
|
+
def _class_name(prefix: str) -> str:
|
|
190
|
+
return "".join(p.capitalize() for p in prefix.replace("-", "_").replace(SEP, "_").split("_")) or "Action"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _check_type(cls: type, name: str, tp: Any) -> None:
|
|
194
|
+
if tp in (int, str, bool, dt.date):
|
|
195
|
+
return
|
|
196
|
+
if typing.get_origin(tp) is Literal and all(isinstance(a, str) for a in typing.get_args(tp)):
|
|
197
|
+
return
|
|
198
|
+
raise TypeError(f"{cls.__qualname__}.{name}: unsupported callback field type {tp!r}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _dump(value: Any) -> str:
|
|
202
|
+
if isinstance(value, bool):
|
|
203
|
+
return "1" if value else "0"
|
|
204
|
+
if isinstance(value, dt.date):
|
|
205
|
+
return value.isoformat()
|
|
206
|
+
return str(value)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _load(raw: str, tp: Any) -> Any:
|
|
210
|
+
try:
|
|
211
|
+
if tp is bool:
|
|
212
|
+
if raw not in ("0", "1"):
|
|
213
|
+
raise ValueError(raw)
|
|
214
|
+
return raw == "1"
|
|
215
|
+
if tp is int:
|
|
216
|
+
return int(raw)
|
|
217
|
+
if tp is dt.date:
|
|
218
|
+
return dt.date.fromisoformat(raw)
|
|
219
|
+
if tp is str:
|
|
220
|
+
return raw
|
|
221
|
+
except ValueError as exc:
|
|
222
|
+
raise CallbackError(f"bad {tp.__name__} {raw!r}") from exc
|
|
223
|
+
if raw not in typing.get_args(tp): # Literal
|
|
224
|
+
raise CallbackError(f"{raw!r} not one of {typing.get_args(tp)}")
|
|
225
|
+
return raw
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from matele.dispatch.context import Context
|
|
2
|
+
from matele.dispatch.dispatcher import Command, Dispatcher, parse_command
|
|
3
|
+
from matele.dispatch.filters import ChatType, Filter, HasAttachment, OnPlatform, Regex, Text, as_filter
|
|
4
|
+
from matele.dispatch.router import Router
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ChatType",
|
|
8
|
+
"Command",
|
|
9
|
+
"Context",
|
|
10
|
+
"Dispatcher",
|
|
11
|
+
"Filter",
|
|
12
|
+
"HasAttachment",
|
|
13
|
+
"OnPlatform",
|
|
14
|
+
"Regex",
|
|
15
|
+
"Router",
|
|
16
|
+
"Text",
|
|
17
|
+
"as_filter",
|
|
18
|
+
"parse_command",
|
|
19
|
+
]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Context: the one object every handler, filter and middleware receives.
|
|
2
|
+
|
|
3
|
+
Subclass it to give handlers typed access to whatever your middleware
|
|
4
|
+
loads, and pass the subclass to the Dispatcher:
|
|
5
|
+
|
|
6
|
+
class Ctx(Context):
|
|
7
|
+
account: Account | None = None
|
|
8
|
+
|
|
9
|
+
dp = Dispatcher(context_class=Ctx)
|
|
10
|
+
|
|
11
|
+
@dp.middleware
|
|
12
|
+
async def load_account(ctx: Ctx, call_next):
|
|
13
|
+
ctx.account = await accounts.find(ctx.platform, ctx.user.id)
|
|
14
|
+
await call_next()
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
22
|
+
from collections.abc import AsyncIterator
|
|
23
|
+
from contextlib import asynccontextmanager
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from matele.callbacks import CallbackData
|
|
27
|
+
from matele.errors import BotApiError
|
|
28
|
+
from matele.fsm import FSMContext
|
|
29
|
+
from matele.types import (
|
|
30
|
+
PRIVATE,
|
|
31
|
+
BotClient,
|
|
32
|
+
CallbackQuery,
|
|
33
|
+
ChatMemberUpdate,
|
|
34
|
+
ChatStatus,
|
|
35
|
+
FileKind,
|
|
36
|
+
FileSource,
|
|
37
|
+
IncomingEvent,
|
|
38
|
+
Keyboard,
|
|
39
|
+
Message,
|
|
40
|
+
User,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Context:
|
|
47
|
+
def __init__(self, event: IncomingEvent, state: FSMContext):
|
|
48
|
+
self.event = event
|
|
49
|
+
self.state = state
|
|
50
|
+
# Free-form storage for middleware and filters.
|
|
51
|
+
self.data: dict[str, Any] = {}
|
|
52
|
+
# Set by the dispatcher before filters run:
|
|
53
|
+
self.payload: CallbackData | None = None # parsed button payload (None if it didn't parse)
|
|
54
|
+
self.command: str | None = None # "start" for "/start abc"
|
|
55
|
+
self.args: str | None = None # "abc" for "/start abc"
|
|
56
|
+
self.match: re.Match[str] | None = None # set by the Regex filter
|
|
57
|
+
self.answered = False
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------ the event
|
|
60
|
+
@property
|
|
61
|
+
def bot(self) -> BotClient:
|
|
62
|
+
return self.event.bot
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def user(self) -> User:
|
|
66
|
+
return self.event.from_user
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def platform(self) -> str:
|
|
70
|
+
return self.event.from_user.platform
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def callback(self) -> CallbackQuery | None:
|
|
74
|
+
return self.event if isinstance(self.event, CallbackQuery) else None
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def is_callback(self) -> bool:
|
|
78
|
+
return isinstance(self.event, CallbackQuery)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def message(self) -> Message | None:
|
|
82
|
+
"""The incoming message, or the message a pressed button is on."""
|
|
83
|
+
if isinstance(self.event, Message):
|
|
84
|
+
return self.event
|
|
85
|
+
if isinstance(self.event, CallbackQuery):
|
|
86
|
+
return self.event.message
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def chat_status(self) -> ChatStatus | None:
|
|
91
|
+
return self.event if isinstance(self.event, ChatStatus) else None
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def member_update(self) -> ChatMemberUpdate | None:
|
|
95
|
+
return self.event if isinstance(self.event, ChatMemberUpdate) else None
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def chat_type(self) -> str:
|
|
99
|
+
"""matele.PRIVATE or matele.GROUP: where the event happened."""
|
|
100
|
+
if isinstance(self.event, ChatMemberUpdate):
|
|
101
|
+
return self.event.chat_type
|
|
102
|
+
message = self.message
|
|
103
|
+
return message.chat_type if message is not None else PRIVATE
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def chat_id(self) -> int | None:
|
|
107
|
+
"""The group the event happened in; None in a private chat."""
|
|
108
|
+
if isinstance(self.event, ChatMemberUpdate):
|
|
109
|
+
return self.event.chat_id
|
|
110
|
+
message = self.message
|
|
111
|
+
return message.chat_id if message is not None and message.chat_type != PRIVATE else None
|
|
112
|
+
|
|
113
|
+
def _target(self) -> dict[str, int]:
|
|
114
|
+
chat_id = self.chat_id
|
|
115
|
+
return {"chat_id": chat_id} if chat_id is not None else {"user_id": self.user.id}
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def text(self) -> str:
|
|
119
|
+
"""Text of an incoming message ("" for anything else)."""
|
|
120
|
+
return (self.event.text or "") if isinstance(self.event, Message) else ""
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------ output
|
|
123
|
+
def _quoted(self, quote: bool) -> str | None:
|
|
124
|
+
message = self.message
|
|
125
|
+
return message.message_id if quote and message is not None and message.message_id else None
|
|
126
|
+
|
|
127
|
+
async def reply(self, text: str, keyboard: Keyboard | None = None, *, quote: bool = False) -> Message:
|
|
128
|
+
"""Sends a new message where the event happened: to the group, or to
|
|
129
|
+
the person's private chat. `quote=True` makes it a reply to the
|
|
130
|
+
incoming message (or the one the pressed button is on)."""
|
|
131
|
+
return await self.bot.send_message(text, keyboard=keyboard, reply_to=self._quoted(quote), **self._target())
|
|
132
|
+
|
|
133
|
+
async def reply_file(
|
|
134
|
+
self,
|
|
135
|
+
source: FileSource,
|
|
136
|
+
*,
|
|
137
|
+
kind: FileKind = "file",
|
|
138
|
+
filename: str | None = None,
|
|
139
|
+
caption: str | None = None,
|
|
140
|
+
keyboard: Keyboard | None = None,
|
|
141
|
+
quote: bool = False,
|
|
142
|
+
) -> Message:
|
|
143
|
+
"""Sends a photo (kind="image"), video, audio or file where the event
|
|
144
|
+
happened. `source`: bytes, a path or a binary file object."""
|
|
145
|
+
return await self.bot.send_file(
|
|
146
|
+
source,
|
|
147
|
+
kind=kind,
|
|
148
|
+
filename=filename,
|
|
149
|
+
caption=caption,
|
|
150
|
+
keyboard=keyboard,
|
|
151
|
+
reply_to=self._quoted(quote),
|
|
152
|
+
**self._target(),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
async def reply_document(self, content: bytes, filename: str, caption: str | None = None) -> Message:
|
|
156
|
+
return await self.reply_file(content, kind="file", filename=filename, caption=caption)
|
|
157
|
+
|
|
158
|
+
@asynccontextmanager
|
|
159
|
+
async def typing(self, every: float = 4.0) -> AsyncIterator[None]:
|
|
160
|
+
"""Shows "typing…" while the block runs (renewed every `every` s):
|
|
161
|
+
|
|
162
|
+
async with ctx.typing():
|
|
163
|
+
answer = await slow_thing()
|
|
164
|
+
"""
|
|
165
|
+
message = self.message
|
|
166
|
+
if message is None:
|
|
167
|
+
yield
|
|
168
|
+
return
|
|
169
|
+
chat_id = message.chat_id
|
|
170
|
+
|
|
171
|
+
async def keep_typing() -> None:
|
|
172
|
+
while True:
|
|
173
|
+
try:
|
|
174
|
+
await self.bot.send_typing(chat_id)
|
|
175
|
+
except asyncio.CancelledError:
|
|
176
|
+
raise
|
|
177
|
+
except Exception as exc: # "typing…" is cosmetic: never break the handler
|
|
178
|
+
logger.debug("send_typing failed: %s", exc)
|
|
179
|
+
await asyncio.sleep(every)
|
|
180
|
+
|
|
181
|
+
task = asyncio.create_task(keep_typing())
|
|
182
|
+
try:
|
|
183
|
+
yield
|
|
184
|
+
finally:
|
|
185
|
+
task.cancel()
|
|
186
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
187
|
+
|
|
188
|
+
async def show(
|
|
189
|
+
self, text: str, keyboard: Keyboard | None = None, *, toast: str | None = None, keep_input: bool = False
|
|
190
|
+
) -> Message | None:
|
|
191
|
+
"""Shows a screen in the person's single menu message (see
|
|
192
|
+
matele.dispatch.panel): edits it instead of sending a new one."""
|
|
193
|
+
from matele.dispatch.panel import show
|
|
194
|
+
|
|
195
|
+
return await show(self, text, keyboard, toast=toast, keep_input=keep_input)
|
|
196
|
+
|
|
197
|
+
async def send_private(self, text: str, keyboard: Keyboard | None = None) -> Message:
|
|
198
|
+
"""Sends to the person's private chat with the bot, even from a group
|
|
199
|
+
(fails if they never started the bot)."""
|
|
200
|
+
return await self.bot.send_message(text, user_id=self.user.id, keyboard=keyboard)
|
|
201
|
+
|
|
202
|
+
async def edit(self, text: str | None, keyboard: Keyboard | None = None) -> None:
|
|
203
|
+
"""Edits the message the pressed button is on."""
|
|
204
|
+
target = self.event.message if isinstance(self.event, CallbackQuery) else None
|
|
205
|
+
if target is None or not target.message_id:
|
|
206
|
+
raise RuntimeError("edit() needs a pressed button on a message the bot can edit")
|
|
207
|
+
await self.bot.edit_message(target.message_id, text, keyboard=keyboard)
|
|
208
|
+
|
|
209
|
+
async def delete(self) -> None:
|
|
210
|
+
"""Deletes the incoming message, or the message a pressed button is on."""
|
|
211
|
+
target = self.message
|
|
212
|
+
if target is None or not target.message_id:
|
|
213
|
+
raise RuntimeError("delete() needs a message")
|
|
214
|
+
await self.bot.delete_message(target.message_id)
|
|
215
|
+
|
|
216
|
+
async def answer(self, text: str | None = None, *, alert: bool = False) -> None:
|
|
217
|
+
"""Answers a button press: stops the spinner, optionally with a toast
|
|
218
|
+
(or an alert). Only the first call counts; the dispatcher answers
|
|
219
|
+
silently after the handler if nobody did. No-op for other events."""
|
|
220
|
+
if not isinstance(self.event, CallbackQuery) or self.answered:
|
|
221
|
+
return
|
|
222
|
+
self.answered = True
|
|
223
|
+
try:
|
|
224
|
+
await self.bot.answer_callback(self.event.id, text=text, alert=alert)
|
|
225
|
+
except BotApiError as exc: # e.g. Telegram's "query is too old"
|
|
226
|
+
logger.debug("answer_callback failed: %s", exc)
|