pynotegram 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ipynb_checkpoints/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Serghei
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,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: pynotegram
3
+ Version: 0.3.0
4
+ Summary: Send simple Telegram notifications from Jupyter notebooks.
5
+ Author: Serghei
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: ipython,jupyter,notebook,notifications,telegram
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: Education
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Communications :: Chat
20
+ Classifier: Topic :: Scientific/Engineering
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx[socks]<1,>=0.27
23
+ Requires-Dist: ipython>=8
24
+ Provides-Extra: relay
25
+ Requires-Dist: fastapi>=0.115; extra == 'relay'
26
+ Requires-Dist: uvicorn>=0.30; extra == 'relay'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # PyNoteGram
30
+
31
+ PyNoteGram отправляет сообщения из Jupyter в **один выбранный Telegram-чат**.
32
+
33
+ После первой настройки в ноутбуках больше не нужно писать токен или `chat_id`.
34
+
35
+ ## Что нужно сделать
36
+
37
+ ### 1. Создать бота
38
+
39
+ В Telegram откройте `@BotFather`, отправьте `/newbot` и сохраните полученный
40
+ токен. Токен является паролем бота.
41
+
42
+ ### 2. Создать отдельную группу
43
+
44
+ 1. Создайте новую группу в Telegram, например **Мои расчеты**.
45
+ 2. Добавьте туда созданного бота.
46
+ 3. Напишите в группе `/start`.
47
+
48
+ ### 3. Установить и настроить PyNoteGram
49
+
50
+ ```powershell
51
+ pip install pynotegram
52
+ pynotegram setup
53
+ ```
54
+
55
+ Во время настройки программа один раз попросит токен от BotFather. После этого
56
+ она сама найдет группу, запомнит ее и отправит проверочное сообщение.
57
+
58
+ ## Использование в Jupyter
59
+
60
+ Настроить библиотеку можно прямо в Jupyter:
61
+
62
+ ```python
63
+ from pynotegram import setup
64
+
65
+ setup()
66
+ ```
67
+
68
+ Под ячейкой появится скрытое поле для токена. После проверки бот попросит
69
+ отправить в группе команду вида `/start@NLP_bot` и подождет появления группы.
70
+
71
+ После настройки перезапустите Jupyter. В любой ячейке можно написать:
72
+
73
+ ```python
74
+ %telegram Расчет закончен!
75
+ ```
76
+
77
+ ## Получение сообщений из Telegram
78
+
79
+ Запустите в Jupyter ячейку:
80
+
81
+ ```python
82
+ %telegram_read 30
83
+ ```
84
+
85
+ Она будет ждать сообщение из сохраненной группы до 30 секунд. Сообщения из
86
+ других чатов игнорируются.
87
+
88
+ Для постоянного ожидания в течение десяти минут:
89
+
90
+ ```python
91
+ %telegram_watch 10
92
+ ```
93
+
94
+ Новые сообщения будут появляться под ячейкой сразу после отправки. Остановить
95
+ ожидание можно кнопкой прерывания ядра Jupyter.
96
+
97
+ Чтобы код отображался с подсветкой, отправьте его в Telegram так:
98
+
99
+ ````text
100
+ ```python
101
+ def hello():
102
+ print("Привет")
103
+ ```
104
+ ````
105
+
106
+ В Jupyter он появится как отдельный блок Python-кода. Полученный код только
107
+ показывается и никогда не запускается автоматически.
108
+
109
+ Сообщение придет только в группу, выбранную во время настройки.
110
+
111
+ Для многострочного сообщения:
112
+
113
+ ```python
114
+ %%telegram
115
+ Обучение модели закончено.
116
+ Точность: 94%.
117
+ ```
118
+
119
+ Также можно использовать обычный Python:
120
+
121
+ ```python
122
+ from pynotegram import send
123
+
124
+ send("Расчет закончен!")
125
+ ```
126
+
127
+ ## Если нужен VPN
128
+
129
+ Включите VPN перед запуском `pynotegram setup` и Jupyter.
130
+
131
+ Если есть отдельный SOCKS5-прокси:
132
+
133
+ ```powershell
134
+ pynotegram setup --proxy "socks5://ЛОГИН:ПАРОЛЬ@АДРЕС:ПОРТ"
135
+ ```
136
+
137
+ ## Проверка
138
+
139
+ Отправить сообщение без Jupyter:
140
+
141
+ ```powershell
142
+ pynotegram send "Проверка"
143
+ ```
144
+
145
+ Посмотреть сохраненный чат:
146
+
147
+ ```powershell
148
+ pynotegram status
149
+ ```
150
+
151
+ Чтобы выбрать другую группу, снова выполните `pynotegram setup`.
152
+
153
+ ## Важное ограничение
154
+
155
+ Совсем обойтись только командой `pip install` нельзя: программа должна хотя бы
156
+ один раз получить токен вашего бота и узнать, какую группу выбрать. Поэтому
157
+ `pynotegram setup` выполняется один раз. В самих ноутбуках настройки больше не
158
+ потребуются.
159
+
160
+ ## Безопасность
161
+
162
+ - Никогда не публикуйте токен Telegram-бота и токен PyPI.
163
+ - Если токен Telegram попал в переписку, Git или ноутбук, отзовите его через
164
+ `@BotFather` и создайте новый.
165
+ - Код из Telegram отображается в Jupyter, но не запускается автоматически.
166
+
167
+ ## Лицензия
168
+
169
+ MIT
@@ -0,0 +1,141 @@
1
+ # PyNoteGram
2
+
3
+ PyNoteGram отправляет сообщения из Jupyter в **один выбранный Telegram-чат**.
4
+
5
+ После первой настройки в ноутбуках больше не нужно писать токен или `chat_id`.
6
+
7
+ ## Что нужно сделать
8
+
9
+ ### 1. Создать бота
10
+
11
+ В Telegram откройте `@BotFather`, отправьте `/newbot` и сохраните полученный
12
+ токен. Токен является паролем бота.
13
+
14
+ ### 2. Создать отдельную группу
15
+
16
+ 1. Создайте новую группу в Telegram, например **Мои расчеты**.
17
+ 2. Добавьте туда созданного бота.
18
+ 3. Напишите в группе `/start`.
19
+
20
+ ### 3. Установить и настроить PyNoteGram
21
+
22
+ ```powershell
23
+ pip install pynotegram
24
+ pynotegram setup
25
+ ```
26
+
27
+ Во время настройки программа один раз попросит токен от BotFather. После этого
28
+ она сама найдет группу, запомнит ее и отправит проверочное сообщение.
29
+
30
+ ## Использование в Jupyter
31
+
32
+ Настроить библиотеку можно прямо в Jupyter:
33
+
34
+ ```python
35
+ from pynotegram import setup
36
+
37
+ setup()
38
+ ```
39
+
40
+ Под ячейкой появится скрытое поле для токена. После проверки бот попросит
41
+ отправить в группе команду вида `/start@NLP_bot` и подождет появления группы.
42
+
43
+ После настройки перезапустите Jupyter. В любой ячейке можно написать:
44
+
45
+ ```python
46
+ %telegram Расчет закончен!
47
+ ```
48
+
49
+ ## Получение сообщений из Telegram
50
+
51
+ Запустите в Jupyter ячейку:
52
+
53
+ ```python
54
+ %telegram_read 30
55
+ ```
56
+
57
+ Она будет ждать сообщение из сохраненной группы до 30 секунд. Сообщения из
58
+ других чатов игнорируются.
59
+
60
+ Для постоянного ожидания в течение десяти минут:
61
+
62
+ ```python
63
+ %telegram_watch 10
64
+ ```
65
+
66
+ Новые сообщения будут появляться под ячейкой сразу после отправки. Остановить
67
+ ожидание можно кнопкой прерывания ядра Jupyter.
68
+
69
+ Чтобы код отображался с подсветкой, отправьте его в Telegram так:
70
+
71
+ ````text
72
+ ```python
73
+ def hello():
74
+ print("Привет")
75
+ ```
76
+ ````
77
+
78
+ В Jupyter он появится как отдельный блок Python-кода. Полученный код только
79
+ показывается и никогда не запускается автоматически.
80
+
81
+ Сообщение придет только в группу, выбранную во время настройки.
82
+
83
+ Для многострочного сообщения:
84
+
85
+ ```python
86
+ %%telegram
87
+ Обучение модели закончено.
88
+ Точность: 94%.
89
+ ```
90
+
91
+ Также можно использовать обычный Python:
92
+
93
+ ```python
94
+ from pynotegram import send
95
+
96
+ send("Расчет закончен!")
97
+ ```
98
+
99
+ ## Если нужен VPN
100
+
101
+ Включите VPN перед запуском `pynotegram setup` и Jupyter.
102
+
103
+ Если есть отдельный SOCKS5-прокси:
104
+
105
+ ```powershell
106
+ pynotegram setup --proxy "socks5://ЛОГИН:ПАРОЛЬ@АДРЕС:ПОРТ"
107
+ ```
108
+
109
+ ## Проверка
110
+
111
+ Отправить сообщение без Jupyter:
112
+
113
+ ```powershell
114
+ pynotegram send "Проверка"
115
+ ```
116
+
117
+ Посмотреть сохраненный чат:
118
+
119
+ ```powershell
120
+ pynotegram status
121
+ ```
122
+
123
+ Чтобы выбрать другую группу, снова выполните `pynotegram setup`.
124
+
125
+ ## Важное ограничение
126
+
127
+ Совсем обойтись только командой `pip install` нельзя: программа должна хотя бы
128
+ один раз получить токен вашего бота и узнать, какую группу выбрать. Поэтому
129
+ `pynotegram setup` выполняется один раз. В самих ноутбуках настройки больше не
130
+ потребуются.
131
+
132
+ ## Безопасность
133
+
134
+ - Никогда не публикуйте токен Telegram-бота и токен PyPI.
135
+ - Если токен Telegram попал в переписку, Git или ноутбук, отзовите его через
136
+ `@BotFather` и создайте новый.
137
+ - Код из Telegram отображается в Jupyter, но не запускается автоматически.
138
+
139
+ ## Лицензия
140
+
141
+ MIT
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pynotegram"
7
+ version = "0.3.0"
8
+ description = "Send simple Telegram notifications from Jupyter notebooks."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Serghei" },
15
+ ]
16
+ keywords = ["telegram", "jupyter", "notebook", "notifications", "ipython"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "Intended Audience :: Education",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Communications :: Chat",
29
+ "Topic :: Scientific/Engineering",
30
+ ]
31
+ dependencies = [
32
+ "httpx[socks]>=0.27,<1",
33
+ "ipython>=8",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ relay = ["fastapi>=0.115", "uvicorn>=0.30"]
38
+
39
+ [project.scripts]
40
+ pynotegram = "pynotegram.cli:main"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/pynotegram"]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = [
47
+ "src/pynotegram",
48
+ "tests",
49
+ "LICENSE",
50
+ "README.md",
51
+ "pyproject.toml",
52
+ ]
@@ -0,0 +1,63 @@
1
+ from .notifier import PyNoteGram, TelegramError
2
+ from .receiver import read, watch
3
+
4
+ __all__ = [
5
+ "PyNoteGram",
6
+ "TelegramError",
7
+ "send",
8
+ "read",
9
+ "watch",
10
+ "setup",
11
+ "load_ipython_extension",
12
+ ]
13
+
14
+
15
+ def send(message: object):
16
+ """Send a message to the one chat selected during setup."""
17
+ with PyNoteGram.from_config() as notifier:
18
+ return notifier.send(message)
19
+
20
+
21
+ def setup(*, token=None, chat_id=None, proxy=None):
22
+ """Configure PyNoteGram interactively inside Jupyter or regular Python."""
23
+ from .cli import setup_notebook
24
+
25
+ return setup_notebook(token=token, chat_id=chat_id, proxy=proxy)
26
+
27
+
28
+ def load_ipython_extension(ipython):
29
+ """Register %telegram and %%telegram in IPython/Jupyter."""
30
+ from IPython.core.magic import Magics, line_cell_magic, magics_class
31
+ from IPython.core.magic import line_magic
32
+
33
+ @magics_class
34
+ class TelegramMagics(Magics):
35
+ @line_cell_magic
36
+ def telegram(self, line, cell=None):
37
+ message = cell if cell is not None else line
38
+ if not message.strip():
39
+ raise ValueError("Сообщение Telegram не может быть пустым")
40
+
41
+ notifier = PyNoteGram.from_config()
42
+ try:
43
+ return notifier.send(message)
44
+ finally:
45
+ notifier.close()
46
+
47
+ @line_magic
48
+ def telegram_read(self, line):
49
+ try:
50
+ wait = int(line.strip() or "30")
51
+ except ValueError:
52
+ raise ValueError("Укажите время ожидания в секундах, например: %telegram_read 30") from None
53
+ read(wait=wait, show=True)
54
+
55
+ @line_magic
56
+ def telegram_watch(self, line):
57
+ try:
58
+ minutes = float(line.strip() or "10")
59
+ except ValueError:
60
+ raise ValueError("Укажите минуты, например: %telegram_watch 10") from None
61
+ watch(minutes=minutes)
62
+
63
+ ipython.register_magics(TelegramMagics)
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import getpass
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from .config import ConfigError, config_path, load_config, save_config
13
+ from .notifier import PyNoteGram, TelegramError
14
+
15
+
16
+ def main() -> None:
17
+ parser = argparse.ArgumentParser(
18
+ prog="pynotegram",
19
+ description="Простые уведомления из Jupyter в один Telegram-чат.",
20
+ )
21
+ commands = parser.add_subparsers(dest="command", required=True)
22
+
23
+ setup_parser = commands.add_parser("setup", help="настроить бота и один чат")
24
+ setup_parser.add_argument("--token", help="токен бота; безопаснее вводить без этого параметра")
25
+ setup_parser.add_argument("--chat-id", help="использовать указанный chat_id")
26
+ setup_parser.add_argument("--proxy", help="HTTP- или SOCKS5-прокси")
27
+ setup_parser.add_argument(
28
+ "--no-jupyter-startup",
29
+ action="store_true",
30
+ help="не подключать команду %%telegram автоматически",
31
+ )
32
+
33
+ send_parser = commands.add_parser("send", help="отправить тестовое сообщение")
34
+ send_parser.add_argument("message")
35
+
36
+ commands.add_parser("status", help="показать выбранный чат")
37
+
38
+ args = parser.parse_args()
39
+ try:
40
+ if args.command == "setup":
41
+ _setup(args)
42
+ elif args.command == "send":
43
+ _send(args.message)
44
+ elif args.command == "status":
45
+ _status()
46
+ except (ConfigError, TelegramError, ValueError) as error:
47
+ parser.exit(1, f"Ошибка: {error}\n")
48
+
49
+
50
+ def _setup(args: argparse.Namespace) -> None:
51
+ token = args.token or getpass.getpass("Вставьте токен от BotFather: ").strip()
52
+ if not token:
53
+ raise ValueError("токен не может быть пустым")
54
+
55
+ with httpx.Client(proxy=args.proxy, timeout=20, follow_redirects=True) as client:
56
+ bot = _telegram_request(client, token, "getMe")
57
+ username = bot.get("username", "")
58
+ print(f"Бот найден: @{username}" if username else "Бот найден.")
59
+
60
+ if args.chat_id:
61
+ chats: dict[str, dict[str, str]] = {}
62
+ else:
63
+ command = f"/start@{username}" if username else "/start"
64
+ print("Теперь откройте нужную группу Telegram.")
65
+ print("Добавьте туда бота, если еще не добавили.")
66
+ print(f"Отправьте в группе команду: {command}")
67
+ print("Ожидаю сообщение из группы...")
68
+ chats = _wait_for_chats(client, token)
69
+
70
+ existing_updates = _telegram_request(
71
+ client,
72
+ token,
73
+ "getUpdates",
74
+ params={"timeout": 0},
75
+ )
76
+
77
+ update_offset = (
78
+ max(int(update["update_id"]) for update in existing_updates) + 1
79
+ if existing_updates
80
+ else 0
81
+ )
82
+
83
+ chat_id = str(args.chat_id) if args.chat_id else _choose_chat(chats)
84
+ path = save_config(
85
+ {
86
+ "token": token,
87
+ "chat_id": chat_id,
88
+ "proxy_url": args.proxy,
89
+ "update_offset": update_offset,
90
+ }
91
+ )
92
+
93
+ if not args.no_jupyter_startup:
94
+ startup = _install_jupyter_startup()
95
+ print(f"Команда %telegram подключена для Jupyter: {startup}")
96
+
97
+ with PyNoteGram.from_config() as notifier:
98
+ notifier.send("PyNoteGram настроен. Сообщения будут приходить только сюда.")
99
+
100
+ username = bot.get("username", "бот")
101
+ title = chats.get(chat_id, {}).get("title", chat_id)
102
+ print(f"Готово. Бот: @{username}. Чат: {title}.")
103
+ print(f"Настройки сохранены: {path}")
104
+
105
+
106
+ def setup_notebook(
107
+ *,
108
+ token: str | None = None,
109
+ chat_id: str | int | None = None,
110
+ proxy: str | None = None,
111
+ ) -> None:
112
+ """Run the same one-time setup directly from a Jupyter cell."""
113
+ args = argparse.Namespace(
114
+ token=token,
115
+ chat_id=chat_id,
116
+ proxy=proxy,
117
+ no_jupyter_startup=False,
118
+ )
119
+ _setup(args)
120
+
121
+
122
+ def _telegram_request(
123
+ client: httpx.Client,
124
+ token: str,
125
+ method: str,
126
+ *,
127
+ params: dict[str, Any] | None = None,
128
+ ) -> Any:
129
+ try:
130
+ response = client.get(
131
+ f"https://api.telegram.org/bot{token}/{method}",
132
+ params=params,
133
+ )
134
+ data = response.json()
135
+ except (httpx.RequestError, ValueError):
136
+ raise TelegramError(
137
+ "не удалось связаться с Telegram. Включите VPN или укажите --proxy"
138
+ ) from None
139
+
140
+ if response.is_error or data.get("ok") is not True:
141
+ raise TelegramError(data.get("description", "Telegram отклонил запрос"))
142
+ return data.get("result")
143
+
144
+
145
+ def _wait_for_chats(
146
+ client: httpx.Client,
147
+ token: str,
148
+ *,
149
+ wait_seconds: int = 90,
150
+ ) -> dict[str, dict[str, str]]:
151
+ deadline = time.monotonic() + wait_seconds
152
+ offset = 0
153
+ while time.monotonic() < deadline:
154
+ updates = _telegram_request(
155
+ client,
156
+ token,
157
+ "getUpdates",
158
+ params={
159
+ "offset": offset,
160
+ "timeout": 10,
161
+ "allowed_updates": json.dumps(["message"]),
162
+ },
163
+ )
164
+ if updates:
165
+ offset = max(int(update["update_id"]) for update in updates) + 1
166
+ group_chats = {
167
+ chat_id: chat
168
+ for chat_id, chat in _find_chats(updates).items()
169
+ if chat.get("type") in {"group", "supergroup"}
170
+ }
171
+ if group_chats:
172
+ return group_chats
173
+
174
+ raise ValueError(
175
+ "группа не найдена за 90 секунд. Проверьте VPN и отправьте команду "
176
+ "/start@имя_бота именно в группе"
177
+ )
178
+
179
+
180
+ def _find_chats(updates: list[dict[str, Any]]) -> dict[str, dict[str, str]]:
181
+ chats: dict[str, dict[str, str]] = {}
182
+ message_keys = ("message", "edited_message", "channel_post", "edited_channel_post")
183
+ for update in updates:
184
+ for key in message_keys:
185
+ chat = update.get(key, {}).get("chat")
186
+ if not chat or "id" not in chat:
187
+ continue
188
+ chat_id = str(chat["id"])
189
+ title = chat.get("title") or chat.get("username") or chat.get("first_name")
190
+ chats[chat_id] = {"title": str(title or chat_id), "type": chat.get("type", "")}
191
+ return chats
192
+
193
+
194
+ def _choose_chat(chats: dict[str, dict[str, str]]) -> str:
195
+ if not chats:
196
+ raise ValueError(
197
+ "чат не найден. Добавьте бота в группу, напишите /start и повторите setup"
198
+ )
199
+ if len(chats) == 1:
200
+ return next(iter(chats))
201
+
202
+ print("Найдено несколько чатов:")
203
+ items = list(chats.items())
204
+ for number, (chat_id, chat) in enumerate(items, start=1):
205
+ print(f" {number}. {chat['title']} ({chat_id})")
206
+ answer = input("Введите номер нужного чата: ").strip()
207
+ try:
208
+ return items[int(answer) - 1][0]
209
+ except (ValueError, IndexError):
210
+ raise ValueError("выбран неправильный номер чата") from None
211
+
212
+
213
+ def _install_jupyter_startup() -> Path:
214
+ startup = Path.home() / ".ipython" / "profile_default" / "startup"
215
+ startup.mkdir(parents=True, exist_ok=True)
216
+ file = startup / "90-pynotegram.py"
217
+ file.write_text(
218
+ "get_ipython().run_line_magic('load_ext', 'pynotegram')\n",
219
+ encoding="utf-8",
220
+ )
221
+ return file
222
+
223
+
224
+ def _send(message: str) -> None:
225
+ with PyNoteGram.from_config() as notifier:
226
+ notifier.send(message)
227
+ print("Отправлено.")
228
+
229
+
230
+ def _status() -> None:
231
+ data = load_config()
232
+ safe = {"chat_id": data["chat_id"], "proxy_url": data.get("proxy_url")}
233
+ print(json.dumps(safe, ensure_ascii=False, indent=2))
234
+ print(f"Файл настроек: {config_path()}")
235
+
236
+
237
+ if __name__ == "__main__":
238
+ main()
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ class ConfigError(RuntimeError):
10
+ pass
11
+
12
+
13
+ def config_path() -> Path:
14
+ custom = os.getenv("PYNOTEGRAM_CONFIG")
15
+ if custom:
16
+ return Path(custom).expanduser()
17
+
18
+ if os.name == "nt":
19
+ base = Path(os.getenv("APPDATA", Path.home() / "AppData" / "Roaming"))
20
+ return base / "PyNoteGram" / "config.json"
21
+
22
+ base = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
23
+ return base / "pynotegram" / "config.json"
24
+
25
+
26
+ def load_config() -> dict[str, Any]:
27
+ path = config_path()
28
+ if not path.exists():
29
+ raise ConfigError(
30
+ "PyNoteGram еще не настроен. Один раз выполните: pynotegram setup"
31
+ )
32
+
33
+ try:
34
+ data = json.loads(path.read_text(encoding="utf-8"))
35
+ except (OSError, ValueError) as error:
36
+ raise ConfigError(f"Не удалось прочитать настройки: {path}") from error
37
+
38
+ if not data.get("token") or not data.get("chat_id"):
39
+ raise ConfigError("В настройках отсутствует token или chat_id")
40
+ return data
41
+
42
+
43
+ def save_config(data: dict[str, Any]) -> Path:
44
+ path = config_path()
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ temporary = path.with_suffix(".tmp")
47
+ temporary.write_text(
48
+ json.dumps(data, ensure_ascii=False, indent=2),
49
+ encoding="utf-8",
50
+ )
51
+ if os.name != "nt":
52
+ temporary.chmod(0o600)
53
+ temporary.replace(path)
54
+ return path
55
+
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Iterator
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+
10
+ TELEGRAM_MESSAGE_LIMIT = 4096
11
+
12
+
13
+ class TelegramError(RuntimeError):
14
+ """A safe-to-display error raised for network or Telegram API failures."""
15
+
16
+
17
+ class PyNoteGram:
18
+ """Send notebook notifications through Telegram Bot API or an HTTPS relay."""
19
+
20
+ def __init__(
21
+ self,
22
+ *,
23
+ chat_id: str | int,
24
+ token: str | None = None,
25
+ proxy_url: str | None = None,
26
+ relay_url: str | None = None,
27
+ relay_api_key: str | None = None,
28
+ timeout: float = 20.0,
29
+ client: httpx.Client | None = None,
30
+ ) -> None:
31
+ if not str(chat_id).strip():
32
+ raise ValueError("chat_id is required")
33
+ if not relay_url and not token:
34
+ raise ValueError("token is required when relay_url is not configured")
35
+ if relay_url and not relay_url.lower().startswith("https://"):
36
+ raise ValueError("relay_url must use HTTPS")
37
+ if client is not None and proxy_url:
38
+ raise ValueError("Pass either client or proxy_url, not both")
39
+
40
+ self.chat_id = str(chat_id)
41
+ self.token = token
42
+ self.relay_url = relay_url
43
+ self.relay_api_key = relay_api_key
44
+ self._owns_client = client is None
45
+ self._client = client or httpx.Client(
46
+ proxy=proxy_url,
47
+ timeout=timeout,
48
+ follow_redirects=True,
49
+ )
50
+
51
+ @classmethod
52
+ def from_env(cls, **overrides: Any) -> PyNoteGram:
53
+ """Create a notifier from TELEGRAM_* environment variables."""
54
+ config: dict[str, Any] = {
55
+ "token": os.getenv("TELEGRAM_BOT_TOKEN"),
56
+ "chat_id": os.getenv("TELEGRAM_CHAT_ID", ""),
57
+ "proxy_url": os.getenv("TELEGRAM_PROXY_URL"),
58
+ "relay_url": os.getenv("TELEGRAM_RELAY_URL"),
59
+ "relay_api_key": os.getenv("TELEGRAM_RELAY_API_KEY"),
60
+ }
61
+ config.update(overrides)
62
+ return cls(**config)
63
+
64
+ @classmethod
65
+ def from_config(cls, **overrides: Any) -> PyNoteGram:
66
+ """Create a notifier for the single chat selected by `pynotegram setup`."""
67
+ from .config import load_config
68
+
69
+ allowed = {
70
+ "token",
71
+ "chat_id",
72
+ "proxy_url",
73
+ "relay_url",
74
+ "relay_api_key",
75
+ "timeout",
76
+ "client",
77
+ }
78
+ config = {
79
+ key: value for key, value in load_config().items() if key in allowed
80
+ }
81
+ config.update(overrides)
82
+ return cls(**config)
83
+
84
+ def send(
85
+ self,
86
+ message: object,
87
+ *,
88
+ parse_mode: str | None = None,
89
+ silent: bool = False,
90
+ ) -> list[dict[str, Any]]:
91
+ """Send text and return one API result for each Telegram-sized chunk."""
92
+ text = str(message)
93
+ if not text.strip():
94
+ raise ValueError("message must not be empty")
95
+
96
+ return [
97
+ self._send_chunk(
98
+ chunk,
99
+ parse_mode=parse_mode,
100
+ silent=silent,
101
+ )
102
+ for chunk in _split_message(text)
103
+ ]
104
+
105
+ def close(self) -> None:
106
+ if self._owns_client:
107
+ self._client.close()
108
+
109
+ def __enter__(self) -> PyNoteGram:
110
+ return self
111
+
112
+ def __exit__(self, *_: object) -> None:
113
+ self.close()
114
+
115
+ def _send_chunk(
116
+ self,
117
+ text: str,
118
+ *,
119
+ parse_mode: str | None,
120
+ silent: bool,
121
+ ) -> dict[str, Any]:
122
+ payload: dict[str, Any] = {
123
+ "chat_id": self.chat_id,
124
+ "text": text,
125
+ "disable_notification": silent,
126
+ }
127
+ if parse_mode:
128
+ payload["parse_mode"] = parse_mode
129
+
130
+ headers: dict[str, str] = {}
131
+ if self.relay_url:
132
+ url = self.relay_url
133
+ if self.relay_api_key:
134
+ headers["Authorization"] = f"Bearer {self.relay_api_key}"
135
+ else:
136
+ url = f"https://api.telegram.org/bot{self.token}/sendMessage"
137
+
138
+ try:
139
+ response = self._client.post(url, json=payload, headers=headers)
140
+ except httpx.RequestError:
141
+ raise TelegramError(
142
+ "Не удалось подключиться к Telegram. Проверьте VPN, proxy_url "
143
+ "или доступность relay-сервера."
144
+ ) from None
145
+
146
+ try:
147
+ data = response.json()
148
+ except ValueError:
149
+ data = None
150
+
151
+ if response.is_error:
152
+ description = _safe_description(data)
153
+ raise TelegramError(
154
+ f"Сервис вернул HTTP {response.status_code}: {description}"
155
+ )
156
+
157
+ if not isinstance(data, dict):
158
+ raise TelegramError("Сервис вернул некорректный JSON")
159
+ if data.get("ok") is False:
160
+ raise TelegramError(
161
+ f"Telegram отклонил запрос: {_safe_description(data)}"
162
+ )
163
+
164
+ result = data.get("result", data)
165
+ return result if isinstance(result, dict) else {"result": result}
166
+
167
+
168
+ def _safe_description(data: object) -> str:
169
+ if isinstance(data, dict):
170
+ description = data.get("description") or data.get("detail")
171
+ if description:
172
+ return str(description)
173
+ return "без описания"
174
+
175
+
176
+ def _split_message(text: str) -> Iterator[str]:
177
+ remaining = text
178
+ while len(remaining) > TELEGRAM_MESSAGE_LIMIT:
179
+ split_at = remaining.rfind("\n", 0, TELEGRAM_MESSAGE_LIMIT)
180
+ if split_at <= 0:
181
+ split_at = TELEGRAM_MESSAGE_LIMIT
182
+ else:
183
+ split_at += 1
184
+ yield remaining[:split_at]
185
+ remaining = remaining[split_at:]
186
+ if remaining:
187
+ yield remaining
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import json
5
+ import re
6
+ import time
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from .config import load_config, save_config
13
+ from .notifier import TelegramError, _safe_description
14
+
15
+
16
+ def read(
17
+ wait: int = 30,
18
+ *,
19
+ show: bool = True,
20
+ show_empty: bool = True,
21
+ client: httpx.Client | None = None,
22
+ ) -> list[dict[str, Any]]:
23
+ """Wait for messages from the configured chat and optionally show them."""
24
+ if not 0 <= wait <= 50:
25
+ raise ValueError("wait должен быть от 0 до 50 секунд")
26
+
27
+ config = load_config()
28
+ token = config.get("token")
29
+ if not token:
30
+ raise TelegramError("Для чтения сообщений нужен токен Telegram-бота")
31
+
32
+ owns_client = client is None
33
+ active_client = client or httpx.Client(
34
+ proxy=config.get("proxy_url"),
35
+ timeout=max(20, wait + 10),
36
+ follow_redirects=True,
37
+ )
38
+ try:
39
+ updates = _get_updates(
40
+ active_client,
41
+ token,
42
+ offset=int(config.get("update_offset", 0)),
43
+ wait=wait,
44
+ )
45
+ finally:
46
+ if owns_client:
47
+ active_client.close()
48
+
49
+ if updates:
50
+ config["update_offset"] = max(int(item["update_id"]) for item in updates) + 1
51
+ save_config(config)
52
+
53
+ messages = [
54
+ message
55
+ for update in updates
56
+ if (message := _extract_message(update)) is not None
57
+ and str(message.get("chat", {}).get("id")) == str(config["chat_id"])
58
+ ]
59
+
60
+ if show:
61
+ if messages:
62
+ for message in messages:
63
+ _display_message(message)
64
+ elif show_empty:
65
+ print("Новых сообщений нет.")
66
+ return messages
67
+
68
+
69
+ def watch(minutes: float = 10) -> list[dict[str, Any]]:
70
+ """Continuously display incoming messages for a limited time."""
71
+ if not 0 < minutes <= 1440:
72
+ raise ValueError("minutes должен быть больше 0 и не больше 1440")
73
+
74
+ print(f"Ожидаю сообщения из Telegram {minutes:g} мин. Для остановки прервите ядро.")
75
+ deadline = time.monotonic() + minutes * 60
76
+ collected: list[dict[str, Any]] = []
77
+ try:
78
+ while (remaining := deadline - time.monotonic()) > 0:
79
+ wait = max(0, min(30, int(remaining)))
80
+ if wait == 0:
81
+ break
82
+ collected.extend(read(wait=wait, show=True, show_empty=False))
83
+ except KeyboardInterrupt:
84
+ print("Ожидание остановлено.")
85
+ return collected
86
+
87
+
88
+ def message_to_markdown(message: dict[str, Any]) -> str:
89
+ """Convert Telegram text and code entities to safe Jupyter Markdown."""
90
+ text = message.get("text") or message.get("caption") or ""
91
+ entities = message.get("entities") or message.get("caption_entities") or []
92
+ return _format_text(text, entities)
93
+
94
+
95
+ def _get_updates(
96
+ client: httpx.Client,
97
+ token: str,
98
+ *,
99
+ offset: int,
100
+ wait: int,
101
+ ) -> list[dict[str, Any]]:
102
+ try:
103
+ response = client.get(
104
+ f"https://api.telegram.org/bot{token}/getUpdates",
105
+ params={
106
+ "offset": offset,
107
+ "timeout": wait,
108
+ "allowed_updates": json.dumps(["message", "edited_message"]),
109
+ },
110
+ )
111
+ data = response.json()
112
+ except (httpx.RequestError, ValueError):
113
+ raise TelegramError(
114
+ "Не удалось получить сообщения. Проверьте VPN или прокси."
115
+ ) from None
116
+
117
+ if response.is_error or not isinstance(data, dict) or data.get("ok") is not True:
118
+ raise TelegramError(f"Telegram отклонил запрос: {_safe_description(data)}")
119
+ result = data.get("result", [])
120
+ return result if isinstance(result, list) else []
121
+
122
+
123
+ def _extract_message(update: dict[str, Any]) -> dict[str, Any] | None:
124
+ return update.get("message") or update.get("edited_message")
125
+
126
+
127
+ def _display_message(message: dict[str, Any]) -> None:
128
+ from IPython.display import Markdown, display
129
+
130
+ sender = message.get("from", {})
131
+ name = " ".join(
132
+ part for part in (sender.get("first_name"), sender.get("last_name")) if part
133
+ ) or sender.get("username") or "Telegram"
134
+ timestamp = datetime.fromtimestamp(message.get("date", 0)).astimezone()
135
+ heading = f"**{html.escape(str(name))}** · {timestamp:%H:%M:%S}"
136
+ body = message_to_markdown(message)
137
+ display(Markdown(f"{heading}\n\n{body}"))
138
+
139
+
140
+ def _format_text(text: str, entities: list[dict[str, Any]]) -> str:
141
+ if not entities:
142
+ return html.escape(text)
143
+
144
+ spans: list[tuple[int, int, dict[str, Any]]] = []
145
+ for entity in entities:
146
+ start = _utf16_to_python_index(text, int(entity.get("offset", 0)))
147
+ end = _utf16_to_python_index(
148
+ text,
149
+ int(entity.get("offset", 0)) + int(entity.get("length", 0)),
150
+ )
151
+ spans.append((start, end, entity))
152
+
153
+ # Code entities matter most in notebooks. Telegram can nest other style
154
+ # entities, so only non-overlapping top-level spans are rewritten here.
155
+ output: list[str] = []
156
+ cursor = 0
157
+ for start, end, entity in sorted(spans, key=lambda item: (item[0], -item[1])):
158
+ if start < cursor:
159
+ continue
160
+ output.append(html.escape(text[cursor:start]))
161
+ value = text[start:end]
162
+ entity_type = entity.get("type")
163
+ if entity_type == "pre":
164
+ language = re.sub(r"[^A-Za-z0-9_+.-]", "", entity.get("language", ""))
165
+ fence = "````" if "```" in value else "```"
166
+ output.append(f"\n{fence}{language}\n{value}\n{fence}\n")
167
+ elif entity_type == "code":
168
+ fence = "``" if "`" in value else "`"
169
+ output.append(f"{fence}{value}{fence}")
170
+ elif entity_type == "bold":
171
+ output.append(f"**{html.escape(value)}**")
172
+ elif entity_type == "italic":
173
+ output.append(f"*{html.escape(value)}*")
174
+ elif entity_type == "strikethrough":
175
+ output.append(f"~~{html.escape(value)}~~")
176
+ elif entity_type == "underline":
177
+ output.append(f"<u>{html.escape(value)}</u>")
178
+ elif entity_type == "text_link":
179
+ url = html.escape(str(entity.get("url", "")), quote=True)
180
+ output.append(f"[{html.escape(value)}]({url})")
181
+ else:
182
+ output.append(html.escape(value))
183
+ cursor = end
184
+ output.append(html.escape(text[cursor:]))
185
+ return "".join(output)
186
+
187
+
188
+ def _utf16_to_python_index(text: str, utf16_index: int) -> int:
189
+ units = 0
190
+ for index, character in enumerate(text):
191
+ if units >= utf16_index:
192
+ return index
193
+ units += 2 if ord(character) > 0xFFFF else 1
194
+ return len(text)
@@ -0,0 +1,245 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+
9
+ from pynotegram import PyNoteGram, TelegramError, read, setup, watch
10
+ from pynotegram.cli import _find_chats, _wait_for_chats
11
+ from pynotegram.config import save_config
12
+ from pynotegram.receiver import message_to_markdown
13
+
14
+
15
+ class PyNoteGramTests(unittest.TestCase):
16
+ def test_notebook_setup_is_public(self):
17
+ self.assertTrue(callable(setup))
18
+
19
+ def test_read_is_public(self):
20
+ self.assertTrue(callable(read))
21
+ self.assertTrue(callable(watch))
22
+
23
+ def test_direct_send(self):
24
+ def handler(request: httpx.Request) -> httpx.Response:
25
+ self.assertEqual(
26
+ str(request.url),
27
+ "https://api.telegram.org/botsecret/sendMessage",
28
+ )
29
+ return httpx.Response(200, json={"ok": True, "result": {"message_id": 7}})
30
+
31
+ client = httpx.Client(transport=httpx.MockTransport(handler))
32
+ notifier = PyNoteGram(token="secret", chat_id=123, client=client)
33
+
34
+ self.assertEqual(notifier.send("done"), [{"message_id": 7}])
35
+
36
+ def test_long_message_is_split(self):
37
+ messages = []
38
+
39
+ def handler(request: httpx.Request) -> httpx.Response:
40
+ messages.append(request.read())
41
+ return httpx.Response(200, json={"ok": True, "result": {}})
42
+
43
+ client = httpx.Client(transport=httpx.MockTransport(handler))
44
+ notifier = PyNoteGram(token="secret", chat_id=123, client=client)
45
+
46
+ notifier.send("x" * 5000)
47
+
48
+ self.assertEqual(len(messages), 2)
49
+
50
+ def test_split_preserves_newlines(self):
51
+ chunks = []
52
+
53
+ def handler(request: httpx.Request) -> httpx.Response:
54
+ chunks.append(json.loads(request.read())["text"])
55
+ return httpx.Response(200, json={"ok": True, "result": {}})
56
+
57
+ client = httpx.Client(transport=httpx.MockTransport(handler))
58
+ notifier = PyNoteGram(token="secret", chat_id=123, client=client)
59
+ original = ("line\n" * 900) + "end"
60
+
61
+ notifier.send(original)
62
+
63
+ self.assertEqual("".join(chunks), original)
64
+ self.assertTrue(all(len(chunk) <= 4096 for chunk in chunks))
65
+
66
+ def test_relay_uses_bearer_key(self):
67
+ def handler(request: httpx.Request) -> httpx.Response:
68
+ self.assertEqual(request.headers["Authorization"], "Bearer relay-secret")
69
+ self.assertEqual(str(request.url), "https://relay.example/send")
70
+ return httpx.Response(200, json={"ok": True, "result": {"message_id": 9}})
71
+
72
+ client = httpx.Client(transport=httpx.MockTransport(handler))
73
+ notifier = PyNoteGram(
74
+ chat_id=123,
75
+ relay_url="https://relay.example/send",
76
+ relay_api_key="relay-secret",
77
+ client=client,
78
+ )
79
+
80
+ self.assertEqual(notifier.send("done"), [{"message_id": 9}])
81
+
82
+ def test_api_error_is_readable(self):
83
+ transport = httpx.MockTransport(
84
+ lambda request: httpx.Response(
85
+ 400,
86
+ json={"ok": False, "description": "Bad Request: chat not found"},
87
+ )
88
+ )
89
+ client = httpx.Client(transport=transport)
90
+ notifier = PyNoteGram(token="secret", chat_id=123, client=client)
91
+
92
+ with self.assertRaisesRegex(TelegramError, "chat not found"):
93
+ notifier.send("done")
94
+
95
+ def test_config_uses_one_saved_chat(self):
96
+ with tempfile.TemporaryDirectory() as directory:
97
+ old_path = os.environ.get("PYNOTEGRAM_CONFIG")
98
+ os.environ["PYNOTEGRAM_CONFIG"] = str(Path(directory) / "config.json")
99
+ try:
100
+ save_config(
101
+ {
102
+ "token": "secret",
103
+ "chat_id": "-100777",
104
+ "update_offset": 42,
105
+ }
106
+ )
107
+ client = httpx.Client(
108
+ transport=httpx.MockTransport(
109
+ lambda request: httpx.Response(
110
+ 200,
111
+ json={"ok": True, "result": {}},
112
+ )
113
+ )
114
+ )
115
+ notifier = PyNoteGram.from_config(client=client)
116
+ self.assertEqual(notifier.chat_id, "-100777")
117
+ finally:
118
+ if old_path is None:
119
+ os.environ.pop("PYNOTEGRAM_CONFIG", None)
120
+ else:
121
+ os.environ["PYNOTEGRAM_CONFIG"] = old_path
122
+
123
+ def test_find_group_chat(self):
124
+ chats = _find_chats(
125
+ [
126
+ {
127
+ "update_id": 1,
128
+ "message": {
129
+ "chat": {
130
+ "id": -100777,
131
+ "type": "supergroup",
132
+ "title": "Calculations",
133
+ }
134
+ },
135
+ }
136
+ ]
137
+ )
138
+
139
+ self.assertEqual(chats["-100777"]["title"], "Calculations")
140
+
141
+ def test_wait_for_group_ignores_private_chat(self):
142
+ updates = [
143
+ {
144
+ "update_id": 10,
145
+ "message": {
146
+ "chat": {"id": 123, "type": "private", "first_name": "User"}
147
+ }
148
+ },
149
+ {
150
+ "update_id": 11,
151
+ "message": {
152
+ "chat": {
153
+ "id": -100777,
154
+ "type": "supergroup",
155
+ "title": "Calculations",
156
+ }
157
+ }
158
+ },
159
+ ]
160
+ client = httpx.Client(
161
+ transport=httpx.MockTransport(
162
+ lambda request: httpx.Response(
163
+ 200,
164
+ json={"ok": True, "result": updates},
165
+ )
166
+ )
167
+ )
168
+
169
+ chats = _wait_for_chats(client, "secret", wait_seconds=1)
170
+
171
+ self.assertEqual(list(chats), ["-100777"])
172
+
173
+ def test_read_only_returns_saved_chat_and_updates_offset(self):
174
+ with tempfile.TemporaryDirectory() as directory:
175
+ old_path = os.environ.get("PYNOTEGRAM_CONFIG")
176
+ os.environ["PYNOTEGRAM_CONFIG"] = str(Path(directory) / "config.json")
177
+ try:
178
+ save_config(
179
+ {
180
+ "token": "secret",
181
+ "chat_id": "-100777",
182
+ "update_offset": 5,
183
+ }
184
+ )
185
+
186
+ def handler(request: httpx.Request) -> httpx.Response:
187
+ self.assertEqual(request.url.params["offset"], "5")
188
+ return httpx.Response(
189
+ 200,
190
+ json={
191
+ "ok": True,
192
+ "result": [
193
+ {
194
+ "update_id": 7,
195
+ "message": {
196
+ "chat": {"id": 999},
197
+ "text": "ignore",
198
+ },
199
+ },
200
+ {
201
+ "update_id": 8,
202
+ "message": {
203
+ "chat": {"id": -100777},
204
+ "text": "keep",
205
+ },
206
+ },
207
+ ],
208
+ },
209
+ )
210
+
211
+ client = httpx.Client(transport=httpx.MockTransport(handler))
212
+ messages = read(wait=0, show=False, client=client)
213
+
214
+ self.assertEqual([item["text"] for item in messages], ["keep"])
215
+ saved = json.loads(Path(os.environ["PYNOTEGRAM_CONFIG"]).read_text())
216
+ self.assertEqual(saved["update_offset"], 9)
217
+ finally:
218
+ if old_path is None:
219
+ os.environ.pop("PYNOTEGRAM_CONFIG", None)
220
+ else:
221
+ os.environ["PYNOTEGRAM_CONFIG"] = old_path
222
+
223
+ def test_telegram_python_code_becomes_fenced_markdown(self):
224
+ text = "Код:\nprint('Привет')"
225
+ start = len("Код:\n".encode("utf-16-le")) // 2
226
+ length = len("print('Привет')".encode("utf-16-le")) // 2
227
+ markdown = message_to_markdown(
228
+ {
229
+ "text": text,
230
+ "entities": [
231
+ {
232
+ "type": "pre",
233
+ "offset": start,
234
+ "length": length,
235
+ "language": "python",
236
+ }
237
+ ],
238
+ }
239
+ )
240
+
241
+ self.assertIn("```python\nprint('Привет')\n```", markdown)
242
+
243
+
244
+ if __name__ == "__main__":
245
+ unittest.main()