TgrEzLi 0.1.2__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of TgrEzLi might be problematic. Click here for more details.
- TgrEzLi/__init__.py +26 -10
- TgrEzLi/api_server.py +120 -0
- TgrEzLi/config.py +5 -0
- TgrEzLi/core.py +482 -373
- TgrEzLi/crypto.py +39 -0
- TgrEzLi/defaults.py +16 -0
- TgrEzLi/handlers.py +70 -0
- TgrEzLi/logger.py +65 -0
- TgrEzLi/models.py +82 -0
- TgrEzLi/requests.py +49 -0
- TgrEzLi/types.py +119 -0
- tgrezli-0.4.0.dist-info/METADATA +274 -0
- tgrezli-0.4.0.dist-info/RECORD +15 -0
- {tgrezli-0.1.2.dist-info → tgrezli-0.4.0.dist-info}/WHEEL +1 -2
- tgrezli-0.4.0.dist-info/entry_points.txt +3 -0
- tgrezli-0.1.2.dist-info/METADATA +0 -238
- tgrezli-0.1.2.dist-info/RECORD +0 -6
- tgrezli-0.1.2.dist-info/top_level.txt +0 -1
TgrEzLi/core.py
CHANGED
|
@@ -1,449 +1,558 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
from
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class _TgCmdData:
|
|
40
|
-
def __init__(self, command, args, msg_id, chat_id, user_id, user_name, timestamp, raw_update):
|
|
41
|
-
self.command = command
|
|
42
|
-
self.args = args
|
|
43
|
-
self.msgId = msg_id
|
|
44
|
-
self.chatId = chat_id
|
|
45
|
-
self.userId = user_id
|
|
46
|
-
self.userName = user_name
|
|
47
|
-
self.timestamp = timestamp
|
|
48
|
-
self.raw_update = raw_update
|
|
49
|
-
|
|
50
|
-
class _TgCbData:
|
|
51
|
-
def __init__(self, text, value, msg_id, chat_id, user_id, user_name, timestamp, raw_update):
|
|
52
|
-
self.text = text
|
|
53
|
-
self.value = value
|
|
54
|
-
self.msgId = msg_id
|
|
55
|
-
self.chatId = chat_id
|
|
56
|
-
self.userId = user_id
|
|
57
|
-
self.userName = user_name
|
|
58
|
-
self.timestamp = timestamp
|
|
59
|
-
self.raw_update = raw_update
|
|
60
|
-
|
|
61
|
-
class _TgArgsData:
|
|
62
|
-
def __init__(self, data_dict: dict):
|
|
63
|
-
self._data = data_dict
|
|
64
|
-
def get(self, key, default=None):
|
|
65
|
-
return self._data.get(key, default)
|
|
66
|
-
|
|
67
|
-
class _TgMsgProxy:
|
|
68
|
-
def __getattr__(self, item):
|
|
69
|
-
if not hasattr(_local, "TgMsg") or _local.TgMsg is None:
|
|
70
|
-
raise AttributeError("TgMsg not available in this context.")
|
|
71
|
-
return getattr(_local.TgMsg, item)
|
|
72
|
-
|
|
73
|
-
class _TgCmdProxy:
|
|
74
|
-
def __getattr__(self, item):
|
|
75
|
-
if not hasattr(_local, "TgCmd") or _local.TgCmd is None:
|
|
76
|
-
raise AttributeError("TgCmd not available in this context.")
|
|
77
|
-
return getattr(_local.TgCmd, item)
|
|
78
|
-
|
|
79
|
-
class _TgCbProxy:
|
|
80
|
-
def __getattr__(self, item):
|
|
81
|
-
if not hasattr(_local, "TgCb") or _local.TgCb is None:
|
|
82
|
-
raise AttributeError("TgCb not available in this context.")
|
|
83
|
-
return getattr(_local.TgCb, item)
|
|
84
|
-
|
|
85
|
-
class _TgArgsProxy:
|
|
86
|
-
def __getattr__(self, item):
|
|
87
|
-
if not hasattr(_local, "TgArgs") or _local.TgArgs is None:
|
|
88
|
-
raise AttributeError("TgArgs not available in this context.")
|
|
89
|
-
return getattr(_local.TgArgs, item)
|
|
90
|
-
|
|
91
|
-
TgMsg = _TgMsgProxy()
|
|
92
|
-
TgCmd = _TgCmdProxy()
|
|
93
|
-
TgCb = _TgCbProxy()
|
|
94
|
-
TgArgs = _TgArgsProxy()
|
|
1
|
+
"""Main bot orchestrator: Telegram polling, handlers, optional API server."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import signal
|
|
6
|
+
import traceback
|
|
7
|
+
from threading import Event, Thread
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton
|
|
11
|
+
from telegram.ext import (
|
|
12
|
+
ApplicationBuilder,
|
|
13
|
+
ContextTypes,
|
|
14
|
+
CallbackQueryHandler,
|
|
15
|
+
MessageHandler,
|
|
16
|
+
filters,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from TgrEzLi.api_server import ApiServer
|
|
20
|
+
from TgrEzLi.defaults import DEFAULT_BANNER_FILLER
|
|
21
|
+
from TgrEzLi.handlers import HandlerRegistry, call_user_function
|
|
22
|
+
from TgrEzLi.logger import get_default_logger
|
|
23
|
+
from TgrEzLi.types import TgCbData, TgCmdData, TgMsgData, TgrezliConfig
|
|
24
|
+
|
|
25
|
+
__version__ = "0.4.0"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _print_banner(
|
|
29
|
+
logger: Any,
|
|
30
|
+
name: str,
|
|
31
|
+
version: str,
|
|
32
|
+
author: str,
|
|
33
|
+
filler: str = DEFAULT_BANNER_FILLER,
|
|
34
|
+
) -> None:
|
|
35
|
+
banner = f"\n{filler * 50}\n{name} {version} by {author}\n{filler * 50}\n"
|
|
36
|
+
logger.info(banner)
|
|
37
|
+
|
|
95
38
|
|
|
96
39
|
class TEL:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
self.
|
|
104
|
-
self.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
self.
|
|
109
|
-
self.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
self.
|
|
113
|
-
|
|
114
|
-
self.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
self.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def connect(self, token, chat_dict: dict):
|
|
157
|
-
logger.info("Initialyzing BOT...")
|
|
40
|
+
"""
|
|
41
|
+
Orchestrates Telegram polling, message/command/callback handlers,
|
|
42
|
+
and an optional embedded HTTP API server for custom POST routes.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, config: TgrezliConfig | None = None) -> None:
|
|
46
|
+
self.config = config or TgrezliConfig()
|
|
47
|
+
self.logger = get_default_logger(
|
|
48
|
+
save_log=self.config.save_log,
|
|
49
|
+
log_file=self.config.log_file,
|
|
50
|
+
)
|
|
51
|
+
self.registry = HandlerRegistry()
|
|
52
|
+
self._credential_manager: Any = None
|
|
53
|
+
|
|
54
|
+
self.chat_ids: dict[str, str] = {}
|
|
55
|
+
self.default_chat_name: str | None = None
|
|
56
|
+
|
|
57
|
+
self.application: Any = None
|
|
58
|
+
self.loop: asyncio.AbstractEventLoop | None = None
|
|
59
|
+
self.polling_thread: Thread | None = None
|
|
60
|
+
self._running = False # True while polling thread is active (send_msg works)
|
|
61
|
+
self._stop_event = Event() # set by stop() so run() returns
|
|
62
|
+
|
|
63
|
+
self.api_server: ApiServer | None = None
|
|
64
|
+
|
|
65
|
+
_print_banner(self.logger, "TgrEzLi", f"v{__version__}", "eaannist")
|
|
66
|
+
|
|
67
|
+
def _get_credential_manager(self) -> Any:
|
|
68
|
+
if self._credential_manager is None:
|
|
69
|
+
from TgrEzLi.crypto import CredentialManager
|
|
70
|
+
self._credential_manager = CredentialManager()
|
|
71
|
+
return self._credential_manager
|
|
72
|
+
|
|
73
|
+
def set_save_log(self, flag: bool) -> None:
|
|
74
|
+
"""Enable or disable writing logs to the log file."""
|
|
75
|
+
self.config.save_log = flag
|
|
76
|
+
self.logger.set_save_log(flag)
|
|
77
|
+
|
|
78
|
+
def set_host(self, host: str) -> None:
|
|
79
|
+
"""Set the API server host."""
|
|
80
|
+
self.config.api_host = host
|
|
81
|
+
|
|
82
|
+
def set_port(self, port: int) -> None:
|
|
83
|
+
"""Set the API server port."""
|
|
84
|
+
self.config.api_port = port
|
|
85
|
+
|
|
86
|
+
def signup(self, token: str, chat_dict: dict[str, str], password: str) -> None:
|
|
87
|
+
"""Encrypt and save token + chat_dict, then connect."""
|
|
88
|
+
self._get_credential_manager().signup(token, chat_dict, password)
|
|
89
|
+
self.login(password)
|
|
90
|
+
|
|
91
|
+
def login(self, password: str) -> None:
|
|
92
|
+
"""Load token + chat_dict from encrypted file and connect."""
|
|
93
|
+
token, chat_dict = self._get_credential_manager().login(password)
|
|
94
|
+
self.connect(token, chat_dict)
|
|
95
|
+
|
|
96
|
+
def connect(self, token: str, chat_dict: dict[str, str]) -> None:
|
|
97
|
+
"""Prepare the bot and start polling in a background thread. send_msg() works immediately; use run() to block the main thread until stop()."""
|
|
158
98
|
if not chat_dict:
|
|
159
99
|
raise ValueError("Chat dictionary is empty.")
|
|
160
100
|
self.chat_ids = {str(k): str(v) for k, v in chat_dict.items()}
|
|
161
101
|
self.default_chat_name = next(iter(self.chat_ids))
|
|
162
|
-
|
|
102
|
+
|
|
103
|
+
self.loop = asyncio.new_event_loop()
|
|
163
104
|
self.application = ApplicationBuilder().token(token).build()
|
|
105
|
+
|
|
164
106
|
self.application.add_handler(MessageHandler(filters.COMMAND, self._command_handler), group=0)
|
|
165
|
-
self.application.add_handler(
|
|
107
|
+
self.application.add_handler(
|
|
108
|
+
MessageHandler(filters.TEXT & ~filters.COMMAND, self._message_handler),
|
|
109
|
+
group=1,
|
|
110
|
+
)
|
|
166
111
|
self.application.add_handler(CallbackQueryHandler(self._callback_handler), group=2)
|
|
167
112
|
self.application.add_error_handler(self._error_handler)
|
|
168
|
-
self._polling_thread = threading.Thread(target=self._run_loop, daemon=True)
|
|
169
|
-
self._polling_thread.start()
|
|
170
|
-
self._connected = True
|
|
171
|
-
logger.info("BOT Connected and polling activated.")
|
|
172
113
|
|
|
173
|
-
|
|
114
|
+
self.polling_thread = Thread(target=self._run_polling, daemon=True)
|
|
115
|
+
self.polling_thread.start()
|
|
116
|
+
self._running = True
|
|
117
|
+
self.logger.info("Bot connected and polling (background). send_msg() works; call run() to block until stop().")
|
|
118
|
+
|
|
119
|
+
def _run_polling(self) -> None:
|
|
174
120
|
try:
|
|
175
|
-
asyncio.set_event_loop(self.
|
|
176
|
-
|
|
177
|
-
|
|
121
|
+
asyncio.set_event_loop(self.loop)
|
|
122
|
+
if self.application and self.loop:
|
|
123
|
+
self.loop.run_until_complete(self.application.run_polling())
|
|
178
124
|
except Exception as e:
|
|
179
|
-
logger.error(
|
|
180
|
-
logger.debug(traceback.format_exc())
|
|
125
|
+
self.logger.error("Error in run_polling: %s", e)
|
|
126
|
+
self.logger.debug(traceback.format_exc())
|
|
181
127
|
finally:
|
|
182
|
-
|
|
183
|
-
self.
|
|
184
|
-
self.
|
|
128
|
+
self._running = False
|
|
129
|
+
self.logger.info("Polling thread exiting.")
|
|
130
|
+
if self.application and self.loop:
|
|
131
|
+
try:
|
|
132
|
+
self.loop.run_until_complete(self.application.shutdown())
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
if self.loop and self.loop.is_running():
|
|
136
|
+
self.loop.stop()
|
|
137
|
+
if self.loop:
|
|
138
|
+
self.loop.close()
|
|
139
|
+
self.loop = None
|
|
140
|
+
self.application = None
|
|
141
|
+
|
|
142
|
+
def run(self) -> None:
|
|
143
|
+
"""Block the main thread until stop() is called (e.g. Ctrl+C). Starts the API server in a background thread if routes are registered. Polling is already active after connect()."""
|
|
144
|
+
if not self._running:
|
|
145
|
+
raise RuntimeError("Call connect() before run().")
|
|
146
|
+
if self.registry.api_routes and not self.api_server:
|
|
147
|
+
self.api_server = ApiServer(
|
|
148
|
+
self.logger, self.registry,
|
|
149
|
+
host=self.config.api_host,
|
|
150
|
+
port=self.config.api_port,
|
|
151
|
+
)
|
|
152
|
+
self.api_server.start()
|
|
153
|
+
|
|
154
|
+
def _on_signal(_sig: int, _frame: object) -> None:
|
|
155
|
+
self.logger.info("Shutdown requested (signal).")
|
|
156
|
+
self.stop()
|
|
185
157
|
|
|
186
|
-
|
|
187
|
-
|
|
158
|
+
try:
|
|
159
|
+
signal.signal(signal.SIGINT, _on_signal)
|
|
160
|
+
except ValueError:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
self._stop_event.clear()
|
|
164
|
+
self.logger.info("Main thread blocking until stop() (Ctrl+C).")
|
|
165
|
+
self._stop_event.wait()
|
|
166
|
+
|
|
167
|
+
def stop(self) -> None:
|
|
168
|
+
"""Stop polling and API server; unblocks run() if it was waiting. Safe to call from a signal handler or another thread."""
|
|
169
|
+
if self.application and getattr(self.application, "updater", None):
|
|
170
|
+
self.application.updater.stop()
|
|
171
|
+
if self.application:
|
|
172
|
+
self.application.stop()
|
|
173
|
+
if self.api_server:
|
|
174
|
+
self.api_server.stop()
|
|
175
|
+
self.api_server = None
|
|
176
|
+
self._stop_event.set()
|
|
177
|
+
if self.polling_thread and self.polling_thread.is_alive():
|
|
178
|
+
self.logger.info("Waiting for polling thread to exit...")
|
|
179
|
+
self.polling_thread.join(timeout=5)
|
|
180
|
+
self._running = False
|
|
181
|
+
self.logger.info("Bot stopped.")
|
|
182
|
+
|
|
183
|
+
async def _error_handler(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
184
|
+
self.logger.error("Unhandled error!")
|
|
185
|
+
self.logger.error(str(context.error))
|
|
186
|
+
self.logger.debug(traceback.format_exc())
|
|
187
|
+
|
|
188
|
+
async def _message_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
189
|
+
if not update.message:
|
|
190
|
+
return
|
|
188
191
|
chat_id = str(update.effective_chat.id)
|
|
189
192
|
chat_name = self._get_chat_name_by_id(chat_id)
|
|
190
|
-
if not chat_name:
|
|
193
|
+
if not chat_name:
|
|
194
|
+
return
|
|
191
195
|
text = update.message.text or ""
|
|
192
|
-
tgmsg_data =
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
196
|
+
tgmsg_data = TgMsgData(
|
|
197
|
+
text=text,
|
|
198
|
+
msg_id=update.message.message_id,
|
|
199
|
+
chat_id=chat_id,
|
|
200
|
+
user_id=update.effective_user.id if update.effective_user else None,
|
|
201
|
+
user_name=update.effective_user.username if update.effective_user else None,
|
|
202
|
+
timestamp=update.message.date,
|
|
203
|
+
raw_update=update,
|
|
204
|
+
)
|
|
205
|
+
for chats, func in self.registry.message_handlers:
|
|
197
206
|
if chat_name in chats:
|
|
198
|
-
self.
|
|
207
|
+
call_user_function(self.logger, func, TgMsg=tgmsg_data)
|
|
199
208
|
|
|
200
|
-
async def _command_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
201
|
-
if not update.message:
|
|
209
|
+
async def _command_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
210
|
+
if not update.message:
|
|
211
|
+
return
|
|
202
212
|
text = update.message.text or ""
|
|
203
213
|
parts = text.strip().split(maxsplit=1)
|
|
204
214
|
command_part = parts[0].lower()
|
|
205
215
|
args_part = parts[1] if len(parts) > 1 else ""
|
|
206
|
-
if
|
|
207
|
-
command_part = command_part.split(
|
|
216
|
+
if "@" in command_part:
|
|
217
|
+
command_part = command_part.split("@", 1)[0]
|
|
208
218
|
command_name = command_part.lstrip("/")
|
|
219
|
+
|
|
209
220
|
chat_id = str(update.effective_chat.id)
|
|
210
221
|
chat_name = self._get_chat_name_by_id(chat_id)
|
|
211
|
-
if not chat_name:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
222
|
+
if not chat_name:
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
tgcmd_data = TgCmdData(
|
|
226
|
+
command=command_part,
|
|
227
|
+
args=args_part,
|
|
228
|
+
msg_id=update.message.message_id,
|
|
229
|
+
chat_id=chat_id,
|
|
230
|
+
user_id=update.effective_user.id if update.effective_user else None,
|
|
231
|
+
user_name=update.effective_user.username if update.effective_user else None,
|
|
232
|
+
timestamp=update.message.date,
|
|
233
|
+
raw_update=update,
|
|
234
|
+
)
|
|
235
|
+
if command_name in self.registry.command_handlers:
|
|
236
|
+
for chats, func in self.registry.command_handlers[command_name]:
|
|
218
237
|
if chat_name in chats:
|
|
219
|
-
self.
|
|
238
|
+
call_user_function(self.logger, func, TgCmd=tgcmd_data)
|
|
220
239
|
|
|
221
|
-
async def _callback_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
222
|
-
if not update.callback_query:
|
|
240
|
+
async def _callback_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
241
|
+
if not update.callback_query:
|
|
242
|
+
return
|
|
243
|
+
cb_query = update.callback_query
|
|
223
244
|
chat_id = str(update.effective_chat.id)
|
|
224
245
|
chat_name = self._get_chat_name_by_id(chat_id)
|
|
225
|
-
if not chat_name:
|
|
226
|
-
|
|
246
|
+
if not chat_name:
|
|
247
|
+
return
|
|
248
|
+
|
|
227
249
|
button_text = None
|
|
228
250
|
if cb_query.message and cb_query.message.reply_markup:
|
|
229
251
|
for row in cb_query.message.reply_markup.inline_keyboard:
|
|
230
252
|
for btn in row:
|
|
231
253
|
if btn.callback_data == cb_query.data:
|
|
232
|
-
button_text = btn.text
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
254
|
+
button_text = btn.text
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
tgcb_data = TgCbData(
|
|
258
|
+
text=button_text,
|
|
259
|
+
value=cb_query.data,
|
|
260
|
+
msg_id=cb_query.message.message_id if cb_query.message else None,
|
|
261
|
+
chat_id=chat_id,
|
|
262
|
+
user_id=cb_query.from_user.id if cb_query.from_user else None,
|
|
263
|
+
user_name=cb_query.from_user.username if cb_query.from_user else None,
|
|
264
|
+
timestamp=cb_query.message.date if cb_query.message else None,
|
|
265
|
+
raw_update=update,
|
|
266
|
+
)
|
|
240
267
|
await cb_query.answer()
|
|
241
|
-
|
|
268
|
+
|
|
269
|
+
for chats, func in self.registry.callback_handlers:
|
|
242
270
|
if chat_name in chats:
|
|
243
|
-
self.
|
|
271
|
+
call_user_function(self.logger, func, TgCb=tgcb_data)
|
|
244
272
|
|
|
245
|
-
def
|
|
246
|
-
|
|
247
|
-
|
|
273
|
+
def on_message(self, chat: str | list[str] | set[str] | None = None) -> Any:
|
|
274
|
+
"""Decorator for message handlers."""
|
|
275
|
+
def decorator(func: Any) -> Any:
|
|
276
|
+
self.registry.register_message_handler(func, self._parse_chat(chat))
|
|
248
277
|
return func
|
|
249
278
|
return decorator
|
|
250
279
|
|
|
251
|
-
def
|
|
280
|
+
def on_command(self, command: str, chat: str | list[str] | set[str] | None = None) -> Any:
|
|
281
|
+
"""Decorator for command handlers."""
|
|
252
282
|
cmd = command.lstrip("/")
|
|
253
|
-
def decorator(func):
|
|
254
|
-
self.
|
|
283
|
+
def decorator(func: Any) -> Any:
|
|
284
|
+
self.registry.register_command_handler(cmd, func, self._parse_chat(chat))
|
|
255
285
|
return func
|
|
256
286
|
return decorator
|
|
257
287
|
|
|
258
|
-
def
|
|
259
|
-
|
|
260
|
-
|
|
288
|
+
def on_callback(self, chat: str | list[str] | set[str] | None = None) -> Any:
|
|
289
|
+
"""Decorator for callback (inline keyboard) handlers."""
|
|
290
|
+
def decorator(func: Any) -> Any:
|
|
291
|
+
self.registry.register_callback_handler(func, self._parse_chat(chat))
|
|
261
292
|
return func
|
|
262
293
|
return decorator
|
|
263
294
|
|
|
264
|
-
def
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
295
|
+
def on_api_req(
|
|
296
|
+
self,
|
|
297
|
+
endpoint: str,
|
|
298
|
+
args: list[str] | None = None,
|
|
299
|
+
host: str | None = None,
|
|
300
|
+
port: int | None = None,
|
|
301
|
+
) -> Any:
|
|
302
|
+
"""Decorator for custom API POST endpoints. Starts the API server if needed."""
|
|
303
|
+
if args is None:
|
|
304
|
+
args = []
|
|
305
|
+
def decorator(func: Any) -> Any:
|
|
306
|
+
self.registry.register_api_route(endpoint, args, func)
|
|
307
|
+
if not self.api_server:
|
|
308
|
+
h = host or self.config.api_host
|
|
309
|
+
p = port or self.config.api_port
|
|
310
|
+
self.api_server = ApiServer(self.logger, self.registry, host=h, port=p)
|
|
311
|
+
self.api_server.start()
|
|
272
312
|
return func
|
|
273
313
|
return decorator
|
|
274
314
|
|
|
275
|
-
def
|
|
315
|
+
def send_msg(self, text: str, chat: str | list[str] | set[str] | None = None) -> None:
|
|
276
316
|
self._send(self.application.bot.send_message, {"text": text}, chat)
|
|
277
317
|
|
|
278
|
-
def
|
|
279
|
-
self
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def
|
|
291
|
-
|
|
318
|
+
def reply_to_msg(
|
|
319
|
+
self,
|
|
320
|
+
text: str,
|
|
321
|
+
msg_id: int,
|
|
322
|
+
chat: str | list[str] | set[str] | None = None,
|
|
323
|
+
) -> None:
|
|
324
|
+
self._send(
|
|
325
|
+
self.application.bot.send_message,
|
|
326
|
+
{"text": text, "reply_to_message_id": msg_id},
|
|
327
|
+
chat,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def send_img(
|
|
331
|
+
self,
|
|
332
|
+
photo_path: str,
|
|
333
|
+
caption: str | None = None,
|
|
334
|
+
chat: str | list[str] | set[str] | None = None,
|
|
335
|
+
) -> None:
|
|
336
|
+
self._send(
|
|
337
|
+
self.application.bot.send_photo,
|
|
338
|
+
{"photo": photo_path, "caption": caption},
|
|
339
|
+
chat,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def send_file(
|
|
343
|
+
self,
|
|
344
|
+
file_path: str,
|
|
345
|
+
caption: str | None = None,
|
|
346
|
+
chat: str | list[str] | set[str] | None = None,
|
|
347
|
+
) -> None:
|
|
348
|
+
self._send(
|
|
349
|
+
self.application.bot.send_document,
|
|
350
|
+
{"document": file_path, "caption": caption},
|
|
351
|
+
chat,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def send_position(
|
|
355
|
+
self,
|
|
356
|
+
latitude: float,
|
|
357
|
+
longitude: float,
|
|
358
|
+
chat: str | list[str] | set[str] | None = None,
|
|
359
|
+
) -> None:
|
|
360
|
+
self._send(
|
|
361
|
+
self.application.bot.send_location,
|
|
362
|
+
{"latitude": latitude, "longitude": longitude},
|
|
363
|
+
chat,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def send_buttons(
|
|
367
|
+
self,
|
|
368
|
+
text: str,
|
|
369
|
+
buttons: list[list[dict[str, str]]],
|
|
370
|
+
chat: str | list[str] | set[str] | None = None,
|
|
371
|
+
) -> None:
|
|
372
|
+
kb = [
|
|
373
|
+
[InlineKeyboardButton(btn["text"], callback_data=btn["value"]) for btn in row]
|
|
374
|
+
for row in buttons
|
|
375
|
+
]
|
|
292
376
|
markup = InlineKeyboardMarkup(kb)
|
|
293
|
-
self._send(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
377
|
+
self._send(
|
|
378
|
+
self.application.bot.send_message,
|
|
379
|
+
{"text": text, "reply_markup": markup},
|
|
380
|
+
chat,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def _send(
|
|
384
|
+
self,
|
|
385
|
+
send_func: Any,
|
|
386
|
+
params: dict[str, Any],
|
|
387
|
+
chat: str | list[str] | set[str] | None,
|
|
388
|
+
) -> None:
|
|
389
|
+
if not self._running or not self.application or not self.loop:
|
|
390
|
+
raise RuntimeError("Bot is not connected. Call connect() first.")
|
|
391
|
+
chats = self._parse_chat(chat)
|
|
392
|
+
for chat_name in chats:
|
|
299
393
|
if chat_name not in self.chat_ids:
|
|
300
|
-
logger.error(
|
|
394
|
+
self.logger.error("Chat '%s' not found. Message not sent.", chat_name)
|
|
301
395
|
continue
|
|
302
396
|
params["chat_id"] = self.chat_ids[chat_name]
|
|
303
397
|
coro = send_func(**params)
|
|
304
|
-
asyncio.run_coroutine_threadsafe(coro, self.
|
|
305
|
-
|
|
306
|
-
def
|
|
398
|
+
asyncio.run_coroutine_threadsafe(coro, self.loop)
|
|
399
|
+
|
|
400
|
+
def send_log(
|
|
401
|
+
self,
|
|
402
|
+
limit: int | None = None,
|
|
403
|
+
chat: str | list[str] | set[str] | None = None,
|
|
404
|
+
) -> None:
|
|
405
|
+
"""Send the last `limit` lines of the log file, or the full log if limit is None."""
|
|
307
406
|
try:
|
|
308
|
-
with open(
|
|
407
|
+
with open(self.config.log_file, "r", encoding="utf-8") as f:
|
|
309
408
|
lines = f.readlines()
|
|
310
409
|
text = "".join(lines[-limit:]) if limit else "".join(lines)
|
|
311
|
-
except
|
|
312
|
-
text = f"
|
|
313
|
-
self.
|
|
410
|
+
except OSError as e:
|
|
411
|
+
text = f"Error reading log file: {e}"
|
|
412
|
+
self.send_msg(text, chat)
|
|
314
413
|
|
|
315
|
-
def
|
|
414
|
+
def send_info(self, chat: str | list[str] | set[str] | None = None) -> None:
|
|
415
|
+
"""Send bot info (chats, handlers, API server, log saving) to the given chat."""
|
|
316
416
|
info = "Bot Info:\n"
|
|
317
417
|
info += f"Chat IDs: {self.chat_ids}\n"
|
|
318
|
-
info += f"onMessage handlers: {len(self.
|
|
319
|
-
info +=
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
info +=
|
|
323
|
-
info += f"
|
|
324
|
-
self.
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
418
|
+
info += f"onMessage handlers: {len(self.registry.message_handlers)}\n"
|
|
419
|
+
info += "onCommand handlers: {\n"
|
|
420
|
+
for cmd, funcs in self.registry.command_handlers.items():
|
|
421
|
+
info += f" /{cmd} : {len(funcs)} handlers\n"
|
|
422
|
+
info += "}\n"
|
|
423
|
+
info += f"onCallback handlers: {len(self.registry.callback_handlers)}\n"
|
|
424
|
+
info += f"API Routes: {list(self.registry.api_routes.keys())}\n"
|
|
425
|
+
if self.api_server:
|
|
426
|
+
info += f"API Server: {self.api_server.host}:{self.api_server.port}\n"
|
|
427
|
+
else:
|
|
428
|
+
info += "API Server: Not started\n"
|
|
429
|
+
info += f"Log saving: {self.config.save_log}\n"
|
|
430
|
+
self.send_msg(info, chat)
|
|
431
|
+
|
|
432
|
+
def send_registered_handlers(
|
|
433
|
+
self,
|
|
434
|
+
chat: str | list[str] | set[str] | None = None,
|
|
435
|
+
) -> None:
|
|
436
|
+
"""Send a list of registered handlers for debugging."""
|
|
437
|
+
txt = "Registered handlers:\n\nonMessage:\n"
|
|
438
|
+
for chats, func in self.registry.message_handlers:
|
|
439
|
+
txt += f" - {func.__name__} on {chats}\n"
|
|
330
440
|
txt += "\nonCommand:\n"
|
|
331
|
-
for cmd, lst in self.
|
|
441
|
+
for cmd, lst in self.registry.command_handlers.items():
|
|
332
442
|
for chats, func in lst:
|
|
333
|
-
txt += f" - /{cmd} -> {func.__name__}
|
|
443
|
+
txt += f" - /{cmd} -> {func.__name__} on {chats}\n"
|
|
334
444
|
txt += "\nonCallback:\n"
|
|
335
|
-
for chats, func in self.
|
|
336
|
-
txt += f" - {func.__name__}
|
|
445
|
+
for chats, func in self.registry.callback_handlers:
|
|
446
|
+
txt += f" - {func.__name__} on {chats}\n"
|
|
337
447
|
txt += "\nAPI Routes:\n"
|
|
338
|
-
for route in self.
|
|
448
|
+
for route in self.registry.api_routes:
|
|
339
449
|
txt += f" - {route}\n"
|
|
340
|
-
self.
|
|
450
|
+
self.send_msg(txt, chat)
|
|
341
451
|
|
|
342
|
-
def
|
|
343
|
-
if chat is None:
|
|
344
|
-
return {self.default_chat_name}
|
|
345
|
-
elif isinstance(chat, str):
|
|
346
|
-
return {chat}
|
|
347
|
-
elif isinstance(chat, (list, tuple, set)):
|
|
348
|
-
return set(chat)
|
|
349
|
-
else:
|
|
350
|
-
raise ValueError(f"Invalid parameter: {chat}")
|
|
351
|
-
|
|
352
|
-
def _get_chat_name_by_id(self, chat_id):
|
|
452
|
+
def _get_chat_name_by_id(self, chat_id: str) -> str | None:
|
|
353
453
|
for name, cid in self.chat_ids.items():
|
|
354
454
|
if cid == chat_id:
|
|
355
455
|
return name
|
|
356
456
|
return None
|
|
357
457
|
|
|
358
|
-
def
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
self.
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
self.
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
self.
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
458
|
+
def _parse_chat(
|
|
459
|
+
self,
|
|
460
|
+
chat: str | list[str] | set[str] | None,
|
|
461
|
+
) -> set[str]:
|
|
462
|
+
if chat is None:
|
|
463
|
+
return {self.default_chat_name} if self.default_chat_name else set()
|
|
464
|
+
if isinstance(chat, str):
|
|
465
|
+
return {chat}
|
|
466
|
+
if isinstance(chat, (list, tuple, set)):
|
|
467
|
+
return set(chat)
|
|
468
|
+
raise ValueError(f"Invalid chat parameter: {chat}")
|
|
469
|
+
|
|
470
|
+
# Aliases for backward-friendly API (camelCase)
|
|
471
|
+
def setSaveLog(self, flag: bool) -> None:
|
|
472
|
+
self.set_save_log(flag)
|
|
473
|
+
|
|
474
|
+
def setHost(self, host: str) -> None:
|
|
475
|
+
self.set_host(host)
|
|
476
|
+
|
|
477
|
+
def setPort(self, port: int) -> None:
|
|
478
|
+
self.set_port(port)
|
|
479
|
+
|
|
480
|
+
def onMessage(self, chat: str | list[str] | set[str] | None = None) -> Any:
|
|
481
|
+
return self.on_message(chat)
|
|
482
|
+
|
|
483
|
+
def onCommand(self, command: str, chat: str | list[str] | set[str] | None = None) -> Any:
|
|
484
|
+
return self.on_command(command, chat)
|
|
485
|
+
|
|
486
|
+
def onCallback(self, chat: str | list[str] | set[str] | None = None) -> Any:
|
|
487
|
+
return self.on_callback(chat)
|
|
488
|
+
|
|
489
|
+
def onApiReq(
|
|
490
|
+
self,
|
|
491
|
+
endpoint: str,
|
|
492
|
+
args: list[str] | None = None,
|
|
493
|
+
host: str | None = None,
|
|
494
|
+
port: int | None = None,
|
|
495
|
+
) -> Any:
|
|
496
|
+
return self.on_api_req(endpoint, args, host, port)
|
|
497
|
+
|
|
498
|
+
def sendMsg(self, text: str, chat: str | list[str] | set[str] | None = None) -> None:
|
|
499
|
+
self.send_msg(text, chat)
|
|
500
|
+
|
|
501
|
+
def replyToMsg(
|
|
502
|
+
self,
|
|
503
|
+
text: str,
|
|
504
|
+
msg_id: int,
|
|
505
|
+
chat: str | list[str] | set[str] | None = None,
|
|
506
|
+
) -> None:
|
|
507
|
+
self.reply_to_msg(text, msg_id, chat)
|
|
508
|
+
|
|
509
|
+
def sendImg(
|
|
510
|
+
self,
|
|
511
|
+
photo_path: str,
|
|
512
|
+
caption: str | None = None,
|
|
513
|
+
chat: str | list[str] | set[str] | None = None,
|
|
514
|
+
) -> None:
|
|
515
|
+
self.send_img(photo_path, caption, chat)
|
|
516
|
+
|
|
517
|
+
def sendFile(
|
|
518
|
+
self,
|
|
519
|
+
file_path: str,
|
|
520
|
+
caption: str | None = None,
|
|
521
|
+
chat: str | list[str] | set[str] | None = None,
|
|
522
|
+
) -> None:
|
|
523
|
+
self.send_file(file_path, caption, chat)
|
|
524
|
+
|
|
525
|
+
def sendPosition(
|
|
526
|
+
self,
|
|
527
|
+
latitude: float,
|
|
528
|
+
longitude: float,
|
|
529
|
+
chat: str | list[str] | set[str] | None = None,
|
|
530
|
+
) -> None:
|
|
531
|
+
self.send_position(latitude, longitude, chat)
|
|
532
|
+
|
|
533
|
+
def sendButtons(
|
|
534
|
+
self,
|
|
535
|
+
text: str,
|
|
536
|
+
buttons: list[list[dict[str, str]]],
|
|
537
|
+
chat: str | list[str] | set[str] | None = None,
|
|
538
|
+
) -> None:
|
|
539
|
+
self.send_buttons(text, buttons, chat)
|
|
540
|
+
|
|
541
|
+
def sendLog(
|
|
542
|
+
self,
|
|
543
|
+
limit: int | None = None,
|
|
544
|
+
chat: str | list[str] | set[str] | None = None,
|
|
545
|
+
) -> None:
|
|
546
|
+
self.send_log(limit, chat)
|
|
547
|
+
|
|
548
|
+
def sendInfo(self, chat: str | list[str] | set[str] | None = None) -> None:
|
|
549
|
+
self.send_info(chat)
|
|
550
|
+
|
|
551
|
+
def sendRegisteredHandlers(
|
|
552
|
+
self,
|
|
553
|
+
chat: str | list[str] | set[str] | None = None,
|
|
554
|
+
) -> None:
|
|
555
|
+
self.send_registered_handlers(chat)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
__all__ = ["TEL", "__version__"]
|