nextgram 0.9.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.
nextgram/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """nextgram: Telegram Bot API 10.3 rich messages and Bot API 10.4 HTML
2
+ widgets, across six frameworks.
3
+
4
+ Pyrogram, Pyrofork and Kurigram are one family (three distributions of
5
+ the same ``pyrogram`` package name) and get full native support here, in
6
+ ``nextgram.rich_ui`` — importing ``nextgram`` itself needs none of them
7
+ installed. Telethon, aiogram and python-telegram-bot are each supported
8
+ through their own optional module, imported explicitly so a bot using
9
+ only one framework never has to install the others:
10
+
11
+ - ``nextgram.telethon`` — ``from nextgram import telethon``
12
+ - ``nextgram.aiogram_rich`` — ``from nextgram import aiogram_rich``
13
+ - ``nextgram.ptb_rich`` — ``from nextgram import ptb_rich``
14
+
15
+ ``nextgram.widgets`` (Bot API 10.4 widgets) is framework-agnostic by
16
+ construction — it only ever needs a bot token — so every function in it
17
+ already works the same from all six frameworks without a separate module
18
+ per framework; see its own docstring.
19
+
20
+ The content builders (``rich_heading``, ``rich_list``, ``rich_button``,
21
+ ``rich_table``, ...) are plain string functions with no framework
22
+ dependency at all — the same HTML works everywhere; only how it gets
23
+ *sent* differs per framework, which is what the modules above are for.
24
+ """
25
+
26
+ from . import rich_ui
27
+ from .rich_ui import *
28
+ from .rich_ui import __all__ as _rich_all
29
+ from . import widgets
30
+ from .widgets import *
31
+ from .widgets import __all__ as _widgets_all
32
+
33
+ __version__ = "0.9.0"
34
+ __all__ = list(_rich_all) + list(_widgets_all) + ["rich_ui", "widgets", "__version__"]
@@ -0,0 +1,123 @@
1
+ """nextgram.aiogram_rich: rich messages and widgets for aiogram bots.
2
+
3
+ aiogram is optional — importing ``nextgram`` never requires it, and this
4
+ module has no import-time dependency on the ``aiogram`` package itself
5
+ (it only reads a few attributes off whatever ``Bot``/``Message`` objects
6
+ are passed in). Rich-message sending goes over the plain Bot HTTP API via
7
+ ``nextgram.http_rich`` (see that module's docstring for exactly what is
8
+ confirmed vs. speculative); widgets go over the same HTTP API via
9
+ ``nextgram.widgets``, which already works with any client exposing a
10
+ ``.token`` attribute — aiogram's ``Bot`` does.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+
16
+ from .http_rich import coerce_markup_to_dict, edit_rich_html_via_http, send_rich_html_via_http
17
+
18
+ logger = logging.getLogger("nextgram.aiogram_rich")
19
+
20
+ __all__ = [
21
+ "aiogram_bot_token",
22
+ "aiogram_rich_send",
23
+ "aiogram_rich_reply",
24
+ "aiogram_rich_edit",
25
+ ]
26
+
27
+
28
+ def aiogram_bot_token(bot) -> str:
29
+ """Pull the bot token off an aiogram ``Bot`` instance."""
30
+ token = getattr(bot, "token", None)
31
+ if not token:
32
+ raise RuntimeError(
33
+ "aiogram_bot_token needs a Bot instance with a .token attribute"
34
+ )
35
+ return token
36
+
37
+
38
+ async def aiogram_rich_send(
39
+ bot,
40
+ chat_id,
41
+ html_text: str,
42
+ *,
43
+ markdown: bool = False,
44
+ reply_markup=None,
45
+ reply_to_message_id=None,
46
+ message_thread_id=None,
47
+ disable_notification=None,
48
+ protect_content=None,
49
+ ):
50
+ """Send rich HTML/Markdown to ``chat_id`` with an aiogram ``Bot``.
51
+
52
+ ``reply_markup`` accepts an aiogram ``InlineKeyboardMarkup`` (or any
53
+ object with ``.model_dump``/``.to_dict``) as well as a plain dict.
54
+ Returns the Bot API ``result`` dict (not an aiogram ``Message``) —
55
+ use ``result["message_id"]`` for the sent message's id.
56
+ """
57
+ token = aiogram_bot_token(bot)
58
+ reply_parameters = (
59
+ {"message_id": reply_to_message_id} if reply_to_message_id else None
60
+ )
61
+ return await send_rich_html_via_http(
62
+ token,
63
+ chat_id,
64
+ html_text,
65
+ markdown=markdown,
66
+ reply_markup=coerce_markup_to_dict(reply_markup),
67
+ reply_parameters=reply_parameters,
68
+ message_thread_id=message_thread_id,
69
+ disable_notification=disable_notification,
70
+ protect_content=protect_content,
71
+ )
72
+
73
+
74
+ async def aiogram_rich_reply(message, html_text: str, *, bot=None, **kwargs):
75
+ """Reply to an aiogram ``Message`` with rich HTML/Markdown.
76
+
77
+ ``bot`` defaults to ``message.bot`` (aiogram sets this on every
78
+ incoming message/update automatically).
79
+ """
80
+ app = bot or getattr(message, "bot", None)
81
+ chat = getattr(getattr(message, "chat", None), "id", None)
82
+ if app is None or chat is None:
83
+ logger.debug("[aiogram_rich_reply] missing bot/chat on message")
84
+ return None
85
+ return await aiogram_rich_send(
86
+ app, chat, html_text, reply_to_message_id=getattr(message, "message_id", None), **kwargs
87
+ )
88
+
89
+
90
+ async def aiogram_rich_edit(
91
+ message,
92
+ html_text: str,
93
+ *,
94
+ bot=None,
95
+ chat_id=None,
96
+ message_id=None,
97
+ markdown: bool = False,
98
+ reply_markup=None,
99
+ ):
100
+ """Edit a message to rich HTML/Markdown with an aiogram ``Bot``.
101
+
102
+ ``message`` is an aiogram ``Message`` (``.chat.id``/``.message_id``
103
+ read automatically) or ``None``, in which case ``chat_id`` and
104
+ ``message_id`` are required.
105
+ """
106
+ if message is not None:
107
+ app = bot or getattr(message, "bot", None)
108
+ chat_id = getattr(getattr(message, "chat", None), "id", None)
109
+ message_id = getattr(message, "message_id", None)
110
+ else:
111
+ app = bot
112
+ if app is None or chat_id is None or message_id is None:
113
+ logger.debug("[aiogram_rich_edit] missing bot/chat_id/message_id")
114
+ return None
115
+ token = aiogram_bot_token(app)
116
+ return await edit_rich_html_via_http(
117
+ token,
118
+ chat_id,
119
+ message_id,
120
+ html_text,
121
+ markdown=markdown,
122
+ reply_markup=coerce_markup_to_dict(reply_markup),
123
+ )
nextgram/http_rich.py ADDED
@@ -0,0 +1,190 @@
1
+ """nextgram.http_rich: raw Bot HTTP API rich-message sending.
2
+
3
+ aiogram and python-telegram-bot have no MTProto layer at all — just a bot
4
+ token and the plain Bot HTTP API — so ``nextgram.aiogram_rich`` and
5
+ ``nextgram.ptb_rich`` both build on this module instead of
6
+ ``nextgram.rich_ui``'s Pyrogram-native path. Sending calls the same
7
+ ``sendRichMessage`` HTTP endpoint ``nextgram.rich_ui._send_blocks_via_http``
8
+ already uses for typed blocks, this time with a plain ``{"html": ...}``
9
+ payload (the HTTP shape of ``InputRichMessage(html=...)``) instead of
10
+ ``blocks``. This is a real, already-used endpoint, not a new guess.
11
+
12
+ Editing is less certain: there is no documented standalone
13
+ ``editRichMessage`` HTTP method, only Pyrogram's native, MTProto-level
14
+ ``edit_message_text(..., rich_message=...)``. :func:`edit_rich_html_via_http`
15
+ tries the same ``rich_message`` field on the plain HTTP ``editMessageText``
16
+ call — speculative, since it has never been confirmed against a live bot —
17
+ and falls back to a plain-HTML edit on any failure, exactly as the send
18
+ side falls back when ``sendRichMessage`` is rejected or unavailable.
19
+
20
+ Buttons built with ``rich_button()``/``rich_button_row()`` are embedded
21
+ *inside* the rich HTML and only become clickable when the rich payload is
22
+ actually accepted and rendered that way by the Telegram client — if a send
23
+ or edit falls back to plain HTML, those buttons degrade to plain text
24
+ labels (via ``rich_ui._plain_fallback``), exactly as they do on the
25
+ Pyrogram path when ``RICH_AVAILABLE`` is ``False``.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+
31
+ from .rich_ui import _has_rich_only_tags, _normalize_html, _only_set, _plain_fallback
32
+ from .widgets import WidgetAPIError, _call_bot_api
33
+
34
+ logger = logging.getLogger("nextgram.http_rich")
35
+
36
+ __all__ = ["send_rich_html_via_http", "edit_rich_html_via_http", "coerce_markup_to_dict"]
37
+
38
+
39
+ def coerce_markup_to_dict(reply_markup):
40
+ """Turn a framework-native keyboard object into a plain JSON-able dict.
41
+
42
+ ``None``/dict pass through unchanged. aiogram v3's Pydantic models use
43
+ ``.model_dump(mode="json", exclude_none=True)``; python-telegram-bot's
44
+ use ``.to_dict()``; both are tried, in that order, before giving up and
45
+ returning the object as-is (letting the eventual JSON encode fail
46
+ loudly rather than silently dropping the keyboard).
47
+ """
48
+ if reply_markup is None or isinstance(reply_markup, dict):
49
+ return reply_markup
50
+ for method_name, kwargs in (
51
+ ("model_dump", {"mode": "json", "exclude_none": True}),
52
+ ("to_dict", {}),
53
+ ):
54
+ method = getattr(reply_markup, method_name, None)
55
+ if callable(method):
56
+ try:
57
+ return method(**kwargs)
58
+ except TypeError:
59
+ try:
60
+ return method()
61
+ except Exception:
62
+ continue
63
+ except Exception:
64
+ continue
65
+ return reply_markup
66
+
67
+
68
+ async def send_rich_html_via_http(
69
+ token,
70
+ chat_id,
71
+ html_text: str,
72
+ *,
73
+ markdown: bool = False,
74
+ reply_markup: dict = None,
75
+ reply_parameters: dict = None,
76
+ message_thread_id=None,
77
+ disable_notification=None,
78
+ protect_content=None,
79
+ api_base=None,
80
+ ):
81
+ """POST ``sendRichMessage``, falling back to plain ``sendMessage``.
82
+
83
+ ``reply_markup``/``reply_parameters`` are plain JSON-able dicts (the
84
+ Bot API wire shape) — a caller holding a framework-native keyboard
85
+ object converts it first (aiogram: ``.model_dump(mode="json",
86
+ exclude_none=True)``; python-telegram-bot: ``.to_dict()``).
87
+
88
+ Returns the Bot API ``result`` dict (with ``message_id``) from
89
+ whichever call succeeds, or ``None`` if both fail.
90
+ """
91
+ if not html_text:
92
+ return None
93
+ wants_rich = bool(markdown or _has_rich_only_tags(html_text))
94
+ rich_message = (
95
+ {"markdown": html_text} if markdown else {"html": _normalize_html(html_text)}
96
+ )
97
+ shared = _only_set(
98
+ reply_markup=reply_markup,
99
+ reply_parameters=reply_parameters,
100
+ message_thread_id=message_thread_id,
101
+ disable_notification=disable_notification,
102
+ protect_content=protect_content,
103
+ )
104
+ if wants_rich:
105
+ try:
106
+ return await _call_bot_api(
107
+ None,
108
+ "sendRichMessage",
109
+ {"chat_id": chat_id, "rich_message": rich_message, **shared},
110
+ token=token,
111
+ api_base=api_base,
112
+ )
113
+ except WidgetAPIError as e:
114
+ logger.debug(f"[send_rich_html_via_http] rich send rejected, falling back: {e}")
115
+ except Exception as e:
116
+ logger.debug(f"[send_rich_html_via_http] rich send failed, falling back: {e}")
117
+
118
+ try:
119
+ return await _call_bot_api(
120
+ None,
121
+ "sendMessage",
122
+ {
123
+ "chat_id": chat_id,
124
+ "text": html_text if markdown else _plain_fallback(html_text),
125
+ "parse_mode": "Markdown" if markdown else "HTML",
126
+ **shared,
127
+ },
128
+ token=token,
129
+ api_base=api_base,
130
+ )
131
+ except Exception as e:
132
+ logger.debug(f"[send_rich_html_via_http] plain send failed: {e}")
133
+ return None
134
+
135
+
136
+ async def edit_rich_html_via_http(
137
+ token,
138
+ chat_id,
139
+ message_id,
140
+ html_text: str,
141
+ *,
142
+ markdown: bool = False,
143
+ reply_markup: dict = None,
144
+ api_base=None,
145
+ ):
146
+ """POST ``editMessageText``, optimistically with ``rich_message`` set
147
+ first (see the module docstring: unconfirmed, may always fall
148
+ through), then a plain edit if that is rejected or fails outright.
149
+ """
150
+ if not html_text:
151
+ return None
152
+ wants_rich = bool(markdown or _has_rich_only_tags(html_text))
153
+ rich_message = (
154
+ {"markdown": html_text} if markdown else {"html": _normalize_html(html_text)}
155
+ )
156
+ shared = _only_set(reply_markup=reply_markup)
157
+ if wants_rich:
158
+ try:
159
+ return await _call_bot_api(
160
+ None,
161
+ "editMessageText",
162
+ {
163
+ "chat_id": chat_id,
164
+ "message_id": message_id,
165
+ "rich_message": rich_message,
166
+ **shared,
167
+ },
168
+ token=token,
169
+ api_base=api_base,
170
+ )
171
+ except Exception as e:
172
+ logger.debug(f"[edit_rich_html_via_http] rich edit rejected, falling back: {e}")
173
+
174
+ try:
175
+ return await _call_bot_api(
176
+ None,
177
+ "editMessageText",
178
+ {
179
+ "chat_id": chat_id,
180
+ "message_id": message_id,
181
+ "text": html_text if markdown else _plain_fallback(html_text),
182
+ "parse_mode": "Markdown" if markdown else "HTML",
183
+ **shared,
184
+ },
185
+ token=token,
186
+ api_base=api_base,
187
+ )
188
+ except Exception as e:
189
+ logger.debug(f"[edit_rich_html_via_http] plain edit failed: {e}")
190
+ return None
nextgram/ptb_rich.py ADDED
@@ -0,0 +1,137 @@
1
+ """nextgram.ptb_rich: rich messages and widgets for python-telegram-bot bots.
2
+
3
+ python-telegram-bot is optional — importing ``nextgram`` never requires
4
+ it, and this module has no import-time dependency on the ``telegram``
5
+ package itself (it only reads a few attributes off whatever ``Bot``/
6
+ ``Message`` objects are passed in). Rich-message sending goes over the
7
+ plain Bot HTTP API via ``nextgram.http_rich`` (see that module's docstring
8
+ for exactly what is confirmed vs. speculative); widgets go over the same
9
+ HTTP API via ``nextgram.widgets``, which already works with any client
10
+ exposing a ``.token`` attribute — python-telegram-bot's ``Bot`` does.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+
16
+ from .http_rich import coerce_markup_to_dict, edit_rich_html_via_http, send_rich_html_via_http
17
+
18
+ logger = logging.getLogger("nextgram.ptb_rich")
19
+
20
+ __all__ = [
21
+ "ptb_bot_token",
22
+ "ptb_rich_send",
23
+ "ptb_rich_reply",
24
+ "ptb_rich_edit",
25
+ ]
26
+
27
+
28
+ def ptb_bot_token(bot) -> str:
29
+ """Pull the bot token off a python-telegram-bot ``Bot`` instance."""
30
+ token = getattr(bot, "token", None)
31
+ if not token:
32
+ raise RuntimeError(
33
+ "ptb_bot_token needs a Bot instance with a .token attribute"
34
+ )
35
+ return token
36
+
37
+
38
+ async def ptb_rich_send(
39
+ bot,
40
+ chat_id,
41
+ html_text: str,
42
+ *,
43
+ markdown: bool = False,
44
+ reply_markup=None,
45
+ reply_to_message_id=None,
46
+ message_thread_id=None,
47
+ disable_notification=None,
48
+ protect_content=None,
49
+ ):
50
+ """Send rich HTML/Markdown to ``chat_id`` with a python-telegram-bot
51
+ ``Bot`` (or ``Bot``-like object — ``context.bot`` works the same way).
52
+
53
+ ``reply_markup`` accepts a python-telegram-bot ``InlineKeyboardMarkup``
54
+ (or any object with ``.to_dict``) as well as a plain dict. Returns the
55
+ Bot API ``result`` dict (not a ``telegram.Message``) — use
56
+ ``result["message_id"]`` for the sent message's id.
57
+ """
58
+ token = ptb_bot_token(bot)
59
+ reply_parameters = (
60
+ {"message_id": reply_to_message_id} if reply_to_message_id else None
61
+ )
62
+ return await send_rich_html_via_http(
63
+ token,
64
+ chat_id,
65
+ html_text,
66
+ markdown=markdown,
67
+ reply_markup=coerce_markup_to_dict(reply_markup),
68
+ reply_parameters=reply_parameters,
69
+ message_thread_id=message_thread_id,
70
+ disable_notification=disable_notification,
71
+ protect_content=protect_content,
72
+ )
73
+
74
+
75
+ async def ptb_rich_reply(message, html_text: str, *, bot=None, **kwargs):
76
+ """Reply to a python-telegram-bot ``Message``/``Update`` with rich
77
+ HTML/Markdown.
78
+
79
+ Accepts either a ``telegram.Message`` or a ``telegram.Update`` (its
80
+ ``.effective_message`` is used). ``bot`` defaults to
81
+ ``message.get_bot()`` when the object exposes that (python-telegram-bot
82
+ messages do).
83
+ """
84
+ target = getattr(message, "effective_message", None) or message
85
+ app = bot
86
+ if app is None:
87
+ get_bot = getattr(target, "get_bot", None)
88
+ app = get_bot() if callable(get_bot) else getattr(target, "_bot", None)
89
+ chat = getattr(getattr(target, "chat", None), "id", None) or getattr(
90
+ target, "chat_id", None
91
+ )
92
+ if app is None or chat is None:
93
+ logger.debug("[ptb_rich_reply] missing bot/chat on message")
94
+ return None
95
+ return await ptb_rich_send(
96
+ app, chat, html_text, reply_to_message_id=getattr(target, "message_id", None), **kwargs
97
+ )
98
+
99
+
100
+ async def ptb_rich_edit(
101
+ message,
102
+ html_text: str,
103
+ *,
104
+ bot=None,
105
+ chat_id=None,
106
+ message_id=None,
107
+ markdown: bool = False,
108
+ reply_markup=None,
109
+ ):
110
+ """Edit a message to rich HTML/Markdown with a python-telegram-bot
111
+ ``Bot``.
112
+
113
+ ``message`` is a ``telegram.Message`` (``.chat_id``/``.message_id``
114
+ read automatically) or ``None``, in which case ``chat_id`` and
115
+ ``message_id`` are required.
116
+ """
117
+ if message is not None:
118
+ if bot is None:
119
+ get_bot = getattr(message, "get_bot", None)
120
+ bot = get_bot() if callable(get_bot) else getattr(message, "_bot", None)
121
+ chat_id = getattr(getattr(message, "chat", None), "id", None) or getattr(
122
+ message, "chat_id", None
123
+ )
124
+ message_id = getattr(message, "message_id", None)
125
+ app = bot
126
+ if app is None or chat_id is None or message_id is None:
127
+ logger.debug("[ptb_rich_edit] missing bot/chat_id/message_id")
128
+ return None
129
+ token = ptb_bot_token(app)
130
+ return await edit_rich_html_via_http(
131
+ token,
132
+ chat_id,
133
+ message_id,
134
+ html_text,
135
+ markdown=markdown,
136
+ reply_markup=coerce_markup_to_dict(reply_markup),
137
+ )