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 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)