telekit 2.2.0a3__tar.gz → 2.3.0b1__tar.gz

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 (73) hide show
  1. {telekit-2.2.0a3/telekit.egg-info → telekit-2.3.0b1}/PKG-INFO +76 -38
  2. {telekit-2.2.0a3 → telekit-2.3.0b1}/README.md +2 -1
  3. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/__init__.py +2 -0
  4. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_handler.py +7 -0
  5. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_inline_buttons.py +1 -1
  6. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_version.py +1 -1
  7. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/entry.py +5 -1
  8. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/text_document.py +6 -2
  9. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/senders.py +130 -39
  10. telekit-2.3.0b1/telekit/traits/__init__.py +2 -0
  11. telekit-2.3.0b1/telekit/traits/paginated_choice.py +109 -0
  12. telekit-2.3.0b1/telekit/traits/track_handoff_origin.py +99 -0
  13. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/utils.py +116 -14
  14. {telekit-2.2.0a3 → telekit-2.3.0b1/telekit.egg-info}/PKG-INFO +76 -38
  15. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit.egg-info/SOURCES.txt +4 -1
  16. {telekit-2.2.0a3 → telekit-2.3.0b1}/LICENSE +0 -0
  17. {telekit-2.2.0a3 → telekit-2.3.0b1}/setup.cfg +0 -0
  18. {telekit-2.2.0a3 → telekit-2.3.0b1}/setup.py +0 -0
  19. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_buildtext/__init__.py +0 -0
  20. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_buildtext/formatter.py +0 -0
  21. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_buildtext/styles.py +0 -0
  22. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_callback_query_handler.py +0 -0
  23. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_chain.py +0 -0
  24. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_chain_base.py +0 -0
  25. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_chain_entry_logic.py +0 -0
  26. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_chain_inline_keyboards_logic.py +0 -0
  27. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_chapters/__init__.py +0 -0
  28. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_chapters/chapters.py +0 -0
  29. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_init.py +0 -0
  30. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_input_handler.py +0 -0
  31. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_logger.py +0 -0
  32. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_on.py +0 -0
  33. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_snapvault/__init__.py +0 -0
  34. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_snapvault/snapcode.py +0 -0
  35. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_snapvault/snapvault.py +0 -0
  36. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_state.py +0 -0
  37. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_telekit_dsl/__init__.py +0 -0
  38. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_telekit_dsl/mixin.py +0 -0
  39. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_telekit_dsl/parser/__init__.py +0 -0
  40. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_telekit_dsl/parser/builder.py +0 -0
  41. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_telekit_dsl/parser/canvas_parser.py +0 -0
  42. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_telekit_dsl/parser/lexer.py +0 -0
  43. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_telekit_dsl/parser/nodes.py +0 -0
  44. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_telekit_dsl/parser/parser.py +0 -0
  45. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_telekit_dsl/parser/token.py +0 -0
  46. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_telekit_dsl/telekit_dsl.py +0 -0
  47. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_telekit_dsl/telekit_orm.py +0 -0
  48. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_timeout.py +0 -0
  49. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/_user.py +0 -0
  50. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/dices.py +0 -0
  51. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/__init__.py +0 -0
  52. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/__init__.py +0 -0
  53. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/complete_hotel.py +0 -0
  54. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/counter.py +0 -0
  55. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/dsl.py +0 -0
  56. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/faq.py +0 -0
  57. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/hotel.py +0 -0
  58. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/on_text.py +0 -0
  59. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/pages.py +0 -0
  60. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/pyapi.py +0 -0
  61. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/qr.py +0 -0
  62. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/quiz.py +0 -0
  63. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/spells.py +0 -0
  64. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_handlers/start.py +0 -0
  65. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/example/example_server.py +0 -0
  66. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/inline_buttons.py +0 -0
  67. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/parameters.py +0 -0
  68. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/server.py +0 -0
  69. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/styles.py +0 -0
  70. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit/types.py +0 -0
  71. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit.egg-info/dependency_links.txt +0 -0
  72. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit.egg-info/requires.txt +0 -0
  73. {telekit-2.2.0a3 → telekit-2.3.0b1}/telekit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telekit
3
- Version: 2.2.0a3
3
+ Version: 2.3.0b1
4
4
  Summary: Declarative, developer-friendly library for building Telegram bots
5
5
  Home-page: https://github.com/Romashkaa/telekit
6
6
  Author: romashka
@@ -101,7 +101,8 @@ Even in its beta stage, Telekit accelerates bot development, offering typed comm
101
101
  - [Dialogue](https://github.com/Romashkaa/telekit/blob/main/docs/examples/dialogue.md)
102
102
  - [Risk Game](https://github.com/Romashkaa/telekit/blob/main/docs/examples/risk_game.md)
103
103
  - [Counter](https://github.com/Romashkaa/telekit/blob/main/docs/examples/counter.md)
104
- - [Quiz (Telekit DSL)](https://github.com/Romashkaa/telekit/blob/main/docs/examples/quiz.md)
104
+ - [Quiz (DSL)](https://github.com/Romashkaa/telekit/blob/main/docs/examples/quiz.md)
105
+ - [Hotel (DSL)](https://github.com/Romashkaa/telekit/blob/main/docs/examples/complete_hotel.md)
105
106
  - [More...](https://github.com/Romashkaa/telekit/blob/main/docs/examples/examples.md)
106
107
 
107
108
  ## Overview
@@ -378,49 +379,86 @@ It tries to make Telegram bot development easier.
378
379
 
379
380
  ---
380
381
 
381
- # Changes in version 2.2.0a3
382
+ # Changes in version 2.3.0b1
382
383
 
383
- ## New Button Types
384
+ ## Traits
384
385
 
385
- | **Name** | **Description** |
386
- |----------------------|----------------------------------------------------------|
387
- | `AlertButton` | A callback button that shows a popup alert when pressed. |
388
- | `NotificationButton` | A callback button that shows a notification. |
389
- | `InvokeButton` | A callback button that calls the object method. |
386
+ | **Name** | **Description** |
387
+ | -------------------------- | --------------------------------------------------------------------- |
388
+ | `TrackHandoffOrigin` | Tracks which handler transferred control to this one via `handoff()`. |
389
+ | `PaginatedChoice` | Adds a paginated inline keyboard for choosing from a list of items. |
390
390
 
391
- ## User Improvements
391
+ ### TrackHandoffOrigin
392
392
 
393
- | **Name** | **Description** |
394
- | ---------------------- | --------------------------------------------------------- |
395
- | `bio` | Bio of the user or description of the chat. |
396
- | `birthdate` | Birthdate of the user, if set and visible. |
397
- | `description` | Description of the group or channel. |
398
- | `mention` | `tg://user?id=` deep link, works even without a username. |
399
- | `is_private` | Whether the message was sent in a private chat. |
400
- | `is_group` | Whether the message was sent in a group. |
401
- | `is_supergroup` | Whether the message was sent in a supergroup. |
402
- | `is_channel` | Whether the message was sent in a channel. |
403
- | `avatar` | File ID of the user's most recent profile photo. |
404
- | `profile_photos_count` | Total number of profile photos the user has set. |
393
+ `TrackHandoffOrigin` adds three members to any handler that inherits it:
405
394
 
406
- - Refactor: `User` now accepts a `Message` object instead of `chat_id` + `from_user`. All properties are derived from `_sender` (`from_user` or `chat`) and migrated to `cached_property`. Fixed broken `get_id` and `get_full_name` references.
407
- - Added `__repr__`.
395
+ | **Member** | **Description** |
396
+ | ------------------- | -------------------------------------------------------------------------------- |
397
+ | `handoff_origin` | The handler instance that handed off to this one, or `None` if invoked directly. |
398
+ | `is_handed_off` | `True` if this handler was reached via `handoff()`, `False` otherwise. |
399
+ | `handoff_back()` | Transfer control back to the origin handler via `self.handoff_origin.handle()` |
400
+ | `handoff_back_or(handler)` | Like `handoff_back()`, but falls back to `handler` on fail. |
408
401
 
409
- ## Sender Improvements
402
+ Set `TRACK_HANDOFF_ORIGIN = False` on any subclass to opt out of tracking.
410
403
 
411
- | **Name** | **Description** |
412
- | -------------------------- | ------------------------------------------------------ |
413
- | `sent_message` | The last message sent by this sender instance. |
414
- | `disable_notification` | Disables notification sound when the message is sent. |
415
- | `protect_content` | Protects the message contents from forwarding and saving. |
416
- | `reply_parameters` | Reply parameters for the message to be sent. |
417
- | `link_preview_options` | Link preview options for the message to be sent. |
418
- | `show_caption_above_media` | Shows the caption above the media instead of below. |
404
+ ```python
405
+ class MyHandler(TrackHandoffOrigin, telekit.Handler):
406
+
407
+ def handle(self):
408
+ ...
409
+ self.chain.set_inline_keyboard({
410
+ Back": self.handoff_back_or(StartHandler)
411
+ })
412
+ self.chain.edit()
413
+ ```
414
+
415
+ ### PaginatedChoice
419
416
 
420
- - Refactored sending system.
417
+ Renders a paginated inline keyboard from any dict or iterable.
418
+ Navigation buttons (`« Back`, `Next »`) are added automatically.
421
419
 
422
- ## Chain Improvements
420
+ | **Member** | **Description** |
421
+ | -------------------- | ---------------------------------------------------------------------------- |
422
+ | `paginated_choice(choices, on_choice, on_update, row_width)` | Display a paginated choice keyboard. |
423
423
 
424
- | **Name** | **Description** |
425
- | ----------- | ------------------------------------------------------------ |
426
- | `received` | The message received from the user during entry processing. |
424
+ ```python
425
+ self.chain.sender.set_title("🔤 What is your initial?")
426
+ self.chain.sender.set_message("Pick the first letter of your name")
427
+ self.chain.sender.set_remove_text(False)
428
+
429
+ self.paginated_choice(
430
+ choices="ABCDEFGHIJKLMNOPQRSTUVWXYZ",
431
+ on_choice=self.handle_letter,
432
+ row_width=5
433
+ )
434
+ ```
435
+
436
+ <details>
437
+ <summary>(Click to see the result)</summary>
438
+ <table>
439
+ <tr>
440
+ <td><img src="https://github.com/Romashkaa/telekit/blob/main/docs/images/paginated_choice.png?raw=true" alt="Example" width="300"></td>
441
+ </tr>
442
+ </table>
443
+ </details>
444
+
445
+ `choices` accepts a `dict[str, T]`, or any `Iterable[T]`.
446
+ If only one item is present, `on_choice` is called immediately without rendering a keyboard.
447
+
448
+ ## Utils
449
+
450
+ | **Name** | **Description** |
451
+ | ---------------- | ------------------------------------------------------------------ |
452
+ | `load_env` | Load all key-value pairs from a `.env` file into a dictionary. |
453
+ | `read_env_var` | Read a single variable by name from a `.env` file. |
454
+
455
+ - `read_token` and `read_canvas_path` now support reading from `.env` files.
456
+ Pass `".env"` to use the default key, or `".env:KEY"` to specify a custom one:
457
+
458
+ ```python
459
+ read_token(".env") # reads TOKEN
460
+ read_token(".env:BOT_TOKEN") # reads BOT_TOKEN
461
+
462
+ read_canvas_path(".env") # reads CANVAS_PATH
463
+ read_canvas_path(".env:MY_CANVAS") # reads MY_CANVAS
464
+ ```
@@ -65,7 +65,8 @@ Even in its beta stage, Telekit accelerates bot development, offering typed comm
65
65
  - [Dialogue](https://github.com/Romashkaa/telekit/blob/main/docs/examples/dialogue.md)
66
66
  - [Risk Game](https://github.com/Romashkaa/telekit/blob/main/docs/examples/risk_game.md)
67
67
  - [Counter](https://github.com/Romashkaa/telekit/blob/main/docs/examples/counter.md)
68
- - [Quiz (Telekit DSL)](https://github.com/Romashkaa/telekit/blob/main/docs/examples/quiz.md)
68
+ - [Quiz (DSL)](https://github.com/Romashkaa/telekit/blob/main/docs/examples/quiz.md)
69
+ - [Hotel (DSL)](https://github.com/Romashkaa/telekit/blob/main/docs/examples/complete_hotel.md)
69
70
  - [More...](https://github.com/Romashkaa/telekit/blob/main/docs/examples/examples.md)
70
71
 
71
72
  ## Overview
@@ -35,12 +35,14 @@ from . import parameters
35
35
  from . import inline_buttons
36
36
  from . import dices
37
37
  from . import utils
38
+ from . import traits
38
39
 
39
40
  Styles = styles.Styles
40
41
 
41
42
  from ._version import __version__
42
43
 
43
44
  __all__ = [
45
+ "traits",
44
46
  "utils",
45
47
  "senders",
46
48
  "types",
@@ -225,9 +225,16 @@ class Handler:
225
225
 
226
226
  handler_instance = handler(self.message)
227
227
  handler_instance.chain._set_previous_message(self.chain.get_previous_message())
228
+ handler_instance._on_handoff(self)
228
229
 
229
230
  return handler_instance
230
231
 
232
+
233
+ def _on_handoff(self, origin: "Handler") -> None:
234
+ """Called when this handler is reached via handoff(). Override to customize."""
235
+ pass
236
+
237
+
231
238
  def freeze(self, func, *args):
232
239
  """
233
240
  Return a zero-argument callback that invokes the given function
@@ -339,8 +339,8 @@ class CallbackButton(InlineButton):
339
339
 
340
340
  def __call__(self, call: CallbackQuery):
341
341
  self._invoke_chain_callback()
342
- self._invoke_callback()
343
342
  self._answer_callback_query(call)
343
+ self._invoke_callback()
344
344
 
345
345
  def __init__(
346
346
  self,
@@ -3,4 +3,4 @@
3
3
  # PyPI history: https://pypi.org/project/telekit/#history
4
4
  # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
5
5
 
6
- __version__ = "2.2.0a3"
6
+ __version__ = "2.3.0b1"
@@ -44,8 +44,12 @@ class EntryHandler(telekit.Handler):
44
44
  # Handling Logic
45
45
  # ------------------------------------------
46
46
 
47
+ def __init__(self, *args, **kwargs):
48
+ super().__init__(*args, **kwargs)
49
+
50
+ self._user_data = UserData(self.user.id)
51
+
47
52
  def handle(self) -> None:
48
- self._user_data = UserData(self.message.chat.id)
49
53
  self.chain.disable_timeout_warnings()
50
54
  self.entry_name()
51
55
 
@@ -1,11 +1,10 @@
1
- from telebot.types import Message
2
1
  from telekit.types import (
3
2
  TextDocument, CopyTextButton, ParseMode
4
3
  )
5
4
  from telekit.styles import Quote, Escape
6
5
  import telekit
7
6
 
8
- class TextDocumentHandler(telekit.Handler):
7
+ class TextDocumentHandler(telekit.traits.TrackHandoffOrigin, telekit.Handler):
9
8
 
10
9
  @classmethod
11
10
  def init_handler(cls) -> None:
@@ -26,6 +25,11 @@ class TextDocumentHandler(telekit.Handler):
26
25
  self.handle_text_document,
27
26
  allowed_extensions=(".txt", ".py", ".md", ".html")
28
27
  )
28
+ self.chain.set_inline_keyboard(
29
+ {
30
+ "« Back": self.handoff_back_or("StartHandler")
31
+ }
32
+ )
29
33
 
30
34
  self.chain.disable_timeout_warnings()
31
35
  self.chain.edit()
@@ -17,7 +17,7 @@
17
17
  # along with Telekit. If not, see <https://www.gnu.org/licenses/>.
18
18
  #
19
19
 
20
- from typing import Any, Literal
20
+ from typing import Any, Literal, NoReturn
21
21
  import textwrap
22
22
  import io
23
23
 
@@ -27,7 +27,7 @@ from telebot.types import (
27
27
  ReplyParameters, LinkPreviewOptions,
28
28
  InputMediaPhoto, InputFile,
29
29
  InputMediaAudio, InputMediaDocument,
30
- InputMediaVideo,
30
+ InputMediaVideo, InputMediaAnimation
31
31
  )
32
32
 
33
33
  from telekit.styles import TextEntity, Escape, Raw, Group, Bold, Italic
@@ -90,6 +90,9 @@ class TempMessageStore:
90
90
  # Base Sender
91
91
  # ---------------------------------------------------------------------------------
92
92
 
93
+ class _FallbackToSend(Exception):
94
+ """Raised when editing is not supported — silently falls back to delete + send."""
95
+
93
96
  class BaseSender:
94
97
 
95
98
  bot: TeleBot
@@ -109,6 +112,15 @@ class BaseSender:
109
112
 
110
113
  parse_mode: Literal["html", "markdown"] | None
111
114
 
115
+ def _get_parse_mode(self):
116
+ match self.parse_mode:
117
+ case "html":
118
+ return "HTML"
119
+ case "markdown":
120
+ return "MarkdownV2"
121
+ case _:
122
+ return None
123
+
112
124
  def __init__(
113
125
  self,
114
126
  chat_id: int,
@@ -872,6 +884,25 @@ class BaseSender:
872
884
  # Internal send dispatcher
873
885
  # --------------------------------------------------------
874
886
 
887
+ def _edit_or_send(self) -> tuple[Message | None, bool]:
888
+ if self.edit_message_id:
889
+
890
+ try:
891
+ return self._edit(), True
892
+ except _FallbackToSend:
893
+ # silently delete and resend
894
+ self._delete_message(self.edit_message_id)
895
+ except Exception as exception:
896
+ if "Bad Request: there is no text in the message to edit" not in str(exception):
897
+ library.warning(f"Failed to edit message {self.edit_message_id}, sending new one instead. Exception: {exception}")
898
+ self._delete_message(self.edit_message_id)
899
+
900
+ return self._send(), False
901
+
902
+ # --------------------------------------------------------
903
+ # Internal methods for sending messages
904
+ # --------------------------------------------------------
905
+
875
906
  def _send(self) -> Message | None:
876
907
  if self.photo:
877
908
  message = self._send_photo()
@@ -982,40 +1013,33 @@ class BaseSender:
982
1013
  )
983
1014
 
984
1015
  # --------------------------------------------------------
985
- # Internal methods for sending and editing messages
1016
+ # Internal methods for editing messages
986
1017
  # --------------------------------------------------------
987
1018
 
988
- def _get_parse_mode(self):
989
- match self.parse_mode:
990
- case "html":
991
- return "HTML"
992
- case "markdown":
993
- return "MarkdownV2"
994
- case _:
995
- return None
996
-
997
1019
  def _edit(self) -> Message | None:
998
1020
  if not self.edit_message_id:
999
1021
  raise ValueError("edit_message_id is None: Unable to edit message without a valid message ID.")
1000
-
1001
- configs = self._get_edit_params()
1002
-
1022
+
1003
1023
  if self.photo:
1004
- media = InputMediaPhoto(
1005
- media=self.photo,
1006
- caption=self.text,
1007
- parse_mode=self._get_parse_mode()
1008
- )
1009
- message = self.bot.edit_message_media(
1010
- media=media,
1011
- **configs
1012
- )
1024
+ message = self._edit_photo()
1025
+ elif self.document:
1026
+ message = self._edit_document()
1027
+ elif self.video:
1028
+ message = self._edit_video()
1029
+ elif self.animation:
1030
+ message = self._edit_animation()
1031
+ elif self.audio:
1032
+ message = self._edit_audio()
1033
+ elif self.voice:
1034
+ message = self._edit_voice()
1035
+ elif self.video_note:
1036
+ message = self._edit_video_note()
1037
+ elif self.venue:
1038
+ message = self._edit_venue()
1039
+ elif self.media:
1040
+ message = self._edit_media()
1013
1041
  else:
1014
- message = self.bot.edit_message_text(
1015
- text=self.text,
1016
- parse_mode=self._get_parse_mode(),
1017
- **configs
1018
- )
1042
+ message = self._edit_text()
1019
1043
 
1020
1044
  self._reset_after_send()
1021
1045
 
@@ -1023,17 +1047,84 @@ class BaseSender:
1023
1047
  self.sent_message = message
1024
1048
  return message
1025
1049
 
1026
- def _edit_or_send(self) -> tuple[Message | None, bool]:
1027
- if self.edit_message_id:
1050
+ def _edit_text(self) -> Message | bool:
1051
+ configs: dict = self._get_edit_params()
1052
+ return self.bot.edit_message_text(
1053
+ text=self.text,
1054
+ parse_mode=self._get_parse_mode(),
1055
+ link_preview_options=self.link_preview_options,
1056
+ **configs,
1057
+ )
1028
1058
 
1029
- try:
1030
- return self._edit(), True
1031
- except Exception as exception:
1032
- if "Bad Request: there is no text in the message to edit" not in str(exception):
1033
- library.warning(f"Failed to edit message {self.edit_message_id}, sending new one instead. Exception: {exception}")
1034
- self._delete_message(self.edit_message_id)
1035
-
1036
- return self._send(), False
1059
+ def _edit_document(self) -> Message | bool:
1060
+ configs: dict = self._get_edit_params()
1061
+ media = InputMediaDocument(
1062
+ media=self.document,
1063
+ caption=self.text,
1064
+ parse_mode=self._get_parse_mode(),
1065
+ )
1066
+ return self.bot.edit_message_media(media=media, **configs)
1067
+
1068
+ def _edit_video(self) -> Message | bool:
1069
+ configs: dict = self._get_edit_params()
1070
+ media = InputMediaVideo(
1071
+ media=self.video,
1072
+ caption=self.text,
1073
+ show_caption_above_media=self.show_caption_above_media,
1074
+ parse_mode=self._get_parse_mode(),
1075
+ )
1076
+ return self.bot.edit_message_media(media=media, **configs)
1077
+
1078
+ def _edit_animation(self) -> Message | bool:
1079
+ configs: dict = self._get_edit_params()
1080
+ media = InputMediaAnimation(
1081
+ media=self.animation,
1082
+ caption=self.text,
1083
+ show_caption_above_media=self.show_caption_above_media,
1084
+ parse_mode=self._get_parse_mode(),
1085
+ )
1086
+ return self.bot.edit_message_media(media=media, **configs)
1087
+
1088
+ def _edit_audio(self) -> Message | bool:
1089
+ configs: dict = self._get_edit_params()
1090
+ media = InputMediaAudio(
1091
+ media=self.audio,
1092
+ caption=self.text,
1093
+ performer=self.audio_performer,
1094
+ title=self.audio_title,
1095
+ parse_mode=self._get_parse_mode(),
1096
+ )
1097
+ return self.bot.edit_message_media(media=media, **configs)
1098
+
1099
+ def _edit_photo(self) -> Message | bool:
1100
+ configs: dict = self._get_edit_params()
1101
+
1102
+ media = InputMediaPhoto(
1103
+ media=self.photo,
1104
+ caption=self.text,
1105
+ show_caption_above_media=self.show_caption_above_media,
1106
+ parse_mode=self._get_parse_mode()
1107
+ )
1108
+ return self.bot.edit_message_media(
1109
+ media=media,
1110
+ **configs
1111
+ )
1112
+
1113
+ def _edit_voice(self) -> NoReturn:
1114
+ # Voice messages cannot be edited.
1115
+ raise _FallbackToSend() # fallback to _edit_or_send
1116
+
1117
+ def _edit_video_note(self) -> NoReturn:
1118
+ # Video notes cannot be edited.
1119
+ raise _FallbackToSend() # fallback to _edit_or_send
1120
+
1121
+ def _edit_venue(self) -> NoReturn:
1122
+ # Venues cannot be edited.
1123
+ raise _FallbackToSend() # fallback to _edit_or_send
1124
+
1125
+ def _edit_media(self) -> NoReturn:
1126
+ # Media groups cannot be edited.
1127
+ raise _FallbackToSend() # fallback to _edit_or_send
1037
1128
 
1038
1129
  # --------------------------------------------------------
1039
1130
  # Methods for deleting messages
@@ -0,0 +1,2 @@
1
+ from .track_handoff_origin import TrackHandoffOrigin
2
+ from .paginated_choice import PaginatedChoice
@@ -0,0 +1,109 @@
1
+ from typing import Callable, Any, Iterable
2
+
3
+ import telekit
4
+ from dataclasses import dataclass
5
+
6
+ @dataclass
7
+ class _NavButton:
8
+ start_index: int
9
+
10
+ class PaginatedChoice(telekit.Handler):
11
+
12
+ # ── entry point ───────────────────────────────────────────────
13
+
14
+ def __init__(self, *args, **kwargs):
15
+ super().__init__(*args, **kwargs)
16
+
17
+ def paginated_choice[T](self, choices: dict[str, T] | Iterable[T], on_choice: Callable[[T], Any], on_update: Callable[[], Any] | None = None, row_width: int = 1, page_size: int = 10) -> None:
18
+ """
19
+ Display a paginated inline keyboard for choosing from a list of items.
20
+
21
+ Navigation buttons (``« Back``, ``Next »``) are added automatically
22
+ when the item count exceeds ``page_size``. If only one item is present,
23
+ ``on_choice`` is called immediately without rendering a keyboard.
24
+
25
+ :param choices: Items to display. Accepts a ``dict[str, T]`` (label: value),
26
+ or any ``Iterable[T]``.
27
+ :type choices: ``dict[str, T] | Iterable[T]``
28
+ :param on_choice: Callback invoked with the selected value.
29
+ :type on_choice: ``Callable[[T], Any]``
30
+ :param on_update: Optional callback invoked before each page render.
31
+ Useful for updating entry text.
32
+ :type on_update: ``Callable[[], Any] | None``
33
+ :param row_width: Number of choice buttons per row. Defaults to ``1``.
34
+ :type row_width: ``int``
35
+ :param page_size: Maximum number of items per page. Defaults to ``10``.
36
+ :type page_size: ``int``
37
+
38
+ Example::
39
+
40
+ self.paginated_choice(
41
+ choices="ABCDEFGHIJKLMNOPQRSTUVWXYZ",
42
+ on_choice=self.handle_letter,
43
+ on_update=lambda: self.chain.set_entry_text(self.handle_letter),
44
+ row_width=5,
45
+ )
46
+ """
47
+ if isinstance(choices, dict):
48
+ _choices: dict[str, T] = choices.copy() # pyright: ignore[reportAssignmentType]
49
+ else:
50
+ _choices: dict[str, T] = {str(c): c for c in choices}
51
+
52
+ if len(_choices) == 1:
53
+ on_choice(next(iter(_choices.values())))
54
+ return
55
+
56
+ self._paginated_choice(0, _choices, on_choice, on_update, row_width, page_size)
57
+
58
+ def _paginated_choice[T](self, start: int, choices: dict[str, T], on_choice: Callable[[T], Any], on_update: Callable[[], Any] | None, row_width: int, page_size: int) -> None:
59
+ total = len(choices)
60
+
61
+ page = start // page_size + 1
62
+ pages = (total + page_size - 1) // page_size
63
+ page_items = list(choices.items())[start : start + page_size]
64
+
65
+ if on_update is not None:
66
+ on_update()
67
+
68
+ keyboard: dict[str, T | _NavButton] = {}
69
+ keyboard.update(page_items)
70
+
71
+ # navigation row
72
+ has_back = start - page_size >= 0
73
+ has_next = start + page_size < total
74
+
75
+ nav: dict[str, T | _NavButton] = {}
76
+
77
+ if has_back:
78
+ nav["« Back"] = _NavButton(start - page_size)
79
+ if has_back and has_next:
80
+ nav[f"({page}/{pages})" ] = _NavButton(start)
81
+ if has_next:
82
+ nav["Next »"] = _NavButton(start + page_size)
83
+
84
+ # each choice on its own row, nav row at the end
85
+ choices_count = len(keyboard)
86
+ nav_count = len(nav)
87
+
88
+ full_rows = choices_count // row_width
89
+ remainder = choices_count % row_width
90
+
91
+ if remainder > 0:
92
+ _row_width = (*([row_width] * full_rows), remainder, nav_count)
93
+ else:
94
+ _row_width = (*([row_width] * full_rows), nav_count)
95
+
96
+ keyboard.update(nav)
97
+
98
+ def _on_choice(choice: T | _NavButton):
99
+ if isinstance(choice, _NavButton):
100
+ self._paginated_choice(choice.start_index, choices, on_choice, on_update, row_width, page_size)
101
+ else:
102
+ on_choice(choice)
103
+
104
+ self.chain.set_inline_choice(
105
+ _on_choice,
106
+ keyboard,
107
+ row_width=_row_width,
108
+ )
109
+ self.chain.edit()
@@ -0,0 +1,99 @@
1
+ from typing import Callable
2
+
3
+ import telekit
4
+
5
+ class TrackHandoffOrigin(telekit.Handler):
6
+
7
+ TRACK_HANDOFF_ORIGIN: bool = True
8
+
9
+ def __init__(self, *args, **kwargs):
10
+ super().__init__(*args, **kwargs)
11
+ self.handoff_origin: telekit.Handler | None = None
12
+
13
+ def _on_handoff(self, origin: telekit.Handler) -> None:
14
+ """
15
+ Called when this handler is reached via :meth:`handoff`.
16
+ Override to customize handoff behaviour.
17
+ """
18
+ if self.TRACK_HANDOFF_ORIGIN:
19
+ self.handoff_origin = origin
20
+
21
+ super()._on_handoff(origin)
22
+
23
+ @property
24
+ def is_handed_off(self) -> bool:
25
+ """
26
+ Whether this handler was reached via :meth:`handoff`.
27
+
28
+ - ``True`` if a previous handler transferred control here,
29
+ - ``False`` if this handler was invoked directly.
30
+ """
31
+ return self.handoff_origin is not None
32
+
33
+ def handoff_back(self, *args, **kwargs):
34
+ """
35
+ Transfer control back to the handler that handed off to this one.
36
+
37
+ Useful for implementing "« Back" buttons without hardcoding the
38
+ previous handler class:
39
+
40
+ .. code-block:: python
41
+ self.chain.set_inline_keyboard({
42
+ "« Back": self.handoff_back
43
+ })
44
+
45
+ Any positional or keyword arguments are forwarded directly to
46
+ ``handoff_origin.handle()``:
47
+
48
+ .. code-block:: python
49
+ self.handoff_back("some_arg", key="value")
50
+ # equivalent to: handoff_origin.handle("some_arg", key="value")
51
+
52
+
53
+ :raises RuntimeError: If this handler was not reached via :meth:`handoff`
54
+ (i.e. ``handoff_origin`` is ``None``).
55
+ """
56
+ if self.handoff_origin is None:
57
+ raise RuntimeError(
58
+ f"{type(self).__name__}().handoff_back() called, but this handler "
59
+ "was not reached via handoff() - handoff_origin is None."
60
+ )
61
+
62
+ self.handoff_origin.chain._set_previous_message(self.chain.get_previous_message())
63
+ self.handoff_origin.handle(*args, **kwargs)
64
+
65
+ def handoff_back_or(self, handler: type[telekit.Handler] | str) -> Callable[..., None]:
66
+ """
67
+ Return a callable that transfers control back to the origin handler,
68
+ or falls back to ``handler`` if this handler was invoked directly.
69
+
70
+ Useful for ``« Back`` buttons in handlers that can be reached both
71
+ via :meth:`handoff` and directly:
72
+
73
+ .. code-block:: python
74
+ self.chain.set_inline_keyboard({
75
+ "« Back": self.handoff_back_or(StartHandler)
76
+ })
77
+
78
+ Any positional or keyword arguments passed to the returned callable
79
+ are forwarded to ``handle()`` of whichever handler is invoked:
80
+
81
+ .. code-block:: python
82
+ back = self.handoff_back_or(StartHandler)
83
+ back("some_arg", key="value")
84
+ # if handed off: handoff_origin.handle("some_arg", key="value")
85
+ # if direct: StartHandler.handle("some_arg", key="value")
86
+
87
+ :param handler: Fallback handler class (or name) to transfer control to when
88
+ ``handoff_origin`` is ``None``.
89
+ :type handler: ``type[Handler]`` | ``str``
90
+ :return: A zero-argument callable that performs the handoff.
91
+ :rtype: ``Callable[..., None]``
92
+ """
93
+ def handoff_invoker(*args, **kwargs):
94
+ if self.handoff_origin is None:
95
+ self.handoff(handler).handle(*args, **kwargs)
96
+ else:
97
+ self.handoff_back(*args, **kwargs)
98
+
99
+ return handoff_invoker
@@ -1,3 +1,4 @@
1
+ import os
1
2
  import re
2
3
 
3
4
  from pathlib import Path
@@ -6,6 +7,10 @@ from typing import Literal
6
7
 
7
8
  ROOT_DIR = Path(__file__).resolve().parent # telekit/
8
9
 
10
+ # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
11
+ # Checks
12
+ # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
13
+
9
14
  def is_valid_callback_data(callback_data: str) -> bool:
10
15
  """
11
16
  Check whether a callback data string is valid for Telegram inline buttons.
@@ -31,6 +36,9 @@ def is_valid_callback_data(callback_data: str) -> bool:
31
36
  byte_size = len(callback_data.encode('utf-8'))
32
37
  return 1 <= byte_size <= 64
33
38
 
39
+ # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
40
+ # Formatting
41
+ # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
34
42
 
35
43
  def format_file_size(size: int, precision: int = 1) -> str:
36
44
  """
@@ -105,46 +113,140 @@ def format_file_size(size: int, precision: int = 1) -> str:
105
113
 
106
114
  return f"{formatted} {units[index]}"
107
115
 
108
- def read_token(path: str = "token.txt") -> str:
116
+ # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
117
+ # Environment
118
+ # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
119
+
120
+ def _split_env_path(path: str, default: str) -> tuple[str, str]:
121
+ if ":" in path:
122
+ path, name = path.split(":", 1)
123
+ else:
124
+ path, name = path, default
125
+
126
+ return path, name
127
+
128
+
129
+ def load_env(path=".env") -> dict[str, str]:
130
+ """
131
+ Load all key-value pairs from a ``.env`` file.
132
+
133
+ Lines starting with ``#`` and empty lines are ignored.
134
+
135
+ :param path: Path to the ``.env`` file. Defaults to ``".env"``.
136
+ :type path: ``str``
137
+ :return: Dictionary of all key-value pairs found in the file,
138
+ or an empty dict if the file does not exist.
139
+ :rtype: ``dict[str, str]``
140
+ """
141
+ if not os.path.exists(path):
142
+ return {}
143
+
144
+ env: dict[str, str] = {}
145
+
146
+ with open(path, "r") as f:
147
+ for line in f:
148
+ line = line.strip()
149
+ if not line or line.startswith("#"):
150
+ continue
151
+
152
+ key, value = line.split("=", 1)
153
+ env[key.strip()] = value.strip()
154
+
155
+ return env
156
+
157
+
158
+ def read_envar(path: str, name: str) -> str:
159
+ """
160
+ Read a single environment variable from a ``.env`` file.
161
+
162
+ :param path: Path to the ``.env`` file.
163
+ :type path: ``str``
164
+ :param name: Name of the key to read.
165
+ :type name: ``str``
166
+ :return: Value of the key.
167
+ :rtype: ``str``
168
+ :raises KeyError: If ``name`` is not found in the file.
109
169
  """
110
- Read the bot token from a file.
170
+ env: dict[str, str] = load_env(path)
111
171
 
112
- Reads only the first line, so multiple tokens can be stored in the file
113
- and swapped quickly by reordering lines — no need to delete or copy.
172
+ if name not in env:
173
+ raise KeyError(f"{name} not found in {path}")
114
174
 
115
- Inline comments are supported — everything after the first whitespace
116
- is ignored::
175
+ return env[name]
117
176
 
177
+
178
+ def read_token(path: str = "token.txt") -> str:
179
+ """
180
+ Read the bot token from a file or ``.env``.
181
+
182
+ **Plain text file** (default)::
183
+
184
+ # token.txt
118
185
  123456789:BotSecretToken Main production bot
119
186
  987654321:AnotherToken Backup bot
120
187
 
121
- :param path: Path to the token file
188
+ Reads only the first line. Inline comments (everything after the first
189
+ whitespace) are ignored. Multiple tokens can be stored and swapped by
190
+ reordering lines.
191
+
192
+ **Environment file** (``.env``)::
193
+
194
+ read_token(".env") # reads the key named TOKEN
195
+ read_token(".env:TOKEN") # same, explicit key
196
+ read_token(".env:BOT_KEY") # reads a custom key
197
+
198
+ :param path: Path to a token file, or ``".env"`` / ``".env:KEY"`` for
199
+ environment files. Defaults to ``"token.txt"``.
122
200
  :type path: ``str``
123
- :return: Bot token string
201
+ :return: Bot token string.
124
202
  :rtype: ``str``
203
+ :raises KeyError: If the key is not found in the ``.env`` file.
204
+ :raises FileNotFoundError: If the file does not exist.
125
205
  """
206
+ if path.endswith(".env") or ".env:" in path:
207
+ return read_envar(*_split_env_path(path, "TOKEN"))
208
+
126
209
  with open(path) as f:
127
210
  first_line: str = f.readline().strip()
128
211
  token, *_ = first_line.split()
129
212
  return token
213
+
130
214
 
131
215
  def read_canvas_path(path: str = "canvas_path.txt") -> str:
132
216
  """
133
- Read the ``.canvas`` file path from a file.
217
+ Read the ``.canvas`` file path from a file or ``.env``.
218
+
219
+ **Plain text file** (default)::
134
220
 
135
- Reads only the first line, so multiple pathes can be stored in the file
136
- and swapped quickly by reordering lines — no need to delete or copy.
221
+ # canvas_path.txt
222
+ /home/user/project/main.canvas Production canvas
223
+ /home/user/project/test.canvas Test canvas
137
224
 
138
- :param path: Path to the file containing the canvas path
225
+ Reads only the first line. Multiple paths can be stored and swapped by
226
+ reordering lines.
227
+
228
+ **Environment file** (``.env``)::
229
+
230
+ read_canvas_path(".env") # reads the key named CANVAS_PATH
231
+ read_canvas_path(".env:CANVAS_PATH") # same, explicit key
232
+ read_canvas_path(".env:MY_CANVAS") # reads a custom key
233
+
234
+ :param path: Path to a canvas path file, or ``".env"`` / ``".env:KEY"`` for
235
+ environment files. Defaults to ``"canvas_path.txt"``.
139
236
  :type path: ``str``
140
- :return: Path to the ``.canvas`` file
237
+ :return: Path to the ``.canvas`` file.
141
238
  :rtype: ``str``
239
+ :raises KeyError: If the key is not found in the ``.env`` file.
240
+ :raises FileNotFoundError: If the file does not exist.
142
241
  """
242
+ if path.endswith(".env") or ".env:" in path:
243
+ return read_envar(*_split_env_path(path, "CANVAS_PATH"))
244
+
143
245
  with open(path) as f:
144
246
  return f.readline().strip()
145
247
 
146
248
  # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
147
- # Link Generators
249
+ # Link Generating
148
250
  # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
149
251
 
150
252
  def make_bot_link(botname: str, start: str | None = None) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telekit
3
- Version: 2.2.0a3
3
+ Version: 2.3.0b1
4
4
  Summary: Declarative, developer-friendly library for building Telegram bots
5
5
  Home-page: https://github.com/Romashkaa/telekit
6
6
  Author: romashka
@@ -101,7 +101,8 @@ Even in its beta stage, Telekit accelerates bot development, offering typed comm
101
101
  - [Dialogue](https://github.com/Romashkaa/telekit/blob/main/docs/examples/dialogue.md)
102
102
  - [Risk Game](https://github.com/Romashkaa/telekit/blob/main/docs/examples/risk_game.md)
103
103
  - [Counter](https://github.com/Romashkaa/telekit/blob/main/docs/examples/counter.md)
104
- - [Quiz (Telekit DSL)](https://github.com/Romashkaa/telekit/blob/main/docs/examples/quiz.md)
104
+ - [Quiz (DSL)](https://github.com/Romashkaa/telekit/blob/main/docs/examples/quiz.md)
105
+ - [Hotel (DSL)](https://github.com/Romashkaa/telekit/blob/main/docs/examples/complete_hotel.md)
105
106
  - [More...](https://github.com/Romashkaa/telekit/blob/main/docs/examples/examples.md)
106
107
 
107
108
  ## Overview
@@ -378,49 +379,86 @@ It tries to make Telegram bot development easier.
378
379
 
379
380
  ---
380
381
 
381
- # Changes in version 2.2.0a3
382
+ # Changes in version 2.3.0b1
382
383
 
383
- ## New Button Types
384
+ ## Traits
384
385
 
385
- | **Name** | **Description** |
386
- |----------------------|----------------------------------------------------------|
387
- | `AlertButton` | A callback button that shows a popup alert when pressed. |
388
- | `NotificationButton` | A callback button that shows a notification. |
389
- | `InvokeButton` | A callback button that calls the object method. |
386
+ | **Name** | **Description** |
387
+ | -------------------------- | --------------------------------------------------------------------- |
388
+ | `TrackHandoffOrigin` | Tracks which handler transferred control to this one via `handoff()`. |
389
+ | `PaginatedChoice` | Adds a paginated inline keyboard for choosing from a list of items. |
390
390
 
391
- ## User Improvements
391
+ ### TrackHandoffOrigin
392
392
 
393
- | **Name** | **Description** |
394
- | ---------------------- | --------------------------------------------------------- |
395
- | `bio` | Bio of the user or description of the chat. |
396
- | `birthdate` | Birthdate of the user, if set and visible. |
397
- | `description` | Description of the group or channel. |
398
- | `mention` | `tg://user?id=` deep link, works even without a username. |
399
- | `is_private` | Whether the message was sent in a private chat. |
400
- | `is_group` | Whether the message was sent in a group. |
401
- | `is_supergroup` | Whether the message was sent in a supergroup. |
402
- | `is_channel` | Whether the message was sent in a channel. |
403
- | `avatar` | File ID of the user's most recent profile photo. |
404
- | `profile_photos_count` | Total number of profile photos the user has set. |
393
+ `TrackHandoffOrigin` adds three members to any handler that inherits it:
405
394
 
406
- - Refactor: `User` now accepts a `Message` object instead of `chat_id` + `from_user`. All properties are derived from `_sender` (`from_user` or `chat`) and migrated to `cached_property`. Fixed broken `get_id` and `get_full_name` references.
407
- - Added `__repr__`.
395
+ | **Member** | **Description** |
396
+ | ------------------- | -------------------------------------------------------------------------------- |
397
+ | `handoff_origin` | The handler instance that handed off to this one, or `None` if invoked directly. |
398
+ | `is_handed_off` | `True` if this handler was reached via `handoff()`, `False` otherwise. |
399
+ | `handoff_back()` | Transfer control back to the origin handler via `self.handoff_origin.handle()` |
400
+ | `handoff_back_or(handler)` | Like `handoff_back()`, but falls back to `handler` on fail. |
408
401
 
409
- ## Sender Improvements
402
+ Set `TRACK_HANDOFF_ORIGIN = False` on any subclass to opt out of tracking.
410
403
 
411
- | **Name** | **Description** |
412
- | -------------------------- | ------------------------------------------------------ |
413
- | `sent_message` | The last message sent by this sender instance. |
414
- | `disable_notification` | Disables notification sound when the message is sent. |
415
- | `protect_content` | Protects the message contents from forwarding and saving. |
416
- | `reply_parameters` | Reply parameters for the message to be sent. |
417
- | `link_preview_options` | Link preview options for the message to be sent. |
418
- | `show_caption_above_media` | Shows the caption above the media instead of below. |
404
+ ```python
405
+ class MyHandler(TrackHandoffOrigin, telekit.Handler):
406
+
407
+ def handle(self):
408
+ ...
409
+ self.chain.set_inline_keyboard({
410
+ Back": self.handoff_back_or(StartHandler)
411
+ })
412
+ self.chain.edit()
413
+ ```
414
+
415
+ ### PaginatedChoice
419
416
 
420
- - Refactored sending system.
417
+ Renders a paginated inline keyboard from any dict or iterable.
418
+ Navigation buttons (`« Back`, `Next »`) are added automatically.
421
419
 
422
- ## Chain Improvements
420
+ | **Member** | **Description** |
421
+ | -------------------- | ---------------------------------------------------------------------------- |
422
+ | `paginated_choice(choices, on_choice, on_update, row_width)` | Display a paginated choice keyboard. |
423
423
 
424
- | **Name** | **Description** |
425
- | ----------- | ------------------------------------------------------------ |
426
- | `received` | The message received from the user during entry processing. |
424
+ ```python
425
+ self.chain.sender.set_title("🔤 What is your initial?")
426
+ self.chain.sender.set_message("Pick the first letter of your name")
427
+ self.chain.sender.set_remove_text(False)
428
+
429
+ self.paginated_choice(
430
+ choices="ABCDEFGHIJKLMNOPQRSTUVWXYZ",
431
+ on_choice=self.handle_letter,
432
+ row_width=5
433
+ )
434
+ ```
435
+
436
+ <details>
437
+ <summary>(Click to see the result)</summary>
438
+ <table>
439
+ <tr>
440
+ <td><img src="https://github.com/Romashkaa/telekit/blob/main/docs/images/paginated_choice.png?raw=true" alt="Example" width="300"></td>
441
+ </tr>
442
+ </table>
443
+ </details>
444
+
445
+ `choices` accepts a `dict[str, T]`, or any `Iterable[T]`.
446
+ If only one item is present, `on_choice` is called immediately without rendering a keyboard.
447
+
448
+ ## Utils
449
+
450
+ | **Name** | **Description** |
451
+ | ---------------- | ------------------------------------------------------------------ |
452
+ | `load_env` | Load all key-value pairs from a `.env` file into a dictionary. |
453
+ | `read_env_var` | Read a single variable by name from a `.env` file. |
454
+
455
+ - `read_token` and `read_canvas_path` now support reading from `.env` files.
456
+ Pass `".env"` to use the default key, or `".env:KEY"` to specify a custom one:
457
+
458
+ ```python
459
+ read_token(".env") # reads TOKEN
460
+ read_token(".env:BOT_TOKEN") # reads BOT_TOKEN
461
+
462
+ read_canvas_path(".env") # reads CANVAS_PATH
463
+ read_canvas_path(".env:MY_CANVAS") # reads MY_CANVAS
464
+ ```
@@ -66,4 +66,7 @@ telekit/example/example_handlers/qr.py
66
66
  telekit/example/example_handlers/quiz.py
67
67
  telekit/example/example_handlers/spells.py
68
68
  telekit/example/example_handlers/start.py
69
- telekit/example/example_handlers/text_document.py
69
+ telekit/example/example_handlers/text_document.py
70
+ telekit/traits/__init__.py
71
+ telekit/traits/paginated_choice.py
72
+ telekit/traits/track_handoff_origin.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes