understanding commited on
Commit
fd8a4bd
·
verified ·
1 Parent(s): 0357cab

Update bot/handlers.py

Browse files
Files changed (1) hide show
  1. bot/handlers.py +382 -237
bot/handlers.py CHANGED
@@ -3,9 +3,10 @@ from __future__ import annotations
3
 
4
  import asyncio
5
  import os
 
6
  import time
7
  from dataclasses import dataclass
8
- from typing import Any, Dict, Optional, Tuple
9
 
10
  from hydrogram import Client, filters
11
  from hydrogram.types import CallbackQuery, Message
@@ -26,13 +27,13 @@ from bot.ui.callbacks import (
26
  UP_EDIT,
27
  UP_GO,
28
  UP_PRIV,
 
29
  NAME_ORIGINAL,
30
  NAME_CAPTION,
31
  NAME_CUSTOM,
32
  BACK,
33
- UP_CANCEL,
34
  )
35
- from bot.ui.keyboards import auth_menu_keyboard, main_menu_keyboard, profiles_keyboard, upload_confirm_keyboard
36
  from bot.ui.parse import parse_cb
37
  from bot.ui.texts import CANCELLED, HELP_TEXT, NEED_AUTH, NOT_ALLOWED, OWNER_ONLY
38
  from bot.core.progress import SpeedETA, human_bytes, human_eta
@@ -40,17 +41,28 @@ from bot.core.settings import Settings
40
  from bot.core.uptime import uptime_text
41
  from bot.youtube.link_parser import parse_telegram_link
42
 
 
 
43
 
44
  @dataclass
45
  class PendingUpload:
46
- src_msg: Message
47
- downloader: Client
 
48
  file_name: str
49
- caption: str
50
- title: str
51
- description: str
 
 
 
 
 
 
 
 
52
  privacy: str = "private"
53
- title_mode: str = "caption" # filename | caption | custom
54
  status_msg: Optional[Message] = None
55
  via_link: bool = False
56
 
@@ -62,9 +74,9 @@ class EditState:
62
  privacy: str
63
 
64
 
 
65
  _PENDING_UPLOAD: Dict[int, PendingUpload] = {}
66
  _AWAIT_EDIT: Dict[int, EditState] = {}
67
- _AWAIT_AUTH_MODE: Dict[int, str] = {}
68
  _PENDING_DELETE: Dict[int, str] = {}
69
  _IN_PROGRESS: Dict[int, bool] = {}
70
  _UPLOAD_TASK: Dict[int, asyncio.Task] = {}
@@ -75,26 +87,20 @@ def _is_admin_or_owner(uid: int) -> bool:
75
  return uid in set(Auth.OWNERS) or uid in set(Auth.ADMINS)
76
 
77
 
78
- async def _ensure_allowed_msg(m: Message) -> bool:
79
- uid = m.from_user.id if m.from_user else 0
80
- if _is_admin_or_owner(uid):
81
- return True
82
- ok = await is_allowed(uid)
83
- if not ok:
84
- await safe_reply(m, NOT_ALLOWED)
85
- return ok
86
-
87
-
88
- async def _ensure_allowed_cb(uid: int, msg_to_edit: Message) -> bool:
89
  """
90
- IMPORTANT FIX:
91
- CallbackQuery should check q.from_user.id (clicker), NOT q.message.from_user (bot).
 
92
  """
93
  if _is_admin_or_owner(uid):
94
  return True
 
95
  ok = await is_allowed(uid)
96
  if not ok:
97
- await safe_edit(msg_to_edit, NOT_ALLOWED, reply_markup=main_menu_keyboard())
 
 
98
  return ok
99
 
100
 
@@ -106,153 +112,169 @@ def _media_and_filename(m: Message) -> Tuple[Optional[Any], str, int]:
106
  return None, "", 0
107
 
108
 
109
- def _build_title_desc_from_mode(src: Message, file_name: str, mode: str) -> Tuple[str, str]:
110
- cap = (src.caption or "").strip()
111
- base = os.path.splitext(file_name)[0].strip()
112
- title_from_filename = (base or "Untitled")[: Settings.MAX_TITLE]
113
 
114
- if cap:
115
- parts = cap.splitlines()
116
- title_from_caption = parts[0].strip()[: Settings.MAX_TITLE]
117
- desc_from_caption = "\n".join([p.strip() for p in parts[1:]]).strip()[: Settings.MAX_DESC]
118
- else:
119
- title_from_caption = ""
120
- desc_from_caption = ""
121
 
122
- mode = (mode or "").lower().strip()
 
 
123
 
124
- if mode == "filename":
125
- # filename as title, keep full caption as description (useful)
126
- return title_from_filename, cap[: Settings.MAX_DESC]
127
- if mode == "caption":
128
- if title_from_caption:
129
- return title_from_caption, desc_from_caption
130
- return title_from_filename, cap[: Settings.MAX_DESC]
131
- # custom fallback
132
- return title_from_filename, cap[: Settings.MAX_DESC]
133
 
 
 
 
 
 
 
 
 
134
 
135
- def _render_preview(p: PendingUpload, size: int) -> str:
136
- cap = (p.caption or "").strip()
137
- cap_short = (cap[:200] + "…") if len(cap) > 200 else (cap or "—")
138
 
139
- mode = (p.title_mode or "caption").lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  return (
141
  "📦 *Ready to upload*\n\n"
142
  f"*File:* `{p.file_name}`\n"
143
- f"*Size:* `{human_bytes(size)}`\n"
144
- f"*Privacy:* `{p.privacy}`\n"
145
- f"*Title source:* `{mode}`\n\n"
146
- f"*Caption (preview):*\n`{cap_short}`\n\n"
147
- f"*Current Title:*\n`{p.title}`\n\n"
148
- f"*Current Description:*\n"
149
- f"`{(p.description[:600] + '…') if len(p.description) > 600 else (p.description or '—')}`"
150
  )
151
 
152
 
153
  async def _start_pending_upload(
154
  *,
155
  uid: int,
156
- request_msg: Message, # where user asked (DM/group)
157
- src: Message, # actual media message (maybe from another chat)
158
  downloader: Client,
159
  via_link: bool = False,
160
- ui_msg: Optional[Message] = None, # if provided, edit this message instead of replying
161
  ) -> None:
162
- media, file_name, size = _media_and_filename(src)
163
  if not media:
164
- if ui_msg:
165
- await safe_edit(ui_msg, "❌ No video/document found in that message.", reply_markup=main_menu_keyboard())
166
- else:
167
- await safe_reply(request_msg, "❌ No video/document found in that message.")
168
  return
169
 
170
- caption = (src.caption or "").strip()
 
 
171
 
172
- # default: prefer caption if present
173
- default_mode = "caption" if caption else "filename"
174
- title, desc = _build_title_desc_from_mode(src, file_name, default_mode)
 
 
 
 
 
 
175
 
176
  p = PendingUpload(
177
- src_msg=src,
 
178
  downloader=downloader,
179
  file_name=file_name,
180
- caption=caption,
181
- title=title,
182
- description=desc,
 
 
 
 
183
  privacy="private",
184
- title_mode=default_mode,
185
  via_link=via_link,
186
  status_msg=None,
187
  )
188
 
189
- txt = _render_preview(p, size)
190
 
191
- # IMPORTANT FIX:
192
- # Preview must appear in the request chat (DM/group), not inside the source chat.
193
- if ui_msg:
194
- p.status_msg = ui_msg
195
- _PENDING_UPLOAD[uid] = p
196
- await safe_edit(ui_msg, txt, reply_markup=upload_confirm_keyboard(p.privacy, p.title_mode))
197
- return
198
 
199
- st = await safe_reply(request_msg, txt, reply_markup=upload_confirm_keyboard(p.privacy, p.title_mode))
200
  p.status_msg = st
201
  _PENDING_UPLOAD[uid] = p
202
 
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  async def _run_upload(uid: int) -> Dict[str, Any]:
 
 
 
 
205
  if _IN_PROGRESS.get(uid):
206
  return {"ok": False, "err": "already_running"}
207
 
208
  pending = _PENDING_UPLOAD.get(uid)
209
- if not pending:
210
  return {"ok": False, "err": "no_pending"}
211
 
212
- st = pending.status_msg
213
- if not st:
214
- return {"ok": False, "err": "no_status_msg"}
215
-
216
  _IN_PROGRESS[uid] = True
 
217
  file_path: Optional[str] = None
218
 
219
  try:
 
220
  prof = await get_default_profile(uid)
221
  if not (isinstance(prof, dict) and prof.get("ok") and prof.get("profile_id") and prof.get("access_token")):
222
  await safe_edit(st, NEED_AUTH, reply_markup=main_menu_keyboard())
223
- return {"ok": False, "err": "need_auth"}
224
 
225
  prof_id = str(prof["profile_id"])
226
  access_token = str(prof["access_token"])
227
 
228
  overall_start = time.time()
229
 
230
- def _as_int(v: Any) -> int:
231
- if v is None:
232
- return 0
233
- if isinstance(v, bool):
234
- return int(v)
235
- if isinstance(v, (int, float)):
236
- return int(v)
237
- if isinstance(v, str):
238
- try:
239
- return int(float(v.strip()))
240
- except Exception:
241
- return 0
242
- if isinstance(v, dict):
243
- for k in ("current", "sent", "bytes", "done", "downloaded", "uploaded", "processed"):
244
- if k in v:
245
- try:
246
- return int(float(v.get(k) or 0))
247
- except Exception:
248
- continue
249
- try:
250
- return int(float(next(iter(v.values()))))
251
- except Exception:
252
- return 0
253
- return 0
254
-
255
- # ---- Download (TG -> temp) ----
256
  await safe_edit(st, "⬇️ Downloading…")
257
  dl_speed = SpeedETA()
258
  dl_last_ui = 0.0
@@ -287,14 +309,15 @@ async def _run_upload(uid: int) -> Dict[str, Any]:
287
  except Exception:
288
  file_bytes = 0
289
 
290
- # ---- Upload (temp -> YouTube) ----
291
  from bot.youtube.uploader import upload_video
292
 
 
293
  ul_speed = SpeedETA()
294
  ul_last_ui = 0.0
295
  ul_start = time.time()
296
 
297
- async def progress_cb(sent: Any, total: Any) -> None:
298
  nonlocal ul_last_ui
299
  now = time.time()
300
  if now - ul_last_ui < 0.8:
@@ -321,7 +344,7 @@ async def _run_upload(uid: int) -> Dict[str, Any]:
321
  title=pending.title,
322
  description=pending.description,
323
  privacy=pending.privacy,
324
- progress_cb=progress_cb,
325
  )
326
 
327
  if not (isinstance(up, dict) and up.get("ok")):
@@ -330,22 +353,19 @@ async def _run_upload(uid: int) -> Dict[str, Any]:
330
 
331
  msg = f"❌ Upload failed: `{err}`"
332
  if detail:
333
- d = str(detail)
334
- msg += f"\n`{d[:280]}`"
335
- if "uploadLimitExceeded" in d or "quotaExceeded" in d:
336
- msg += "\n\nℹ️ Daily quota limit. Try another profile or retry later."
337
-
338
  await safe_edit(st, msg, reply_markup=main_menu_keyboard())
339
- return {"ok": False, "err": err, "detail": detail}
 
 
340
 
341
- url = up.get("url") or ""
342
  await record_upload(uid, prof_id)
343
 
344
  ul_dur = max(0.001, time.time() - ul_start)
345
  total_dur = max(0.001, time.time() - overall_start)
346
 
347
- dl_avg = (human_bytes(file_bytes / dl_dur) + "/s") if file_bytes else "—"
348
- ul_avg = (human_bytes(file_bytes / ul_dur) + "/s") if file_bytes else "—"
349
 
350
  done = "✅ *Uploaded!*"
351
  if url:
@@ -356,6 +376,7 @@ async def _run_upload(uid: int) -> Dict[str, Any]:
356
  f"\n*Total:* `{total_dur:.1f}s`"
357
  )
358
  await safe_edit(st, done, reply_markup=main_menu_keyboard())
 
359
  return {"ok": True, "url": url}
360
 
361
  except asyncio.CancelledError:
@@ -364,14 +385,13 @@ async def _run_upload(uid: int) -> Dict[str, Any]:
364
  except Exception:
365
  pass
366
  return {"ok": False, "err": "cancelled"}
367
-
368
  except Exception as e:
369
- await safe_edit(st, f"❌ Error: `{str(e)[:180]}`", reply_markup=main_menu_keyboard())
370
  return {"ok": False, "err": "exception", "detail": str(e)}
371
-
372
  finally:
373
  _PENDING_UPLOAD.pop(uid, None)
374
  _AWAIT_EDIT.pop(uid, None)
 
375
  if file_path:
376
  try:
377
  cleanup_file(file_path)
@@ -381,16 +401,79 @@ async def _run_upload(uid: int) -> Dict[str, Any]:
381
  _UPLOAD_TASK.pop(uid, None)
382
 
383
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
 
385
  @app.on_message(filters.command(["start", "help"]))
386
- async def start_cmd(_, m: Message) -> None:
387
- from bot.ui.texts import START_TEXT
388
- await safe_reply(m, START_TEXT, reply_markup=main_menu_keyboard())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
 
390
  @app.on_message(filters.command("cancel"))
391
  async def cancel_cmd(_, m: Message) -> None:
392
  uid = m.from_user.id
393
- _AWAIT_AUTH_MODE.pop(uid, None)
394
  _AWAIT_EDIT.pop(uid, None)
395
  _PENDING_UPLOAD.pop(uid, None)
396
  _PENDING_DELETE.pop(uid, None)
@@ -404,8 +487,10 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
404
  btask.cancel()
405
 
406
  _IN_PROGRESS.pop(uid, None)
 
407
  await safe_reply(m, CANCELLED, reply_markup=main_menu_keyboard())
408
 
 
409
  @app.on_message(filters.command("allow"))
410
  async def allow_cmd(_, m: Message) -> None:
411
  uid = m.from_user.id
@@ -479,19 +564,19 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
479
  f"Owners: `{len(Auth.OWNERS)}` Admins: `{len(Auth.ADMINS)}`",
480
  )
481
 
 
482
  @app.on_message(filters.command("auth"))
483
  async def auth_cmd(_, m: Message) -> None:
484
- if not await _ensure_allowed_msg(m):
485
- return
486
  uid = m.from_user.id
487
- _AWAIT_AUTH_MODE[uid] = "oauth"
 
488
  await safe_reply(m, "🔐 Add a profile:", reply_markup=auth_menu_keyboard())
489
 
490
  @app.on_message(filters.command("profiles"))
491
  async def profiles_cmd(_, m: Message) -> None:
492
- if not await _ensure_allowed_msg(m):
493
- return
494
  uid = m.from_user.id
 
 
495
  data = await list_profiles(uid, only_connected=False)
496
  if not (isinstance(data, dict) and data.get("ok")):
497
  await safe_reply(m, "❌ Failed to list profiles.")
@@ -500,23 +585,22 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
500
  default_id = data.get("default_profile_id") or ""
501
  await safe_reply(m, "👤 *Profiles*", reply_markup=profiles_keyboard(profiles, default_id))
502
 
503
- # accept media in any chat (DM/group) - still gated by allowed
504
- @app.on_message((filters.video | filters.document))
505
- async def media_in_chat(_, m: Message) -> None:
506
- if not await _ensure_allowed_msg(m):
507
- return
508
  uid = m.from_user.id
 
 
509
  if _IN_PROGRESS.get(uid):
510
  await safe_reply(m, "⏳ Upload already running. Use /cancel to stop.")
511
  return
 
512
 
513
- await _start_pending_upload(uid=uid, request_msg=m, src=m, downloader=app, via_link=False, ui_msg=None)
514
-
515
- # ✅ /yt /dl /archive in ANY chat; preview + progress stays in request chat
516
  @app.on_message(filters.command(["yt", "dl", "archive"]))
517
  async def archive_cmd(_, m: Message) -> None:
518
  uid = m.from_user.id
519
- if not await _ensure_allowed_msg(m):
520
  return
521
  if not _is_admin_or_owner(uid):
522
  await safe_reply(m, OWNER_ONLY)
@@ -527,7 +611,7 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
527
 
528
  args = (m.text or "").split(maxsplit=1)
529
  if len(args) < 2:
530
- await safe_reply(m, "Usage: /yt <t.me message link>")
531
  return
532
 
533
  st = await safe_reply(m, "🔎 Fetching message…")
@@ -537,24 +621,24 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
537
  try:
538
  chat_ref, msg_id = parse_telegram_link(args[1].strip())
539
  except Exception as e:
540
- await safe_edit(st, f"Bad link: {e}", reply_markup=main_menu_keyboard())
541
  return
542
 
543
  try:
544
  src = await user_app.get_messages(chat_ref, msg_id)
545
  except Exception as e:
546
- await safe_edit(st, f"❌ Fetch failed: `{str(e)[:180]}`", reply_markup=main_menu_keyboard())
547
  return
548
 
549
  await safe_edit(st, "✅ Message fetched. Preparing preview…")
550
- await _start_pending_upload(uid=uid, request_msg=m, src=src, downloader=user_app, via_link=True, ui_msg=st)
551
 
552
- # batch sequential, stop on first failure by default
553
  @app.on_message(filters.command("batch"))
554
  async def batch_cmd(_, m: Message) -> None:
555
- if not await _ensure_allowed_msg(m):
556
- return
557
  uid = m.from_user.id
 
 
558
  if not _is_admin_or_owner(uid):
559
  await safe_reply(m, OWNER_ONLY)
560
  return
@@ -562,51 +646,100 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
562
  await safe_reply(m, "❌ Batch mode is not configured (user session missing).")
563
  return
564
 
565
- args = (m.text or "").split(maxsplit=1)
 
 
 
 
 
566
  if len(args) < 2:
567
- await safe_reply(m, "Send: /batch <t.me message links> (one per line)")
568
  return
569
 
570
- links = [ln.strip() for ln in args[1].splitlines() if ln.strip()]
571
- if not links:
 
 
 
 
572
  await safe_reply(m, "No links found. Put one t.me link per line after /batch")
573
  return
574
 
575
- if _BATCH_TASK.get(uid) and not _BATCH_TASK[uid].done():
576
- await safe_reply(m, "⏳ A batch is already running. Use /cancel to stop it.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
  return
578
 
579
- await safe_reply(m, f"🧾 Batch starting: {len(links)} item(s). (Sequential queue)")
580
 
581
  async def runner() -> None:
582
  batch_start = time.time()
583
- for i, link in enumerate(links, 1):
584
- st = await safe_reply(m, f"🔎 Batch {i}/{len(links)}: fetching…")
 
 
 
 
 
585
  if not st:
 
 
586
  continue
587
 
588
- try:
589
- chat_ref, msg_id = parse_telegram_link(link)
590
- except Exception as e:
591
- await safe_edit(st, f"❌ Batch {i}/{len(links)} bad link: {e}", reply_markup=main_menu_keyboard())
592
- return
593
-
594
  try:
595
  src = await user_app.get_messages(chat_ref, msg_id)
596
  except Exception as e:
597
- await safe_edit(st, f"❌ Batch {i}/{len(links)} fetch failed: `{str(e)[:180]}`", reply_markup=main_menu_keyboard())
598
- return
 
 
599
 
600
- await safe_edit(st, f"✅ Batch {i}/{len(links)}: preview…")
601
- await _start_pending_upload(uid=uid, request_msg=m, src=src, downloader=user_app, via_link=True, ui_msg=st)
 
 
602
 
603
- await safe_edit(st, f"⏳ Batch {i}/{len(links)}: starting upload…")
604
- res = await _run_upload(uid)
605
 
606
- if not (isinstance(res, dict) and res.get("ok")):
607
- # STOP ON FAILURE (your requested behavior)
608
- await safe_reply(m, f"🛑 Batch stopped at item {i}/{len(links)} due to failure.")
609
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
 
611
  batch_dur = max(0.001, time.time() - batch_start)
612
  await safe_reply(m, f"✅ Batch done in {batch_dur:.1f}s.")
@@ -614,59 +747,52 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
614
  t = asyncio.create_task(runner())
615
  _BATCH_TASK[uid] = t
616
 
 
617
  @app.on_callback_query()
618
  async def cb_handler(_, q: CallbackQuery) -> None:
619
  uid = q.from_user.id
620
  action, value = parse_cb(q.data or "")
621
 
622
- if action == MENU_HELP:
623
- await safe_edit(q.message, HELP_TEXT, reply_markup=main_menu_keyboard())
624
  return
625
 
626
- if action == BACK:
627
- from bot.ui.texts import START_TEXT
628
- await safe_edit(q.message, START_TEXT, reply_markup=main_menu_keyboard())
629
  return
630
 
631
  if action == MENU_SPEED:
632
- if not await _ensure_allowed_cb(uid, q.message):
633
  return
634
- from bot.core.speedtest import net_download_test, net_upload_test, public_ip, ping_ms
635
  await safe_edit(q.message, "⏱ Running speed test…")
636
-
637
- ip = await public_ip()
638
- ping = await ping_ms()
639
-
640
  dl = await net_download_test()
641
  ul = await net_upload_test()
 
642
 
643
  dl_bps = float((dl or {}).get("bps", 0) or 0)
644
  ul_bps = float((ul or {}).get("bps", 0) or 0)
645
 
646
- lines = [
647
- "📶 *Speed Test*",
648
- f"Uptime: `{uptime_text()}`",
649
- ]
650
- if ip:
651
- lines.append(f"Public IP: `{ip}`")
652
- if ping is not None:
653
- lines.append(f"Ping(443): `{ping:.0f} ms`")
654
-
655
- lines.append("")
656
- lines.append(f"Download: `{human_bytes(dl_bps)}/s`")
657
- lines.append(f"Upload: `{human_bytes(ul_bps)}/s`")
658
-
659
- await safe_edit(q.message, "\n".join(lines), reply_markup=main_menu_keyboard())
660
  return
661
 
662
  if action == MENU_AUTH:
663
- if not await _ensure_allowed_cb(uid, q.message):
664
  return
665
  await safe_edit(q.message, "🔐 Add a profile:", reply_markup=auth_menu_keyboard())
666
  return
667
 
668
  if action == MENU_PROFILES:
669
- if not await _ensure_allowed_cb(uid, q.message):
670
  return
671
  data = await list_profiles(uid, only_connected=False)
672
  if not (isinstance(data, dict) and data.get("ok")):
@@ -677,37 +803,48 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
677
  await safe_edit(q.message, "👤 *Profiles*", reply_markup=profiles_keyboard(profiles, default_id))
678
  return
679
 
680
- # title-source buttons
 
 
 
 
681
  if action in (NAME_ORIGINAL, NAME_CAPTION, NAME_CUSTOM):
682
  p = _PENDING_UPLOAD.get(uid)
683
  if not p:
684
  return
685
 
686
  if action == NAME_ORIGINAL:
687
- p.title_mode = "filename"
688
- p.title, p.description = _build_title_desc_from_mode(p.src_msg, p.file_name, "filename")
689
- elif action == NAME_CAPTION:
690
- p.title_mode = "caption"
691
- p.title, p.description = _build_title_desc_from_mode(p.src_msg, p.file_name, "caption")
692
- else:
693
- # custom -> reuse edit flow
694
- p.title_mode = "custom"
695
- _AWAIT_EDIT[uid] = EditState(title=p.title, description=p.description, privacy=p.privacy)
 
 
 
 
 
 
696
  _PENDING_UPLOAD[uid] = p
 
 
 
 
 
697
  await safe_edit(
698
  q.message,
699
- "✍️ Send new title + description like this:\n\n"
700
  "Title line\n"
701
  "Description lines…\n\n"
702
  "(Send only 1 line to change title only)",
703
  )
704
  return
705
 
706
- _PENDING_UPLOAD[uid] = p
707
- media, _, size = _media_and_filename(p.src_msg)
708
- await safe_edit(q.message, _render_preview(p, size), reply_markup=upload_confirm_keyboard(p.privacy, p.title_mode))
709
- return
710
-
711
  if action == UP_PRIV:
712
  p = _PENDING_UPLOAD.get(uid)
713
  if not p:
@@ -715,20 +852,17 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
715
  cycle = {"private": "unlisted", "unlisted": "public", "public": "private"}
716
  p.privacy = cycle.get(p.privacy, "private")
717
  _PENDING_UPLOAD[uid] = p
718
- _, _, size = _media_and_filename(p.src_msg)
719
- await safe_edit(q.message, _render_preview(p, size), reply_markup=upload_confirm_keyboard(p.privacy, p.title_mode))
720
  return
721
 
722
  if action == UP_EDIT:
723
  p = _PENDING_UPLOAD.get(uid)
724
  if not p:
725
  return
726
- p.title_mode = "custom"
727
  _AWAIT_EDIT[uid] = EditState(title=p.title, description=p.description, privacy=p.privacy)
728
- _PENDING_UPLOAD[uid] = p
729
  await safe_edit(
730
  q.message,
731
- "✍️ Send new title + description like this:\n\n"
732
  "Title line\n"
733
  "Description lines…\n\n"
734
  "(Send only 1 line to change title only)",
@@ -737,6 +871,7 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
737
 
738
  if action in (UP_DEL, UP_CANCEL):
739
  _PENDING_UPLOAD.pop(uid, None)
 
740
  await safe_edit(q.message, CANCELLED, reply_markup=main_menu_keyboard())
741
  return
742
 
@@ -754,9 +889,9 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
754
  _UPLOAD_TASK[uid] = t
755
  return
756
 
757
- # profile actions
758
  if action == "pdel":
759
- if not await _ensure_allowed_cb(uid, q.message):
760
  return
761
  pid = value
762
  _PENDING_DELETE[uid] = pid
@@ -764,7 +899,7 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
764
  return
765
 
766
  if action == "pdef":
767
- if not await _ensure_allowed_cb(uid, q.message):
768
  return
769
  pid = value
770
  out = await set_default_profile(uid, pid)
@@ -778,7 +913,7 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
778
  return
779
 
780
  if action == "plog":
781
- if not await _ensure_allowed_cb(uid, q.message):
782
  return
783
  pid = value
784
  chk = await profile_check_auth(uid, pid)
@@ -792,10 +927,14 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
792
  await safe_edit(q.message, "❌ Failed to create login URL.", reply_markup=main_menu_keyboard())
793
  return
794
 
 
795
  @app.on_message(filters.text)
796
- async def text_in_chat(_, m: Message) -> None:
797
- uid = m.from_user.id if m.from_user else 0
 
 
798
 
 
799
  if uid in _PENDING_DELETE:
800
  if (m.text or "").strip().lower() == "yes":
801
  pid = _PENDING_DELETE.pop(uid)
@@ -809,11 +948,13 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
809
  await safe_reply(m, "Cancelled.", reply_markup=main_menu_keyboard())
810
  return
811
 
 
812
  if uid in _AWAIT_EDIT:
813
- _AWAIT_EDIT.pop(uid, None)
814
  p = _PENDING_UPLOAD.get(uid)
815
  if not p:
816
  return
 
817
  txt = (m.text or "").strip()
818
  lines = txt.splitlines()
819
  if len(lines) == 1:
@@ -821,8 +962,12 @@ def setup_handlers(app: Client, user_app: Optional[Client]) -> None:
821
  else:
822
  p.title = lines[0].strip()[: Settings.MAX_TITLE]
823
  p.description = "\n".join(lines[1:]).strip()[: Settings.MAX_DESC]
824
- p.title_mode = "custom"
825
  _PENDING_UPLOAD[uid] = p
826
- _, _, size = _media_and_filename(p.src_msg)
827
- await safe_reply(m, _render_preview(p, size), reply_markup=upload_confirm_keyboard(p.privacy, p.title_mode))
 
 
 
 
828
  return
 
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
 
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
 
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
50
+ src_msg: Message # message containing media to download (could be from user session)
51
+ downloader: Client # which client downloads src_msg media
52
  file_name: str
53
+ size_hint: int = 0
54
+
55
+ # candidates
56
+ caption_raw: str = ""
57
+ title_from_filename: str = ""
58
+ title_from_caption: str = ""
59
+ desc_from_caption: str = ""
60
+
61
+ # chosen final
62
+ title: str = ""
63
+ description: str = ""
64
  privacy: str = "private"
65
+
66
  status_msg: Optional[Message] = None
67
  via_link: bool = False
68
 
 
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] = {}
81
  _IN_PROGRESS: Dict[int, bool] = {}
82
  _UPLOAD_TASK: Dict[int, asyncio.Task] = {}
 
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:
 
 
 
 
 
 
 
 
 
 
91
  """
92
+ IMPORTANT:
93
+ - For Message handlers: uid = m.from_user.id
94
+ - For CallbackQuery handlers: uid = q.from_user.id (NOT q.message.from_user.id)
95
  """
96
  if _is_admin_or_owner(uid):
97
  return True
98
+
99
  ok = await is_allowed(uid)
100
  if not ok:
101
+ # try edit first (callbacks), fallback to reply
102
+ if not await safe_edit(reply_target, NOT_ALLOWED, reply_markup=main_menu_keyboard()):
103
+ await safe_reply(reply_target, NOT_ALLOWED, reply_markup=main_menu_keyboard())
104
  return ok
105
 
106
 
 
112
  return None, "", 0
113
 
114
 
115
+ def _caption_text(m: Message) -> str:
116
+ return (m.caption or "").strip()
 
 
117
 
 
 
 
 
 
 
 
118
 
119
+ def _filename_title(file_name: str) -> str:
120
+ base = os.path.splitext(file_name or "")[0].strip()
121
+ return (base or "Untitled")[: Settings.MAX_TITLE]
122
 
 
 
 
 
 
 
 
 
 
123
 
124
+ def _caption_title_desc(caption: str) -> Tuple[str, str]:
125
+ caption = (caption or "").strip()
126
+ if not caption:
127
+ return "", ""
128
+ parts = caption.splitlines()
129
+ title = parts[0].strip()[: Settings.MAX_TITLE]
130
+ desc = "\n".join([p.strip() for p in parts[1:]]).strip()[: Settings.MAX_DESC]
131
+ return title, desc
132
 
 
 
 
133
 
134
+ def _render_choice_prompt(p: PendingUpload) -> str:
135
+ cap = p.caption_raw.strip()
136
+ cap_show = cap if len(cap) <= 900 else (cap[:900] + "…")
137
+
138
+ return (
139
+ "📝 *Choose YouTube title/description source*\n\n"
140
+ f"📄 *Filename*\n`{p.file_name}`\n"
141
+ f"→ Title: *{p.title_from_filename or '—'}*\n\n"
142
+ f"📝 *Caption*\n{cap_show if cap_show else '—'}\n"
143
+ f"→ Title: *{p.title_from_caption or '—'}*\n\n"
144
+ "Pick one option below:"
145
+ )
146
+
147
+
148
+ def _render_preview(p: PendingUpload) -> str:
149
+ cap = p.caption_raw.strip()
150
+ cap_show = cap if len(cap) <= 400 else (cap[:400] + "…")
151
+ desc = (p.description or "").strip()
152
+ desc_show = desc if len(desc) <= 800 else (desc[:800] + "…")
153
+
154
+ size_line = human_bytes(p.size_hint) if p.size_hint else "—"
155
+
156
  return (
157
  "📦 *Ready to upload*\n\n"
158
  f"*File:* `{p.file_name}`\n"
159
+ f"*Size:* `{size_line}`\n"
160
+ f"*Privacy:* `{p.privacy}`\n\n"
161
+ f"*Caption:* {cap_show if cap_show else '—'}\n\n"
162
+ f"*Title:* {p.title or '—'}\n"
163
+ f"*Description:* {desc_show if desc_show else '—'}"
 
 
164
  )
165
 
166
 
167
  async def _start_pending_upload(
168
  *,
169
  uid: int,
170
+ request_msg: Message, # where to show preview/progress
171
+ src_msg: Message, # where media actually is
172
  downloader: Client,
173
  via_link: bool = False,
 
174
  ) -> None:
175
+ media, file_name, size = _media_and_filename(src_msg)
176
  if not media:
177
+ await safe_reply(request_msg, "❌ No video/document found in that message.")
 
 
 
178
  return
179
 
180
+ caption = _caption_text(src_msg)
181
+ t_fn = _filename_title(file_name)
182
+ t_cap, d_cap = _caption_title_desc(caption)
183
 
184
+ # Default choice:
185
+ # - If caption exists, default title+desc from caption
186
+ # - Else filename title
187
+ if caption and t_cap:
188
+ chosen_title = t_cap
189
+ chosen_desc = d_cap
190
+ else:
191
+ chosen_title = t_fn
192
+ chosen_desc = caption[: Settings.MAX_DESC] if caption else ""
193
 
194
  p = PendingUpload(
195
+ request_msg=request_msg,
196
+ src_msg=src_msg,
197
  downloader=downloader,
198
  file_name=file_name,
199
+ size_hint=int(size or 0),
200
+ caption_raw=caption,
201
+ title_from_filename=t_fn,
202
+ title_from_caption=t_cap,
203
+ desc_from_caption=d_cap,
204
+ title=chosen_title,
205
+ description=chosen_desc,
206
  privacy="private",
 
207
  via_link=via_link,
208
  status_msg=None,
209
  )
210
 
211
+ _PENDING_UPLOAD[uid] = p
212
 
213
+ # Create ONE status message in the request chat. Everything edits THIS message.
214
+ if caption:
215
+ st = await safe_reply(request_msg, _render_choice_prompt(p), reply_markup=filename_keyboard())
216
+ else:
217
+ st = await safe_reply(request_msg, _render_preview(p), reply_markup=upload_confirm_keyboard(p.privacy))
 
 
218
 
 
219
  p.status_msg = st
220
  _PENDING_UPLOAD[uid] = p
221
 
222
 
223
+ def _as_int(v: Any) -> int:
224
+ if v is None:
225
+ return 0
226
+ if isinstance(v, bool):
227
+ return int(v)
228
+ if isinstance(v, (int, float)):
229
+ return int(v)
230
+ if isinstance(v, str):
231
+ try:
232
+ return int(float(v.strip()))
233
+ except Exception:
234
+ return 0
235
+ if isinstance(v, dict):
236
+ for k in ("current", "sent", "bytes", "done", "downloaded", "uploaded", "processed", "offset"):
237
+ if k in v:
238
+ try:
239
+ return int(float(v.get(k) or 0))
240
+ except Exception:
241
+ continue
242
+ try:
243
+ return int(float(next(iter(v.values()))))
244
+ except Exception:
245
+ return 0
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.
252
+ Returns: {"ok": True, "url": "..."} or {"ok": False, "err": "..."}
253
+ """
254
  if _IN_PROGRESS.get(uid):
255
  return {"ok": False, "err": "already_running"}
256
 
257
  pending = _PENDING_UPLOAD.get(uid)
258
+ if not pending or not pending.status_msg:
259
  return {"ok": False, "err": "no_pending"}
260
 
 
 
 
 
261
  _IN_PROGRESS[uid] = True
262
+ st = pending.status_msg
263
  file_path: Optional[str] = None
264
 
265
  try:
266
+ # Ensure default profile exists
267
  prof = await get_default_profile(uid)
268
  if not (isinstance(prof, dict) and prof.get("ok") and prof.get("profile_id") and prof.get("access_token")):
269
  await safe_edit(st, NEED_AUTH, reply_markup=main_menu_keyboard())
270
+ return {"ok": False, "err": "not_authorized"}
271
 
272
  prof_id = str(prof["profile_id"])
273
  access_token = str(prof["access_token"])
274
 
275
  overall_start = time.time()
276
 
277
+ # ---- Download with live speed ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  await safe_edit(st, "⬇️ Downloading…")
279
  dl_speed = SpeedETA()
280
  dl_last_ui = 0.0
 
309
  except Exception:
310
  file_bytes = 0
311
 
312
+ # ---- Upload with live speed ----
313
  from bot.youtube.uploader import upload_video
314
 
315
+ await safe_edit(st, "⬆️ Uploading…")
316
  ul_speed = SpeedETA()
317
  ul_last_ui = 0.0
318
  ul_start = time.time()
319
 
320
+ async def ul_progress(sent: Any, total: Any) -> None:
321
  nonlocal ul_last_ui
322
  now = time.time()
323
  if now - ul_last_ui < 0.8:
 
344
  title=pending.title,
345
  description=pending.description,
346
  privacy=pending.privacy,
347
+ progress_cb=ul_progress,
348
  )
349
 
350
  if not (isinstance(up, dict) and up.get("ok")):
 
353
 
354
  msg = f"❌ Upload failed: `{err}`"
355
  if detail:
356
+ msg += f"\n`{str(detail)[:280]}`"
 
 
 
 
357
  await safe_edit(st, msg, reply_markup=main_menu_keyboard())
358
+ return {"ok": False, "err": str(err), "detail": detail}
359
+
360
+ url = str(up.get("url") or "")
361
 
 
362
  await record_upload(uid, prof_id)
363
 
364
  ul_dur = max(0.001, time.time() - ul_start)
365
  total_dur = max(0.001, time.time() - overall_start)
366
 
367
+ dl_avg = (human_bytes(file_bytes / dl_dur) + "/s") if file_bytes and dl_dur else "—"
368
+ ul_avg = (human_bytes(file_bytes / ul_dur) + "/s") if file_bytes and ul_dur else "—"
369
 
370
  done = "✅ *Uploaded!*"
371
  if url:
 
376
  f"\n*Total:* `{total_dur:.1f}s`"
377
  )
378
  await safe_edit(st, done, reply_markup=main_menu_keyboard())
379
+
380
  return {"ok": True, "url": url}
381
 
382
  except asyncio.CancelledError:
 
385
  except Exception:
386
  pass
387
  return {"ok": False, "err": "cancelled"}
 
388
  except Exception as e:
389
+ await safe_edit(st, f"❌ Error: `{str(e)[:220]}`", reply_markup=main_menu_keyboard())
390
  return {"ok": False, "err": "exception", "detail": str(e)}
 
391
  finally:
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)
 
401
  _UPLOAD_TASK.pop(uid, None)
402
 
403
 
404
+ _BATCH_RANGE_RE = re.compile(r"(.*?)/(\d+)\s*-\s*(\d+)\s*$")
405
+
406
+
407
+ def _parse_link_or_range(line: str) -> Tuple[ChatRef, int, int]:
408
+ """
409
+ Accepts:
410
+ https://t.me/c/<chat>/<4012>
411
+ https://t.me/c/<chat>/<4012-4046>
412
+ https://t.me/<user>/<10-22>
413
+ Returns (chat_ref, start, end)
414
+ """
415
+ s = (line or "").strip()
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()
423
+ a = int(m.group(2))
424
+ b = int(m.group(3))
425
+ if a <= 0 or b <= 0:
426
+ raise ValueError("bad_range")
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"))
452
+ async def speedtest_cmd(_, m: Message) -> 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)
464
+
465
+ msg = (
466
+ "📶 *Speed Test*\n\n"
467
+ f"*Uptime:* `{uptime_text()}`\n"
468
+ f"*Public IP:* `{ip or '—'}`\n\n"
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)
 
487
  btask.cancel()
488
 
489
  _IN_PROGRESS.pop(uid, None)
490
+
491
  await safe_reply(m, CANCELLED, reply_markup=main_menu_keyboard())
492
 
493
+ # ---- admin allowlist ----
494
  @app.on_message(filters.command("allow"))
495
  async def allow_cmd(_, m: Message) -> None:
496
  uid = m.from_user.id
 
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)
581
  if not (isinstance(data, dict) and data.get("ok")):
582
  await safe_reply(m, "❌ Failed to list profiles.")
 
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):
595
  await safe_reply(m, "⏳ Upload already running. Use /cancel to stop.")
596
  return
597
+ await _start_pending_upload(uid=uid, request_msg=m, src_msg=m, downloader=app, via_link=False)
598
 
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
605
  if not _is_admin_or_owner(uid):
606
  await safe_reply(m, OWNER_ONLY)
 
611
 
612
  args = (m.text or "").split(maxsplit=1)
613
  if len(args) < 2:
614
+ await safe_reply(m, "Usage: /archive <t.me message link>")
615
  return
616
 
617
  st = await safe_reply(m, "🔎 Fetching message…")
 
621
  try:
622
  chat_ref, msg_id = parse_telegram_link(args[1].strip())
623
  except Exception as e:
624
+ await safe_edit(st, f"Bad link: `{str(e)[:160]}`")
625
  return
626
 
627
  try:
628
  src = await user_app.get_messages(chat_ref, msg_id)
629
  except Exception as e:
630
+ await safe_edit(st, f"❌ Fetch failed: `{str(e)[:180]}`")
631
  return
632
 
633
  await safe_edit(st, "✅ Message fetched. Preparing preview…")
634
+ await _start_pending_upload(uid=uid, request_msg=m, src_msg=src, downloader=user_app, via_link=True)
635
 
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
642
  if not _is_admin_or_owner(uid):
643
  await safe_reply(m, OWNER_ONLY)
644
  return
 
646
  await safe_reply(m, "❌ Batch mode is not configured (user session missing).")
647
  return
648
 
649
+ if _BATCH_TASK.get(uid) and not _BATCH_TASK[uid].done():
650
+ await safe_reply(m, "⏳ A batch is already running. Use /cancel to stop it.")
651
+ return
652
+
653
+ raw = (m.text or "")
654
+ args = raw.split(maxsplit=1)
655
  if len(args) < 2:
656
+ await safe_reply(m, "Send: /batch <t.me links> (one per line)\nOptional: /batch --continue <links...>")
657
  return
658
 
659
+ payload = args[1].strip()
660
+ continue_on_fail = ("--continue" in payload) or ("-c" in payload)
661
+ payload = payload.replace("--continue", "").replace("-c", "").strip()
662
+
663
+ lines = [ln.strip() for ln in payload.splitlines() if ln.strip()]
664
+ if not lines:
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:
672
+ chat_ref, a, b = _parse_link_or_range(ln)
673
+ except Exception:
674
+ await safe_reply(m, f"❌ Bad link/range: `{ln}`")
675
+ if not continue_on_fail:
676
+ return
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
685
+
686
+ for mid in range(a, b + 1):
687
+ items.append((chat_ref, mid))
688
+
689
+ if not items:
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:
706
+ break
707
  continue
708
 
 
 
 
 
 
 
709
  try:
710
  src = await user_app.get_messages(chat_ref, msg_id)
711
  except Exception as e:
712
+ await safe_edit(st, f"❌ Batch {i}/{total} fetch failed: `{str(e)[:180]}`")
713
+ if not continue_on_fail:
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
721
 
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.")
 
747
  t = asyncio.create_task(runner())
748
  _BATCH_TASK[uid] = t
749
 
750
+ # ---- callbacks ----
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())
763
  return
764
 
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)
776
 
777
+ await safe_edit(
778
+ q.message,
779
+ "📶 *Speed Test*\n\n"
780
+ f"*Uptime:* `{uptime_text()}`\n"
781
+ f"*Public IP:* `{ip or '—'}`\n\n"
782
+ f"*Download:* `{human_bytes(dl_bps)}/s`\n"
783
+ f"*Upload:* `{human_bytes(ul_bps)}/s`",
784
+ reply_markup=main_menu_keyboard(),
785
+ )
 
 
 
 
 
786
  return
787
 
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
 
794
  if action == MENU_PROFILES:
795
+ if not await _ensure_allowed_uid(uid, q.message):
796
  return
797
  data = await list_profiles(uid, only_connected=False)
798
  if not (isinstance(data, dict) and data.get("ok")):
 
803
  await safe_edit(q.message, "👤 *Profiles*", reply_markup=profiles_keyboard(profiles, default_id))
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)
813
  if not p:
814
  return
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))
822
+ return
823
+
824
+ if action == NAME_CAPTION:
825
+ if p.title_from_caption:
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
833
+ await safe_edit(q.message, _render_preview(p), reply_markup=upload_confirm_keyboard(p.privacy))
834
+ return
835
+
836
+ if action == NAME_CUSTOM:
837
+ _AWAIT_EDIT[uid] = EditState(title=p.title, description=p.description, privacy=p.privacy)
838
  await safe_edit(
839
  q.message,
840
+ "✍️ Send custom title + description like:\n\n"
841
  "Title line\n"
842
  "Description lines…\n\n"
843
  "(Send only 1 line to change title only)",
844
  )
845
  return
846
 
847
+ # Upload buttons
 
 
 
 
848
  if action == UP_PRIV:
849
  p = _PENDING_UPLOAD.get(uid)
850
  if not p:
 
852
  cycle = {"private": "unlisted", "unlisted": "public", "public": "private"}
853
  p.privacy = cycle.get(p.privacy, "private")
854
  _PENDING_UPLOAD[uid] = p
855
+ await safe_edit(q.message, _render_preview(p), reply_markup=upload_confirm_keyboard(p.privacy))
 
856
  return
857
 
858
  if action == UP_EDIT:
859
  p = _PENDING_UPLOAD.get(uid)
860
  if not p:
861
  return
 
862
  _AWAIT_EDIT[uid] = EditState(title=p.title, description=p.description, privacy=p.privacy)
 
863
  await safe_edit(
864
  q.message,
865
+ "✍️ Send new title + description like:\n\n"
866
  "Title line\n"
867
  "Description lines…\n\n"
868
  "(Send only 1 line to change title only)",
 
871
 
872
  if action in (UP_DEL, UP_CANCEL):
873
  _PENDING_UPLOAD.pop(uid, None)
874
+ _AWAIT_EDIT.pop(uid, None)
875
  await safe_edit(q.message, CANCELLED, reply_markup=main_menu_keyboard())
876
  return
877
 
 
889
  _UPLOAD_TASK[uid] = t
890
  return
891
 
892
+ # Profile callbacks (from profiles_keyboard)
893
  if action == "pdel":
894
+ if not await _ensure_allowed_uid(uid, q.message):
895
  return
896
  pid = value
897
  _PENDING_DELETE[uid] = pid
 
899
  return
900
 
901
  if action == "pdef":
902
+ if not await _ensure_allowed_uid(uid, q.message):
903
  return
904
  pid = value
905
  out = await set_default_profile(uid, pid)
 
913
  return
914
 
915
  if action == "plog":
916
+ if not await _ensure_allowed_uid(uid, q.message):
917
  return
918
  pid = value
919
  chk = await profile_check_auth(uid, pid)
 
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":
940
  pid = _PENDING_DELETE.pop(uid)
 
948
  await safe_reply(m, "Cancelled.", reply_markup=main_menu_keyboard())
949
  return
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
957
+
958
  txt = (m.text or "").strip()
959
  lines = txt.splitlines()
960
  if len(lines) == 1:
 
962
  else:
963
  p.title = lines[0].strip()[: Settings.MAX_TITLE]
964
  p.description = "\n".join(lines[1:]).strip()[: Settings.MAX_DESC]
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:
972
+ await safe_reply(m, _render_preview(p), reply_markup=upload_confirm_keyboard(p.privacy))
973
  return