Rubka 4.6.2__py3-none-any.whl → 5.2.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.
rubka/api.py CHANGED
@@ -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,8 +179,46 @@ 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
191
+ def on_update(self, filters: Optional[Callable[[Message], bool]] = None, commands: Optional[List[str]] = None):
192
+ def decorator(func: Callable[[Any, Message], None]):
193
+ self._message_handlers.append({
194
+ "func": func,
195
+ "filters": filters,
196
+ "commands": commands
197
+ })
198
+ return func
199
+ return decorator
175
200
 
176
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):
177
222
  def decorator(func: Callable[[Any, Message], None]):
178
223
  if not hasattr(self, "_callback_handlers"):
179
224
  self._callback_handlers = []
@@ -209,6 +254,15 @@ class Robot:
209
254
  if update.get("type") == "ReceiveQuery":
210
255
  msg = update.get("inline_message", {})
211
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
+
212
266
  threading.Thread(target=self._handle_inline_query, args=(context,), daemon=True).start()
213
267
  return
214
268
 
@@ -290,83 +344,173 @@ class Robot:
290
344
  import time
291
345
 
292
346
 
293
- def run(self):
294
- print("Bot started running...")
295
- self._processed_message_ids: Dict[str, float] = {}
296
-
297
- while True:
298
- try:
299
- if self.web_hook:
300
- updates = self.update_webhook()
301
-
302
- if isinstance(updates, list):
303
- for item in updates:
304
- data = item.get("data", {})
305
-
306
- received_at_str = item.get("received_at")
307
- if received_at_str:
308
- received_at_ts = datetime.datetime.strptime(received_at_str, "%Y-%m-%d %H:%M:%S").timestamp()
309
- if time.time() - received_at_ts > 20:
310
- continue
311
-
312
- update = None
313
- if "update" in data:
314
- update = data["update"]
315
- elif "inline_message" in data:
316
- update = {
317
- "type": "ReceiveQuery",
318
- "inline_message": data["inline_message"]
319
- }
320
- else:
321
- continue
347
+ import datetime
322
348
 
323
- message_id = None
324
- if update.get("type") == "NewMessage":
325
- message_id = update.get("new_message", {}).get("message_id")
326
- elif update.get("type") == "ReceiveQuery":
327
- message_id = update.get("inline_message", {}).get("message_id")
328
- elif "message_id" in update:
329
- message_id = update.get("message_id")
330
349
 
331
- if message_id is not None:
332
- 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...")
333
382
 
334
- if message_id and self._is_duplicate(received_at_str):
335
- continue
383
+ self._processed_message_ids: Dict[str, float] = {}
384
+ error_count = 0
385
+ start_time = time.time()
336
386
 
337
- 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
338
418
 
339
- if message_id:
340
- 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
341
432
 
342
- else:
343
- 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
344
461
 
345
- if updates and updates.get("data"):
346
- for update in updates["data"].get("updates", []):
347
- message_id = None
348
- if update.get("type") == "NewMessage":
349
- message_id = update.get("new_message", {}).get("message_id")
350
- elif update.get("type") == "ReceiveQuery":
351
- message_id = update.get("inline_message", {}).get("message_id")
352
- elif "message_id" in update:
353
- 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
+ )
354
469
 
355
- if message_id is not None:
356
- message_id = str(message_id)
470
+ if message_id is not None:
471
+ message_id = str(message_id)
357
472
 
358
- if message_id and self._is_duplicate(message_id):
359
- 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()
360
482
 
361
- self._process_update(update)
483
+ self._offset_id = updates["data"].get("next_offset_id", self._offset_id)
362
484
 
363
- if message_id:
364
- self._processed_message_ids[message_id] = time.time()
485
+ if sleep_time:
486
+ time.sleep(sleep_time)
365
487
 
366
- 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.")
367
513
 
368
- except Exception as e:
369
- print(f"❌ Error in run loop: {e}")
370
514
  def send_message(
371
515
  self,
372
516
  chat_id: str,
@@ -618,6 +762,38 @@ class Robot:
618
762
  data = response.json()
619
763
  return data.get('data', {}).get('file_id')
620
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
+ )
621
797
 
622
798
  def get_upload_url(self, media_type: Literal['File', 'Image', 'Voice', 'Music', 'Gif','Video']) -> str:
623
799
  allowed = ['File', 'Image', 'Voice', 'Music', 'Gif','Video']
rubka/asynco.py CHANGED
@@ -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({
@@ -196,8 +199,37 @@ class Robot:
196
199
  })
197
200
  return func
198
201
  return decorator
202
+ def on_update(self, filters: Optional[Callable[[Message], bool]] = None, commands: Optional[List[str]] = None):
203
+ def decorator(func: Callable[[Any, Message], None]):
204
+ self._message_handlers.append({
205
+ "func": func,
206
+ "filters": filters,
207
+ "commands": commands
208
+ })
209
+ return func
210
+ return decorator
199
211
 
200
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):
201
233
  def decorator(func: Callable[[Any, Message], None]):
202
234
  if not hasattr(self, "_callback_handlers"):
203
235
  self._callback_handlers = []
@@ -263,6 +295,14 @@ class Robot:
263
295
  if update.get("type") == "ReceiveQuery":
264
296
  msg = update.get("inline_message", {})
265
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
+
266
306
  asyncio.create_task(self._handle_inline_query(context))
267
307
  return
268
308
 
@@ -345,22 +385,389 @@ class Robot:
345
385
  return False
346
386
 
347
387
 
348
- async def run(self):
349
- """
350
- Starts the bot.
351
- 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
+ ):
352
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
+
353
743
  await check_rubka_version()
354
744
  await self._initialize_webhook()
355
- print("Bot started running...")
745
+ await self.geteToken()
746
+ _log("Bot started running...", "info")
356
747
 
357
748
  try:
358
749
  while True:
359
750
  try:
360
- if self.web_hook:
361
-
362
-
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:
363
769
  webhook_data = await self.update_webhook()
770
+ received_updates = []
364
771
  if isinstance(webhook_data, list):
365
772
  for item in webhook_data:
366
773
  data = item.get("data", {})
@@ -369,10 +776,12 @@ class Robot:
369
776
  if received_at_str:
370
777
  try:
371
778
  received_at_ts = datetime.datetime.strptime(received_at_str, "%Y-%m-%d %H:%M:%S").timestamp()
372
- 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")
373
782
  continue
374
783
  except (ValueError, TypeError):
375
- pass
784
+ pass
376
785
 
377
786
  update = None
378
787
  if "update" in data:
@@ -382,6 +791,7 @@ class Robot:
382
791
  else:
383
792
  continue
384
793
 
794
+
385
795
  message_id = None
386
796
  if update.get("type") == "NewMessage":
387
797
  message_id = update.get("new_message", {}).get("message_id")
@@ -389,17 +799,21 @@ class Robot:
389
799
  message_id = update.get("inline_message", {}).get("message_id")
390
800
  elif "message_id" in update:
391
801
  message_id = update.get("message_id")
802
+
392
803
 
393
- if message_id and not self._is_duplicate(str(received_at_str)):
394
- 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
395
808
 
809
+ if message_id and dup_ok:
810
+ received_updates.append(update)
396
811
  else:
397
-
398
- 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 = []
399
814
  if get_updates_response and get_updates_response.get("data"):
400
815
  updates = get_updates_response["data"].get("updates", [])
401
816
  self._offset_id = get_updates_response["data"].get("next_offset_id", self._offset_id)
402
-
403
817
  for update in updates:
404
818
  message_id = None
405
819
  if update.get("type") == "NewMessage":
@@ -408,18 +822,138 @@ class Robot:
408
822
  message_id = update.get("inline_message", {}).get("message_id")
409
823
  elif "message_id" in update:
410
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:
411
860
 
412
- if message_id and not self._is_duplicate(str(message_id)):
413
- 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)
414
891
 
415
- await asyncio.sleep(0)
416
892
  except Exception as e:
417
- print(f"❌ Error in run loop: {e}")
418
- 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
+
419
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
+
420
920
  if self._aiohttp_session:
421
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
+
422
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
+
423
957
 
424
958
  async def send_message(
425
959
  self,
@@ -479,6 +1013,52 @@ class Robot:
479
1013
  member_guids = await asyncio.to_thread(client.get_all_members, channel_guid, just_get_guids=True)
480
1014
  return user_id in member_guids
481
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
+ )
482
1062
 
483
1063
  def get_all_member(self, channel_guid: str, search_text: str = None, start_id: str = None, just_get_guids: bool = False):
484
1064
 
rubka/button.py CHANGED
@@ -16,6 +16,115 @@ class InlineBuilder:
16
16
  raise ValueError("حداقل یک دکمه باید به row داده شود")
17
17
  self.rows.append({"buttons": list(buttons)})
18
18
  return self
19
+ def button_open_chat(self, id: str, text: str, object_guid: str, object_type: str ="User") -> Dict:
20
+ return {
21
+ "id": id,
22
+ "type": 'Link',
23
+ "button_text": text,
24
+ "button_link": {
25
+ "type": 'openchat',
26
+ "open_chat_data": {
27
+ "object_guid": object_guid,
28
+ "object_type": object_type
29
+ }
30
+ }
31
+ }
32
+ def button_join_channel(self, id: str, text: str, username: str, ask_join: bool = False) -> Dict:
33
+ """
34
+ Creates an inline button that prompts the user to join a Rubika channel.
35
+
36
+ Args:
37
+ id (str): Unique identifier for the button (used for event handling).
38
+ text (str): The text displayed on the button.
39
+ username (str): The channel username (can be with or without '@').
40
+ ask_join (bool, optional): If True, the user will be prompted with a
41
+ confirmation dialog before joining.
42
+ Defaults to False.
43
+
44
+ Returns:
45
+ dict: A dictionary representing the inline button, which can be passed
46
+ to inline keyboard builder methods.
47
+
48
+ Example:
49
+ ```python
50
+ from rubka.button import InlineBuilder
51
+
52
+ buttons = (
53
+ InlineBuilder()
54
+ .row(
55
+ InlineBuilder().button_join_channel(
56
+ id="join_btn",
57
+ text="Join our channel 📢",
58
+ username="rubka_library",
59
+ ask_join=True
60
+ )
61
+ )
62
+ .build()
63
+ )
64
+
65
+ await message.reply_inline(
66
+ text="Please join our channel before using the bot.",
67
+ inline_keypad=buttons
68
+ )
69
+ ```
70
+ """
71
+ return {
72
+ "id": id,
73
+ "type": 'Link',
74
+ "button_text": text,
75
+ "button_link": {
76
+ "type": 'joinchannel',
77
+ "joinchannel_data": {
78
+ "username": username.replace("@", ""),
79
+ "ask_join": ask_join
80
+ }
81
+ }
82
+ }
83
+
84
+ def button_url_link(self, id: str, text: str, url: str) -> Dict:
85
+ """
86
+ Creates an inline button that opens a given URL when clicked.
87
+
88
+ Args:
89
+ id (str): Unique identifier for the button (used for event handling if needed).
90
+ text (str): The text displayed on the button.
91
+ url (str): The destination URL that will be opened when the button is clicked.
92
+
93
+ Returns:
94
+ dict: A dictionary representing the inline button, which can be passed
95
+ to inline keyboard builder methods.
96
+
97
+ Example:
98
+ ```python
99
+ from rubka.button import InlineBuilder
100
+
101
+ buttons = (
102
+ InlineBuilder()
103
+ .row(
104
+ InlineBuilder().button_url_link(
105
+ id="website_btn",
106
+ text="Visit our website 🌐",
107
+ url="https://api-free.ir"
108
+ )
109
+ )
110
+ .build()
111
+ )
112
+
113
+ await message.reply_inline(
114
+ text="Click the button below to visit our website.",
115
+ inline_keypad=buttons
116
+ )
117
+ ```
118
+ """
119
+ return {
120
+ "id": id,
121
+ "type": 'Link',
122
+ "button_text": text,
123
+ "button_link": {
124
+ "type": 'url',
125
+ "link_url": url
126
+ }
127
+ }
19
128
 
20
129
  def button_simple(self, id: str, text: str) -> Dict:
21
130
  return {"id": id, "type": "Simple", "button_text": text}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Rubka
3
- Version: 4.6.2
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,7 +1,7 @@
1
1
  rubka/__init__.py,sha256=TR1DABU5Maz2eO62ZEFiwOqNU0dH6l6HZfqRUxeo4eY,194
2
- rubka/api.py,sha256=-ayu17rFI35hU1O7ePxLQNTK-upj2uJxOflA0JUdWdI,39421
3
- rubka/asynco.py,sha256=f2OH2JDdB98x7T4c6B1n-dTWpCe8R5KkkHUdwr9IP9g,37433
4
- rubka/button.py,sha256=4fMSZR7vUADxSmw1R3_pZ4dw5uMLZX5sOkwPPyNTBDE,8437
2
+ rubka/api.py,sha256=PGTQoKqKz8DdOtHjrEi0nDkI-jsXitf5Dw0GZkpIpQ8,47144
3
+ rubka/asynco.py,sha256=slWprOtMDcWo95hZrWN7hO5oQAN2dAOvgSNQqcTStl0,60562
4
+ rubka/button.py,sha256=VLYEBczH4gevqw2cBO06X9QXZivdQ19vvDdVp99s_TY,12229
5
5
  rubka/config.py,sha256=Bck59xkOiqioLv0GkQ1qPGnBXVctz1hKk6LT4h2EPx0,78
6
6
  rubka/context.py,sha256=-98bKHFF3u8ZRqULpYZJVtNhaycsKUY2ZvXJHlWTX-s,18122
7
7
  rubka/decorators.py,sha256=hGwUoE4q2ImrunJIGJ_kzGYYxQf1ueE0isadqraKEts,1157
@@ -33,7 +33,7 @@ rubka/adaptorrubka/types/socket/message.py,sha256=0WgLMZh4eow8Zn7AiSX4C3GZjQTkIg
33
33
  rubka/adaptorrubka/utils/__init__.py,sha256=OgCFkXdNFh379quNwIVOAWY2NP5cIOxU5gDRRALTk4o,54
34
34
  rubka/adaptorrubka/utils/configs.py,sha256=nMUEOJh1NqDJsf9W9PurkN_DLYjO6kKPMm923i4Jj_A,492
35
35
  rubka/adaptorrubka/utils/utils.py,sha256=5-LioLNYX_TIbQGDeT50j7Sg9nAWH2LJUUs-iEXpsUY,8816
36
- rubka-4.6.2.dist-info/METADATA,sha256=ZSQxZZpvEAYf8cZ83vdgaE5mh0sKl2Ei7fndKzgdQWY,33335
37
- rubka-4.6.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
38
- rubka-4.6.2.dist-info/top_level.txt,sha256=vy2A4lot11cRMdQS-F4HDCIXL3JK8RKfu7HMDkezJW4,6
39
- rubka-4.6.2.dist-info/RECORD,,
36
+ rubka-5.2.0.dist-info/METADATA,sha256=LSyZAZRQr0ogt2NA9WTNs6F_dJMfjecZT5PwN9osnKo,33335
37
+ rubka-5.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
38
+ rubka-5.2.0.dist-info/top_level.txt,sha256=vy2A4lot11cRMdQS-F4HDCIXL3JK8RKfu7HMDkezJW4,6
39
+ rubka-5.2.0.dist-info/RECORD,,
File without changes