Rubka 5.0.0__tar.gz → 5.2.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.
Files changed (44) hide show
  1. {rubka-5.0.0 → rubka-5.2.0}/PKG-INFO +1 -1
  2. {rubka-5.0.0 → rubka-5.2.0}/Rubka.egg-info/PKG-INFO +1 -1
  3. {rubka-5.0.0 → rubka-5.2.0}/rubka/api.py +232 -65
  4. {rubka-5.0.0 → rubka-5.2.0}/rubka/asynco.py +596 -25
  5. {rubka-5.0.0 → rubka-5.2.0}/setup.py +1 -1
  6. {rubka-5.0.0 → rubka-5.2.0}/README.md +0 -0
  7. {rubka-5.0.0 → rubka-5.2.0}/Rubka.egg-info/SOURCES.txt +0 -0
  8. {rubka-5.0.0 → rubka-5.2.0}/Rubka.egg-info/dependency_links.txt +0 -0
  9. {rubka-5.0.0 → rubka-5.2.0}/Rubka.egg-info/requires.txt +0 -0
  10. {rubka-5.0.0 → rubka-5.2.0}/Rubka.egg-info/top_level.txt +0 -0
  11. {rubka-5.0.0 → rubka-5.2.0}/rubka/__init__.py +0 -0
  12. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/__init__.py +0 -0
  13. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/client/__init__.py +0 -0
  14. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/client/client.py +0 -0
  15. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/crypto/__init__.py +0 -0
  16. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/crypto/crypto.py +0 -0
  17. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/enums.py +0 -0
  18. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/exceptions.py +0 -0
  19. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/methods/__init__.py +0 -0
  20. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/methods/methods.py +0 -0
  21. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/network/__init__.py +0 -0
  22. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/network/helper.py +0 -0
  23. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/network/network.py +0 -0
  24. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/network/socket.py +0 -0
  25. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/sessions/__init__.py +0 -0
  26. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/sessions/sessions.py +0 -0
  27. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/types/__init__.py +0 -0
  28. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/types/socket/__init__.py +0 -0
  29. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/types/socket/message.py +0 -0
  30. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/utils/__init__.py +0 -0
  31. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/utils/configs.py +0 -0
  32. {rubka-5.0.0 → rubka-5.2.0}/rubka/adaptorrubka/utils/utils.py +0 -0
  33. {rubka-5.0.0 → rubka-5.2.0}/rubka/button.py +0 -0
  34. {rubka-5.0.0 → rubka-5.2.0}/rubka/config.py +0 -0
  35. {rubka-5.0.0 → rubka-5.2.0}/rubka/context.py +0 -0
  36. {rubka-5.0.0 → rubka-5.2.0}/rubka/decorators.py +0 -0
  37. {rubka-5.0.0 → rubka-5.2.0}/rubka/exceptions.py +0 -0
  38. {rubka-5.0.0 → rubka-5.2.0}/rubka/jobs.py +0 -0
  39. {rubka-5.0.0 → rubka-5.2.0}/rubka/keyboards.py +0 -0
  40. {rubka-5.0.0 → rubka-5.2.0}/rubka/keypad.py +0 -0
  41. {rubka-5.0.0 → rubka-5.2.0}/rubka/logger.py +0 -0
  42. {rubka-5.0.0 → rubka-5.2.0}/rubka/rubino.py +0 -0
  43. {rubka-5.0.0 → rubka-5.2.0}/rubka/utils.py +0 -0
  44. {rubka-5.0.0 → rubka-5.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Rubka
3
- Version: 5.0.0
3
+ Version: 5.2.0
4
4
  Summary: A Python library for interacting with Rubika Bot API.
5
5
  Home-page: https://github.com/Mahdy-Ahmadi/Rubka
6
6
  Download-URL: https://github.com/Mahdy-Ahmadi/rubka/blob/main/project_library.zip
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Rubka
3
- Version: 5.0.0
3
+ Version: 5.2.0
4
4
  Summary: A Python library for interacting with Rubika Bot API.
5
5
  Home-page: https://github.com/Mahdy-Ahmadi/Rubka
6
6
  Download-URL: https://github.com/Mahdy-Ahmadi/rubka/blob/main/project_library.zip
@@ -17,6 +17,7 @@ import mimetypes
17
17
  import re
18
18
  import sys
19
19
  import subprocess
20
+ class InvalidTokenError(Exception):pass
20
21
  def install_package(package_name):
21
22
  try:
22
23
  subprocess.check_call([sys.executable, "-m", "pip", "install", package_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
@@ -109,6 +110,7 @@ class Robot:
109
110
  self.show_progress = show_progress
110
111
  self.session_name = session_name
111
112
  self.Key = Key
113
+
112
114
  self.platform = platform
113
115
  self.web_hook = web_hook
114
116
  self.hook = web_hook
@@ -121,6 +123,7 @@ class Robot:
121
123
  self._message_handlers: List[dict] = []
122
124
  self._callback_handlers = None
123
125
  self._callback_handlers = []
126
+ self.geteToken()
124
127
  if web_hook:
125
128
  try:
126
129
  json_url = requests.get(web_hook, timeout=self.timeout).json().get('url', web_hook)
@@ -141,7 +144,6 @@ class Robot:
141
144
 
142
145
 
143
146
  logger.info(f"Initialized RubikaBot with token: {token[:8]}***")
144
-
145
147
  def _post(self, method: str, data: Dict[str, Any]) -> Dict[str, Any]:
146
148
  url = f"{API_URL}/{self.token}/{method}"
147
149
  try:
@@ -163,6 +165,11 @@ class Robot:
163
165
  def get_me(self) -> Dict[str, Any]:
164
166
  """Get info about the bot itself."""
165
167
  return self._post("getMe", {})
168
+ def geteToken(self):
169
+ """b"""
170
+ if self.get_me()['status'] != "OK":
171
+ raise InvalidTokenError("The provided bot token is invalid or expired.")
172
+
166
173
  def on_message(self, filters: Optional[Callable[[Message], bool]] = None, commands: Optional[List[str]] = None):
167
174
  def decorator(func: Callable[[Any, Message], None]):
168
175
  self._message_handlers.append({
@@ -172,6 +179,15 @@ class Robot:
172
179
  })
173
180
  return func
174
181
  return decorator
182
+ def message_handler(self, filters: Optional[Callable[[Message], bool]] = None, commands: Optional[List[str]] = None):
183
+ def decorator(func: Callable[[Any, Message], None]):
184
+ self._message_handlers.append({
185
+ "func": func,
186
+ "filters": filters,
187
+ "commands": commands
188
+ })
189
+ return func
190
+ return decorator
175
191
  def on_update(self, filters: Optional[Callable[[Message], bool]] = None, commands: Optional[List[str]] = None):
176
192
  def decorator(func: Callable[[Any, Message], None]):
177
193
  self._message_handlers.append({
@@ -183,6 +199,26 @@ class Robot:
183
199
  return decorator
184
200
 
185
201
  def on_callback(self, button_id: Optional[str] = None):
202
+ def decorator(func: Callable[[Any, Message], None]):
203
+ if not hasattr(self, "_callback_handlers"):
204
+ self._callback_handlers = []
205
+ self._callback_handlers.append({
206
+ "func": func,
207
+ "button_id": button_id
208
+ })
209
+ return func
210
+ return decorator
211
+ def callback_query(self, button_id: Optional[str] = None):
212
+ def decorator(func: Callable[[Any, Message], None]):
213
+ if not hasattr(self, "_callback_handlers"):
214
+ self._callback_handlers = []
215
+ self._callback_handlers.append({
216
+ "func": func,
217
+ "button_id": button_id
218
+ })
219
+ return func
220
+ return decorator
221
+ def callback_query_handler(self, button_id: Optional[str] = None):
186
222
  def decorator(func: Callable[[Any, Message], None]):
187
223
  if not hasattr(self, "_callback_handlers"):
188
224
  self._callback_handlers = []
@@ -218,6 +254,15 @@ class Robot:
218
254
  if update.get("type") == "ReceiveQuery":
219
255
  msg = update.get("inline_message", {})
220
256
  context = InlineMessage(bot=self, raw_data=msg)
257
+
258
+
259
+ if hasattr(self, "_callback_handlers"):
260
+ for handler in self._callback_handlers:
261
+ cb_id = getattr(context.aux_data, "button_id", None)
262
+ if not handler["button_id"] or handler["button_id"] == cb_id:
263
+ threading.Thread(target=handler["func"], args=(self, context), daemon=True).start()
264
+
265
+
221
266
  threading.Thread(target=self._handle_inline_query, args=(context,), daemon=True).start()
222
267
  return
223
268
 
@@ -299,83 +344,173 @@ class Robot:
299
344
  import time
300
345
 
301
346
 
302
- def run(self):
303
- print("Bot started running...")
304
- self._processed_message_ids: Dict[str, float] = {}
305
-
306
- while True:
307
- try:
308
- if self.web_hook:
309
- updates = self.update_webhook()
310
-
311
- if isinstance(updates, list):
312
- for item in updates:
313
- data = item.get("data", {})
314
-
315
- received_at_str = item.get("received_at")
316
- if received_at_str:
317
- received_at_ts = datetime.datetime.strptime(received_at_str, "%Y-%m-%d %H:%M:%S").timestamp()
318
- if time.time() - received_at_ts > 20:
319
- continue
320
-
321
- update = None
322
- if "update" in data:
323
- update = data["update"]
324
- elif "inline_message" in data:
325
- update = {
326
- "type": "ReceiveQuery",
327
- "inline_message": data["inline_message"]
328
- }
329
- else:
330
- continue
347
+ import datetime
331
348
 
332
- message_id = None
333
- if update.get("type") == "NewMessage":
334
- message_id = update.get("new_message", {}).get("message_id")
335
- elif update.get("type") == "ReceiveQuery":
336
- message_id = update.get("inline_message", {}).get("message_id")
337
- elif "message_id" in update:
338
- message_id = update.get("message_id")
339
349
 
340
- if message_id is not None:
341
- message_id = str(message_id)
350
+ def run(
351
+ self,
352
+ debug=False,
353
+ sleep_time=0.1,
354
+ webhook_timeout=20,
355
+ update_limit=100,
356
+ retry_delay=5,
357
+ stop_on_error=False,
358
+ max_errors=None,
359
+ max_runtime=None,
360
+ allowed_update_types=None,
361
+ ignore_duplicate_messages=True,
362
+ skip_inline_queries=False,
363
+ skip_channel_posts=False,
364
+ skip_service_messages=False,
365
+ skip_edited_messages=False,
366
+ skip_bot_messages=False,
367
+ log_file=None,
368
+ print_exceptions=True,
369
+ error_handler=None,
370
+ shutdown_hook=None,
371
+ log_to_console=True,
372
+ custom_update_fetcher=None,
373
+ custom_update_processor=None,
374
+ message_filter=None,
375
+ notify_on_error=False,
376
+ notification_handler=None,
377
+ ):
378
+ import time
379
+ from typing import Dict
380
+ if debug:
381
+ print("[DEBUG] Bot started running server...")
342
382
 
343
- if message_id and self._is_duplicate(received_at_str):
344
- continue
383
+ self._processed_message_ids: Dict[str, float] = {}
384
+ error_count = 0
385
+ start_time = time.time()
345
386
 
346
- self._process_update(update)
387
+ try:
388
+ while True:
389
+ try:
390
+
391
+ if max_runtime and (time.time() - start_time > max_runtime):
392
+ if debug:
393
+ print("[DEBUG] Max runtime reached, stopping...")
394
+ break
395
+
396
+
397
+ if self.web_hook:
398
+ updates = custom_update_fetcher() if custom_update_fetcher else self.update_webhook()
399
+ if isinstance(updates, list):
400
+ for item in updates:
401
+ data = item.get("data", {})
402
+ received_at_str = item.get("received_at")
403
+
404
+ if received_at_str:
405
+ try:
406
+ received_at_ts = datetime.datetime.strptime(received_at_str, "%Y-%m-%d %H:%M:%S").timestamp()
407
+ if time.time() - received_at_ts > webhook_timeout:
408
+ continue
409
+ except (ValueError, TypeError):
410
+ pass
411
+
412
+ update = data.get("update") or (
413
+ {"type": "ReceiveQuery", "inline_message": data.get("inline_message")}
414
+ if "inline_message" in data else None
415
+ )
416
+ if not update:
417
+ continue
347
418
 
348
- if message_id:
349
- self._processed_message_ids[message_id] = time.time()
419
+
420
+ if skip_inline_queries and update.get("type") == "ReceiveQuery":
421
+ continue
422
+ if skip_channel_posts and update.get("type") == "ChannelPost":
423
+ continue
424
+ if skip_service_messages and update.get("type") == "ServiceMessage":
425
+ continue
426
+ if skip_edited_messages and update.get("type") == "EditedMessage":
427
+ continue
428
+ if skip_bot_messages and update.get("from", {}).get("is_bot"):
429
+ continue
430
+ if allowed_update_types and update.get("type") not in allowed_update_types:
431
+ continue
350
432
 
351
- else:
352
- updates = self.get_updates(offset_id=self._offset_id, limit=100)
433
+ message_id = (
434
+ update.get("new_message", {}).get("message_id")
435
+ if update.get("type") == "NewMessage"
436
+ else update.get("inline_message", {}).get("message_id")
437
+ if update.get("type") == "ReceiveQuery"
438
+ else update.get("message_id")
439
+ )
440
+
441
+ if message_id is not None:
442
+ message_id = str(message_id)
443
+
444
+ if message_id and (not ignore_duplicate_messages or not self._is_duplicate(received_at_str)):
445
+ if message_filter and not message_filter(update):
446
+ continue
447
+ if custom_update_processor:
448
+ custom_update_processor(update)
449
+ else:
450
+ self._process_update(update)
451
+ if message_id:
452
+ self._processed_message_ids[message_id] = time.time()
453
+
454
+
455
+ else:
456
+ updates = custom_update_fetcher() if custom_update_fetcher else self.get_updates(offset_id=self._offset_id, limit=update_limit)
457
+ if updates and updates.get("data"):
458
+ for update in updates["data"].get("updates", []):
459
+ if allowed_update_types and update.get("type") not in allowed_update_types:
460
+ continue
353
461
 
354
- if updates and updates.get("data"):
355
- for update in updates["data"].get("updates", []):
356
- message_id = None
357
- if update.get("type") == "NewMessage":
358
- message_id = update.get("new_message", {}).get("message_id")
359
- elif update.get("type") == "ReceiveQuery":
360
- message_id = update.get("inline_message", {}).get("message_id")
361
- elif "message_id" in update:
362
- message_id = update.get("message_id")
462
+ message_id = (
463
+ update.get("new_message", {}).get("message_id")
464
+ if update.get("type") == "NewMessage"
465
+ else update.get("inline_message", {}).get("message_id")
466
+ if update.get("type") == "ReceiveQuery"
467
+ else update.get("message_id")
468
+ )
363
469
 
364
- if message_id is not None:
365
- message_id = str(message_id)
470
+ if message_id is not None:
471
+ message_id = str(message_id)
366
472
 
367
- if message_id and self._is_duplicate(message_id):
368
- continue
473
+ if message_id and (not ignore_duplicate_messages or not self._is_duplicate(message_id)):
474
+ if message_filter and not message_filter(update):
475
+ continue
476
+ if custom_update_processor:
477
+ custom_update_processor(update)
478
+ else:
479
+ self._process_update(update)
480
+ if message_id:
481
+ self._processed_message_ids[message_id] = time.time()
369
482
 
370
- self._process_update(update)
483
+ self._offset_id = updates["data"].get("next_offset_id", self._offset_id)
371
484
 
372
- if message_id:
373
- self._processed_message_ids[message_id] = time.time()
485
+ if sleep_time:
486
+ time.sleep(sleep_time)
374
487
 
375
- self._offset_id = updates["data"].get("next_offset_id", self._offset_id)
488
+ except Exception as e:
489
+ error_count += 1
490
+ if log_to_console:
491
+ print(f"Error in run loop: {e}")
492
+ if log_file:
493
+ with open(log_file, "a", encoding="utf-8") as f:
494
+ f.write(f"{datetime.datetime.now()} - ERROR: {e}\n")
495
+ if print_exceptions:
496
+ import traceback
497
+ traceback.print_exc()
498
+ if error_handler:
499
+ error_handler(e)
500
+ if notify_on_error and notification_handler:
501
+ notification_handler(e)
502
+
503
+ if max_errors and error_count >= max_errors and stop_on_error:
504
+ break
505
+
506
+ time.sleep(retry_delay)
507
+
508
+ finally:
509
+ if shutdown_hook:
510
+ shutdown_hook()
511
+ if debug:
512
+ print("Bot stopped and session closed.")
376
513
 
377
- except Exception as e:
378
- print(f"❌ Error in run loop: {e}")
379
514
  def send_message(
380
515
  self,
381
516
  chat_id: str,
@@ -627,6 +762,38 @@ class Robot:
627
762
  data = response.json()
628
763
  return data.get('data', {}).get('file_id')
629
764
 
765
+ def send_button_join(self, chat_id, title_button, username, text, reply_to_message_id=None, id="None"):
766
+ from .button import InlineBuilder
767
+ return self.send_message(
768
+ chat_id=chat_id,
769
+ text=text,
770
+ inline_keypad=InlineBuilder()
771
+ .row(
772
+ InlineBuilder()
773
+ .button_join_channel(
774
+ text=title_button,
775
+ id=id,
776
+ username=username
777
+ )
778
+ ).build(),
779
+ reply_to_message_id=reply_to_message_id
780
+ )
781
+ def send_button_url(self, chat_id, title_button, url, text, reply_to_message_id=None, id="None"):
782
+ from .button import InlineBuilder
783
+ return self.send_message(
784
+ chat_id=chat_id,
785
+ text=text,
786
+ inline_keypad=InlineBuilder()
787
+ .row(
788
+ InlineBuilder()
789
+ .button_url_link(
790
+ text=title_button,
791
+ id=id,
792
+ url=url
793
+ )
794
+ ).build(),
795
+ reply_to_message_id=reply_to_message_id
796
+ )
630
797
 
631
798
  def get_upload_url(self, media_type: Literal['File', 'Image', 'Voice', 'Music', 'Gif','Video']) -> str:
632
799
  allowed = ['File', 'Image', 'Voice', 'Music', 'Gif','Video']
@@ -12,7 +12,7 @@ except (ImportError, ModuleNotFoundError):
12
12
 
13
13
  from tqdm.asyncio import tqdm
14
14
  from urllib.parse import urlparse, parse_qs
15
-
15
+ class InvalidTokenError(Exception):pass
16
16
  import mimetypes
17
17
  from pathlib import Path
18
18
  import time
@@ -121,12 +121,12 @@ class Robot:
121
121
  self._callback_handler = None
122
122
  self._message_handler = None
123
123
  self._inline_query_handler = None
124
+
124
125
  self._callback_handlers: List[dict] = []
125
126
  self._processed_message_ids: Dict[str, float] = {}
126
- self._message_handlers: List[dict] = []
127
+ self._message_handlers: List[dict] = []
127
128
 
128
129
  logger.info(f"Initialized RubikaBot with token: {token[:8]}***")
129
-
130
130
  async def _get_session(self) -> aiohttp.ClientSession:
131
131
  """Lazily creates and returns the aiohttp session."""
132
132
  if self._aiohttp_session is None or self._aiohttp_session.closed:
@@ -186,7 +186,10 @@ class Robot:
186
186
  async def get_me(self) -> Dict[str, Any]:
187
187
  """Get info about the bot itself."""
188
188
  return await self._post("getMe", {})
189
-
189
+ async def geteToken(self):
190
+ """Check if the bot token is valid."""
191
+ if (await self.get_me())['status'] != "OK":
192
+ raise InvalidTokenError("The provided bot token is invalid or expired.")
190
193
  def on_message(self, filters: Optional[Callable[[Message], bool]] = None, commands: Optional[List[str]] = None):
191
194
  def decorator(func: Callable[[Any, Message], None]):
192
195
  self._message_handlers.append({
@@ -204,9 +207,29 @@ class Robot:
204
207
  "commands": commands
205
208
  })
206
209
  return func
207
- return decorator
210
+ return decorator
208
211
 
209
212
  def on_callback(self, button_id: Optional[str] = None):
213
+ def decorator(func: Callable[[Any, Union[Message, InlineMessage]], None]):
214
+ if not hasattr(self, "_callback_handlers"):
215
+ self._callback_handlers = []
216
+ self._callback_handlers.append({
217
+ "func": func,
218
+ "button_id": button_id
219
+ })
220
+ return func
221
+ return decorator
222
+ def callback_query_handler(self, button_id: Optional[str] = None):
223
+ def decorator(func: Callable[[Any, Message], None]):
224
+ if not hasattr(self, "_callback_handlers"):
225
+ self._callback_handlers = []
226
+ self._callback_handlers.append({
227
+ "func": func,
228
+ "button_id": button_id
229
+ })
230
+ return func
231
+ return decorator
232
+ def callback_query(self, button_id: Optional[str] = None):
210
233
  def decorator(func: Callable[[Any, Message], None]):
211
234
  if not hasattr(self, "_callback_handlers"):
212
235
  self._callback_handlers = []
@@ -272,6 +295,14 @@ class Robot:
272
295
  if update.get("type") == "ReceiveQuery":
273
296
  msg = update.get("inline_message", {})
274
297
  context = InlineMessage(bot=self, raw_data=msg)
298
+
299
+
300
+ if hasattr(self, "_callback_handlers"):
301
+ for handler in self._callback_handlers:
302
+ if not handler["button_id"] or getattr(context.aux_data, "button_id", None) == handler["button_id"]:
303
+ asyncio.create_task(handler["func"](self, context))
304
+
305
+
275
306
  asyncio.create_task(self._handle_inline_query(context))
276
307
  return
277
308
 
@@ -354,22 +385,389 @@ class Robot:
354
385
  return False
355
386
 
356
387
 
357
- async def run(self):
358
- """
359
- Starts the bot.
360
- This method is now corrected to handle webhook updates similarly to the original synchronous code.
388
+
389
+
390
+ async def run(
391
+ self,
392
+ debug: bool = False,
393
+ sleep_time: float = 0.2,
394
+ webhook_timeout: int = 20,
395
+ update_limit: int = 100,
396
+ retry_delay: float = 5.0,
397
+ stop_on_error: bool = False,
398
+ max_errors: int = 0,
399
+ auto_restart: bool = False,
400
+ max_runtime: float | None = None,
401
+ loop_forever: bool = True,
402
+ allowed_update_types: list[str] | None = None,
403
+ ignore_duplicate_messages: bool = True,
404
+ skip_inline_queries: bool = False,
405
+ skip_channel_posts: bool = False,
406
+ skip_service_messages: bool = False,
407
+ skip_edited_messages: bool = False,
408
+ skip_bot_messages: bool = False,
409
+ log_file: str | None = None,
410
+ log_level: str = "info",
411
+ print_exceptions: bool = True,
412
+ error_handler=None,
413
+ shutdown_hook=None,
414
+ save_unprocessed_updates: bool = False,
415
+ log_to_console: bool = True,
416
+ rate_limit: float | None = None,
417
+ max_message_size: int | None = None,
418
+ ignore_users: set[str] | None = None,
419
+ ignore_groups: set[str] | None = None,
420
+ require_auth_token: bool = False,
421
+ only_private_chats: bool = False,
422
+ only_groups: bool = False,
423
+ require_admin_rights: bool = False,
424
+ custom_update_fetcher=None,
425
+ custom_update_processor=None,
426
+ process_in_background: bool = False,
427
+ max_queue_size: int = 1000,
428
+ thread_workers: int = 3,
429
+ message_filter=None,
430
+ pause_on_idle: bool = False,
431
+ max_concurrent_tasks: int | None = None,
432
+ metrics_enabled: bool = False,
433
+ metrics_handler=None,
434
+ notify_on_error: bool = False,
435
+ notification_handler=None,
436
+ watchdog_timeout: float | None = None,
437
+ ):
361
438
  """
439
+ Starts the bot's main execution loop with extensive configuration options.
440
+
441
+ This function handles:
442
+ - Update fetching and processing with optional filters for types and sources.
443
+ - Error handling, retry mechanisms, and automatic restart options.
444
+ - Logging to console and/or files with configurable log levels.
445
+ - Message filtering based on users, groups, chat types, admin rights, and more.
446
+ - Custom update fetchers and processors for advanced use cases.
447
+ - Background processing with threading and task concurrency controls.
448
+ - Metrics collection and error notifications.
449
+ - Optional runtime limits, sleep delays, rate limiting, and watchdog monitoring.
450
+
451
+ Parameters
452
+ ----------
453
+ debug : bool
454
+ Enable debug mode for detailed logging and runtime checks.
455
+ sleep_time : float
456
+ Delay between update fetch cycles (seconds).
457
+ webhook_timeout : int
458
+ Timeout for webhook requests (seconds).
459
+ update_limit : int
460
+ Maximum updates to fetch per request.
461
+ retry_delay : float
462
+ Delay before retrying after failure (seconds).
463
+ stop_on_error : bool
464
+ Stop bot on unhandled errors.
465
+ max_errors : int
466
+ Maximum consecutive errors before stopping (0 = unlimited).
467
+ auto_restart : bool
468
+ Automatically restart the bot if it stops unexpectedly.
469
+ max_runtime : float | None
470
+ Maximum runtime in seconds before stopping.
471
+ loop_forever : bool
472
+ Keep the bot running continuously.
473
+
474
+ allowed_update_types : list[str] | None
475
+ Limit processing to specific update types.
476
+ ignore_duplicate_messages : bool
477
+ Skip identical messages.
478
+ skip_inline_queries : bool
479
+ Ignore inline query updates.
480
+ skip_channel_posts : bool
481
+ Ignore channel post updates.
482
+ skip_service_messages : bool
483
+ Ignore service messages.
484
+ skip_edited_messages : bool
485
+ Ignore edited messages.
486
+ skip_bot_messages : bool
487
+ Ignore messages from other bots.
488
+
489
+ log_file : str | None
490
+ File path for logging.
491
+ log_level : str
492
+ Logging level (debug, info, warning, error).
493
+ print_exceptions : bool
494
+ Print exceptions to console.
495
+ error_handler : callable | None
496
+ Custom function to handle errors.
497
+ shutdown_hook : callable | None
498
+ Function to execute on shutdown.
499
+ save_unprocessed_updates : bool
500
+ Save updates that failed processing.
501
+ log_to_console : bool
502
+ Enable/disable console logging.
503
+
504
+ rate_limit : float | None
505
+ Minimum delay between processing updates from the same user/group.
506
+ max_message_size : int | None
507
+ Maximum allowed message size.
508
+ ignore_users : set[str] | None
509
+ User IDs to ignore.
510
+ ignore_groups : set[str] | None
511
+ Group IDs to ignore.
512
+ require_auth_token : bool
513
+ Require users to provide authentication token.
514
+ only_private_chats : bool
515
+ Process only private chats.
516
+ only_groups : bool
517
+ Process only group chats.
518
+ require_admin_rights : bool
519
+ Process only if sender is admin.
520
+
521
+ custom_update_fetcher : callable | None
522
+ Custom update fetching function.
523
+ custom_update_processor : callable | None
524
+ Custom update processing function.
525
+ process_in_background : bool
526
+ Run processing in background threads.
527
+ max_queue_size : int
528
+ Maximum updates in processing queue.
529
+ thread_workers : int
530
+ Number of background worker threads.
531
+ message_filter : callable | None
532
+ Function to filter messages.
533
+ pause_on_idle : bool
534
+ Pause processing if idle.
535
+ max_concurrent_tasks : int | None
536
+ Maximum concurrent processing tasks.
537
+
538
+ metrics_enabled : bool
539
+ Enable metrics collection.
540
+ metrics_handler : callable | None
541
+ Function to handle metrics.
542
+ notify_on_error : bool
543
+ Send notifications on errors.
544
+ notification_handler : callable | None
545
+ Function to send error notifications.
546
+ watchdog_timeout : float | None
547
+ Maximum idle time before triggering watchdog restart.
548
+ """
549
+ import asyncio, time, datetime, traceback
550
+ from collections import deque
551
+ def _log(msg: str, level: str = "info"):
552
+ level_order = {"debug": 10, "info": 20, "warning": 30, "error": 40}
553
+ if level not in level_order:
554
+ level = "info"
555
+ if level_order[level] < level_order.get(log_level, 20):
556
+ return
557
+ line = f"[{level.upper()}] {datetime.datetime.now().isoformat()} - {msg}"
558
+ if log_to_console:
559
+ print(msg)
560
+ if log_file:
561
+ try:
562
+ with open(log_file, "a", encoding="utf-8") as f:
563
+ f.write(line + "\n")
564
+ except Exception:
565
+ pass
566
+
567
+ def _get_sender_and_chat(update: dict):
568
+
569
+ sender = None
570
+ chat = None
571
+ t = update.get("type")
572
+ if t == "NewMessage":
573
+ nm = update.get("new_message", {})
574
+ sender = nm.get("author_object_guid") or nm.get("author_guid") or nm.get("from_id")
575
+ chat = nm.get("object_guid") or nm.get("chat_id")
576
+ elif t == "ReceiveQuery":
577
+ im = update.get("inline_message", {})
578
+ sender = im.get("author_object_guid") or im.get("author_guid")
579
+ chat = im.get("object_guid") or im.get("chat_id")
580
+ else:
581
+ sender = update.get("author_guid") or update.get("from_id")
582
+ chat = update.get("object_guid") or update.get("chat_id")
583
+ return str(sender) if sender is not None else None, str(chat) if chat is not None else None
584
+
585
+ def _is_group_chat(chat_guid: str | None) -> bool | None:
586
+
587
+ if chat_guid is None:
588
+ return None
589
+ if hasattr(self, "_is_group_chat") and callable(getattr(self, "_is_group_chat")):
590
+ try:
591
+ return bool(self._is_group_chat(chat_guid))
592
+ except Exception:
593
+ return None
594
+ return None
595
+
596
+ async def _maybe_notify(err: Exception, context: dict):
597
+ if notify_on_error and notification_handler:
598
+ try:
599
+ if asyncio.iscoroutinefunction(notification_handler):
600
+ await notification_handler(err, context)
601
+ else:
602
+
603
+ notification_handler(err, context)
604
+ except Exception:
605
+ pass
606
+
607
+ async def _handle_error(err: Exception, context: dict):
608
+ if print_exceptions:
609
+ _log("Exception occurred:\n" + "".join(traceback.format_exception(type(err), err, err.__traceback__)), "error")
610
+ else:
611
+ _log(f"Exception occurred: {err}", "error")
612
+ await _maybe_notify(err, context)
613
+ if error_handler:
614
+ try:
615
+ if asyncio.iscoroutinefunction(error_handler):
616
+ await error_handler(err, context)
617
+ else:
618
+ error_handler(err, context)
619
+ except Exception as e2:
620
+ _log(f"Error in error_handler: {e2}", "error")
621
+
622
+
623
+ rate_window = deque()
624
+ def _rate_ok():
625
+ if rate_limit is None or rate_limit <= 0:
626
+ return True
627
+ now = time.time()
628
+
629
+ while rate_window and now - rate_window[0] > 1.0:
630
+ rate_window.popleft()
631
+ if len(rate_window) < int(rate_limit):
632
+ rate_window.append(now)
633
+ return True
634
+ return False
635
+
636
+
637
+ queue = asyncio.Queue(maxsize=max_queue_size) if process_in_background else None
638
+ active_workers = []
639
+
640
+ sem = asyncio.Semaphore(max_concurrent_tasks) if max_concurrent_tasks and max_concurrent_tasks > 0 else None
641
+
642
+ async def _process(update: dict):
643
+
644
+ if allowed_update_types and update.get("type") not in allowed_update_types:
645
+ return False
646
+
647
+
648
+ t = update.get("type")
649
+ if skip_inline_queries and t == "ReceiveQuery":
650
+ return False
651
+ if skip_service_messages and t == "ServiceMessage":
652
+ return False
653
+ if skip_edited_messages and t == "EditMessage":
654
+ return False
655
+ if skip_channel_posts and t == "ChannelPost":
656
+ return False
657
+
658
+
659
+ sender, chat = _get_sender_and_chat(update)
660
+ if ignore_users and sender and sender in ignore_users:
661
+ return False
662
+ if ignore_groups and chat and chat in ignore_groups:
663
+ return False
664
+ if require_auth_token and not getattr(self, "_has_auth_token", False):
665
+ return False
666
+ if only_private_chats:
667
+ is_group = _is_group_chat(chat)
668
+ if is_group is True:
669
+ return False
670
+ if only_groups:
671
+ is_group = _is_group_chat(chat)
672
+ if is_group is False:
673
+ return False
674
+ if skip_bot_messages and getattr(self, "_is_bot_guid", None) and sender == self._is_bot_guid:
675
+ return False
676
+
677
+ if max_message_size is not None and max_message_size > 0:
678
+
679
+ content = None
680
+ if t == "NewMessage":
681
+ content = (update.get("new_message") or {}).get("text")
682
+ elif t == "ReceiveQuery":
683
+ content = (update.get("inline_message") or {}).get("text")
684
+ elif "text" in update:
685
+ content = update.get("text")
686
+ if content and isinstance(content, str) and len(content) > max_message_size:
687
+ return False
688
+
689
+ if message_filter:
690
+ try:
691
+ if not message_filter(update):
692
+ return False
693
+ except Exception:
694
+
695
+ pass
696
+
697
+
698
+ if not _rate_ok():
699
+ return False
700
+
701
+
702
+ if custom_update_processor:
703
+ if asyncio.iscoroutinefunction(custom_update_processor):
704
+ await custom_update_processor(update)
705
+ else:
706
+
707
+ await asyncio.get_running_loop().run_in_executor(None, custom_update_processor, update)
708
+ else:
709
+
710
+ await self._process_update(update)
711
+ return True
712
+
713
+ async def _worker():
714
+ while True:
715
+ update = await queue.get()
716
+ try:
717
+ if sem:
718
+ async with sem:
719
+ await _process(update)
720
+ else:
721
+ await _process(update)
722
+ except Exception as e:
723
+ await _handle_error(e, {"stage": "worker_process", "update": update})
724
+ finally:
725
+ queue.task_done()
726
+
727
+
728
+ start_ts = time.time()
729
+ error_count = 0
730
+ last_loop_tick = time.time()
731
+ processed_count = 0
732
+ skipped_count = 0
733
+ enqueued_count = 0
734
+ unprocessed_storage = []
735
+
736
+
737
+ if process_in_background:
738
+ n_workers = max(1, int(thread_workers))
739
+ for _ in range(n_workers):
740
+ active_workers.append(asyncio.create_task(_worker()))
741
+
742
+
362
743
  await check_rubka_version()
363
744
  await self._initialize_webhook()
364
- print("Bot started running...")
745
+ await self.geteToken()
746
+ _log("Bot started running...", "info")
365
747
 
366
748
  try:
367
749
  while True:
368
750
  try:
369
- if self.web_hook:
370
-
371
-
751
+
752
+ if max_runtime is not None and (time.time() - start_ts) >= max_runtime:
753
+ _log("Max runtime reached. Stopping loop.", "warning")
754
+ break
755
+
756
+
757
+ now = time.time()
758
+ if watchdog_timeout and (now - last_loop_tick) > watchdog_timeout:
759
+ _log(f"Watchdog triggered (> {watchdog_timeout}s)", "warning")
760
+ if auto_restart:
761
+ break
762
+ last_loop_tick = now
763
+
764
+
765
+ received_updates = None
766
+ if custom_update_fetcher:
767
+ received_updates = await custom_update_fetcher()
768
+ elif self.web_hook:
372
769
  webhook_data = await self.update_webhook()
770
+ received_updates = []
373
771
  if isinstance(webhook_data, list):
374
772
  for item in webhook_data:
375
773
  data = item.get("data", {})
@@ -378,10 +776,12 @@ class Robot:
378
776
  if received_at_str:
379
777
  try:
380
778
  received_at_ts = datetime.datetime.strptime(received_at_str, "%Y-%m-%d %H:%M:%S").timestamp()
381
- if time.time() - received_at_ts > 20:
779
+ if time.time() - received_at_ts > webhook_timeout:
780
+ if debug:
781
+ _log(f"Skipped old webhook update ({received_at_str})", "debug")
382
782
  continue
383
783
  except (ValueError, TypeError):
384
- pass
784
+ pass
385
785
 
386
786
  update = None
387
787
  if "update" in data:
@@ -391,6 +791,7 @@ class Robot:
391
791
  else:
392
792
  continue
393
793
 
794
+
394
795
  message_id = None
395
796
  if update.get("type") == "NewMessage":
396
797
  message_id = update.get("new_message", {}).get("message_id")
@@ -398,17 +799,21 @@ class Robot:
398
799
  message_id = update.get("inline_message", {}).get("message_id")
399
800
  elif "message_id" in update:
400
801
  message_id = update.get("message_id")
802
+
401
803
 
402
- if message_id and not self._is_duplicate(str(received_at_str)):
403
- await self._process_update(update)
804
+ dup_ok = True
805
+ if ignore_duplicate_messages:
806
+ key = str(received_at_str) if received_at_str else str(message_id)
807
+ dup_ok = (not self._is_duplicate(str(key))) if key else True
404
808
 
809
+ if message_id and dup_ok:
810
+ received_updates.append(update)
405
811
  else:
406
-
407
- get_updates_response = await self.get_updates(offset_id=self._offset_id, limit=100)
812
+ get_updates_response = await self.get_updates(offset_id=self._offset_id, limit=update_limit)
813
+ received_updates = []
408
814
  if get_updates_response and get_updates_response.get("data"):
409
815
  updates = get_updates_response["data"].get("updates", [])
410
816
  self._offset_id = get_updates_response["data"].get("next_offset_id", self._offset_id)
411
-
412
817
  for update in updates:
413
818
  message_id = None
414
819
  if update.get("type") == "NewMessage":
@@ -417,18 +822,138 @@ class Robot:
417
822
  message_id = update.get("inline_message", {}).get("message_id")
418
823
  elif "message_id" in update:
419
824
  message_id = update.get("message_id")
825
+
826
+ dup_ok = True
827
+ if ignore_duplicate_messages:
828
+ dup_ok = (not self._is_duplicate(str(message_id))) if message_id else True
829
+ if message_id and dup_ok:
830
+ received_updates.append(update)
831
+
832
+
833
+ if not received_updates:
834
+ if pause_on_idle and sleep_time == 0:
835
+ await asyncio.sleep(0.05)
836
+ else:
837
+ await asyncio.sleep(sleep_time)
838
+ if not loop_forever and max_runtime is None:
839
+ break
840
+ continue
841
+
842
+
843
+ for update in received_updates:
844
+ if require_admin_rights:
845
+
846
+ sender, _ = _get_sender_and_chat(update)
847
+ if hasattr(self, "is_admin") and callable(getattr(self, "is_admin")):
848
+ try:
849
+ if not await self.is_admin(sender) if asyncio.iscoroutinefunction(self.is_admin) else not self.is_admin(sender):
850
+ skipped_count += 1
851
+ continue
852
+ except Exception:
853
+ pass
854
+
855
+ if process_in_background:
856
+ try:
857
+ queue.put_nowait(update)
858
+ enqueued_count += 1
859
+ except asyncio.QueueFull:
420
860
 
421
- if message_id and not self._is_duplicate(str(message_id)):
422
- await self._process_update(update)
861
+ skipped_count += 1
862
+ if save_unprocessed_updates:
863
+ unprocessed_storage.append(update)
864
+ else:
865
+ try:
866
+ if sem:
867
+ async with sem:
868
+ ok = await _process(update)
869
+ else:
870
+ ok = await _process(update)
871
+ processed_count += 1 if ok else 0
872
+ skipped_count += 0 if ok else 1
873
+ except Exception as e:
874
+ await _handle_error(e, {"stage": "inline_process", "update": update})
875
+ error_count += 1
876
+ if save_unprocessed_updates:
877
+ unprocessed_storage.append(update)
878
+ if stop_on_error or (max_errors and error_count >= max_errors):
879
+ raise
880
+
881
+
882
+ if process_in_background and queue.qsize() > 0:
883
+ await asyncio.sleep(0)
884
+
885
+
886
+ if debug:
887
+ _log(f"Loop stats — processed: {processed_count}, enqueued: {enqueued_count}, skipped: {skipped_count}, queue: {queue.qsize() if queue else 0}", "debug")
888
+
889
+
890
+ await asyncio.sleep(sleep_time)
423
891
 
424
- await asyncio.sleep(0)
425
892
  except Exception as e:
426
- print(f"❌ Error in run loop: {e}")
427
- await asyncio.sleep(5)
893
+ await _handle_error(e, {"stage": "run_loop"})
894
+ error_count += 1
895
+ if stop_on_error or (max_errors and error_count >= max_errors):
896
+ break
897
+ await asyncio.sleep(retry_delay)
898
+
899
+
900
+ if not loop_forever and max_runtime is None:
901
+ break
902
+
428
903
  finally:
904
+
905
+ if process_in_background and queue:
906
+ try:
907
+ await queue.join()
908
+ except Exception:
909
+ pass
910
+ for w in active_workers:
911
+ w.cancel()
912
+
913
+ for w in active_workers:
914
+ try:
915
+ await w
916
+ except Exception:
917
+ pass
918
+
919
+
429
920
  if self._aiohttp_session:
430
921
  await self._aiohttp_session.close()
922
+
923
+
924
+ stats = {
925
+ "processed": processed_count,
926
+ "skipped": skipped_count,
927
+ "enqueued": enqueued_count,
928
+ "errors": error_count,
929
+ "uptime_sec": round(time.time() - start_ts, 3),
930
+ }
931
+ if metrics_enabled and metrics_handler:
932
+ try:
933
+ if asyncio.iscoroutinefunction(metrics_handler):
934
+ await metrics_handler(stats)
935
+ else:
936
+ metrics_handler(stats)
937
+ except Exception:
938
+ pass
939
+
940
+ if shutdown_hook:
941
+ try:
942
+ if asyncio.iscoroutinefunction(shutdown_hook):
943
+ await shutdown_hook(stats)
944
+ else:
945
+ shutdown_hook(stats)
946
+ except Exception:
947
+ pass
948
+
431
949
  print("Bot stopped and session closed.")
950
+
951
+
952
+ if auto_restart:
953
+
954
+
955
+ _log("Auto-restart requested. You can call run(...) again as needed.", "warning")
956
+
432
957
 
433
958
  async def send_message(
434
959
  self,
@@ -488,6 +1013,52 @@ class Robot:
488
1013
  member_guids = await asyncio.to_thread(client.get_all_members, channel_guid, just_get_guids=True)
489
1014
  return user_id in member_guids
490
1015
  return False
1016
+ async def send_button_join(
1017
+ self,
1018
+ chat_id,
1019
+ title_button,
1020
+ username,
1021
+ text,
1022
+ reply_to_message_id=None,
1023
+ id="None"):
1024
+ from .button import InlineBuilder
1025
+ return await self.send_message(
1026
+ chat_id=chat_id,
1027
+ text=text,
1028
+ inline_keypad=InlineBuilder()
1029
+ .row(
1030
+ InlineBuilder()
1031
+ .button_join_channel(
1032
+ text=title_button,
1033
+ id=id,
1034
+ username=username
1035
+ )
1036
+ ).build(),
1037
+ reply_to_message_id=reply_to_message_id
1038
+ )
1039
+ async def send_button_link(
1040
+ self,
1041
+ chat_id,
1042
+ title_button,
1043
+ url,
1044
+ text,
1045
+ reply_to_message_id=None,
1046
+ id="None"):
1047
+ from .button import InlineBuilder
1048
+ return await self.send_message(
1049
+ chat_id=chat_id,
1050
+ text=text,
1051
+ inline_keypad=InlineBuilder()
1052
+ .row(
1053
+ InlineBuilder()
1054
+ .button_url_link(
1055
+ text=title_button,
1056
+ id=id,
1057
+ url=url
1058
+ )
1059
+ ).build(),
1060
+ reply_to_message_id=reply_to_message_id
1061
+ )
491
1062
 
492
1063
  def get_all_member(self, channel_guid: str, search_text: str = None, start_id: str = None, just_get_guids: bool = False):
493
1064
 
@@ -8,7 +8,7 @@ except FileNotFoundError:
8
8
 
9
9
  setup(
10
10
  name='Rubka',
11
- version='5.0.0',
11
+ version='5.2.0',
12
12
  description='A Python library for interacting with Rubika Bot API.',
13
13
  long_description=long_description,
14
14
  long_description_content_type='text/markdown',
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes