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.
- pynotegram-0.3.0/.gitignore +8 -0
- pynotegram-0.3.0/LICENSE +21 -0
- pynotegram-0.3.0/PKG-INFO +169 -0
- pynotegram-0.3.0/README.md +141 -0
- pynotegram-0.3.0/pyproject.toml +52 -0
- pynotegram-0.3.0/src/pynotegram/__init__.py +63 -0
- pynotegram-0.3.0/src/pynotegram/cli.py +238 -0
- pynotegram-0.3.0/src/pynotegram/config.py +55 -0
- pynotegram-0.3.0/src/pynotegram/notifier.py +187 -0
- pynotegram-0.3.0/src/pynotegram/receiver.py +194 -0
- pynotegram-0.3.0/tests/test_notifier.py +245 -0
pynotegram-0.3.0/LICENSE
ADDED
|
@@ -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()
|