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.
- xtr_telegram_notifier/.agents/skills/xtr-telegram-notifier/SKILL.md +118 -0
- xtr_telegram_notifier/__init__.py +15 -0
- xtr_telegram_notifier/_markdown_v2.py +24 -0
- xtr_telegram_notifier/_payload.py +186 -0
- xtr_telegram_notifier/py.typed +0 -0
- xtr_telegram_notifier/recipe/__init__.py +15 -0
- xtr_telegram_notifier/recipe/manifest.toml +23 -0
- xtr_telegram_notifier/reply/__init__.py +5 -0
- xtr_telegram_notifier/reply/markup/__init__.py +17 -0
- xtr_telegram_notifier/reply/markup/abstract_telegram_reply_markup.py +25 -0
- xtr_telegram_notifier/reply/markup/button/__init__.py +9 -0
- xtr_telegram_notifier/reply/markup/button/abstract_keyboard_button.py +23 -0
- xtr_telegram_notifier/reply/markup/button/inline_keyboard_button.py +64 -0
- xtr_telegram_notifier/reply/markup/button/keyboard_button.py +34 -0
- xtr_telegram_notifier/reply/markup/force_reply.py +26 -0
- xtr_telegram_notifier/reply/markup/inline_keyboard_markup.py +30 -0
- xtr_telegram_notifier/reply/markup/reply_keyboard_markup.py +45 -0
- xtr_telegram_notifier/reply/markup/reply_keyboard_remove.py +26 -0
- xtr_telegram_notifier/telegram_options.py +235 -0
- xtr_telegram_notifier/telegram_transport.py +196 -0
- xtr_telegram_notifier/telegram_transport_factory.py +69 -0
- xtr_telegram_notifier-5.0.0.dist-info/METADATA +190 -0
- xtr_telegram_notifier-5.0.0.dist-info/RECORD +26 -0
- xtr_telegram_notifier-5.0.0.dist-info/WHEEL +4 -0
- xtr_telegram_notifier-5.0.0.dist-info/entry_points.txt +6 -0
- 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,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
|