richpyro 1.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.
- richpyro/__init__.py +762 -0
- richpyro/py.typed +0 -0
- richpyro-1.0.0.dist-info/METADATA +144 -0
- richpyro-1.0.0.dist-info/RECORD +7 -0
- richpyro-1.0.0.dist-info/WHEEL +5 -0
- richpyro-1.0.0.dist-info/licenses/LICENSE +21 -0
- richpyro-1.0.0.dist-info/top_level.txt +1 -0
richpyro/__init__.py
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
"""
|
|
2
|
+
richpyro — a complete, easy-to-call wrapper around kurigram's Rich Messages
|
|
3
|
+
(Bot API 10.1+ InputRichMessage / InputRichBlock* / RichText* / raw
|
|
4
|
+
Client.send_rich_message / Message.edit_text(rich_message=...) / Message.reply_rich).
|
|
5
|
+
|
|
6
|
+
coded by devgagan — https://github.com/devgaganin
|
|
7
|
+
|
|
8
|
+
────────────────────────────────────────────────────────────────────────
|
|
9
|
+
WHY THIS EXISTS (read this before using rich messages anywhere)
|
|
10
|
+
────────────────────────────────────────────────────────────────────────
|
|
11
|
+
Two real production bugs, both discovered live on this project, are baked
|
|
12
|
+
into this library as automatic protections so nobody hits them again:
|
|
13
|
+
|
|
14
|
+
1. RICH MESSAGES ARE BOT-ONLY.
|
|
15
|
+
Telegram only renders `rich_message=...` for a BOT account. Send it
|
|
16
|
+
from a real user/userbot client (a logged-in session, not a bot
|
|
17
|
+
token) and Telegram silently drops/rejects it — progress bars, status
|
|
18
|
+
cards, everything just stops updating with no visible error.
|
|
19
|
+
Fix baked in here: every send/edit/reply call in this library checks
|
|
20
|
+
`client.me.is_bot` and automatically falls back to a plain text
|
|
21
|
+
message with equivalent content when the client isn't a bot. You
|
|
22
|
+
never have to remember to branch on this yourself.
|
|
23
|
+
|
|
24
|
+
2. A NUMERIC-STRING chat_id LOOKS LIKE A PHONE NUMBER TO kurigram.
|
|
25
|
+
`Client.resolve_peer()` takes any string chat_id, strips `+()-` and
|
|
26
|
+
whitespace, and if what's left is all digits it assumes it's a phone
|
|
27
|
+
number and calls `contacts.ResolvePhone` — a method BOT ACCOUNTS
|
|
28
|
+
CANNOT CALL AT ALL (`[400 BOT_METHOD_INVALID]`), and which fails with
|
|
29
|
+
`PHONE_NOT_OCCUPIED` for user accounts too unless that literal string
|
|
30
|
+
happens to be someone's real registered phone number. This bites
|
|
31
|
+
silently any time a chat_id is passed as `str(chat.id)` instead of
|
|
32
|
+
`int` — which is an extremely easy mistake since Telegram chat IDs
|
|
33
|
+
are commonly stringified for DB storage/dict keys elsewhere in a
|
|
34
|
+
codebase and then reused as-is for an API call.
|
|
35
|
+
Fix baked in here: every chat_id passed through this library is
|
|
36
|
+
auto-int-ified (`"-1001234567890"` -> `-1001234567890`) before it
|
|
37
|
+
ever reaches kurigram. Usernames/`@handles` pass through untouched.
|
|
38
|
+
|
|
39
|
+
────────────────────────────────────────────────────────────────────────
|
|
40
|
+
INSTALL
|
|
41
|
+
────────────────────────────────────────────────────────────────────────
|
|
42
|
+
pip install richpyro
|
|
43
|
+
|
|
44
|
+
Requires Kurigram (the actively-maintained Pyrogram fork, `import pyrogram`
|
|
45
|
+
still works) — richpyro builds on its Rich Messages support (Bot API
|
|
46
|
+
10.1+). Plain Pyrogram/Pyrofork installs without Rich Messages will import
|
|
47
|
+
fine but the send()/edit()/reply() wrappers need the raw types this
|
|
48
|
+
library constructs.
|
|
49
|
+
|
|
50
|
+
────────────────────────────────────────────────────────────────────────
|
|
51
|
+
QUICK START
|
|
52
|
+
────────────────────────────────────────────────────────────────────────
|
|
53
|
+
import richpyro as rp
|
|
54
|
+
|
|
55
|
+
# simplest possible call — one line, works from a bot OR a user client:
|
|
56
|
+
await rp.quick(client, chat_id, "Processing your request...")
|
|
57
|
+
|
|
58
|
+
# a structured card with formatting + buttons:
|
|
59
|
+
card = rp.message(
|
|
60
|
+
rp.para("Hello ", rp.bold(name), "! Choose a plan:"),
|
|
61
|
+
rp.divider(),
|
|
62
|
+
rp.para("● ", rp.bold("Day Plan"), " — ₹20 (24 hours)"),
|
|
63
|
+
rp.buttons(
|
|
64
|
+
rp.btn("Day Plan ₹20", "pay_day"),
|
|
65
|
+
rp.url_btn("Learn more", "https://example.com"),
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
sent = await rp.send(client, chat_id, card)
|
|
69
|
+
...
|
|
70
|
+
await rp.edit(client, chat_id, sent.id, rp.text("Payment confirmed."))
|
|
71
|
+
|
|
72
|
+
# replying directly to an incoming message (uses its own client):
|
|
73
|
+
await rp.reply(message, card)
|
|
74
|
+
|
|
75
|
+
Every builder below returns a plain kurigram object — you can always drop
|
|
76
|
+
down to raw `types.InputRichMessage(...)` etc. if you need something this
|
|
77
|
+
library doesn't wrap yet; richpyro objects and raw kurigram objects mix
|
|
78
|
+
freely since richpyro never subclasses anything, it only constructs.
|
|
79
|
+
"""
|
|
80
|
+
from __future__ import annotations
|
|
81
|
+
|
|
82
|
+
from datetime import datetime
|
|
83
|
+
from typing import Any, Iterable, Union
|
|
84
|
+
|
|
85
|
+
from pyrogram import enums, types
|
|
86
|
+
|
|
87
|
+
__version__ = "0.1.0"
|
|
88
|
+
__author__ = "devgagan"
|
|
89
|
+
__all__ = [
|
|
90
|
+
# text formatting
|
|
91
|
+
"bold", "italic", "underline", "strike", "spoiler", "code", "link",
|
|
92
|
+
"user_mention", "mention", "custom_emoji", "hashtag", "bot_command",
|
|
93
|
+
"phone_number", "email", "cashtag", "bank_card", "date_time",
|
|
94
|
+
"subscript", "superscript", "anchor_target", "anchor_link",
|
|
95
|
+
"footnote_ref", "footnote_link", "marked", "math_inline", "inline_button",
|
|
96
|
+
# buttons
|
|
97
|
+
"Style", "btn", "url_btn", "webapp_btn", "login_btn", "switch_inline_btn",
|
|
98
|
+
"switch_inline_here_btn", "switch_inline_chosen_btn", "copy_btn",
|
|
99
|
+
"disabled_btn", "buttons",
|
|
100
|
+
# media
|
|
101
|
+
"photo_media", "video_media", "animation_media", "audio_media",
|
|
102
|
+
"document_media", "voice_media", "caption", "named_media",
|
|
103
|
+
# blocks
|
|
104
|
+
"para", "heading", "divider", "footer", "anchor_block", "photo_block",
|
|
105
|
+
"video_block", "animation_block", "audio_block", "document_block",
|
|
106
|
+
"voice_block", "collage", "slideshow", "list_item", "bullet_list",
|
|
107
|
+
"table_cell", "table", "details", "blockquote", "expandable_quote",
|
|
108
|
+
"pull_quote", "preformatted", "map_block", "math_block", "thinking",
|
|
109
|
+
# message builders
|
|
110
|
+
"message", "blocks_message", "html_message", "markdown_message",
|
|
111
|
+
"text", "text_message",
|
|
112
|
+
# plain-text fallback rendering
|
|
113
|
+
"flatten_message",
|
|
114
|
+
# send / edit / reply
|
|
115
|
+
"is_bot_client", "send", "edit", "reply", "quick", "quick_edit",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
# A "rich text" argument anywhere in this module accepts: a plain str, a
|
|
119
|
+
# list mixing str/RichText parts (auto-concatenated), or a single RichText
|
|
120
|
+
# node (bold(...), italic(...), etc. — nest freely, e.g. bold(italic("x"))).
|
|
121
|
+
RichTextArg = Union[str, list, "types.RichText"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ════════════════════════════════════════════════════════════════════
|
|
125
|
+
# TEXT FORMATTING (wraps every RichText* input type)
|
|
126
|
+
# ════════════════════════════════════════════════════════════════════
|
|
127
|
+
def bold(text: RichTextArg) -> types.RichTextBold:
|
|
128
|
+
return types.RichTextBold(text=text)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def italic(text: RichTextArg) -> types.RichTextItalic:
|
|
132
|
+
return types.RichTextItalic(text=text)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def underline(text: RichTextArg) -> types.RichTextUnderline:
|
|
136
|
+
return types.RichTextUnderline(text=text)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def strike(text: RichTextArg) -> types.RichTextStrikethrough:
|
|
140
|
+
return types.RichTextStrikethrough(text=text)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def spoiler(text: RichTextArg) -> types.RichTextSpoiler:
|
|
144
|
+
return types.RichTextSpoiler(text=text)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def code(text: RichTextArg) -> types.RichTextCode:
|
|
148
|
+
"""Inline monospace code (for a full code block use `preformatted()`)."""
|
|
149
|
+
return types.RichTextCode(text=text)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def link(text: RichTextArg, url: str) -> types.RichTextUrl:
|
|
153
|
+
"""A clickable text hyperlink: link("Docs", "https://docs.pyrogram.org")."""
|
|
154
|
+
return types.RichTextUrl(text=text, url=url)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def user_mention(text: RichTextArg, user: "types.User") -> types.RichTextTextMention:
|
|
158
|
+
"""Mention a user by their User object — works even if they have no
|
|
159
|
+
@username (unlike `mention()` below)."""
|
|
160
|
+
return types.RichTextTextMention(text=text, user=user)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def mention(text: RichTextArg, username: str) -> types.RichTextMention:
|
|
164
|
+
"""Mention a user/channel by @username string."""
|
|
165
|
+
return types.RichTextMention(text=text, username=username)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def custom_emoji(custom_emoji_id: str, alternative_text: str) -> types.RichTextCustomEmoji:
|
|
169
|
+
"""A premium custom emoji. `alternative_text` is what non-premium
|
|
170
|
+
clients / plain-text fallback show instead (e.g. the matching plain emoji)."""
|
|
171
|
+
return types.RichTextCustomEmoji(custom_emoji_id=custom_emoji_id, alternative_text=alternative_text)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def hashtag(text: RichTextArg, tag: str) -> types.RichTextHashtag:
|
|
175
|
+
return types.RichTextHashtag(text=text, hashtag=tag)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def bot_command(text: RichTextArg, command: str) -> types.RichTextBotCommand:
|
|
179
|
+
return types.RichTextBotCommand(text=text, bot_command=command)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def phone_number(text: RichTextArg, number: str) -> types.RichTextPhoneNumber:
|
|
183
|
+
return types.RichTextPhoneNumber(text=text, phone_number=number)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def email(text: RichTextArg, address: str) -> types.RichTextEmailAddress:
|
|
187
|
+
return types.RichTextEmailAddress(text=text, email_address=address)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def cashtag(text: RichTextArg, tag: str) -> types.RichTextCashtag:
|
|
191
|
+
return types.RichTextCashtag(text=text, cashtag=tag)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def bank_card(text: RichTextArg, number: str) -> types.RichTextBankCardNumber:
|
|
195
|
+
return types.RichTextBankCardNumber(text=text, bank_card_number=number)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def date_time(text: RichTextArg, date: datetime, fmt: str | None = None) -> types.RichTextDateTime:
|
|
199
|
+
"""`fmt` is an optional display format string; omit to use Telegram's default."""
|
|
200
|
+
return types.RichTextDateTime(text=text, date=date, date_time_format=fmt)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def subscript(text: RichTextArg) -> types.RichTextSubscript:
|
|
204
|
+
return types.RichTextSubscript(text=text)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def superscript(text: RichTextArg) -> types.RichTextSuperscript:
|
|
208
|
+
return types.RichTextSuperscript(text=text)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def anchor_target(text: RichTextArg, name: str) -> types.RichTextAnchor:
|
|
212
|
+
"""Marks `text` as a named jump-target (`name`) that `anchor_link()`
|
|
213
|
+
or `anchor_block()` can jump to within the same rich message."""
|
|
214
|
+
return types.RichTextAnchor(text=text, name=name)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def anchor_link(text: RichTextArg, anchor_name: str) -> types.RichTextAnchorLink:
|
|
218
|
+
"""A clickable link that jumps to a target defined by `anchor_target()`
|
|
219
|
+
or `anchor_block()` with the same name."""
|
|
220
|
+
return types.RichTextAnchorLink(text=text, anchor_name=anchor_name)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def footnote_ref(text: RichTextArg, name: str) -> types.RichTextReference:
|
|
224
|
+
"""Superscript footnote marker — pair with `footnote_link()` elsewhere
|
|
225
|
+
in the message to link back to this reference point."""
|
|
226
|
+
return types.RichTextReference(text=text, name=name)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def footnote_link(text: RichTextArg, reference_name: str) -> types.RichTextReferenceLink:
|
|
230
|
+
return types.RichTextReferenceLink(text=text, reference_name=reference_name)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def marked(text: RichTextArg) -> types.RichTextMarked:
|
|
234
|
+
"""Highlighter-style marked/highlighted text."""
|
|
235
|
+
return types.RichTextMarked(text=text)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def math_inline(expression: str) -> types.RichTextMathematicalExpression:
|
|
239
|
+
"""Inline LaTeX-style math, e.g. math_inline(r"x^2 + y^2 = z^2")."""
|
|
240
|
+
return types.RichTextMathematicalExpression(expression=expression)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def inline_button(button: "types.RichMessageButton") -> types.RichTextButton:
|
|
244
|
+
"""Embeds a button (see `btn()`/`url_btn()` etc. below) inline within
|
|
245
|
+
running text instead of its own button row."""
|
|
246
|
+
return types.RichTextButton(button=button)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ════════════════════════════════════════════════════════════════════
|
|
250
|
+
# BUTTONS
|
|
251
|
+
# ════════════════════════════════════════════════════════════════════
|
|
252
|
+
Style = enums.ButtonStyle # Style.DEFAULT / .PRIMARY / .DANGER / .SUCCESS / .LINK
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def btn(text: RichTextArg, callback_data: str | bytes, style: enums.ButtonStyle = Style.DEFAULT) -> types.RichMessageButton:
|
|
256
|
+
"""A standard callback button (handled by your @on_callback_query)."""
|
|
257
|
+
return types.RichMessageButton(text=text, callback_data=callback_data, style=style)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def url_btn(text: RichTextArg, url: str, style: enums.ButtonStyle = Style.LINK) -> types.RichMessageButton:
|
|
261
|
+
return types.RichMessageButton(text=text, url=url, style=style)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def webapp_btn(text: RichTextArg, url: str, style: enums.ButtonStyle = Style.DEFAULT) -> types.RichMessageButton:
|
|
265
|
+
"""Opens a Telegram Mini App / web app."""
|
|
266
|
+
return types.RichMessageButton(text=text, web_app=types.WebAppInfo(url=url), style=style)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def login_btn(
|
|
270
|
+
text: RichTextArg, url: str, *,
|
|
271
|
+
forward_text: str | None = None, bot_username: str | None = None,
|
|
272
|
+
request_write_access: bool | None = None, style: enums.ButtonStyle = Style.DEFAULT,
|
|
273
|
+
) -> types.RichMessageButton:
|
|
274
|
+
"""Telegram Login Widget button."""
|
|
275
|
+
return types.RichMessageButton(
|
|
276
|
+
text=text, style=style,
|
|
277
|
+
login_url=types.LoginUrl(
|
|
278
|
+
url=url, forward_text=forward_text, bot_username=bot_username,
|
|
279
|
+
request_write_access=request_write_access,
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def switch_inline_btn(text: RichTextArg, query: str = "", style: enums.ButtonStyle = Style.DEFAULT) -> types.RichMessageButton:
|
|
285
|
+
"""Prompts the user to pick another chat and opens your bot's inline
|
|
286
|
+
mode there with `query` pre-filled."""
|
|
287
|
+
return types.RichMessageButton(text=text, switch_inline_query=query, style=style)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def switch_inline_here_btn(text: RichTextArg, query: str = "", style: enums.ButtonStyle = Style.DEFAULT) -> types.RichMessageButton:
|
|
291
|
+
"""Same as `switch_inline_btn()` but opens inline mode in the CURRENT chat."""
|
|
292
|
+
return types.RichMessageButton(text=text, switch_inline_query_current_chat=query, style=style)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def switch_inline_chosen_btn(
|
|
296
|
+
text: RichTextArg, *, query: str | None = None,
|
|
297
|
+
allow_user_chats: bool | None = None, allow_bot_chats: bool | None = None,
|
|
298
|
+
allow_group_chats: bool | None = None, allow_channel_chats: bool | None = None,
|
|
299
|
+
style: enums.ButtonStyle = Style.DEFAULT,
|
|
300
|
+
) -> types.RichMessageButton:
|
|
301
|
+
"""Like `switch_inline_btn()` but restricts which chat types the user
|
|
302
|
+
can pick (only relevant chat kinds are shown in the picker)."""
|
|
303
|
+
return types.RichMessageButton(
|
|
304
|
+
text=text, style=style,
|
|
305
|
+
switch_inline_query_chosen_chat=types.SwitchInlineQueryChosenChat(
|
|
306
|
+
query=query, allow_user_chats=allow_user_chats, allow_bot_chats=allow_bot_chats,
|
|
307
|
+
allow_group_chats=allow_group_chats, allow_channel_chats=allow_channel_chats,
|
|
308
|
+
),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def copy_btn(text: RichTextArg, copy_text: str, style: enums.ButtonStyle = Style.DEFAULT) -> types.RichMessageButton:
|
|
313
|
+
"""Tapping the button copies `copy_text` to the user's clipboard."""
|
|
314
|
+
return types.RichMessageButton(text=text, copy_text=types.CopyTextButton(text=copy_text), style=style)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def disabled_btn(text: RichTextArg, style: enums.ButtonStyle = Style.DEFAULT) -> types.RichMessageButton:
|
|
318
|
+
"""A visually-present but non-interactive button (e.g. a status label)."""
|
|
319
|
+
return types.RichMessageButton(text=text, disabled=types.DisabledButton(), style=style)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def buttons(*btns: "types.RichMessageButton", align: str | None = None) -> types.InputRichBlockButtons:
|
|
323
|
+
"""One row of up to 8 buttons. Call multiple times (one per row) inside
|
|
324
|
+
`message()`/`blocks_message()` to build a multi-row keyboard."""
|
|
325
|
+
assert 1 <= len(btns) <= 8, "a button row holds 1-8 buttons"
|
|
326
|
+
return types.InputRichBlockButtons(buttons=list(btns), align=align)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ════════════════════════════════════════════════════════════════════
|
|
330
|
+
# MEDIA (for photo_block/video_block/etc. below — these are the
|
|
331
|
+
# *input* media wrappers, same PathType | BinaryIO | file_id you'd
|
|
332
|
+
# already pass to send_photo/send_video/...)
|
|
333
|
+
# ════════════════════════════════════════════════════════════════════
|
|
334
|
+
def photo_media(media, *, caption: str = "", has_spoiler: bool | None = None, **kw) -> types.InputMediaPhoto:
|
|
335
|
+
return types.InputMediaPhoto(media=media, caption=caption, has_spoiler=has_spoiler, **kw)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def video_media(media, *, caption: str = "", thumb=None, width=0, height=0, duration=0,
|
|
339
|
+
supports_streaming=True, has_spoiler: bool | None = None, **kw) -> types.InputMediaVideo:
|
|
340
|
+
return types.InputMediaVideo(
|
|
341
|
+
media=media, caption=caption, thumb=thumb, width=width, height=height,
|
|
342
|
+
duration=duration, supports_streaming=supports_streaming, has_spoiler=has_spoiler, **kw
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def animation_media(media, *, caption: str = "", thumb=None, width=0, height=0, duration=0,
|
|
347
|
+
has_spoiler: bool | None = None, **kw) -> types.InputMediaAnimation:
|
|
348
|
+
return types.InputMediaAnimation(
|
|
349
|
+
media=media, caption=caption, thumb=thumb, width=width, height=height,
|
|
350
|
+
duration=duration, has_spoiler=has_spoiler, **kw
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def audio_media(media, *, caption: str = "", thumb=None, duration=0, performer="", title="", **kw) -> types.InputMediaAudio:
|
|
355
|
+
return types.InputMediaAudio(media=media, caption=caption, thumb=thumb, duration=duration, performer=performer, title=title, **kw)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def document_media(media, *, caption: str = "", thumb=None, **kw) -> types.InputMediaDocument:
|
|
359
|
+
return types.InputMediaDocument(media=media, caption=caption, thumb=thumb, **kw)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def voice_media(media, *, caption: str = "", duration=0, **kw) -> types.InputMediaVoiceNote:
|
|
363
|
+
return types.InputMediaVoiceNote(media=media, caption=caption, duration=duration, **kw)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def caption(text: RichTextArg, credit: RichTextArg | None = None) -> types.RichBlockCaption:
|
|
367
|
+
"""Rich (formattable) caption for photo_block/video_block/etc. —
|
|
368
|
+
`credit` renders as a smaller attribution line under the caption."""
|
|
369
|
+
return types.RichBlockCaption(text=text, credit=credit)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def named_media(id: str, media) -> types.InputRichMessageMedia:
|
|
373
|
+
"""A named media attachment for `html_message()`/`markdown_message()`
|
|
374
|
+
(pass a list of these as `message(..., media=[...])`'s `media=` arg) —
|
|
375
|
+
lets your HTML/Markdown content reference it inline by `id` (e.g.
|
|
376
|
+
`<img src="{id}">` in HTML) instead of needing a separate block.
|
|
377
|
+
`media` is one of the `*_media()` builders above (photo_media(),
|
|
378
|
+
video_media(), etc.)."""
|
|
379
|
+
return types.InputRichMessageMedia(id=id, media=media)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ════════════════════════════════════════════════════════════════════
|
|
383
|
+
# BLOCKS (wraps every InputRichBlock* type — the building blocks of
|
|
384
|
+
# a rich message body, used inside `message()`/`blocks_message()`)
|
|
385
|
+
# ════════════════════════════════════════════════════════════════════
|
|
386
|
+
def para(*parts: RichTextArg) -> types.InputRichBlockParagraph:
|
|
387
|
+
"""A paragraph block — the workhorse block, equivalent to one line/
|
|
388
|
+
paragraph of a plain message. Pass one string, or several parts that
|
|
389
|
+
get concatenated: para("Hello ", bold(name), "!")."""
|
|
390
|
+
text = parts[0] if len(parts) == 1 else list(parts)
|
|
391
|
+
return types.InputRichBlockParagraph(text=text)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def heading(text: RichTextArg, size: int = 2) -> types.InputRichBlockSectionHeading:
|
|
395
|
+
"""A section heading. `size` is 1 (largest) to ~4 (smallest)."""
|
|
396
|
+
return types.InputRichBlockSectionHeading(text=text, size=size)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def divider() -> types.InputRichBlockDivider:
|
|
400
|
+
return types.InputRichBlockDivider()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def footer(text: RichTextArg) -> types.InputRichBlockFooter:
|
|
404
|
+
"""Small, muted footer line — good for 'Powered by ...' style credits."""
|
|
405
|
+
return types.InputRichBlockFooter(text=text)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def anchor_block(name: str) -> types.InputRichBlockAnchor:
|
|
409
|
+
"""An invisible jump-target block — pair with `anchor_link()` text to
|
|
410
|
+
build a table-of-contents style message."""
|
|
411
|
+
return types.InputRichBlockAnchor(name=name)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def photo_block(media: "types.InputMediaPhoto", cap: "types.RichBlockCaption | None" = None) -> types.InputRichBlockPhoto:
|
|
415
|
+
return types.InputRichBlockPhoto(photo=media, caption=cap)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def video_block(media: "types.InputMediaVideo", cap: "types.RichBlockCaption | None" = None) -> types.InputRichBlockVideo:
|
|
419
|
+
return types.InputRichBlockVideo(video=media, caption=cap)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def animation_block(media: "types.InputMediaAnimation", cap: "types.RichBlockCaption | None" = None) -> types.InputRichBlockAnimation:
|
|
423
|
+
return types.InputRichBlockAnimation(animation=media, caption=cap)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def audio_block(media: "types.InputMediaAudio", cap: "types.RichBlockCaption | None" = None) -> types.InputRichBlockAudio:
|
|
427
|
+
return types.InputRichBlockAudio(audio=media, caption=cap)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def document_block(media: "types.InputMediaDocument", cap: "types.RichBlockCaption | None" = None) -> types.InputRichBlockDocument:
|
|
431
|
+
return types.InputRichBlockDocument(document=media, caption=cap)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def voice_block(media: "types.InputMediaVoiceNote", cap: "types.RichBlockCaption | None" = None) -> types.InputRichBlockVoiceNote:
|
|
435
|
+
return types.InputRichBlockVoiceNote(voice_note=media, caption=cap)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def collage(*blocks, cap: "types.RichBlockCaption | None" = None) -> types.InputRichBlockCollage:
|
|
439
|
+
"""A grid of media blocks (photo_block/video_block/...) sharing one caption."""
|
|
440
|
+
return types.InputRichBlockCollage(blocks=list(blocks), caption=cap)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def slideshow(*blocks, cap: "types.RichBlockCaption | None" = None) -> types.InputRichBlockSlideshow:
|
|
444
|
+
"""Like `collage()` but swipeable one-at-a-time instead of a grid."""
|
|
445
|
+
return types.InputRichBlockSlideshow(blocks=list(blocks), caption=cap)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def list_item(*blocks, has_checkbox: bool | None = None, is_checked: bool | None = None,
|
|
449
|
+
value: int | None = None, type: str | None = None) -> types.InputRichBlockListItem:
|
|
450
|
+
"""One item of `bullet_list()`. Pass `has_checkbox=True` for a checklist
|
|
451
|
+
item, `type="ordered"` for numbered lists (default is bulleted)."""
|
|
452
|
+
return types.InputRichBlockListItem(blocks=list(blocks), has_checkbox=has_checkbox, is_checked=is_checked, value=value, type=type)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def bullet_list(*items: "types.InputRichBlockListItem") -> types.InputRichBlockList:
|
|
456
|
+
"""Build items with `list_item()` first: bullet_list(list_item(para("A")), list_item(para("B")))."""
|
|
457
|
+
return types.InputRichBlockList(items=list(items))
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def table_cell(text: RichTextArg | None = None, *, is_header: bool | None = None,
|
|
461
|
+
colspan: int | None = None, rowspan: int | None = None,
|
|
462
|
+
align: str | None = None, valign: str | None = None) -> types.RichBlockTableCell:
|
|
463
|
+
return types.RichBlockTableCell(text=text, is_header=is_header, colspan=colspan, rowspan=rowspan, align=align, valign=valign)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def table(rows: list[list["types.RichBlockTableCell"]], *, bordered: bool | None = None,
|
|
467
|
+
striped: bool | None = None, compact: bool | None = None,
|
|
468
|
+
cap: RichTextArg | None = None) -> types.InputRichBlockTable:
|
|
469
|
+
"""`rows` is a list of rows, each a list of `table_cell(...)`."""
|
|
470
|
+
return types.InputRichBlockTable(cells=rows, is_bordered=bordered, is_striped=striped, is_compact=compact, caption=cap)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def details(summary: RichTextArg, *blocks, is_open: bool | None = None) -> types.InputRichBlockDetails:
|
|
474
|
+
"""A collapsible <details>-style section: a `summary` line the user
|
|
475
|
+
taps to reveal the nested `blocks`."""
|
|
476
|
+
return types.InputRichBlockDetails(summary=summary, blocks=list(blocks), is_open=is_open)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def blockquote(*blocks, credit: RichTextArg | None = None) -> types.InputRichBlockBlockQuotation:
|
|
480
|
+
"""A block-level quotation wrapping one or more nested blocks."""
|
|
481
|
+
return types.InputRichBlockBlockQuotation(blocks=list(blocks), credit=credit)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def expandable_quote(text: RichTextArg, credit: RichTextArg | None = None) -> types.InputRichBlockExpandableBlockQuotation:
|
|
485
|
+
"""A quote block that starts collapsed with a 'Show more' toggle."""
|
|
486
|
+
return types.InputRichBlockExpandableBlockQuotation(text=text, credit=credit)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def pull_quote(text: RichTextArg, credit: RichTextArg | None = None) -> types.InputRichBlockPullQuotation:
|
|
490
|
+
"""A visually-emphasized standalone quote (magazine-style pull-quote)."""
|
|
491
|
+
return types.InputRichBlockPullQuotation(text=text, credit=credit)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def preformatted(text: RichTextArg, language: str | None = None) -> types.InputRichBlockPreformatted:
|
|
495
|
+
"""A full code block. `language` enables syntax highlighting (e.g. "python")."""
|
|
496
|
+
return types.InputRichBlockPreformatted(text=text, language=language)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def map_block(latitude: float, longitude: float, *, zoom: int | None = None,
|
|
500
|
+
width: int | None = None, height: int | None = None,
|
|
501
|
+
cap: "types.RichBlockCaption | None" = None) -> types.InputRichBlockMap:
|
|
502
|
+
return types.InputRichBlockMap(
|
|
503
|
+
location=types.Location(latitude=latitude, longitude=longitude),
|
|
504
|
+
zoom=zoom, width=width, height=height, caption=cap,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def math_block(expression: str) -> types.InputRichBlockMathematicalExpression:
|
|
509
|
+
"""A standalone (block-level, centered) LaTeX-style expression."""
|
|
510
|
+
return types.InputRichBlockMathematicalExpression(expression=expression)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def thinking(text: RichTextArg) -> types.InputRichBlockThinking:
|
|
514
|
+
"""Renders as a collapsed 'thought process' style block (chain-of-thought UI)."""
|
|
515
|
+
return types.InputRichBlockThinking(text=text)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# ════════════════════════════════════════════════════════════════════
|
|
519
|
+
# MESSAGE (wraps InputRichMessage construction)
|
|
520
|
+
# ════════════════════════════════════════════════════════════════════
|
|
521
|
+
def message(*blocks, media: list | None = None, rtl: bool | None = None,
|
|
522
|
+
skip_entity_detection: bool | None = None) -> types.InputRichMessage:
|
|
523
|
+
"""The main way to build a rich message: message(para(...), divider(), buttons(...), ...)."""
|
|
524
|
+
return types.InputRichMessage(blocks=list(blocks), media=media, is_rtl=rtl, skip_entity_detection=skip_entity_detection)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# alias — `blocks_message` reads more explicitly next to `html_message`/`markdown_message`
|
|
528
|
+
blocks_message = message
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def html_message(html: str, *, rtl: bool | None = None, skip_entity_detection: bool | None = None) -> types.InputRichMessage:
|
|
532
|
+
"""Build straight from an HTML string instead of block-by-block."""
|
|
533
|
+
return types.InputRichMessage(html=html, is_rtl=rtl, skip_entity_detection=skip_entity_detection)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def markdown_message(md: str, *, rtl: bool | None = None, skip_entity_detection: bool | None = None) -> types.InputRichMessage:
|
|
537
|
+
"""Build straight from a Markdown string instead of block-by-block."""
|
|
538
|
+
return types.InputRichMessage(markdown=md, is_rtl=rtl, skip_entity_detection=skip_entity_detection)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def text(content: RichTextArg) -> types.InputRichMessage:
|
|
542
|
+
"""The rich-message equivalent of a plain `send_message(text)` call —
|
|
543
|
+
a single paragraph, no buttons. The most common case — use this unless
|
|
544
|
+
you actually need multiple blocks/buttons."""
|
|
545
|
+
return types.InputRichMessage(blocks=[types.InputRichBlockParagraph(text=content)])
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
# common alias some codebases expect
|
|
549
|
+
text_message = text
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# ════════════════════════════════════════════════════════════════════
|
|
553
|
+
# PLAIN-TEXT FALLBACK RENDERING
|
|
554
|
+
# Best-effort flatten of a RichText tree / block list down to a plain
|
|
555
|
+
# string, for the automatic bot->user degrade in send()/edit()/reply()
|
|
556
|
+
# below, or for logging/previews. Formatting-only nodes (bold/italic/
|
|
557
|
+
# etc.) degrade to Pyrogram markdown syntax so *some* formatting still
|
|
558
|
+
# shows even in the plain fallback; structural nodes (media/table/map/
|
|
559
|
+
# math/...) degrade to a short bracketed label since there's no text
|
|
560
|
+
# equivalent for them.
|
|
561
|
+
# ════════════════════════════════════════════════════════════════════
|
|
562
|
+
def _flatten_richtext(node: Any) -> str:
|
|
563
|
+
if node is None:
|
|
564
|
+
return ""
|
|
565
|
+
if isinstance(node, str):
|
|
566
|
+
return node
|
|
567
|
+
if isinstance(node, (list, tuple)):
|
|
568
|
+
return "".join(_flatten_richtext(n) for n in node)
|
|
569
|
+
|
|
570
|
+
inner = _flatten_richtext(getattr(node, "text", None))
|
|
571
|
+
if isinstance(node, types.RichTextBold):
|
|
572
|
+
return f"**{inner}**"
|
|
573
|
+
if isinstance(node, types.RichTextItalic):
|
|
574
|
+
return f"__{inner}__"
|
|
575
|
+
if isinstance(node, types.RichTextUnderline):
|
|
576
|
+
return f"--{inner}--"
|
|
577
|
+
if isinstance(node, types.RichTextStrikethrough):
|
|
578
|
+
return f"~~{inner}~~"
|
|
579
|
+
if isinstance(node, types.RichTextSpoiler):
|
|
580
|
+
return f"||{inner}||"
|
|
581
|
+
if isinstance(node, types.RichTextCode):
|
|
582
|
+
return f"`{inner}`"
|
|
583
|
+
if isinstance(node, types.RichTextUrl):
|
|
584
|
+
return f"[{inner}]({node.url})"
|
|
585
|
+
if isinstance(node, types.RichTextTextMention):
|
|
586
|
+
return f"[{inner}](tg://user?id={node.user.id})"
|
|
587
|
+
if isinstance(node, types.RichTextMention):
|
|
588
|
+
return f"{inner} (@{node.username})" if inner and inner != node.username else f"@{node.username}"
|
|
589
|
+
if isinstance(node, types.RichTextCustomEmoji):
|
|
590
|
+
return node.alternative_text or ""
|
|
591
|
+
if isinstance(node, (types.RichTextHashtag, types.RichTextBotCommand, types.RichTextPhoneNumber,
|
|
592
|
+
types.RichTextEmailAddress, types.RichTextCashtag, types.RichTextBankCardNumber,
|
|
593
|
+
types.RichTextSubscript, types.RichTextSuperscript, types.RichTextMarked,
|
|
594
|
+
types.RichTextAnchor, types.RichTextAnchorLink, types.RichTextReference,
|
|
595
|
+
types.RichTextReferenceLink, types.RichTextDateTime)):
|
|
596
|
+
return inner
|
|
597
|
+
if isinstance(node, types.RichTextMathematicalExpression):
|
|
598
|
+
return node.expression
|
|
599
|
+
if isinstance(node, types.RichTextButton):
|
|
600
|
+
return f"[{_flatten_richtext(getattr(node.button, 'text', ''))}]"
|
|
601
|
+
return inner or str(node)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _flatten_block(block: Any) -> str:
|
|
605
|
+
if isinstance(block, types.InputRichBlockParagraph):
|
|
606
|
+
return _flatten_richtext(block.text)
|
|
607
|
+
if isinstance(block, types.InputRichBlockFooter):
|
|
608
|
+
return _flatten_richtext(block.text)
|
|
609
|
+
if isinstance(block, types.InputRichBlockSectionHeading):
|
|
610
|
+
return _flatten_richtext(block.text).upper()
|
|
611
|
+
if isinstance(block, types.InputRichBlockDivider):
|
|
612
|
+
return "───────────"
|
|
613
|
+
if isinstance(block, types.InputRichBlockPreformatted):
|
|
614
|
+
return f"```{block.language or ''}\n{_flatten_richtext(block.text)}\n```"
|
|
615
|
+
if isinstance(block, (types.InputRichBlockPullQuotation, types.InputRichBlockExpandableBlockQuotation)):
|
|
616
|
+
return f"> {_flatten_richtext(block.text)}"
|
|
617
|
+
if isinstance(block, types.InputRichBlockBlockQuotation):
|
|
618
|
+
return "\n".join(f"> {line}" for line in "\n".join(_flatten_block(b) for b in block.blocks).splitlines())
|
|
619
|
+
if isinstance(block, types.InputRichBlockDetails):
|
|
620
|
+
inner = "\n".join(_flatten_block(b) for b in block.blocks)
|
|
621
|
+
return f"{_flatten_richtext(block.summary)}\n{inner}"
|
|
622
|
+
if isinstance(block, types.InputRichBlockList):
|
|
623
|
+
lines = []
|
|
624
|
+
for item in block.items:
|
|
625
|
+
mark = "☑" if item.is_checked else ("☐" if item.has_checkbox else "•")
|
|
626
|
+
lines.append(f"{mark} " + " ".join(_flatten_block(b) for b in item.blocks))
|
|
627
|
+
return "\n".join(lines)
|
|
628
|
+
if isinstance(block, types.InputRichBlockButtons):
|
|
629
|
+
return " | ".join(f"[{_flatten_richtext(b.text)}]" for b in block.buttons)
|
|
630
|
+
if isinstance(block, types.InputRichBlockAnchor):
|
|
631
|
+
return ""
|
|
632
|
+
if isinstance(block, types.InputRichBlockMathematicalExpression):
|
|
633
|
+
return block.expression
|
|
634
|
+
if isinstance(block, (types.InputRichBlockPhoto, types.InputRichBlockVideo, types.InputRichBlockAnimation,
|
|
635
|
+
types.InputRichBlockAudio, types.InputRichBlockDocument, types.InputRichBlockVoiceNote,
|
|
636
|
+
types.InputRichBlockCollage, types.InputRichBlockSlideshow, types.InputRichBlockMap)):
|
|
637
|
+
label = type(block).__name__.replace("InputRichBlock", "")
|
|
638
|
+
cap = getattr(block, "caption", None)
|
|
639
|
+
cap_text = f": {_flatten_richtext(cap.text)}" if cap else ""
|
|
640
|
+
return f"[{label}{cap_text}]"
|
|
641
|
+
if isinstance(block, types.InputRichBlockThinking):
|
|
642
|
+
return f"[Thinking] {_flatten_richtext(block.text)}"
|
|
643
|
+
if isinstance(block, types.InputRichBlockTable):
|
|
644
|
+
rows = [" | ".join(_flatten_richtext(c.text) for c in row) for row in block.cells]
|
|
645
|
+
return "\n".join(rows)
|
|
646
|
+
return ""
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def flatten_message(rich_message: "types.InputRichMessage") -> str:
|
|
650
|
+
"""Best-effort plain-text rendering of an InputRichMessage — used
|
|
651
|
+
automatically by send()/edit()/reply() below when the sending client
|
|
652
|
+
is a user account (Rich Messages don't render for those). Pass your
|
|
653
|
+
own `fallback_text=` to those calls instead whenever you want a
|
|
654
|
+
hand-written plain version rather than this generic flatten."""
|
|
655
|
+
if rich_message.markdown:
|
|
656
|
+
return rich_message.markdown
|
|
657
|
+
if rich_message.html:
|
|
658
|
+
return rich_message.html # best-effort — no HTML stripping, shown as-is
|
|
659
|
+
lines = [_flatten_block(b) for b in (rich_message.blocks or [])]
|
|
660
|
+
return "\n".join(line for line in lines if line)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
# ════════════════════════════════════════════════════════════════════
|
|
664
|
+
# SEND / EDIT / REPLY — the easy, safe call surface.
|
|
665
|
+
# Handles the two footguns from the module docstring automatically:
|
|
666
|
+
# bot-only rich messages, and numeric-string chat_id misdetection.
|
|
667
|
+
# ════════════════════════════════════════════════════════════════════
|
|
668
|
+
def _safe_chat_id(chat_id):
|
|
669
|
+
"""int-ify a numeric chat_id string (e.g. str(chat.id) from elsewhere
|
|
670
|
+
in a codebase) so kurigram's resolve_peer() can't mistake it for a
|
|
671
|
+
phone number. Usernames / "@handle" strings pass through untouched."""
|
|
672
|
+
if isinstance(chat_id, str):
|
|
673
|
+
s = chat_id.strip()
|
|
674
|
+
digits = s[1:] if s.startswith("-") else s
|
|
675
|
+
if digits.isdigit():
|
|
676
|
+
return int(s)
|
|
677
|
+
return chat_id
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
async def is_bot_client(client) -> bool:
|
|
681
|
+
"""True if `client` is logged in as a bot account. Rich Messages only
|
|
682
|
+
render for bots — see the module docstring. Uses the cached
|
|
683
|
+
`client.me` (set once at Client.start()); fetches it if not cached yet."""
|
|
684
|
+
try:
|
|
685
|
+
if client.me is None:
|
|
686
|
+
await client.get_me()
|
|
687
|
+
return bool(client.me and client.me.is_bot)
|
|
688
|
+
except Exception:
|
|
689
|
+
return False
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
async def send(
|
|
693
|
+
client, chat_id, msg: "types.InputRichMessage", *,
|
|
694
|
+
fallback_text: str | None = None, is_bot: bool | None = None,
|
|
695
|
+
reply_markup=None, disable_notification: bool | None = None,
|
|
696
|
+
reply_parameters=None, effect_id: int | None = None,
|
|
697
|
+
):
|
|
698
|
+
"""Send a rich message. Auto-degrades to plain text if `client` isn't
|
|
699
|
+
a bot account, and auto-fixes a numeric-string `chat_id`. Pass
|
|
700
|
+
`is_bot=True/False` yourself to skip the `client.me` check (e.g. if
|
|
701
|
+
you already know it, for a hot loop); pass `fallback_text=` for a
|
|
702
|
+
hand-written plain version instead of the generic flatten."""
|
|
703
|
+
cid = _safe_chat_id(chat_id)
|
|
704
|
+
bot = is_bot if is_bot is not None else await is_bot_client(client)
|
|
705
|
+
if bot:
|
|
706
|
+
return await client.send_rich_message(
|
|
707
|
+
cid, rich_message=msg, reply_markup=reply_markup,
|
|
708
|
+
disable_notification=disable_notification,
|
|
709
|
+
reply_parameters=reply_parameters, effect_id=effect_id,
|
|
710
|
+
)
|
|
711
|
+
return await client.send_message(
|
|
712
|
+
cid, fallback_text if fallback_text is not None else flatten_message(msg),
|
|
713
|
+
reply_markup=reply_markup, disable_notification=disable_notification,
|
|
714
|
+
reply_parameters=reply_parameters,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
async def edit(
|
|
719
|
+
client, chat_id, message_id: int, msg: "types.InputRichMessage", *,
|
|
720
|
+
fallback_text: str | None = None, is_bot: bool | None = None, reply_markup=None,
|
|
721
|
+
):
|
|
722
|
+
"""Edit a message in place with new rich content. Same auto-degrade /
|
|
723
|
+
auto-int-ify guarantees as send()."""
|
|
724
|
+
cid = _safe_chat_id(chat_id)
|
|
725
|
+
bot = is_bot if is_bot is not None else await is_bot_client(client)
|
|
726
|
+
if bot:
|
|
727
|
+
return await client.edit_message_text(cid, message_id, rich_message=msg, reply_markup=reply_markup)
|
|
728
|
+
return await client.edit_message_text(
|
|
729
|
+
cid, message_id, fallback_text if fallback_text is not None else flatten_message(msg),
|
|
730
|
+
reply_markup=reply_markup,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
async def reply(
|
|
735
|
+
message_obj: "types.Message", msg: "types.InputRichMessage", *,
|
|
736
|
+
fallback_text: str | None = None, is_bot: bool | None = None, reply_markup=None,
|
|
737
|
+
disable_notification: bool | None = None,
|
|
738
|
+
):
|
|
739
|
+
"""Reply to an incoming Message, using its own client automatically
|
|
740
|
+
(message_obj._client — the same internal attribute Message.reply_text/
|
|
741
|
+
reply_rich use). Same auto-degrade guarantee as send()."""
|
|
742
|
+
client = message_obj._client
|
|
743
|
+
bot = is_bot if is_bot is not None else await is_bot_client(client)
|
|
744
|
+
if bot:
|
|
745
|
+
return await message_obj.reply_rich(
|
|
746
|
+
rich_message=msg, reply_markup=reply_markup, disable_notification=disable_notification,
|
|
747
|
+
)
|
|
748
|
+
return await message_obj.reply_text(
|
|
749
|
+
fallback_text if fallback_text is not None else flatten_message(msg),
|
|
750
|
+
reply_markup=reply_markup, disable_notification=disable_notification,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
async def quick(client, chat_id, plain_text: str, **kw):
|
|
755
|
+
"""The single-line shortcut for the common case: one line of text, no
|
|
756
|
+
blocks/buttons to compose by hand. Equivalent to
|
|
757
|
+
send(client, chat_id, text(plain_text)), skipping message()/text() entirely."""
|
|
758
|
+
return await send(client, chat_id, text(plain_text), fallback_text=plain_text, **kw)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
async def quick_edit(client, chat_id, message_id: int, plain_text: str, **kw):
|
|
762
|
+
return await edit(client, chat_id, message_id, text(plain_text), fallback_text=plain_text, **kw)
|
richpyro/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: richpyro
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A complete, easy-to-call wrapper for Pyrogram Rich Messages (Bot API 10.1+) — every block, every text style, every button type, with bot/user-account fallback and chat_id safety built in.
|
|
5
|
+
Author: devgagan
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/devgaganin/richpyro
|
|
8
|
+
Project-URL: Repository, https://github.com/devgaganin/richpyro
|
|
9
|
+
Project-URL: Issues, https://github.com/devgaganin/richpyro/issues
|
|
10
|
+
Keywords: telegram,pyrogram,kurigram,telegram-bot,bot-api,rich-message,rich-messages,telegram-api
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Communications :: Chat
|
|
21
|
+
Classifier: Topic :: Internet
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: Kurigram>=2.2.26
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# richpyro
|
|
31
|
+
|
|
32
|
+
A complete, easy-to-call wrapper around [kurigram](https://github.com/kurimuzon/kurigram)'s
|
|
33
|
+
**Rich Messages** (Telegram Bot API 10.1+ `InputRichMessage` / `InputRichBlock*` /
|
|
34
|
+
`RichText*`, plus `Client.send_rich_message`, `Message.edit_text(rich_message=...)`
|
|
35
|
+
and `Message.reply_rich`).
|
|
36
|
+
|
|
37
|
+
Every block type, every text style, every button type — one flat, documented
|
|
38
|
+
function per thing, plus a safe `send()` / `edit()` / `reply()` call surface
|
|
39
|
+
that handles two real Telegram/kurigram footguns automatically so you never
|
|
40
|
+
have to think about them.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install richpyro
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Why
|
|
47
|
+
|
|
48
|
+
Rich Messages are powerful but the raw API is deep (~50 constructor types)
|
|
49
|
+
and has two sharp edges that fail silently in production:
|
|
50
|
+
|
|
51
|
+
1. **Rich Messages only render for bot accounts.** Send `rich_message=...`
|
|
52
|
+
from a real user/userbot client (a logged-in session, not a bot token)
|
|
53
|
+
and Telegram silently drops or rejects it — no visible error, your
|
|
54
|
+
progress bar / status card just stops updating.
|
|
55
|
+
2. **A numeric-string `chat_id` looks like a phone number to kurigram.**
|
|
56
|
+
`Client.resolve_peer()` strips `+()-` and whitespace from any string
|
|
57
|
+
`chat_id`; if what's left is all digits, it assumes it's a phone number
|
|
58
|
+
and calls `contacts.ResolvePhone` — a method **bot accounts cannot call
|
|
59
|
+
at all** (`[400 BOT_METHOD_INVALID]`), and which fails with
|
|
60
|
+
`PHONE_NOT_OCCUPIED` for user accounts too unless that string happens to
|
|
61
|
+
be someone's real registered phone number. This bites silently any time
|
|
62
|
+
a chat id is passed as `str(chat.id)` instead of `int` — an extremely
|
|
63
|
+
easy mistake, since chat ids get stringified for DB keys/dict lookups
|
|
64
|
+
all over a typical bot codebase and then get reused as-is for an API call.
|
|
65
|
+
|
|
66
|
+
`richpyro.send()` / `.edit()` / `.reply()` check `client.me.is_bot` and
|
|
67
|
+
auto-degrade to an equivalent plain-text message for user-account senders,
|
|
68
|
+
and auto-int-ify any numeric-string `chat_id` before it reaches kurigram
|
|
69
|
+
(`@usernames` pass through untouched). You get both fixes for free just by
|
|
70
|
+
using this library's call surface instead of the raw kurigram methods.
|
|
71
|
+
|
|
72
|
+
## Quick start
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import richpyro as rp
|
|
76
|
+
|
|
77
|
+
# simplest possible call — one line, works from a bot OR a user client:
|
|
78
|
+
await rp.quick(client, chat_id, "Processing your request...")
|
|
79
|
+
|
|
80
|
+
# a structured card with formatting + buttons:
|
|
81
|
+
card = rp.message(
|
|
82
|
+
rp.para("Hello ", rp.bold(name), "! Choose a plan:"),
|
|
83
|
+
rp.divider(),
|
|
84
|
+
rp.para("● ", rp.bold("Day Plan"), " — ₹20 (24 hours)"),
|
|
85
|
+
rp.buttons(
|
|
86
|
+
rp.btn("Day Plan ₹20", "pay_day"),
|
|
87
|
+
rp.url_btn("Learn more", "https://example.com"),
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
sent = await rp.send(client, chat_id, card)
|
|
91
|
+
...
|
|
92
|
+
await rp.edit(client, chat_id, sent.id, rp.text("Payment confirmed."))
|
|
93
|
+
|
|
94
|
+
# replying directly to an incoming message (uses its own client):
|
|
95
|
+
await rp.reply(message, card)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Every builder returns a plain kurigram object — you can always drop down to
|
|
99
|
+
raw `types.InputRichMessage(...)` etc. for anything this library doesn't
|
|
100
|
+
wrap yet. richpyro objects and raw kurigram objects mix freely; nothing here
|
|
101
|
+
subclasses kurigram, it only constructs.
|
|
102
|
+
|
|
103
|
+
## What's covered
|
|
104
|
+
|
|
105
|
+
**Text formatting** — `bold`, `italic`, `underline`, `strike`, `spoiler`,
|
|
106
|
+
`code`, `link`, `user_mention`, `mention`, `custom_emoji`, `hashtag`,
|
|
107
|
+
`bot_command`, `phone_number`, `email`, `cashtag`, `bank_card`, `date_time`,
|
|
108
|
+
`subscript`, `superscript`, `anchor_target`, `anchor_link`, `footnote_ref`,
|
|
109
|
+
`footnote_link`, `marked`, `math_inline`, `inline_button`.
|
|
110
|
+
|
|
111
|
+
**Buttons** — `btn` (callback), `url_btn`, `webapp_btn`, `login_btn`,
|
|
112
|
+
`switch_inline_btn`, `switch_inline_here_btn`, `switch_inline_chosen_btn`,
|
|
113
|
+
`copy_btn`, `disabled_btn`, plus `buttons(...)` to lay out a row and
|
|
114
|
+
`Style` (`DEFAULT` / `PRIMARY` / `DANGER` / `SUCCESS` / `LINK`).
|
|
115
|
+
|
|
116
|
+
**Blocks** — `para`, `heading`, `divider`, `footer`, `anchor_block`,
|
|
117
|
+
`photo_block`, `video_block`, `animation_block`, `audio_block`,
|
|
118
|
+
`document_block`, `voice_block` (with matching `*_media` input helpers and
|
|
119
|
+
`caption(...)`), `collage`, `slideshow`, `list_item` / `bullet_list`,
|
|
120
|
+
`table_cell` / `table`, `details`, `blockquote`, `expandable_quote`,
|
|
121
|
+
`pull_quote`, `preformatted`, `map_block`, `math_block`, `thinking`.
|
|
122
|
+
|
|
123
|
+
**Message assembly** — `message(*blocks)` (the main entry point),
|
|
124
|
+
`html_message`, `markdown_message`, `text(...)` (the single-line shortcut
|
|
125
|
+
for the common case).
|
|
126
|
+
|
|
127
|
+
**Safe send surface** — `send`, `edit`, `reply`, `quick`, `quick_edit`,
|
|
128
|
+
`is_bot_client`, and `flatten_message` (the plain-text renderer used
|
|
129
|
+
automatically for the bot → user-account fallback, also handy standalone
|
|
130
|
+
for logs/previews).
|
|
131
|
+
|
|
132
|
+
## Requirements
|
|
133
|
+
|
|
134
|
+
- Python 3.9+
|
|
135
|
+
- [Kurigram](https://pypi.org/project/Kurigram/) 2.2.26+ (the actively
|
|
136
|
+
maintained Pyrogram fork with Rich Messages support — still `import pyrogram`)
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT — see [LICENSE](LICENSE).
|
|
141
|
+
|
|
142
|
+
## Author
|
|
143
|
+
|
|
144
|
+
coded by **devgagan** — [github.com/devgaganin](https://github.com/devgaganin)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
richpyro/__init__.py,sha256=B49ydcZjC-Ey6jpnY-XWdJT8jaaqYD7v-uzvkVk6o10,39064
|
|
2
|
+
richpyro/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
richpyro-1.0.0.dist-info/licenses/LICENSE,sha256=vGQnZGz_0vhLg1MAnpwwy7BgdA_Q_RebjCE38vKovL4,1065
|
|
4
|
+
richpyro-1.0.0.dist-info/METADATA,sha256=OsWv14Wb5vupDSI456vO5Rcni0krTz2N1mhFJ_lXokc,6264
|
|
5
|
+
richpyro-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
richpyro-1.0.0.dist-info/top_level.txt,sha256=KKmyQQPgYtJyuEyrOikG4p7rjfmU2QaHlULD50HEW8Y,9
|
|
7
|
+
richpyro-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 devgagan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
richpyro
|