xtr-telegram-notifier 5.0.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.
Files changed (26) hide show
  1. xtr_telegram_notifier/.agents/skills/xtr-telegram-notifier/SKILL.md +118 -0
  2. xtr_telegram_notifier/__init__.py +15 -0
  3. xtr_telegram_notifier/_markdown_v2.py +24 -0
  4. xtr_telegram_notifier/_payload.py +186 -0
  5. xtr_telegram_notifier/py.typed +0 -0
  6. xtr_telegram_notifier/recipe/__init__.py +15 -0
  7. xtr_telegram_notifier/recipe/manifest.toml +23 -0
  8. xtr_telegram_notifier/reply/__init__.py +5 -0
  9. xtr_telegram_notifier/reply/markup/__init__.py +17 -0
  10. xtr_telegram_notifier/reply/markup/abstract_telegram_reply_markup.py +25 -0
  11. xtr_telegram_notifier/reply/markup/button/__init__.py +9 -0
  12. xtr_telegram_notifier/reply/markup/button/abstract_keyboard_button.py +23 -0
  13. xtr_telegram_notifier/reply/markup/button/inline_keyboard_button.py +64 -0
  14. xtr_telegram_notifier/reply/markup/button/keyboard_button.py +34 -0
  15. xtr_telegram_notifier/reply/markup/force_reply.py +26 -0
  16. xtr_telegram_notifier/reply/markup/inline_keyboard_markup.py +30 -0
  17. xtr_telegram_notifier/reply/markup/reply_keyboard_markup.py +45 -0
  18. xtr_telegram_notifier/reply/markup/reply_keyboard_remove.py +26 -0
  19. xtr_telegram_notifier/telegram_options.py +235 -0
  20. xtr_telegram_notifier/telegram_transport.py +196 -0
  21. xtr_telegram_notifier/telegram_transport_factory.py +69 -0
  22. xtr_telegram_notifier-5.0.0.dist-info/METADATA +190 -0
  23. xtr_telegram_notifier-5.0.0.dist-info/RECORD +26 -0
  24. xtr_telegram_notifier-5.0.0.dist-info/WHEEL +4 -0
  25. xtr_telegram_notifier-5.0.0.dist-info/entry_points.txt +6 -0
  26. xtr_telegram_notifier-5.0.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,118 @@
1
+ ---
2
+ name: xtr-telegram-notifier
3
+ description: How to send chat messages to Telegram with xtr-telegram-notifier, the Telegram provider of xtr-notifier — a telegram DSN served with no wiring, TelegramOptions for keyboards, photos and other media, file uploads, locations, venues, contacts, editing a sent message and answering a callback query, with MarkdownV2 escaping. Use when an application must notify a Telegram chat or channel through a bot, configure TELEGRAM_DSN, build Telegram message options or reply keyboards, upload a file to a chat, edit or answer a Telegram message, or test code that sends to Telegram.
4
+ ---
5
+
6
+ # xtr-telegram-notifier
7
+
8
+ The Telegram provider of xtr-notifier. Installed beside it, a `telegram://` DSN is served by
9
+ `TelegramTransportFactory`, found through its entry point. For notifications, channels and the
10
+ bundle, load the `xtr-notifier` skill.
11
+
12
+ ## Quick reference
13
+
14
+ - DSN — `telegram://BOT_ID:SECRET@default?channel=CHAT_ID`; `default` is `api.telegram.org`;
15
+ `ssl=false` (or `sslmode=disable`) for plain HTTP. No token: `Missing token.`; half: `Malformed token.`
16
+ - `TelegramTransport(token, channel, http_client, event_dispatcher=None)`; its string form never
17
+ shows the token.
18
+ - `TelegramOptions()` — fluent builder; `.to_options()` renders the `MessageOptions` a
19
+ `ChatMessage` carries. `chat_id(...)` wins over the DSN's channel.
20
+ - One kind per message: `photo`, `video`, `audio`, `document`, `animation`, `sticker`,
21
+ `location`, `venue`, `contact`, `edit`, `answer_callback_query` — two raise
22
+ `MultipleExclusiveOptionsUsedError`.
23
+ - Text is MarkdownV2 by default, escaped (markup kept); `parse_mode(TelegramOptions.PARSE_MODE_HTML)`
24
+ sends it as written.
25
+ - Keyboards — `xtr_telegram_notifier.reply.markup`: `InlineKeyboardMarkup`,
26
+ `ReplyKeyboardMarkup`, `ReplyKeyboardRemove`, `ForceReply`; buttons in `.button`:
27
+ `InlineKeyboardButton`, `KeyboardButton`.
28
+
29
+ ## Send a message with a keyboard
30
+
31
+ ```python
32
+ from xtr_notifier.message import ChatMessage
33
+ from xtr_telegram_notifier import TelegramOptions
34
+ from xtr_telegram_notifier.reply.markup import InlineKeyboardMarkup
35
+ from xtr_telegram_notifier.reply.markup.button import InlineKeyboardButton
36
+
37
+ options = TelegramOptions().reply_markup(
38
+ InlineKeyboardMarkup().inline_keyboard(
39
+ [InlineKeyboardButton("Approve").callback_data("approve:42")]
40
+ )
41
+ )
42
+ await chatter.send(ChatMessage("Deploy *42* is waiting.", options.to_options()))
43
+ ```
44
+
45
+ ## Send media, a place or a contact
46
+
47
+ ```python
48
+ TelegramOptions().photo("https://example.com/chart.png") # the text is the caption
49
+ TelegramOptions().upload_document("/var/reports/daily.pdf") # opened, sent and closed at send
50
+ TelegramOptions().location(48.8566, 2.3522) # the text is not sent
51
+ TelegramOptions().contact("+33100000000", "Ops", "Team")
52
+ ```
53
+
54
+ An upload keeps its path, so the message can be queued; the transport sends the fields as a
55
+ form body the HTTP client turns into `multipart/form-data`.
56
+
57
+ ## Edit or answer
58
+
59
+ ```python
60
+ TelegramOptions().edit(message_id) # editMessageText
61
+ TelegramOptions().edit(message_id).photo("https://example.com/new.png") # editMessageMedia
62
+ TelegramOptions().edit_caption(message_id) # editMessageCaption
63
+ TelegramOptions().answer_callback_query(query_id, show_alert=True, cache_time=5)
64
+ ```
65
+
66
+ `SentMessage.message_id` is the id the bot API gave the message, as text — keep it to edit it.
67
+
68
+ ## Testing
69
+
70
+ ```python
71
+ from xtr_http_client import JsonMockResponse, MockHttpClient
72
+ from xtr_notifier.message import ChatMessage
73
+ from xtr_telegram_notifier import TelegramTransport
74
+
75
+
76
+ def answer(method, url, options):
77
+ assert url == "https://api.telegram.org/bot1:T/sendMessage"
78
+ assert options["json"] == {"chat_id": "ops", "text": "Hi", "parse_mode": "MarkdownV2"}
79
+ return JsonMockResponse({"ok": True, "result": {"message_id": 7}})
80
+
81
+
82
+ sent = await TelegramTransport("1:T", "ops", MockHttpClient(answer)).send(ChatMessage("Hi"))
83
+ assert sent.message_id == "7"
84
+ ```
85
+
86
+ An upload reaches the script as an open file under `options["body"]`.
87
+
88
+ ## Use in an application
89
+
90
+ `uv run xtr-recipes recipes:sync` applies the recipe: it writes `TELEGRAM_DSN` in `.env` and
91
+ prints the configuration step.
92
+
93
+ 1. **Install** — `uv add xtr-telegram-notifier`, beside xtr-notifier set up as the
94
+ `xtr-notifier` skill says.
95
+ 2. **Configure** — add `telegram=env("TELEGRAM_DSN")` to `chatter_transports` in
96
+ `<app>/config/notifier.py`.
97
+ 3. **Environment** — `TELEGRAM_DSN`, `telegram://TOKEN@default?channel=CHAT_ID`.
98
+ 4. **Check** — `debug:config notifier` lists `telegram` under `chatter_transports`.
99
+ 5. **Remove** — drop the entry and `TELEGRAM_DSN`, then `uv remove xtr-telegram-notifier`.
100
+
101
+ There is no bundle to activate.
102
+
103
+ ## Errors
104
+
105
+ | Error | When |
106
+ |---|---|
107
+ | `IncompleteDsnError` | `Missing token.` / `Malformed token.` |
108
+ | `MultipleExclusiveOptionsUsedError` | Two kinds of message picked at once |
109
+ | `LogicError` | Editing a media with a file to upload — use a URL or a file id |
110
+ | `TransportError` | `Unable to post the Telegram message: <description> (code <n>).` (or `edit`, `answer callback query`); `Could not reach the remote Telegram server.` |
111
+ | `UnsupportedMessageTypeError` | A message that is not a `ChatMessage` |
112
+
113
+ ## Do not
114
+
115
+ - Do not write the bot token into code or configuration; read the DSN with `env("TELEGRAM_DSN")`.
116
+ - Do not pass open files in options; use the `upload_*` methods, which keep a path.
117
+ - Do not escape MarkdownV2 yourself; the transport does, and keeps what is already escaped.
118
+ - Do not combine `edit` with an `upload_*` media; the bot API refuses it.
@@ -0,0 +1,15 @@
1
+ """Telegram for xtr-notifier: chat messages sent, edited and answered through a bot.
2
+
3
+ Install it beside xtr-notifier and a ``telegram://BOT_ID:TOKEN@default?channel=CHAT_ID``
4
+ DSN is served by :class:`TelegramTransportFactory` with no wiring. Build a message's
5
+ options with :class:`TelegramOptions` — keyboards from :mod:`.reply.markup`, media, an
6
+ edit of an earlier message, a callback query's answer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .telegram_options import TelegramOptions
12
+ from .telegram_transport import TelegramTransport
13
+ from .telegram_transport_factory import TelegramTransportFactory
14
+
15
+ __all__ = ["TelegramOptions", "TelegramTransport", "TelegramTransportFactory"]
@@ -0,0 +1,24 @@
1
+ """Escaping a message for the MarkdownV2 parse mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Final
7
+
8
+ __all__ = ["escape_markdown_v2"]
9
+
10
+ # Matched in order: an already escaped character, kept; a spoiler marker, kept; a block
11
+ # quotation (">" or "**>" opening a line), kept; a custom emoji ("!["), kept; and a reserved
12
+ # character outside of those, the one group captured and escaped.
13
+ _RESERVED: Final = re.compile(r"\\[\x01-\x7E]|\|\||^(?:\*\*)?>|!\[|([.!#+\-=|{}>])", re.MULTILINE)
14
+
15
+
16
+ def escape_markdown_v2(text: str) -> str:
17
+ """Escape the characters the bot API rejects unescaped, leaving the markup alone.
18
+
19
+ Paired markers (``*bold*``, ``_italic_``, `` `code` ``, ``~strike~``,
20
+ ``||spoiler||``, ``[link](url)``), block quotations and custom emojis are
21
+ kept, so a message written in MarkdownV2 is sent as written, while a plain
22
+ sentence ending in ``.`` or ``!`` is no longer refused.
23
+ """
24
+ return _RESERVED.sub(lambda match: match[0] if match[1] is None else f"\\{match[1]}", text)
@@ -0,0 +1,186 @@
1
+ """Turning a message's options into the bot API method and the fields it is called with.
2
+
3
+ Every function reads or returns the fields as a plain mapping. What a message
4
+ is follows from which field is set: ``photo`` makes it ``sendPhoto``,
5
+ ``message_id`` makes it an edit, and so on down :data:`EXCLUSIVE_OPTIONS`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Mapping, Sequence
12
+ from io import IOBase
13
+ from typing import Final, cast
14
+
15
+ from xtr_notifier.exception import LogicError, MultipleExclusiveOptionsUsedError
16
+
17
+ __all__ = [
18
+ "EXCLUSIVE_OPTIONS",
19
+ "action_for",
20
+ "build_input_media",
21
+ "ensure_exclusive_options",
22
+ "expand_options",
23
+ "form_fields",
24
+ "method_for",
25
+ "non_empty",
26
+ "text_option_for",
27
+ ]
28
+
29
+ #: The options that each pick what a message is; only one of them may be set.
30
+ EXCLUSIVE_OPTIONS: Final = (
31
+ "message_id",
32
+ "callback_query_id",
33
+ "photo",
34
+ "location",
35
+ "audio",
36
+ "document",
37
+ "video",
38
+ "animation",
39
+ "venue",
40
+ "contact",
41
+ "sticker",
42
+ )
43
+
44
+ _MEDIA_TYPES: Final = ("photo", "video", "document", "audio", "animation")
45
+ _SPOILER_MEDIA_TYPES: Final = ("photo", "video", "animation")
46
+ # Checked in this order: the first one set names the method.
47
+ _SEND_METHODS: Final = (
48
+ ("photo", "sendPhoto"),
49
+ ("location", "sendLocation"),
50
+ ("audio", "sendAudio"),
51
+ ("document", "sendDocument"),
52
+ ("video", "sendVideo"),
53
+ ("animation", "sendAnimation"),
54
+ ("venue", "sendVenue"),
55
+ ("contact", "sendContact"),
56
+ ("sticker", "sendSticker"),
57
+ )
58
+ # Checked in this order: the option a message's text goes in, None when it is not sent.
59
+ _TEXT_OPTIONS: Final = (
60
+ ("edit_caption", "caption"),
61
+ ("photo", "caption"),
62
+ ("audio", "caption"),
63
+ ("document", "caption"),
64
+ ("video", "caption"),
65
+ ("animation", "caption"),
66
+ ("sticker", None),
67
+ ("location", None),
68
+ ("venue", None),
69
+ ("contact", None),
70
+ )
71
+
72
+
73
+ def _is_set(options: Mapping[str, object], key: str) -> bool:
74
+ """Tell whether ``key`` is set to something other than ``None``."""
75
+ return options.get(key) is not None
76
+
77
+
78
+ def method_for(options: Mapping[str, object]) -> str:
79
+ """Return the bot API method a message with ``options`` is sent with."""
80
+ if _is_set(options, "message_id"):
81
+ return _edit_method_for(options)
82
+ if _is_set(options, "callback_query_id"):
83
+ return "answerCallbackQuery"
84
+ return next(
85
+ (method for option, method in _SEND_METHODS if _is_set(options, option)), "sendMessage"
86
+ )
87
+
88
+
89
+ def _edit_method_for(options: Mapping[str, object]) -> str:
90
+ """Return the edit method: the media, the caption, or the text."""
91
+ if any(_is_set(options, media_type) for media_type in _MEDIA_TYPES):
92
+ return "editMessageMedia"
93
+ return "editMessageCaption" if _is_set(options, "edit_caption") else "editMessageText"
94
+
95
+
96
+ def action_for(options: Mapping[str, object]) -> str:
97
+ """Return what sending ``options`` does, as an error message names it."""
98
+ if _is_set(options, "message_id"):
99
+ return "edit"
100
+ if _is_set(options, "callback_query_id"):
101
+ return "answer callback query"
102
+ return "post"
103
+
104
+
105
+ def text_option_for(options: Mapping[str, object]) -> str | None:
106
+ """Return the field a message's text goes in, or ``None`` when its kind sends no text."""
107
+ return next(
108
+ (text_option for option, text_option in _TEXT_OPTIONS if _is_set(options, option)),
109
+ "text",
110
+ )
111
+
112
+
113
+ def ensure_exclusive_options(options: Mapping[str, object]) -> None:
114
+ """Refuse options that pick more than one kind of message.
115
+
116
+ Editing a message replaces its media, so ``message_id`` goes with one media type.
117
+
118
+ Raises:
119
+ MultipleExclusiveOptionsUsedError: When more than one is set.
120
+ """
121
+ used = [key for key in options if key in EXCLUSIVE_OPTIONS]
122
+ if "message_id" in options and sum(key in _MEDIA_TYPES for key in options) == 1:
123
+ used.remove("message_id")
124
+ if len(used) > 1:
125
+ raise MultipleExclusiveOptionsUsedError(used, EXCLUSIVE_OPTIONS)
126
+
127
+
128
+ def build_input_media(options: Mapping[str, object], text: str) -> dict[str, object]:
129
+ """Move the media of an edit, with its caption, parse mode and spoiler, into ``media``.
130
+
131
+ Raises:
132
+ LogicError: When the media is a file being uploaded: the bot API takes an
133
+ edit's new media as a URL or a file id only.
134
+ """
135
+ built = dict(options)
136
+ media_type = next((kind for kind in _MEDIA_TYPES if _is_set(built, kind)), None)
137
+ if media_type is None:
138
+ return built
139
+ if isinstance(built[media_type], IOBase):
140
+ raise LogicError(
141
+ "Editing a message with a locally uploaded media is not supported yet, "
142
+ "use a URL or a Telegram file ID instead."
143
+ )
144
+ media: dict[str, object] = {"type": media_type, "media": built[media_type], "caption": text}
145
+ if _is_set(built, "parse_mode"):
146
+ media["parse_mode"] = built["parse_mode"]
147
+ if _is_set(built, "has_spoiler") and media_type in _SPOILER_MEDIA_TYPES:
148
+ media["has_spoiler"] = built["has_spoiler"]
149
+ for key in (media_type, "caption", "parse_mode", "has_spoiler"):
150
+ _ = built.pop(key, None)
151
+ built["media"] = media
152
+ return built
153
+
154
+
155
+ def expand_options(options: Mapping[str, object], *keys: str) -> dict[str, object]:
156
+ """Replace each of ``keys`` holding a mapping by that mapping's fields, at the top level."""
157
+ expanded = dict(options)
158
+ for key in keys:
159
+ if not _is_set(expanded, key):
160
+ continue
161
+ value = expanded[key]
162
+ if isinstance(value, Mapping):
163
+ expanded.update(cast("Mapping[str, object]", value))
164
+ del expanded[key]
165
+ return expanded
166
+
167
+
168
+ def non_empty(options: Mapping[str, object]) -> dict[str, object]:
169
+ """Drop the fields left empty — ``None``, ``False``, ``0``, ``""``, an empty collection."""
170
+ return {key: value for key, value in options.items() if value}
171
+
172
+
173
+ def form_fields(fields: Mapping[str, object]) -> dict[str, object]:
174
+ """Render ``fields`` for a multipart body: each nested value as JSON text.
175
+
176
+ A form part holds one scalar, so the bot API reads an object or a list sent
177
+ beside an upload — a reply markup, a media group — as JSON. Scalars and the
178
+ files are left as they are.
179
+ """
180
+ return {
181
+ key: json.dumps(value)
182
+ if isinstance(value, Mapping)
183
+ or (isinstance(value, Sequence) and not isinstance(value, (str, bytes)))
184
+ else value
185
+ for key, value in fields.items()
186
+ }
File without changes
@@ -0,0 +1,15 @@
1
+ """This directory holds the package's recipe: its manifest.
2
+
3
+ The recipe is what *Use in an application* in the README lists, written as
4
+ data: the DSN to set, and the step adding the transport to the notifier's
5
+ configuration, which a recipe prints rather than makes. An application
6
+ applies it with ``xtr-recipes recipes:sync``, which finds this module through
7
+ the ``xtr_recipes`` entry point.
8
+
9
+ Nothing is importable from here. The manifest is read as a file, so a recipe
10
+ is the same from a wheel as from a working tree.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ __all__: list[str] = []
@@ -0,0 +1,23 @@
1
+ # The recipe for xtr-telegram-notifier: what *Use in an application* in the README takes,
2
+ # as data. Keep the two in step — the README is the source.
3
+ #
4
+ # No [bundles] and no [files]: the package ships no bundle — the notifier's discovers its
5
+ # factory by the DSN scheme — and the notifier's configuration module is the application's.
6
+
7
+ [env]
8
+ # The bot's DSN. TOKEN is the token the bot API hands out, BOT_ID:SECRET; CHAT_ID is the chat
9
+ # a message naming none goes to.
10
+ TELEGRAM_DSN = "telegram://TOKEN@default?channel=CHAT_ID"
11
+
12
+ [gitignore]
13
+ # Nothing of its own: a message is sent, never written to disk.
14
+ lines = []
15
+
16
+ [notes]
17
+ steps = [
18
+ "Set TELEGRAM_DSN to the bot's DSN: telegram://BOT_ID:SECRET@default?channel=CHAT_ID.",
19
+ "Add the transport to chatter_transports in config/notifier.py: telegram=env(\"TELEGRAM_DSN\").",
20
+ ]
21
+ check = [
22
+ "<script> debug:config notifier — telegram is listed under chatter_transports",
23
+ ]
@@ -0,0 +1,5 @@
1
+ """Ways for a message to ask for an answer; the markups live in :mod:`.markup`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []
@@ -0,0 +1,17 @@
1
+ """What a message shows the user to reply with: keyboards, their removal, a forced reply."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .abstract_telegram_reply_markup import AbstractTelegramReplyMarkup
6
+ from .force_reply import ForceReply
7
+ from .inline_keyboard_markup import InlineKeyboardMarkup
8
+ from .reply_keyboard_markup import ReplyKeyboardMarkup
9
+ from .reply_keyboard_remove import ReplyKeyboardRemove
10
+
11
+ __all__ = [
12
+ "AbstractTelegramReplyMarkup",
13
+ "ForceReply",
14
+ "InlineKeyboardMarkup",
15
+ "ReplyKeyboardMarkup",
16
+ "ReplyKeyboardRemove",
17
+ ]
@@ -0,0 +1,25 @@
1
+ """The base of every reply markup a message can carry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from abc import ABC
7
+
8
+ __all__ = ["AbstractTelegramReplyMarkup"]
9
+
10
+
11
+ class AbstractTelegramReplyMarkup(ABC): # noqa: B024 -- abstract by role: only its subclasses mean anything to the bot API
12
+ """What the chat shows under a message: a keyboard, its removal, or a forced reply.
13
+
14
+ A subclass fills :attr:`_options` with the bot API's fields;
15
+ :meth:`~xtr_telegram_notifier.TelegramOptions.reply_markup` renders it into
16
+ the message's options.
17
+ """
18
+
19
+ def __init__(self) -> None:
20
+ """Start with no field set."""
21
+ self._options: dict[str, object] = {}
22
+
23
+ def to_array(self) -> dict[str, object]:
24
+ """Render the markup as the bot API reads it — a copy, so it stays as it was."""
25
+ return copy.deepcopy(self._options)
@@ -0,0 +1,9 @@
1
+ """The buttons a keyboard is made of."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .abstract_keyboard_button import AbstractKeyboardButton
6
+ from .inline_keyboard_button import InlineKeyboardButton
7
+ from .keyboard_button import KeyboardButton
8
+
9
+ __all__ = ["AbstractKeyboardButton", "InlineKeyboardButton", "KeyboardButton"]
@@ -0,0 +1,23 @@
1
+ """The base of every keyboard button."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from abc import ABC
7
+
8
+ __all__ = ["AbstractKeyboardButton"]
9
+
10
+
11
+ class AbstractKeyboardButton(ABC): # noqa: B024 -- abstract by role: only its subclasses mean anything to the bot API
12
+ """One button of a keyboard, rendered into the keyboard's rows.
13
+
14
+ A subclass fills :attr:`_options` with the bot API's fields.
15
+ """
16
+
17
+ def __init__(self) -> None:
18
+ """Start with no field set."""
19
+ self._options: dict[str, object] = {}
20
+
21
+ def to_array(self) -> dict[str, object]:
22
+ """Render the button as the bot API reads it — a copy, so it stays as it was."""
23
+ return copy.deepcopy(self._options)
@@ -0,0 +1,64 @@
1
+ """A button attached to a message."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Self, final
6
+
7
+ from .abstract_keyboard_button import AbstractKeyboardButton
8
+
9
+ __all__ = ["InlineKeyboardButton"]
10
+
11
+
12
+ @final
13
+ class InlineKeyboardButton(AbstractKeyboardButton):
14
+ """A button under a message: it opens a link, logs in, calls the bot back, or pays.
15
+
16
+ The bot API wants exactly one action per button; which one is set is left
17
+ to the caller, as the API reports a button with none or several.
18
+ """
19
+
20
+ def __init__(self, text: str = "") -> None:
21
+ """Label the button ``text``."""
22
+ super().__init__()
23
+ self._options["text"] = text
24
+ self._login_url: dict[str, object] = {}
25
+
26
+ def url(self, url: str) -> Self:
27
+ """Open ``url`` when tapped."""
28
+ self._options["url"] = url
29
+ return self
30
+
31
+ def login_url(self, url: str) -> Self:
32
+ """Log the user in to ``url`` when tapped."""
33
+ self._login_option("url", url)
34
+ return self
35
+
36
+ def login_url_forward_text(self, text: str) -> Self:
37
+ """Label the button ``text`` in a forwarded copy of the message."""
38
+ self._login_option("forward_text", text)
39
+ return self
40
+
41
+ def request_write_access(self, request: bool) -> Self:
42
+ """Ask the user, on login, to let the bot message them."""
43
+ self._login_option("request_write_access", request)
44
+ return self
45
+
46
+ def callback_data(self, data: str) -> Self:
47
+ """Send ``data`` back to the bot as a callback query when tapped."""
48
+ self._options["callback_data"] = data
49
+ return self
50
+
51
+ def switch_inline_query(self, query: str) -> Self:
52
+ """Pick a chat and start an inline query for ``query`` in it when tapped."""
53
+ self._options["switch_inline_query"] = query
54
+ return self
55
+
56
+ def pay_button(self, pay: bool) -> Self:
57
+ """Make it the message's payment button."""
58
+ self._options["pay"] = pay
59
+ return self
60
+
61
+ def _login_option(self, key: str, value: object) -> None:
62
+ """Set one field of the login URL, placing the login URL where it was first set."""
63
+ self._login_url[key] = value
64
+ self._options["login_url"] = self._login_url
@@ -0,0 +1,34 @@
1
+ """A button of a custom reply keyboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Self, final
6
+
7
+ from .abstract_keyboard_button import AbstractKeyboardButton
8
+
9
+ __all__ = ["KeyboardButton"]
10
+
11
+
12
+ @final
13
+ class KeyboardButton(AbstractKeyboardButton):
14
+ """A reply button: tapped, it sends its text — or the user's contact, location or a poll."""
15
+
16
+ def __init__(self, text: str) -> None:
17
+ """Label the button ``text``, which is what a tap sends."""
18
+ super().__init__()
19
+ self._options["text"] = text
20
+
21
+ def request_contact(self, request: bool) -> Self:
22
+ """Send the user's phone contact when tapped."""
23
+ self._options["request_contact"] = request
24
+ return self
25
+
26
+ def request_location(self, request: bool) -> Self:
27
+ """Send the user's location when tapped."""
28
+ self._options["request_location"] = request
29
+ return self
30
+
31
+ def request_poll_type(self, poll_type: str) -> Self:
32
+ """Ask the user to create a poll of ``poll_type`` (``quiz`` or ``regular``) when tapped."""
33
+ self._options["request_poll"] = {"type": poll_type}
34
+ return self
@@ -0,0 +1,26 @@
1
+ """Opening the reply box as if the user had chosen to reply."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import final
6
+
7
+ from .abstract_telegram_reply_markup import AbstractTelegramReplyMarkup
8
+
9
+ __all__ = ["ForceReply"]
10
+
11
+
12
+ @final
13
+ class ForceReply(AbstractTelegramReplyMarkup):
14
+ """Shows the user a reply box for this message, as if they had tapped *Reply*."""
15
+
16
+ def __init__(self, force_reply: bool = False, selective: bool = False) -> None:
17
+ """Force the reply, for everyone or only the users the message mentions.
18
+
19
+ Args:
20
+ force_reply: Whether the reply box is opened.
21
+ selective: Whether only the mentioned users, and the sender of the
22
+ message replied to, get it.
23
+ """
24
+ super().__init__()
25
+ self._options["force_reply"] = force_reply
26
+ self._options["selective"] = selective
@@ -0,0 +1,30 @@
1
+ """Buttons shown right under a message."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Self, final
6
+
7
+ from .abstract_telegram_reply_markup import AbstractTelegramReplyMarkup
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Iterable
11
+
12
+ from .button.inline_keyboard_button import InlineKeyboardButton
13
+
14
+ __all__ = ["InlineKeyboardMarkup"]
15
+
16
+
17
+ @final
18
+ class InlineKeyboardMarkup(AbstractTelegramReplyMarkup):
19
+ """Rows of buttons attached to the message itself: links, callbacks, payments."""
20
+
21
+ def __init__(self) -> None:
22
+ """Start with no row."""
23
+ super().__init__()
24
+ self._rows: list[list[dict[str, object]]] = []
25
+ self._options["inline_keyboard"] = self._rows
26
+
27
+ def inline_keyboard(self, buttons: Iterable[InlineKeyboardButton]) -> Self:
28
+ """Add a row of ``buttons`` below the rows added so far."""
29
+ self._rows.append([button.to_array() for button in buttons])
30
+ return self