understanding commited on
Commit
5348659
Β·
verified Β·
1 Parent(s): 42291ba

Update bot/handlers.py

Browse files
Files changed (1) hide show
  1. bot/handlers.py +341 -62
bot/handlers.py CHANGED
@@ -2,48 +2,65 @@
2
  from __future__ import annotations
3
 
4
  import asyncio
 
5
  import os
6
  import re
 
7
  import time
8
  from dataclasses import dataclass
9
- from typing import Any, Dict, Optional, Tuple, List, Union
10
 
 
11
  from hydrogram import Client, filters
12
  from hydrogram.types import CallbackQuery, Message
13
 
14
- from bot.config import Auth, Workers
 
 
 
15
  from bot.integrations.auth import allow_user, disallow_user, get_stats, is_allowed
16
- from bot.integrations.cf_worker1 import profile_check_auth, profile_delete
17
- from bot.integrations.cf_worker2 import get_default_profile, list_profiles, record_upload, set_default_profile
 
 
 
 
 
18
  from bot.telegram.files import cleanup_file
19
  from bot.telegram.media import download_to_temp
20
  from bot.telegram.replies import safe_edit, safe_reply
21
  from bot.ui.callbacks import (
 
 
 
 
22
  MENU_AUTH,
23
  MENU_HELP,
24
  MENU_PROFILES,
25
  MENU_SPEED,
 
 
 
 
26
  UP_DEL,
27
  UP_EDIT,
28
  UP_GO,
29
  UP_PRIV,
30
- UP_CANCEL,
31
- NAME_ORIGINAL,
32
- NAME_CAPTION,
33
- NAME_CUSTOM,
34
- BACK,
35
  )
36
- from bot.ui.keyboards import auth_menu_keyboard, filename_keyboard, main_menu_keyboard, profiles_keyboard, upload_confirm_keyboard
 
 
 
 
 
 
37
  from bot.ui.parse import parse_cb
38
- from bot.ui.texts import CANCELLED, HELP_TEXT, NEED_AUTH, NOT_ALLOWED, OWNER_ONLY
39
- from bot.core.progress import SpeedETA, human_bytes, human_eta
40
- from bot.core.settings import Settings
41
- from bot.core.uptime import uptime_text
42
  from bot.youtube.link_parser import parse_telegram_link
43
 
44
  ChatRef = Union[int, str]
45
 
46
-
47
  @dataclass
48
  class PendingUpload:
49
  request_msg: Message # where user triggered command / sent media
@@ -74,7 +91,6 @@ class EditState:
74
  privacy: str
75
 
76
 
77
- # In-memory state
78
  _PENDING_UPLOAD: Dict[int, PendingUpload] = {}
79
  _AWAIT_EDIT: Dict[int, EditState] = {}
80
  _PENDING_DELETE: Dict[int, str] = {}
@@ -82,9 +98,36 @@ _IN_PROGRESS: Dict[int, bool] = {}
82
  _UPLOAD_TASK: Dict[int, asyncio.Task] = {}
83
  _BATCH_TASK: Dict[int, asyncio.Task] = {}
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
  def _is_admin_or_owner(uid: int) -> bool:
87
- return uid in set(Auth.OWNERS) or uid in set(Auth.ADMINS)
 
 
 
88
 
89
 
90
  async def _ensure_allowed_uid(uid: int, reply_target: Message) -> bool:
@@ -106,9 +149,9 @@ async def _ensure_allowed_uid(uid: int, reply_target: Message) -> bool:
106
 
107
  def _media_and_filename(m: Message) -> Tuple[Optional[Any], str, int]:
108
  if m.video:
109
- return m.video, (m.video.file_name or "video.mp4"), (m.video.file_size or 0)
110
  if m.document:
111
- return m.document, (m.document.file_name or "file.bin"), (m.document.file_size or 0)
112
  return None, "", 0
113
 
114
 
@@ -246,6 +289,20 @@ def _as_int(v: Any) -> int:
246
  return 0
247
 
248
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  async def _run_upload(uid: int) -> Dict[str, Any]:
250
  """
251
  Runs one upload for uid, editing p.status_msg.
@@ -392,6 +449,7 @@ async def _run_upload(uid: int) -> Dict[str, Any]:
392
  _PENDING_UPLOAD.pop(uid, None)
393
  _AWAIT_EDIT.pop(uid, None)
394
  _PENDING_DELETE.pop(uid, None)
 
395
  if file_path:
396
  try:
397
  cleanup_file(file_path)
@@ -416,7 +474,6 @@ def _parse_link_or_range(line: str) -> Tuple[ChatRef, int, int]:
416
  if not s:
417
  raise ValueError("empty")
418
 
419
- # quick detect ".... / 4012-4046"
420
  m = _BATCH_RANGE_RE.match(s)
421
  if m:
422
  base = m.group(1).strip()
@@ -427,25 +484,173 @@ def _parse_link_or_range(line: str) -> Tuple[ChatRef, int, int]:
427
  if a > b:
428
  a, b = b, a
429
 
430
- # rebuild a normal single link to extract chat_ref
431
  single = f"{base}/{a}"
432
  chat_ref, _mid = parse_telegram_link(single)
433
  return chat_ref, a, b
434
 
435
- # normal single link
436
  chat_ref, mid = parse_telegram_link(s)
437
  return chat_ref, int(mid), int(mid)
438
 
439
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
441
  # ---- basic ----
442
  @app.on_message(filters.command(["start", "help"]))
443
  async def start_help_cmd(_, m: Message) -> None:
444
- # always show menu + help text
445
  if (m.text or "").strip().lower().startswith("/help"):
446
  await safe_reply(m, HELP_TEXT, reply_markup=main_menu_keyboard())
447
  else:
448
- from bot.ui.texts import START_TEXT
449
  await safe_reply(m, START_TEXT, reply_markup=main_menu_keyboard())
450
 
451
  @app.on_message(filters.command("speedtest"))
@@ -453,11 +658,12 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
453
  uid = m.from_user.id if m.from_user else 0
454
  if not await _ensure_allowed_uid(uid, m):
455
  return
456
- from bot.core.speedtest import net_download_test, net_upload_test, public_ip
457
- await safe_reply(m, "⏱ Running speed test…")
 
458
  dl = await net_download_test()
459
  ul = await net_upload_test()
460
- ip = await public_ip()
461
 
462
  dl_bps = float((dl or {}).get("bps", 0) or 0)
463
  ul_bps = float((ul or {}).get("bps", 0) or 0)
@@ -469,11 +675,16 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
469
  f"*Download:* `{human_bytes(dl_bps)}/s`\n"
470
  f"*Upload:* `{human_bytes(ul_bps)}/s`"
471
  )
472
- await safe_reply(m, msg, reply_markup=main_menu_keyboard())
 
 
 
473
 
474
  @app.on_message(filters.command("cancel"))
475
  async def cancel_cmd(_, m: Message) -> None:
476
- uid = m.from_user.id
 
 
477
  _AWAIT_EDIT.pop(uid, None)
478
  _PENDING_UPLOAD.pop(uid, None)
479
  _PENDING_DELETE.pop(uid, None)
@@ -493,7 +704,7 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
493
  # ---- admin allowlist ----
494
  @app.on_message(filters.command("allow"))
495
  async def allow_cmd(_, m: Message) -> None:
496
- uid = m.from_user.id
497
  if not _is_admin_or_owner(uid):
498
  await safe_reply(m, OWNER_ONLY)
499
  return
@@ -511,7 +722,7 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
511
 
512
  @app.on_message(filters.command("disallow"))
513
  async def disallow_cmd(_, m: Message) -> None:
514
- uid = m.from_user.id
515
  if not _is_admin_or_owner(uid):
516
  await safe_reply(m, OWNER_ONLY)
517
  return
@@ -529,7 +740,7 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
529
 
530
  @app.on_message(filters.command("stats"))
531
  async def stats_cmd(_, m: Message) -> None:
532
- uid = m.from_user.id
533
  if not _is_admin_or_owner(uid):
534
  await safe_reply(m, OWNER_ONLY)
535
  return
@@ -547,7 +758,7 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
547
 
548
  @app.on_message(filters.command("diag"))
549
  async def diag_cmd(_, m: Message) -> None:
550
- uid = m.from_user.id
551
  if not _is_admin_or_owner(uid):
552
  await safe_reply(m, OWNER_ONLY)
553
  return
@@ -561,20 +772,22 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
561
  f"Uptime: `{uptime_text()}`\n"
562
  f"WORKER1_URL: `{w1}`\n"
563
  f"WORKER2_URL: `{w2}`\n"
564
- f"Owners: `{len(Auth.OWNERS)}` Admins: `{len(Auth.ADMINS)}`",
 
565
  )
566
 
567
  # ---- auth/profile flow ----
568
  @app.on_message(filters.command("auth"))
569
  async def auth_cmd(_, m: Message) -> None:
570
- uid = m.from_user.id
571
  if not await _ensure_allowed_uid(uid, m):
572
  return
 
573
  await safe_reply(m, "πŸ” Add a profile:", reply_markup=auth_menu_keyboard())
574
 
575
  @app.on_message(filters.command("profiles"))
576
  async def profiles_cmd(_, m: Message) -> None:
577
- uid = m.from_user.id
578
  if not await _ensure_allowed_uid(uid, m):
579
  return
580
  data = await list_profiles(uid, only_connected=False)
@@ -585,10 +798,17 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
585
  default_id = data.get("default_profile_id") or ""
586
  await safe_reply(m, "πŸ‘€ *Profiles*", reply_markup=profiles_keyboard(profiles, default_id))
587
 
588
- # ---- upload from DM media ----
589
  @app.on_message(filters.private & (filters.video | filters.document))
590
  async def media_in_dm(_, m: Message) -> None:
 
 
591
  uid = m.from_user.id
 
 
 
 
 
592
  if not await _ensure_allowed_uid(uid, m):
593
  return
594
  if _IN_PROGRESS.get(uid):
@@ -599,6 +819,8 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
599
  # ---- upload from link (admin/owner) ----
600
  @app.on_message(filters.command(["yt", "dl", "archive"]))
601
  async def archive_cmd(_, m: Message) -> None:
 
 
602
  uid = m.from_user.id
603
  if not await _ensure_allowed_uid(uid, m):
604
  return
@@ -636,6 +858,8 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
636
  # ---- batch mode ----
637
  @app.on_message(filters.command("batch"))
638
  async def batch_cmd(_, m: Message) -> None:
 
 
639
  uid = m.from_user.id
640
  if not await _ensure_allowed_uid(uid, m):
641
  return
@@ -665,7 +889,6 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
665
  await safe_reply(m, "No links found. Put one t.me link per line after /batch")
666
  return
667
 
668
- # Expand links/ranges
669
  items: List[Tuple[ChatRef, int]] = []
670
  for ln in lines:
671
  try:
@@ -677,8 +900,8 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
677
  continue
678
 
679
  count = (b - a + 1)
680
- if count > Settings.BATCH_MAX_RANGE:
681
- await safe_reply(m, f"❌ Range too large ({count}). Max is {Settings.BATCH_MAX_RANGE}.")
682
  if not continue_on_fail:
683
  return
684
  continue
@@ -690,16 +913,16 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
690
  await safe_reply(m, "No valid items to process.")
691
  return
692
 
693
- await safe_reply(m, f"🧾 Batch starting: {len(items)} item(s). Mode: `{'continue' if continue_on_fail else 'stop_on_fail'}`")
 
 
 
694
 
695
  async def runner() -> None:
696
  batch_start = time.time()
697
  total = len(items)
698
- for i, (chat_ref, msg_id) in enumerate(items, 1):
699
- if _IN_PROGRESS.get(uid):
700
- # should not happen (we do sequential), but keep safe
701
- await asyncio.sleep(0.2)
702
 
 
703
  st = await safe_reply(m, f"πŸ”Ž Batch {i}/{total}: fetching message `{msg_id}`…")
704
  if not st:
705
  if not continue_on_fail:
@@ -714,7 +937,7 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
714
  break
715
  continue
716
 
717
- media, file_name, size = _media_and_filename(src)
718
  if not media:
719
  await safe_edit(st, f"⏭ Batch {i}/{total}: no media in that message. Skipped.")
720
  continue
@@ -722,27 +945,25 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
722
  # Create pending upload targeting THIS chat (m), not source chat
723
  await _start_pending_upload(uid=uid, request_msg=m, src_msg=src, downloader=user_app, via_link=True)
724
 
725
- # Force the status message to be this st (so progress edits this line)
726
  p = _PENDING_UPLOAD.get(uid)
727
  if p:
728
  p.status_msg = st
729
  _PENDING_UPLOAD[uid] = p
730
 
731
- # Apply the current chosen title/desc and start upload
732
  await safe_edit(st, f"⏳ Batch {i}/{total}: starting upload…")
733
  out = await _run_upload(uid)
734
 
735
- if not out.get("ok"):
736
- if not continue_on_fail:
737
- await safe_reply(m, f"πŸ›‘ Batch stopped (failed at {i}/{total}).")
738
- break
739
  else:
740
  await safe_edit(st, f"❌ Batch {i}/{total}: internal error (no pending).")
741
  if not continue_on_fail:
742
  break
743
 
744
  batch_dur = max(0.001, time.time() - batch_start)
745
- await safe_reply(m, f"βœ… Batch done in {batch_dur:.1f}s.")
746
 
747
  t = asyncio.create_task(runner())
748
  _BATCH_TASK[uid] = t
@@ -751,12 +972,27 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
751
  @app.on_callback_query()
752
  async def cb_handler(_, q: CallbackQuery) -> None:
753
  uid = q.from_user.id
 
 
 
 
 
 
 
754
  action, value = parse_cb(q.data or "")
755
 
756
- # Ignore noop
757
  if action == "noop":
758
  return
759
 
 
 
 
 
 
 
 
 
 
760
  # Menus
761
  if action == MENU_HELP:
762
  await safe_edit(q.message, HELP_TEXT, reply_markup=main_menu_keyboard())
@@ -765,11 +1001,12 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
765
  if action == MENU_SPEED:
766
  if not await _ensure_allowed_uid(uid, q.message):
767
  return
768
- from bot.core.speedtest import net_download_test, net_upload_test, public_ip
 
769
  await safe_edit(q.message, "⏱ Running speed test…")
770
  dl = await net_download_test()
771
  ul = await net_upload_test()
772
- ip = await public_ip()
773
 
774
  dl_bps = float((dl or {}).get("bps", 0) or 0)
775
  ul_bps = float((ul or {}).get("bps", 0) or 0)
@@ -788,6 +1025,7 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
788
  if action == MENU_AUTH:
789
  if not await _ensure_allowed_uid(uid, q.message):
790
  return
 
791
  await safe_edit(q.message, "πŸ” Add a profile:", reply_markup=auth_menu_keyboard())
792
  return
793
 
@@ -804,9 +1042,40 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
804
  return
805
 
806
  if action == BACK:
 
807
  await safe_edit(q.message, "🏠 Menu", reply_markup=main_menu_keyboard())
808
  return
809
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
810
  # Filename/Caption choice
811
  if action in (NAME_ORIGINAL, NAME_CAPTION, NAME_CUSTOM):
812
  p = _PENDING_UPLOAD.get(uid)
@@ -815,7 +1084,6 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
815
 
816
  if action == NAME_ORIGINAL:
817
  p.title = (p.title_from_filename or "Untitled")[: Settings.MAX_TITLE]
818
- # keep caption as description (if present)
819
  p.description = (p.caption_raw or "")[: Settings.MAX_DESC]
820
  _PENDING_UPLOAD[uid] = p
821
  await safe_edit(q.message, _render_preview(p), reply_markup=upload_confirm_keyboard(p.privacy))
@@ -826,7 +1094,6 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
826
  p.title = p.title_from_caption[: Settings.MAX_TITLE]
827
  p.description = p.desc_from_caption[: Settings.MAX_DESC]
828
  else:
829
- # no caption actually
830
  p.title = (p.title_from_filename or "Untitled")[: Settings.MAX_TITLE]
831
  p.description = ""
832
  _PENDING_UPLOAD[uid] = p
@@ -880,7 +1147,11 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
880
  if not p:
881
  return
882
  if _IN_PROGRESS.get(uid):
883
- await safe_edit(q.message, "⏳ Upload already running. Use /cancel to stop.", reply_markup=main_menu_keyboard())
 
 
 
 
884
  return
885
  p.status_msg = q.message
886
  _PENDING_UPLOAD[uid] = p
@@ -927,13 +1198,22 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
927
  await safe_edit(q.message, "❌ Failed to create login URL.", reply_markup=main_menu_keyboard())
928
  return
929
 
930
- # ---- edit text handler (private or group, only triggers when waiting) ----
931
  @app.on_message(filters.text)
932
  async def text_anywhere(_, m: Message) -> None:
933
  if not m.from_user:
934
  return
935
  uid = m.from_user.id
936
 
 
 
 
 
 
 
 
 
 
937
  # Confirm delete
938
  if uid in _PENDING_DELETE:
939
  if (m.text or "").strip().lower() == "yes":
@@ -950,7 +1230,7 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
950
 
951
  # Edit title/desc
952
  if uid in _AWAIT_EDIT:
953
- st = _AWAIT_EDIT.pop(uid)
954
  p = _PENDING_UPLOAD.get(uid)
955
  if not p:
956
  return
@@ -965,7 +1245,6 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
965
 
966
  _PENDING_UPLOAD[uid] = p
967
 
968
- # Update preview in the current chat
969
  if p.status_msg:
970
  await safe_edit(p.status_msg, _render_preview(p), reply_markup=upload_confirm_keyboard(p.privacy))
971
  else:
 
2
  from __future__ import annotations
3
 
4
  import asyncio
5
+ import json
6
  import os
7
  import re
8
+ import tempfile
9
  import time
10
  from dataclasses import dataclass
11
+ from typing import Any, Dict, List, Optional, Tuple, Union
12
 
13
+ import httpx
14
  from hydrogram import Client, filters
15
  from hydrogram.types import CallbackQuery, Message
16
 
17
+ from bot.config import Auth, Telegram, Workers
18
+ from bot.core.progress import SpeedETA, human_bytes, human_eta
19
+ from bot.core.settings import Settings
20
+ from bot.core.uptime import uptime_text
21
  from bot.integrations.auth import allow_user, disallow_user, get_stats, is_allowed
22
+ from bot.integrations.cf_worker1 import profile_add, profile_check_auth, profile_delete
23
+ from bot.integrations.cf_worker2 import (
24
+ get_default_profile,
25
+ list_profiles,
26
+ record_upload,
27
+ set_default_profile,
28
+ )
29
  from bot.telegram.files import cleanup_file
30
  from bot.telegram.media import download_to_temp
31
  from bot.telegram.replies import safe_edit, safe_reply
32
  from bot.ui.callbacks import (
33
+ AUTH_CI,
34
+ AUTH_JSON,
35
+ BACK,
36
+ CANCEL,
37
  MENU_AUTH,
38
  MENU_HELP,
39
  MENU_PROFILES,
40
  MENU_SPEED,
41
+ NAME_CAPTION,
42
+ NAME_CUSTOM,
43
+ NAME_ORIGINAL,
44
+ UP_CANCEL,
45
  UP_DEL,
46
  UP_EDIT,
47
  UP_GO,
48
  UP_PRIV,
 
 
 
 
 
49
  )
50
+ from bot.ui.keyboards import (
51
+ auth_menu_keyboard,
52
+ filename_keyboard,
53
+ main_menu_keyboard,
54
+ profiles_keyboard,
55
+ upload_confirm_keyboard,
56
+ )
57
  from bot.ui.parse import parse_cb
58
+ from bot.ui.texts import CANCELLED, HELP_TEXT, NEED_AUTH, NOT_ALLOWED, OWNER_ONLY, START_TEXT
 
 
 
59
  from bot.youtube.link_parser import parse_telegram_link
60
 
61
  ChatRef = Union[int, str]
62
 
63
+ # ---- In-memory state ----
64
  @dataclass
65
  class PendingUpload:
66
  request_msg: Message # where user triggered command / sent media
 
91
  privacy: str
92
 
93
 
 
94
  _PENDING_UPLOAD: Dict[int, PendingUpload] = {}
95
  _AWAIT_EDIT: Dict[int, EditState] = {}
96
  _PENDING_DELETE: Dict[int, str] = {}
 
98
  _UPLOAD_TASK: Dict[int, asyncio.Task] = {}
99
  _BATCH_TASK: Dict[int, asyncio.Task] = {}
100
 
101
+ # βœ… Auth input state (fixes: auth buttons "no response")
102
+ # value: "json" or "ci"
103
+ _AWAIT_AUTH: Dict[int, str] = {}
104
+
105
+ # Batch range cap (Settings may not define it yet)
106
+ _BATCH_MAX_RANGE = int(getattr(Settings, "BATCH_MAX_RANGE", 80) or 80)
107
+
108
+
109
+ def _norm_ids(xs: Any) -> set[int]:
110
+ out: set[int] = set()
111
+ if not xs:
112
+ return out
113
+ if isinstance(xs, (int, float, str)):
114
+ xs = [xs]
115
+ for v in list(xs):
116
+ try:
117
+ s = str(v).strip()
118
+ if not s:
119
+ continue
120
+ out.add(int(float(s)))
121
+ except Exception:
122
+ continue
123
+ return out
124
+
125
 
126
  def _is_admin_or_owner(uid: int) -> bool:
127
+ # Accept both Auth.* and Telegram.* and handle str/int mixes safely.
128
+ owners = _norm_ids(getattr(Auth, "OWNERS", None)) | _norm_ids(getattr(Telegram, "OWNER_ID", None))
129
+ admins = _norm_ids(getattr(Auth, "ADMINS", None)) | _norm_ids(getattr(Telegram, "ADMIN_IDS", None))
130
+ return uid in owners or uid in admins
131
 
132
 
133
  async def _ensure_allowed_uid(uid: int, reply_target: Message) -> bool:
 
149
 
150
  def _media_and_filename(m: Message) -> Tuple[Optional[Any], str, int]:
151
  if m.video:
152
+ return m.video, (m.video.file_name or "video.mp4"), int(m.video.file_size or 0)
153
  if m.document:
154
+ return m.document, (m.document.file_name or "file.bin"), int(m.document.file_size or 0)
155
  return None, "", 0
156
 
157
 
 
289
  return 0
290
 
291
 
292
+ async def _get_public_ip() -> Optional[str]:
293
+ # No extra dependency / no extra file needed
294
+ try:
295
+ async with httpx.AsyncClient(timeout=10) as c:
296
+ r = await c.get("https://api.ipify.org", params={"format": "text"})
297
+ if r.status_code < 400:
298
+ ip = (r.text or "").strip()
299
+ if ip and len(ip) <= 60:
300
+ return ip
301
+ except Exception:
302
+ pass
303
+ return None
304
+
305
+
306
  async def _run_upload(uid: int) -> Dict[str, Any]:
307
  """
308
  Runs one upload for uid, editing p.status_msg.
 
449
  _PENDING_UPLOAD.pop(uid, None)
450
  _AWAIT_EDIT.pop(uid, None)
451
  _PENDING_DELETE.pop(uid, None)
452
+ _AWAIT_AUTH.pop(uid, None)
453
  if file_path:
454
  try:
455
  cleanup_file(file_path)
 
474
  if not s:
475
  raise ValueError("empty")
476
 
 
477
  m = _BATCH_RANGE_RE.match(s)
478
  if m:
479
  base = m.group(1).strip()
 
484
  if a > b:
485
  a, b = b, a
486
 
 
487
  single = f"{base}/{a}"
488
  chat_ref, _mid = parse_telegram_link(single)
489
  return chat_ref, a, b
490
 
 
491
  chat_ref, mid = parse_telegram_link(s)
492
  return chat_ref, int(mid), int(mid)
493
 
494
 
495
+ def _extract_client_secrets_from_json(obj: dict) -> Tuple[str, str]:
496
+ """
497
+ Accepts client_secret.json formats:
498
+ {"installed": {"client_id": "...", "client_secret": "..."}}
499
+ {"web": {"client_id": "...", "client_secret": "..."}}
500
+ {"client_id": "...", "client_secret": "..."}
501
+ Returns (client_id, client_secret) or raises.
502
+ """
503
+ if not isinstance(obj, dict):
504
+ raise ValueError("json_not_object")
505
+
506
+ for k in ("installed", "web"):
507
+ v = obj.get(k)
508
+ if isinstance(v, dict) and v.get("client_id") and v.get("client_secret"):
509
+ return str(v["client_id"]).strip(), str(v["client_secret"]).strip()
510
+
511
+ if obj.get("client_id") and obj.get("client_secret"):
512
+ return str(obj["client_id"]).strip(), str(obj["client_secret"]).strip()
513
+
514
+ raise ValueError("missing_client_id_or_secret")
515
+
516
+
517
+ async def _handle_auth_json_input(app: Client, m: Message) -> bool:
518
+ """
519
+ Returns True if message was consumed as auth input.
520
+ """
521
+ if not m.from_user:
522
+ return False
523
+ uid = m.from_user.id
524
+ if _AWAIT_AUTH.get(uid) != "json":
525
+ return False
526
+
527
+ if not await _ensure_allowed_uid(uid, m):
528
+ return True
529
+
530
+ # document .json
531
+ if m.document:
532
+ name = (m.document.file_name or "").lower()
533
+ if not name.endswith(".json"):
534
+ await safe_reply(m, "❌ Please send a `.json` file (Google client_secret.json). Or paste JSON text.")
535
+ return True
536
+
537
+ fd, path = tempfile.mkstemp(prefix="yt_auth_", suffix=".json")
538
+ os.close(fd)
539
+ try:
540
+ await app.download_media(message=m, file_name=path)
541
+ raw = ""
542
+ try:
543
+ with open(path, "r", encoding="utf-8") as f:
544
+ raw = f.read()
545
+ except Exception:
546
+ with open(path, "rb") as f:
547
+ raw = f.read().decode("utf-8", errors="ignore")
548
+
549
+ obj = json.loads(raw)
550
+ client_id, client_secret = _extract_client_secrets_from_json(obj)
551
+
552
+ out = await profile_add(uid, client_id=client_id, client_secret=client_secret, label="main")
553
+ if isinstance(out, dict) and out.get("ok"):
554
+ _AWAIT_AUTH.pop(uid, None)
555
+ await safe_reply(
556
+ m,
557
+ "βœ… Profile added.\n\nNow run /profiles β†’ *Login* β†’ *Set default*.",
558
+ reply_markup=main_menu_keyboard(),
559
+ )
560
+ else:
561
+ err = (out or {}).get("err") if isinstance(out, dict) else "profile_add_failed"
562
+ await safe_reply(m, f"❌ Failed to add profile: `{err}`")
563
+ return True
564
+ except Exception as e:
565
+ await safe_reply(m, f"❌ JSON parse/add failed: `{str(e)[:220]}`")
566
+ return True
567
+ finally:
568
+ try:
569
+ if os.path.exists(path):
570
+ os.remove(path)
571
+ except Exception:
572
+ pass
573
+
574
+ # pasted JSON
575
+ txt = (m.text or "").strip()
576
+ if txt.startswith("{") and txt.endswith("}"):
577
+ try:
578
+ obj = json.loads(txt)
579
+ client_id, client_secret = _extract_client_secrets_from_json(obj)
580
+
581
+ out = await profile_add(uid, client_id=client_id, client_secret=client_secret, label="main")
582
+ if isinstance(out, dict) and out.get("ok"):
583
+ _AWAIT_AUTH.pop(uid, None)
584
+ await safe_reply(
585
+ m,
586
+ "βœ… Profile added.\n\nNow run /profiles β†’ *Login* β†’ *Set default*.",
587
+ reply_markup=main_menu_keyboard(),
588
+ )
589
+ else:
590
+ err = (out or {}).get("err") if isinstance(out, dict) else "profile_add_failed"
591
+ await safe_reply(m, f"❌ Failed to add profile: `{err}`")
592
+ except Exception as e:
593
+ await safe_reply(m, f"❌ JSON parse/add failed: `{str(e)[:220]}`")
594
+ return True
595
+
596
+ await safe_reply(m, "πŸ“„ Send the `.json` file or paste JSON text (client_secret.json).")
597
+ return True
598
+
599
+
600
+ async def _handle_auth_ci_input(m: Message) -> bool:
601
+ """
602
+ Returns True if message was consumed as auth input.
603
+ """
604
+ if not m.from_user:
605
+ return False
606
+ uid = m.from_user.id
607
+ if _AWAIT_AUTH.get(uid) != "ci":
608
+ return False
609
+
610
+ if not await _ensure_allowed_uid(uid, m):
611
+ return True
612
+
613
+ txt = (m.text or "").strip()
614
+ if not txt:
615
+ await safe_reply(m, "Send:\n`<CLIENT_ID>`\n`<CLIENT_SECRET>`\n(Optional 3rd line: label)")
616
+ return True
617
+
618
+ lines = [ln.strip() for ln in txt.splitlines() if ln.strip()]
619
+ if len(lines) == 1:
620
+ # maybe space separated
621
+ parts = [p for p in lines[0].split() if p.strip()]
622
+ if len(parts) >= 2:
623
+ client_id, client_secret = parts[0], parts[1]
624
+ label = parts[2] if len(parts) >= 3 else "main"
625
+ else:
626
+ await safe_reply(m, "Send:\n`<CLIENT_ID>`\n`<CLIENT_SECRET>`\n(Optional 3rd line: label)")
627
+ return True
628
+ else:
629
+ client_id = lines[0]
630
+ client_secret = lines[1] if len(lines) >= 2 else ""
631
+ label = lines[2] if len(lines) >= 3 else "main"
632
+
633
+ out = await profile_add(uid, client_id=client_id, client_secret=client_secret, label=label)
634
+ if isinstance(out, dict) and out.get("ok"):
635
+ _AWAIT_AUTH.pop(uid, None)
636
+ await safe_reply(
637
+ m,
638
+ f"βœ… Profile added (label: `{label}`).\n\nNow run /profiles β†’ *Login* β†’ *Set default*.",
639
+ reply_markup=main_menu_keyboard(),
640
+ )
641
+ else:
642
+ err = (out or {}).get("err") if isinstance(out, dict) else "profile_add_failed"
643
+ await safe_reply(m, f"❌ Failed to add profile: `{err}`")
644
+ return True
645
+
646
+
647
  def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
648
  # ---- basic ----
649
  @app.on_message(filters.command(["start", "help"]))
650
  async def start_help_cmd(_, m: Message) -> None:
 
651
  if (m.text or "").strip().lower().startswith("/help"):
652
  await safe_reply(m, HELP_TEXT, reply_markup=main_menu_keyboard())
653
  else:
 
654
  await safe_reply(m, START_TEXT, reply_markup=main_menu_keyboard())
655
 
656
  @app.on_message(filters.command("speedtest"))
 
658
  uid = m.from_user.id if m.from_user else 0
659
  if not await _ensure_allowed_uid(uid, m):
660
  return
661
+ from bot.core.speedtest import net_download_test, net_upload_test
662
+
663
+ st = await safe_reply(m, "⏱ Running speed test…")
664
  dl = await net_download_test()
665
  ul = await net_upload_test()
666
+ ip = await _get_public_ip()
667
 
668
  dl_bps = float((dl or {}).get("bps", 0) or 0)
669
  ul_bps = float((ul or {}).get("bps", 0) or 0)
 
675
  f"*Download:* `{human_bytes(dl_bps)}/s`\n"
676
  f"*Upload:* `{human_bytes(ul_bps)}/s`"
677
  )
678
+ if st:
679
+ await safe_edit(st, msg, reply_markup=main_menu_keyboard())
680
+ else:
681
+ await safe_reply(m, msg, reply_markup=main_menu_keyboard())
682
 
683
  @app.on_message(filters.command("cancel"))
684
  async def cancel_cmd(_, m: Message) -> None:
685
+ uid = m.from_user.id if m.from_user else 0
686
+
687
+ _AWAIT_AUTH.pop(uid, None)
688
  _AWAIT_EDIT.pop(uid, None)
689
  _PENDING_UPLOAD.pop(uid, None)
690
  _PENDING_DELETE.pop(uid, None)
 
704
  # ---- admin allowlist ----
705
  @app.on_message(filters.command("allow"))
706
  async def allow_cmd(_, m: Message) -> None:
707
+ uid = m.from_user.id if m.from_user else 0
708
  if not _is_admin_or_owner(uid):
709
  await safe_reply(m, OWNER_ONLY)
710
  return
 
722
 
723
  @app.on_message(filters.command("disallow"))
724
  async def disallow_cmd(_, m: Message) -> None:
725
+ uid = m.from_user.id if m.from_user else 0
726
  if not _is_admin_or_owner(uid):
727
  await safe_reply(m, OWNER_ONLY)
728
  return
 
740
 
741
  @app.on_message(filters.command("stats"))
742
  async def stats_cmd(_, m: Message) -> None:
743
+ uid = m.from_user.id if m.from_user else 0
744
  if not _is_admin_or_owner(uid):
745
  await safe_reply(m, OWNER_ONLY)
746
  return
 
758
 
759
  @app.on_message(filters.command("diag"))
760
  async def diag_cmd(_, m: Message) -> None:
761
+ uid = m.from_user.id if m.from_user else 0
762
  if not _is_admin_or_owner(uid):
763
  await safe_reply(m, OWNER_ONLY)
764
  return
 
772
  f"Uptime: `{uptime_text()}`\n"
773
  f"WORKER1_URL: `{w1}`\n"
774
  f"WORKER2_URL: `{w2}`\n"
775
+ f"Owners: `{len(_norm_ids(getattr(Auth, 'OWNERS', None)) | _norm_ids(getattr(Telegram, 'OWNER_ID', None)))}` "
776
+ f"Admins: `{len(_norm_ids(getattr(Auth, 'ADMINS', None)) | _norm_ids(getattr(Telegram, 'ADMIN_IDS', None)))}`",
777
  )
778
 
779
  # ---- auth/profile flow ----
780
  @app.on_message(filters.command("auth"))
781
  async def auth_cmd(_, m: Message) -> None:
782
+ uid = m.from_user.id if m.from_user else 0
783
  if not await _ensure_allowed_uid(uid, m):
784
  return
785
+ _AWAIT_AUTH.pop(uid, None)
786
  await safe_reply(m, "πŸ” Add a profile:", reply_markup=auth_menu_keyboard())
787
 
788
  @app.on_message(filters.command("profiles"))
789
  async def profiles_cmd(_, m: Message) -> None:
790
+ uid = m.from_user.id if m.from_user else 0
791
  if not await _ensure_allowed_uid(uid, m):
792
  return
793
  data = await list_profiles(uid, only_connected=False)
 
798
  default_id = data.get("default_profile_id") or ""
799
  await safe_reply(m, "πŸ‘€ *Profiles*", reply_markup=profiles_keyboard(profiles, default_id))
800
 
801
+ # ---- upload from DM media / OR auth-json doc handling ----
802
  @app.on_message(filters.private & (filters.video | filters.document))
803
  async def media_in_dm(_, m: Message) -> None:
804
+ if not m.from_user:
805
+ return
806
  uid = m.from_user.id
807
+
808
+ # βœ… If we're waiting for auth JSON, consume json file instead of treating it as "upload media"
809
+ if await _handle_auth_json_input(app, m):
810
+ return
811
+
812
  if not await _ensure_allowed_uid(uid, m):
813
  return
814
  if _IN_PROGRESS.get(uid):
 
819
  # ---- upload from link (admin/owner) ----
820
  @app.on_message(filters.command(["yt", "dl", "archive"]))
821
  async def archive_cmd(_, m: Message) -> None:
822
+ if not m.from_user:
823
+ return
824
  uid = m.from_user.id
825
  if not await _ensure_allowed_uid(uid, m):
826
  return
 
858
  # ---- batch mode ----
859
  @app.on_message(filters.command("batch"))
860
  async def batch_cmd(_, m: Message) -> None:
861
+ if not m.from_user:
862
+ return
863
  uid = m.from_user.id
864
  if not await _ensure_allowed_uid(uid, m):
865
  return
 
889
  await safe_reply(m, "No links found. Put one t.me link per line after /batch")
890
  return
891
 
 
892
  items: List[Tuple[ChatRef, int]] = []
893
  for ln in lines:
894
  try:
 
900
  continue
901
 
902
  count = (b - a + 1)
903
+ if count > _BATCH_MAX_RANGE:
904
+ await safe_reply(m, f"❌ Range too large ({count}). Max is {_BATCH_MAX_RANGE}.")
905
  if not continue_on_fail:
906
  return
907
  continue
 
913
  await safe_reply(m, "No valid items to process.")
914
  return
915
 
916
+ await safe_reply(
917
+ m,
918
+ f"🧾 Batch starting: {len(items)} item(s).\nMode: `{'continue' if continue_on_fail else 'stop_on_fail'}`",
919
+ )
920
 
921
  async def runner() -> None:
922
  batch_start = time.time()
923
  total = len(items)
 
 
 
 
924
 
925
+ for i, (chat_ref, msg_id) in enumerate(items, 1):
926
  st = await safe_reply(m, f"πŸ”Ž Batch {i}/{total}: fetching message `{msg_id}`…")
927
  if not st:
928
  if not continue_on_fail:
 
937
  break
938
  continue
939
 
940
+ media, _, _ = _media_and_filename(src)
941
  if not media:
942
  await safe_edit(st, f"⏭ Batch {i}/{total}: no media in that message. Skipped.")
943
  continue
 
945
  # Create pending upload targeting THIS chat (m), not source chat
946
  await _start_pending_upload(uid=uid, request_msg=m, src_msg=src, downloader=user_app, via_link=True)
947
 
948
+ # Force progress to edit THIS batch line
949
  p = _PENDING_UPLOAD.get(uid)
950
  if p:
951
  p.status_msg = st
952
  _PENDING_UPLOAD[uid] = p
953
 
 
954
  await safe_edit(st, f"⏳ Batch {i}/{total}: starting upload…")
955
  out = await _run_upload(uid)
956
 
957
+ if not out.get("ok") and not continue_on_fail:
958
+ await safe_reply(m, f"πŸ›‘ Batch stopped (failed at {i}/{total}).")
959
+ break
 
960
  else:
961
  await safe_edit(st, f"❌ Batch {i}/{total}: internal error (no pending).")
962
  if not continue_on_fail:
963
  break
964
 
965
  batch_dur = max(0.001, time.time() - batch_start)
966
+ await safe_reply(m, f"βœ… Batch done in `{batch_dur:.1f}s`.", reply_markup=main_menu_keyboard())
967
 
968
  t = asyncio.create_task(runner())
969
  _BATCH_TASK[uid] = t
 
972
  @app.on_callback_query()
973
  async def cb_handler(_, q: CallbackQuery) -> None:
974
  uid = q.from_user.id
975
+
976
+ # βœ… Stop Telegram loading spinner
977
+ try:
978
+ await q.answer()
979
+ except Exception:
980
+ pass
981
+
982
  action, value = parse_cb(q.data or "")
983
 
 
984
  if action == "noop":
985
  return
986
 
987
+ # Cancel from keyboards (used in filename keyboard, and we support it globally)
988
+ if action == CANCEL:
989
+ _AWAIT_AUTH.pop(uid, None)
990
+ _AWAIT_EDIT.pop(uid, None)
991
+ _PENDING_UPLOAD.pop(uid, None)
992
+ _PENDING_DELETE.pop(uid, None)
993
+ await safe_edit(q.message, CANCELLED, reply_markup=main_menu_keyboard())
994
+ return
995
+
996
  # Menus
997
  if action == MENU_HELP:
998
  await safe_edit(q.message, HELP_TEXT, reply_markup=main_menu_keyboard())
 
1001
  if action == MENU_SPEED:
1002
  if not await _ensure_allowed_uid(uid, q.message):
1003
  return
1004
+ from bot.core.speedtest import net_download_test, net_upload_test
1005
+
1006
  await safe_edit(q.message, "⏱ Running speed test…")
1007
  dl = await net_download_test()
1008
  ul = await net_upload_test()
1009
+ ip = await _get_public_ip()
1010
 
1011
  dl_bps = float((dl or {}).get("bps", 0) or 0)
1012
  ul_bps = float((ul or {}).get("bps", 0) or 0)
 
1025
  if action == MENU_AUTH:
1026
  if not await _ensure_allowed_uid(uid, q.message):
1027
  return
1028
+ _AWAIT_AUTH.pop(uid, None)
1029
  await safe_edit(q.message, "πŸ” Add a profile:", reply_markup=auth_menu_keyboard())
1030
  return
1031
 
 
1042
  return
1043
 
1044
  if action == BACK:
1045
+ _AWAIT_AUTH.pop(uid, None)
1046
  await safe_edit(q.message, "🏠 Menu", reply_markup=main_menu_keyboard())
1047
  return
1048
 
1049
+ # βœ… AUTH buttons (THIS is what was missing -> no response)
1050
+ if action == AUTH_JSON:
1051
+ if not await _ensure_allowed_uid(uid, q.message):
1052
+ return
1053
+ _AWAIT_AUTH[uid] = "json"
1054
+ await safe_edit(
1055
+ q.message,
1056
+ "πŸ“„ *Send JSON credentials*\n\n"
1057
+ "Send `client_secret.json` as a file OR paste the JSON text here.\n\n"
1058
+ "Use /cancel to stop.",
1059
+ reply_markup=auth_menu_keyboard(),
1060
+ )
1061
+ return
1062
+
1063
+ if action == AUTH_CI:
1064
+ if not await _ensure_allowed_uid(uid, q.message):
1065
+ return
1066
+ _AWAIT_AUTH[uid] = "ci"
1067
+ await safe_edit(
1068
+ q.message,
1069
+ "πŸ”‘ *Send Client ID + Secret*\n\n"
1070
+ "Send like this:\n"
1071
+ "`CLIENT_ID`\n"
1072
+ "`CLIENT_SECRET`\n"
1073
+ "(Optional 3rd line: label)\n\n"
1074
+ "Use /cancel to stop.",
1075
+ reply_markup=auth_menu_keyboard(),
1076
+ )
1077
+ return
1078
+
1079
  # Filename/Caption choice
1080
  if action in (NAME_ORIGINAL, NAME_CAPTION, NAME_CUSTOM):
1081
  p = _PENDING_UPLOAD.get(uid)
 
1084
 
1085
  if action == NAME_ORIGINAL:
1086
  p.title = (p.title_from_filename or "Untitled")[: Settings.MAX_TITLE]
 
1087
  p.description = (p.caption_raw or "")[: Settings.MAX_DESC]
1088
  _PENDING_UPLOAD[uid] = p
1089
  await safe_edit(q.message, _render_preview(p), reply_markup=upload_confirm_keyboard(p.privacy))
 
1094
  p.title = p.title_from_caption[: Settings.MAX_TITLE]
1095
  p.description = p.desc_from_caption[: Settings.MAX_DESC]
1096
  else:
 
1097
  p.title = (p.title_from_filename or "Untitled")[: Settings.MAX_TITLE]
1098
  p.description = ""
1099
  _PENDING_UPLOAD[uid] = p
 
1147
  if not p:
1148
  return
1149
  if _IN_PROGRESS.get(uid):
1150
+ await safe_edit(
1151
+ q.message,
1152
+ "⏳ Upload already running. Use /cancel to stop.",
1153
+ reply_markup=main_menu_keyboard(),
1154
+ )
1155
  return
1156
  p.status_msg = q.message
1157
  _PENDING_UPLOAD[uid] = p
 
1198
  await safe_edit(q.message, "❌ Failed to create login URL.", reply_markup=main_menu_keyboard())
1199
  return
1200
 
1201
+ # ---- text handler ----
1202
  @app.on_message(filters.text)
1203
  async def text_anywhere(_, m: Message) -> None:
1204
  if not m.from_user:
1205
  return
1206
  uid = m.from_user.id
1207
 
1208
+ # βœ… Auth CI input
1209
+ if await _handle_auth_ci_input(m):
1210
+ return
1211
+
1212
+ # βœ… Auth JSON pasted input (when user pastes text)
1213
+ if _AWAIT_AUTH.get(uid) == "json":
1214
+ if await _handle_auth_json_input(app, m):
1215
+ return
1216
+
1217
  # Confirm delete
1218
  if uid in _PENDING_DELETE:
1219
  if (m.text or "").strip().lower() == "yes":
 
1230
 
1231
  # Edit title/desc
1232
  if uid in _AWAIT_EDIT:
1233
+ _ = _AWAIT_EDIT.pop(uid)
1234
  p = _PENDING_UPLOAD.get(uid)
1235
  if not p:
1236
  return
 
1245
 
1246
  _PENDING_UPLOAD[uid] = p
1247
 
 
1248
  if p.status_msg:
1249
  await safe_edit(p.status_msg, _render_preview(p), reply_markup=upload_confirm_keyboard(p.privacy))
1250
  else: