telekit 2.3.0b2__tar.gz → 2.3.0b3__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 (78) hide show
  1. {telekit-2.3.0b2/telekit.egg-info → telekit-2.3.0b3}/PKG-INFO +3 -145
  2. {telekit-2.3.0b2 → telekit-2.3.0b3}/setup.py +1 -2
  3. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/__init__.py +4 -0
  4. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_chain.py +2 -1
  5. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_chain_base.py +3 -3
  6. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_init.py +1 -1
  7. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_inline_buttons.py +11 -0
  8. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_state.py +15 -2
  9. telekit-2.3.0b3/telekit/_trait.py +10 -0
  10. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_version.py +1 -1
  11. telekit-2.3.0b3/telekit/debug.py +8 -0
  12. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/__init__.py +2 -1
  13. telekit-2.3.0b3/telekit/example/example_handlers/calendar.py +223 -0
  14. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/start.py +2 -1
  15. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/senders.py +3 -1
  16. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/server.py +4 -2
  17. telekit-2.3.0b3/telekit/traits/__init__.py +3 -0
  18. telekit-2.3.0b3/telekit/traits/calendar_pick.py +807 -0
  19. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/traits/paginated_choice.py +1 -1
  20. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/traits/track_handoff_origin.py +1 -1
  21. {telekit-2.3.0b2 → telekit-2.3.0b3/telekit.egg-info}/PKG-INFO +3 -145
  22. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit.egg-info/SOURCES.txt +4 -0
  23. telekit-2.3.0b2/telekit/traits/__init__.py +0 -2
  24. {telekit-2.3.0b2 → telekit-2.3.0b3}/LICENSE +0 -0
  25. {telekit-2.3.0b2 → telekit-2.3.0b3}/README.md +0 -0
  26. {telekit-2.3.0b2 → telekit-2.3.0b3}/setup.cfg +0 -0
  27. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_buildtext/__init__.py +0 -0
  28. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_buildtext/formatter.py +0 -0
  29. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_buildtext/styles.py +0 -0
  30. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_callback_query_handler.py +0 -0
  31. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_chain_entry_logic.py +0 -0
  32. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_chain_inline_keyboards_logic.py +0 -0
  33. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_chapters/__init__.py +0 -0
  34. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_chapters/chapters.py +0 -0
  35. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_handler.py +0 -0
  36. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_input_handler.py +0 -0
  37. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_logger.py +0 -0
  38. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_on.py +0 -0
  39. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_snapvault/__init__.py +0 -0
  40. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_snapvault/snapcode.py +0 -0
  41. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_snapvault/snapvault.py +0 -0
  42. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_telekit_dsl/__init__.py +0 -0
  43. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_telekit_dsl/mixin.py +0 -0
  44. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_telekit_dsl/parser/__init__.py +0 -0
  45. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_telekit_dsl/parser/builder.py +0 -0
  46. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_telekit_dsl/parser/canvas_parser.py +0 -0
  47. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_telekit_dsl/parser/lexer.py +0 -0
  48. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_telekit_dsl/parser/nodes.py +0 -0
  49. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_telekit_dsl/parser/parser.py +0 -0
  50. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_telekit_dsl/parser/token.py +0 -0
  51. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_telekit_dsl/telekit_dsl.py +0 -0
  52. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_telekit_dsl/telekit_orm.py +0 -0
  53. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_timeout.py +0 -0
  54. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/_user.py +0 -0
  55. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/dices.py +0 -0
  56. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/__init__.py +0 -0
  57. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/complete_hotel.py +0 -0
  58. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/counter.py +0 -0
  59. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/dsl.py +0 -0
  60. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/entry.py +0 -0
  61. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/faq.py +0 -0
  62. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/hotel.py +0 -0
  63. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/on_text.py +0 -0
  64. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/pages.py +0 -0
  65. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/pyapi.py +0 -0
  66. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/qr.py +0 -0
  67. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/quiz.py +0 -0
  68. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/spells.py +0 -0
  69. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_handlers/text_document.py +0 -0
  70. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/example/example_server.py +0 -0
  71. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/inline_buttons.py +0 -0
  72. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/parameters.py +0 -0
  73. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/styles.py +0 -0
  74. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/types.py +0 -0
  75. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit/utils.py +0 -0
  76. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit.egg-info/dependency_links.txt +0 -0
  77. {telekit-2.3.0b2 → telekit-2.3.0b3}/telekit.egg-info/requires.txt +0 -0
  78. {telekit-2.3.0b2 → telekit-2.3.0b3}/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.3.0b2
3
+ Version: 2.3.0b3
4
4
  Summary: Declarative, developer-friendly library for building Telegram bots
5
5
  Home-page: https://github.com/Romashkaa/telekit
6
6
  Author: romashka
@@ -379,148 +379,6 @@ It tries to make Telegram bot development easier.
379
379
 
380
380
  ---
381
381
 
382
- # Changes in version 2.3.0b2
382
+ # Changes in version 2.3.0b3
383
383
 
384
- ## Inline Buttons
385
-
386
- | **Name** | **Description** |
387
- | ---------------- | ------------------------------------------------------------------------------- |
388
- | `StaticButton` | A non-interactive inline button that performs no action when pressed. |
389
- | `AnswerButton` | A button that responds to a callback query with a notification or alert without executing custom logic. |
390
-
391
- - `AnswerButton` and its subclasses (`AlertButton`, `NotificationButton`) now support the `persistent` parameter (Defauts to `True`):
392
- - `True` — non-blocking hint (does not affect further interactions)
393
- - `False` — terminates interaction after click
394
-
395
- ## Traits
396
-
397
- | **Name** | **Description** |
398
- | -------------------------- | --------------------------------------------------------------------- |
399
- | `TrackHandoffOrigin` | Tracks which handler transferred control to this one via `handoff()`. |
400
- | `PaginatedChoice` | Adds a paginated inline keyboard for choosing from a list of items. |
401
-
402
- ### TrackHandoffOrigin
403
-
404
- `TrackHandoffOrigin` adds three members to any handler that inherits it:
405
-
406
- | **Member** | **Description** |
407
- | ------------------- | -------------------------------------------------------------------------------- |
408
- | `handoff_origin` | The handler instance that handed off to this one, or `None` if invoked directly. |
409
- | `is_handed_off` | `True` if this handler was reached via `handoff()`, `False` otherwise. |
410
- | `handoff_back()` | Transfer control back to the origin handler via `self.handoff_origin.handle()` |
411
- | `handoff_back_or(handler)` | Like `handoff_back()`, but falls back to `handler` on fail. |
412
-
413
- Set `TRACK_HANDOFF_ORIGIN = False` on any subclass to opt out of tracking.
414
-
415
- ```python
416
- class MyHandler(TrackHandoffOrigin, telekit.Handler):
417
-
418
- def handle(self):
419
- ...
420
- self.chain.set_inline_keyboard({
421
- "« Back": self.handoff_back_or(StartHandler)
422
- })
423
- self.chain.edit()
424
- ```
425
-
426
- ### PaginatedChoice
427
-
428
- Renders a paginated inline keyboard from any dict or iterable.
429
- Navigation buttons (`« Back`, `Next »`) are added automatically.
430
-
431
- | **Member** | **Description** |
432
- | -------------------- | ---------------------------------------------------------------------------- |
433
- | `paginated_choice(choices, on_choice, ...)` | Display a paginated choice keyboard. |
434
- | `PAGINATED_CHOICE_BACK_LABEL` | Label for the back navigation button. Defaults to `« Back`. |
435
- | `PAGINATED_CHOICE_NEXT_LABEL` | Label for the next navigation button. Defaults to `Next »`. |
436
- | `PAGINATED_CHOICE_PAGE_LABEL` | Label template for the page indicator button. Supports `{page}` and `{pages}` placeholders. Set to `None` to hide. Defaults to `{page} / {pages}`. |
437
-
438
-
439
- ```python
440
- self.chain.sender.set_title("🔤 What is your initial?")
441
- self.chain.sender.set_message("Pick the first letter of your name")
442
- self.chain.sender.set_remove_text(False)
443
-
444
- self.paginated_choice(
445
- choices="ABCDEFGHIJKLMNOPQRSTUVWXYZ",
446
- on_choice=self.handle_letter,
447
- row_width=5
448
- )
449
- ```
450
-
451
- Override labels per handler to localise or restyle navigation:
452
-
453
- ```python
454
- class MyHandler(PaginatedChoice, telekit.Handler):
455
- PAGINATED_CHOICE_BACK_LABEL = "⬅️ Назад"
456
- PAGINATED_CHOICE_NEXT_LABEL = "Далі ➡️"
457
- PAGINATED_CHOICE_PAGE_LABEL = None # hide page indicator
458
- ```
459
-
460
- <details>
461
- <summary>(Click to see the result)</summary>
462
- <table>
463
- <tr>
464
- <td><img src="https://github.com/Romashkaa/telekit/blob/main/docs/images/paginated_choice.png?raw=true" alt="Example" width="300"></td>
465
- </tr>
466
- </table>
467
- </details>
468
-
469
- `choices` accepts a `dict[str, T]`, or any `Iterable[T]`.
470
- If only one item is present, `on_choice` is called immediately without rendering a keyboard.
471
-
472
- ## Utils
473
-
474
- | **Name** | **Description** |
475
- | ------------------ | ------------------------------------------------------------------ |
476
- | `load_env` | Load all key-value pairs from a `.env` file into a dictionary. |
477
- | `read_env_var` | Read a single variable by name from a `.env` file. |
478
- | `compose_keyboard` | Merge multiple button groups into a single inline keyboard with computed `row_width`. |
479
-
480
- ### Environment Utils
481
-
482
- - `read_token` and `read_canvas_path` now support reading from `.env` files.
483
- Pass `".env"` to use the default key, or `".env:KEY"` to specify a custom one:
484
-
485
- ```python
486
- read_token(".env") # reads TOKEN
487
- read_token(".env:BOT_TOKEN") # reads BOT_TOKEN
488
-
489
- read_canvas_path(".env") # reads CANVAS_PATH
490
- read_canvas_path(".env:MY_CANVAS") # reads MY_CANVAS
491
- ```
492
-
493
- ### Inline Keyboard Utils
494
-
495
- `compose_keyboard` combines multiple button groups into a single keyboard and automatically calculates `row_width` for each group.
496
-
497
- Each group is laid out independently using its corresponding width from `widths`.
498
- A width of `-1` means "all buttons in one row" (i.e. ``len(group)``).
499
-
500
- | **Param** | **Type** | **Description** |
501
- |-----------|------------------|---------------------------------------------|
502
- | `groups` | `dict[str, Any]` | One or more button groups |
503
- | `widths` | `Iterable[int]` | Row width per group or single value for all |
504
-
505
- | **Returns** | **Type** |
506
- |------------|----------|
507
- | `keyboard` | `dict[str, Any]` |
508
- | `row_width` | `tuple[int, ...]` |
509
-
510
- ```py
511
- # row_width → (1, 3, 3, 3, 2)
512
- # layout:
513
- # | 🆕 Create |
514
- # | 1 | 2 | 3 |
515
- # | 4 | 5 | 6 |
516
- # | 7 | 8 | 9 |
517
- # | « Back | Next » |
518
-
519
- keyboard, row_width = compose_keyboard(
520
- {"🆕 Create": "create"},
521
- {str(n): str(n) for n in range(1, 10)},
522
- {"« Back": "back", "Next »": "next"},
523
- widths=(-1, 3, -1), # or (1, 3, 2)
524
- )
525
- self.chain.set_inline_choice(keyboard, row_width)
526
- ```
384
+ [View changelog on GitHub](https://github.com/Romashkaa/telekit/blob/main/CHANGELOG.md)
@@ -28,8 +28,7 @@ def readme():
28
28
  return f.read()
29
29
 
30
30
  def changelog():
31
- with open('CHANGELOG.md', 'r') as f:
32
- return f.read()
31
+ return f"[View changelog on GitHub](https://github.com/Romashkaa/telekit/blob/main/CHANGELOG.md)"
33
32
 
34
33
  def install_requires():
35
34
  with open('telekit/requirements.txt', 'r') as f:
@@ -18,6 +18,7 @@
18
18
  #
19
19
 
20
20
  from ._handler import Handler
21
+ from ._trait import Trait
21
22
  from ._chain import Chain
22
23
  from ._callback_query_handler import CallbackQueryHandler
23
24
  from .server import Server, example
@@ -36,6 +37,7 @@ from . import inline_buttons
36
37
  from . import dices
37
38
  from . import utils
38
39
  from . import traits
40
+ from . import debug
39
41
 
40
42
  Styles = styles.Styles
41
43
 
@@ -56,6 +58,7 @@ __all__ = [
56
58
 
57
59
  "Server",
58
60
  "Chain",
61
+ "Trait",
59
62
  "Handler",
60
63
  "CallbackQueryHandler",
61
64
 
@@ -66,4 +69,5 @@ __all__ = [
66
69
  "enable_file_logging",
67
70
  "chapters",
68
71
  "example",
72
+ "debug",
69
73
  ]
@@ -26,6 +26,7 @@ from telebot.types import Message
26
26
 
27
27
  # Local modules
28
28
  from . import senders
29
+ from .debug import Debug
29
30
  from .styles import TextEntity
30
31
 
31
32
  # Chain modules
@@ -154,7 +155,7 @@ class Chain(ChainInlineKeyboardLogic, ChainEntryLogic):
154
155
  self.sender.set_edit_message(None)
155
156
  self._previous_message = message
156
157
 
157
- if self._timeout_warnings_enabled and _handler and not _timeout:
158
+ if Debug.timeout_warnings and _handler and not _timeout:
158
159
  library.warning(
159
160
  "Next-message handler is active, but no timeout was set for the chain. "
160
161
  "This may cause the bot to wait indefinitely."
@@ -31,14 +31,13 @@ from . import _input_handler
31
31
  from . import _timeout
32
32
 
33
33
  # Logging
34
+ from .debug import Debug
34
35
  from ._logger import logger
35
36
  library = logger.library
36
37
 
37
38
  class ChainBase:
38
39
 
39
40
  bot: telebot.TeleBot
40
-
41
- _timeout_warnings_enabled: bool = True
42
41
 
43
42
  @classmethod
44
43
  def _init(cls, bot: telebot.TeleBot):
@@ -206,4 +205,5 @@ class ChainBase:
206
205
  # Timeout API
207
206
 
208
207
  def disable_timeout_warnings(self, value: bool = True) -> None:
209
- self._timeout_warnings_enabled = not value
208
+ """.. deprecated:: Set ``Debug.timeout_warnings`` directly."""
209
+ Debug.timeout_warnings = not value
@@ -31,7 +31,7 @@ import telebot
31
31
  __all__ = ["init"]
32
32
 
33
33
  def init(bot: telebot.TeleBot) -> None:
34
- TelekitState.init(bot)
34
+ TelekitState._init(bot)
35
35
  BaseSender._init(bot)
36
36
  Handler._init(bot)
37
37
  Chain._init(bot)
@@ -26,6 +26,8 @@ from telebot.types import InlineKeyboardButton, CallbackQuery
26
26
  from telebot import TeleBot
27
27
  from ._callback_query_handler import CallbackQueryHandler
28
28
 
29
+ from ._state import TelekitState
30
+
29
31
  __all__ = [
30
32
  "InlineButton",
31
33
 
@@ -122,6 +124,15 @@ class InlineButton:
122
124
  raise ValueError(f"Unknown style: {style!r}. Must be one of {_BUTTON_STYLES_LIST}")
123
125
  raise TypeError(f"Style must be str, ButtonStyle, or None, got {type(style)}")
124
126
 
127
+ def _normalize_custom_emoji_id(self, custom_emoji_id: int | str | None) -> str | None:
128
+ if not custom_emoji_id:
129
+ return None
130
+
131
+ if not TelekitState.is_premium():
132
+ return None
133
+
134
+ return str(custom_emoji_id)
135
+
125
136
  class StaticButton(InlineButton):
126
137
 
127
138
  def __init__(self, *, style: str | None | ButtonStyle = None, **kwargs):
@@ -1,16 +1,27 @@
1
1
  from telebot import TeleBot
2
2
 
3
3
 
4
+ class DebugLevel:
5
+ NONE: int = 0
6
+ INFO: int = 1
7
+ DEBUG: int = 2
8
+
9
+
4
10
  class TelekitState:
5
11
 
6
12
  __bot: TeleBot
7
13
 
8
14
  @classmethod
9
- def init(cls, bot: TeleBot) -> None:
15
+ def _init(cls, bot: TeleBot) -> None:
10
16
  if hasattr(cls, "_TelekitState__bot"):
11
17
  raise RuntimeError("TelekitState is already initialized")
12
18
 
13
19
  cls.__bot = bot
20
+ cls._update()
21
+
22
+ @classmethod
23
+ def _update(cls):
24
+ cls.__info = cls.__bot.get_me()
14
25
 
15
26
  @classmethod
16
27
  def get_bot(cls) -> TeleBot:
@@ -19,4 +30,6 @@ class TelekitState:
19
30
 
20
31
  return cls.__bot
21
32
 
22
- DEBUG: bool = False
33
+ @classmethod
34
+ def is_premium(cls) -> bool:
35
+ return cls.__info.is_premium or False
@@ -0,0 +1,10 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from telekit import Handler as _Base
5
+ else:
6
+ _Base = object
7
+
8
+ class Trait(_Base):
9
+ """Base for all traits."""
10
+ pass
@@ -3,4 +3,4 @@
3
3
  # PyPI history: https://pypi.org/project/telekit/#history
4
4
  # ––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
5
5
 
6
- __version__ = "2.3.0b2"
6
+ __version__ = "2.3.0b3"
@@ -0,0 +1,8 @@
1
+ class Debug:
2
+ timeout_warnings: bool = False
3
+ deletion_warnings: bool = False
4
+
5
+ @classmethod
6
+ def set_all(cls, value: bool) -> None:
7
+ for key in cls.__annotations__:
8
+ setattr(cls, key, value)
@@ -12,5 +12,6 @@ from . import (
12
12
  hotel,
13
13
  complete_hotel,
14
14
  text_document,
15
- qr
15
+ qr,
16
+ calendar,
16
17
  )
@@ -0,0 +1,223 @@
1
+ """
2
+ Event distance bot — pick any date and find out how far it is from today.
3
+ Works for both past events ("it was X ago") and future events ("it will be in X").
4
+ Uses the Calendar trait for date selection; no database required.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import datetime
9
+
10
+ import telekit
11
+ import telekit.traits
12
+
13
+ from telekit.styles import Bold, Code, Group, Italic, Quote
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Time-distance helpers
18
+ # ---------------------------------------------------------------------------
19
+
20
+ def _diff(a: datetime.date, b: datetime.date) -> tuple[int, int, int]:
21
+ """
22
+ Return the absolute difference between *a* and *b* as (years, months, days).
23
+
24
+ Always returns non-negative values; direction is determined by the caller.
25
+
26
+ :param a: First date.
27
+ :param b: Second date (order does not matter).
28
+ :returns: Tuple ``(years, months, days)`` with all values >= 0.
29
+ """
30
+ if a > b:
31
+ a, b = b, a
32
+
33
+ years = b.year - a.year
34
+ months = b.month - a.month
35
+ days = b.day - a.day
36
+
37
+ if days < 0:
38
+ months -= 1
39
+ prev_month_last = (datetime.date(b.year, b.month, 1) - datetime.timedelta(days=1)).day
40
+ days += prev_month_last
41
+
42
+ if months < 0:
43
+ years -= 1
44
+ months += 12
45
+
46
+ return years, months, days
47
+
48
+
49
+ def _fmt(years: int, months: int, days: int) -> str:
50
+ """
51
+ Format a duration, omitting zero components.
52
+
53
+ Examples::
54
+
55
+ _fmt(0, 0, 1) -> "1 day"
56
+ _fmt(2, 3, 0) -> "2 years and 3 months"
57
+ _fmt(1, 0, 5) -> "1 year and 5 days"
58
+
59
+ :param years: Number of whole years.
60
+ :param months: Number of remaining whole months.
61
+ :param days: Number of remaining days.
62
+ :returns: Human-readable duration string.
63
+ """
64
+ parts: list[str] = []
65
+ if years:
66
+ parts.append(f"{years} year{'s' if years != 1 else ''}")
67
+ if months:
68
+ parts.append(f"{months} month{'s' if months != 1 else ''}")
69
+ if days:
70
+ parts.append(f"{days} day{'s' if days != 1 else ''}")
71
+
72
+ if not parts:
73
+ return "today"
74
+ if len(parts) == 1:
75
+ return parts[0]
76
+ return ", ".join(parts[:-1]) + " and " + parts[-1]
77
+
78
+
79
+ def _describe(chosen: datetime.date) -> str:
80
+ """
81
+ Build a human-readable distance string from *chosen* to today.
82
+
83
+ :param chosen: The date selected by the user.
84
+ :returns: Sentence describing how far the date is from today.
85
+ """
86
+ today = datetime.date.today()
87
+ y, mo, d = _diff(chosen, today)
88
+ duration = _fmt(y, mo, d)
89
+
90
+ if chosen == today:
91
+ return "That's today! 🎉"
92
+ elif chosen < today:
93
+ return f"That was {duration} ago."
94
+ else:
95
+ return f"That will be in {duration}."
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # In-memory store (no DB)
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Help text
105
+ # ---------------------------------------------------------------------------
106
+
107
+ _HELP_TEXT = Group(
108
+ Bold("Buttons"),
109
+ Group(
110
+ Group(Bold("MAY"), " — tap the month name to switch to month picker"),
111
+ Group(Bold("2025"), " — tap the year to switch to year picker"),
112
+ Group(Bold("This month"), " — jump back to the current month"),
113
+ sep="\n",
114
+ ),
115
+ Bold("Keyboard input"),
116
+ Group(
117
+ "Type a date and send it as a message:",
118
+ Group(Code("YYYY"), " — jump to that year"),
119
+ Group(Code("YYYY.MM"), " — jump to that month"),
120
+ Group(Code("YYYY.MM.DD"), " — select that date immediately"),
121
+ Quote("Separator: . or -", end=""),
122
+ sep="\n",
123
+ ),
124
+ Italic("Values outside the allowed range are silently ignored."),
125
+ sep="\n\n",
126
+ )
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Handler
131
+ # ---------------------------------------------------------------------------
132
+
133
+ class CalendarHandler(
134
+ telekit.traits.CalendarPick,
135
+ telekit.Handler,
136
+ ):
137
+ @classmethod
138
+ def init_handler(cls) -> None:
139
+ cls.on.command("calendar").invoke(cls.handle)
140
+
141
+ def __init__(self, *args, **kwargs) -> None:
142
+ super().__init__(*args, **kwargs)
143
+ self._date: datetime.date | None = None
144
+
145
+ # -----------------------------------------------------------------------
146
+ # Step 1 — welcome screen
147
+ # -----------------------------------------------------------------------
148
+
149
+ def handle(self) -> None:
150
+ """Entry point: welcome message with Help and Begin buttons side by side."""
151
+ self._date = None
152
+
153
+ self.chain.sender.set_title("📅 Event Distance")
154
+ self.chain.sender.set_message(
155
+ "Pick any date and find out how far it is from today — "
156
+ "whether it happened long ago or is still coming"
157
+ )
158
+ self.chain.set_inline_keyboard(
159
+ {
160
+ "?Help": self.show_help,
161
+ "Begin »": self.entry_date,
162
+ },
163
+ row_width=2,
164
+ )
165
+ self.chain.edit()
166
+
167
+ # -----------------------------------------------------------------------
168
+ # Help screen
169
+ # -----------------------------------------------------------------------
170
+
171
+ def show_help(self) -> None:
172
+ """Show calendar navigation tips; a Back button returns to the welcome screen."""
173
+ self.chain.sender.set_title("📖 How to use the calendar")
174
+ self.chain.sender.set_message(_HELP_TEXT)
175
+ self.chain.set_inline_keyboard({"✓ Okay": self.handle}, row_width=1)
176
+ self.chain.edit()
177
+
178
+ # -----------------------------------------------------------------------
179
+ # Step 2 — calendar
180
+ # -----------------------------------------------------------------------
181
+
182
+ def entry_date(self) -> None:
183
+ """
184
+ Open the calendar picker.
185
+
186
+ Restores the previously chosen date as the initial view so the user
187
+ lands on a familiar position when changing their answer.
188
+ """
189
+ self.chain.sender.set_title("📅 Choose a date")
190
+ self.chain.sender.set_message("Select any date — past or future:")
191
+ self.chain.sender.set_remove_text(False)
192
+
193
+ self.calendar_pick(
194
+ self.handle_date,
195
+ initial=self._date,
196
+ show_nav_hints=True,
197
+ )
198
+
199
+ # -----------------------------------------------------------------------
200
+ # Step 3 — result
201
+ # -----------------------------------------------------------------------
202
+
203
+ def handle_date(self, date: datetime.date) -> None:
204
+ """
205
+ Display the distance result for *date*.
206
+
207
+ :param date: The date chosen by the user in the calendar.
208
+ """
209
+ self._date = date
210
+
211
+ self.chain.sender.set_title(f"🗓 {date.strftime('%d %B %Y')}")
212
+ self.chain.sender.set_message(
213
+ _describe(date)
214
+ )
215
+ # «Change reopens the calendar at the previously selected month.
216
+ self.chain.set_inline_keyboard(
217
+ {
218
+ "« Change": self.entry_date,
219
+ "↺ New date": self.handle,
220
+ },
221
+ row_width=2,
222
+ )
223
+ self.chain.edit()
@@ -30,7 +30,8 @@ class StartHandler(telekit.Handler):
30
30
  "📄 File Info": "TextDocumentHandler",
31
31
 
32
32
  "🖼️ QR Editor": "QRHandler",
33
- }, row_width=[3, 1, 3, 1]
33
+ "📆 Calendar": "CalendarHandler",
34
+ }, row_width=[3, 1, 3, 2]
34
35
  )
35
36
  def handle_response(handler: str):
36
37
  self.handoff(handler).handle()
@@ -30,6 +30,7 @@ from telebot.types import (
30
30
  InputMediaVideo, InputMediaAnimation
31
31
  )
32
32
 
33
+ from telekit.debug import Debug
33
34
  from telekit.styles import TextEntity, Escape, Raw, Group, Bold, Italic
34
35
  from telekit.types import ParseMode, Effect as _Effect, ChatAction as _ChatAction
35
36
  from telekit import dices
@@ -1143,7 +1144,8 @@ class BaseSender:
1143
1144
  try:
1144
1145
  return self.bot.delete_message(chat_id=self.chat_id, message_id=message_id)
1145
1146
  except Exception as exception:
1146
- library.warning(f"Failed to delete message {message_id}. Maybe the user deleted it. Exception: {exception}")
1147
+ if Debug.deletion_warnings:
1148
+ library.warning(f"Failed to delete message {message_id}. Maybe the user deleted it. Exception: {exception}")
1147
1149
  return False
1148
1150
 
1149
1151
  def delete_message(self, message: Message | None, only_user_messages: bool=False) -> bool:
@@ -22,7 +22,7 @@ import traceback
22
22
  import sys
23
23
  from typing import Optional
24
24
 
25
- from . import _init, _state
25
+ from . import _init, _state, debug as _debug
26
26
  import telebot
27
27
 
28
28
  from ._logger import logger
@@ -49,7 +49,7 @@ class Server:
49
49
  ):
50
50
 
51
51
  self._auto_restart = auto_restart
52
- _state.TelekitState.DEBUG = debug
52
+ _debug.Debug.set_all(debug)
53
53
 
54
54
  if isinstance(bot, str):
55
55
  bot = telebot.TeleBot(bot)
@@ -133,6 +133,7 @@ class Server:
133
133
  raise exception
134
134
  finally:
135
135
  time.sleep(10)
136
+ _state.TelekitState._update()
136
137
 
137
138
  def long_polling(self, *, timeout: int = 60):
138
139
  """Long `bot.polling(none_stop=True, timeout=timeout)` polling with custom timeout"""
@@ -150,6 +151,7 @@ class Server:
150
151
  raise exception
151
152
  finally:
152
153
  time.sleep(5)
154
+ _state.TelekitState._update()
153
155
 
154
156
 
155
157
  # Example
@@ -0,0 +1,3 @@
1
+ from .track_handoff_origin import TrackHandoffOrigin
2
+ from .paginated_choice import PaginatedChoice
3
+ from .calendar_pick import CalendarPick