understanding commited on
Commit
2bfb43c
·
verified ·
1 Parent(s): 76c0ac4

Update bot/handlers.py

Browse files
Files changed (1) hide show
  1. bot/handlers.py +600 -682
bot/handlers.py CHANGED
@@ -1,807 +1,725 @@
1
- # PATH: bot/handlers.py
2
  from __future__ import annotations
3
 
4
- import asyncio
5
- import json
6
  import os
 
7
  import time
8
- from typing import Dict, Optional, Any
 
 
9
 
10
  from hydrogram import Client, filters
11
- from hydrogram.types import Message, CallbackQuery
12
-
13
- from bot.config import Telegram, Workers
14
- from bot.ui import texts
15
- from bot.ui.keyboards import (
16
- main_menu_keyboard,
17
- auth_menu_keyboard,
18
- profiles_keyboard,
19
- filename_keyboard,
20
- upload_confirm_keyboard,
21
- )
22
- from bot.ui.callbacks import (
23
- parse_cb,
24
- AUTH_JSON, AUTH_CI, CANCEL, BACK,
25
- MENU_HELP, MENU_AUTH, MENU_PROFILES, MENU_SPEEDTEST,
26
- NAME_ORIGINAL, NAME_CAPTION, NAME_CUSTOM,
27
- UP_GO, UP_EDIT, UP_PRIV, UP_CANCEL,
28
  )
29
 
30
- from bot.core.auth import is_owner_id, is_admin_id, require_allowed
31
- from bot.core.link_parser import parse_link
32
  from bot.core.progress import SpeedETA, human_bytes, human_eta
33
- from bot.core.speedtest import (
34
- ping_ms,
35
- net_download_test,
36
- net_upload_test,
37
- disk_total_free,
38
- bytes_per_sec_to_mb_s,
39
  )
40
- from bot.integrations.cf_worker1 import profile_add
 
41
  from bot.integrations.cf_worker2 import (
42
- allow_user,
43
- disallow_user,
44
  pick_profile,
45
  access_token,
46
  record_upload,
 
 
47
  stats_today,
48
- profile_list,
49
- profile_set_default,
50
  )
51
- from bot.integrations.diag_extra import dns_check
52
- from bot.integrations.http import fetch_status
53
 
54
- from bot.telegram.media import download_to_temp
55
- from bot.telegram.parse import extract_title_description
56
  from bot.telegram.replies import safe_reply, safe_edit
 
 
57
  from bot.temp.files import cleanup_file
58
- from bot.youtube.uploader import upload_video
59
-
60
-
61
- # -----------------------
62
- # GLOBAL STATE (in-memory)
63
- # -----------------------
64
- _AWAIT_AUTH: Dict[int, str] = {} # {uid: "json"|"ci"}
65
- _AWAIT_CUSTOM: Dict[int, bool] = {} # {uid: True}
66
- _AWAIT_EDIT: Dict[int, bool] = {} # {uid: True}
67
- _SPEED_COOLDOWN: Dict[int, float] = {} # {uid: last_ts}
68
-
69
- # pending job state per-user:
70
- # {
71
- # "mode": "direct"|"link",
72
- # "src_msg": Message,
73
- # "status_msg": Message,
74
- # "downloader": Client,
75
- # "original_name": str,
76
- # "caption_name": str,
77
- # "filename": str (chosen),
78
- # "title": str,
79
- # "description": str,
80
- # "privacy": str,
81
- # "link": str,
82
- # }
83
- _PENDING: Dict[int, Dict[str, Any]] = {}
84
-
85
- # batch gate (one batch at a time)
86
- BATCH_SEM = asyncio.Semaphore(1)
87
- _STARTED_AT = time.time()
88
-
89
-
90
- # -----------------------
91
- # HELPERS
92
- # -----------------------
93
- def _pick_login_url(j: dict) -> str:
94
- if not isinstance(j, dict):
95
- return ""
96
- data = j.get("data") if isinstance(j.get("data"), dict) else {}
97
-
98
- def _get(d: dict, k: str) -> str:
99
- v = d.get(k)
100
- return (v or "").strip() if isinstance(v, str) else ""
101
 
102
- return (
103
- _get(j, "login_url")
104
- or _get(j, "loginUrl")
105
- or _get(j, "auth_url")
106
- or _get(j, "url")
107
- or _get(data, "login_url")
108
- or _get(data, "loginUrl")
109
- or _get(data, "auth_url")
110
- or _get(data, "url")
111
- )
112
-
113
-
114
- async def _render_speedtest(mb: int) -> str:
115
- mb = max(1, min(200, int(mb)))
116
- dl_bytes = mb * 1024 * 1024
117
- up_mb = min(20, max(3, mb // 5))
118
- up_bytes = up_mb * 1024 * 1024
119
- up_sec = int(max(0, time.time() - _STARTED_AT))
 
 
 
 
 
 
 
 
 
 
 
120
 
121
- d = disk_total_free("/")
122
- total_gb = d["total"] / (1024.0**3)
123
- free_gb = d["free"] / (1024.0**3)
124
 
125
- dl_task = asyncio.create_task(net_download_test(bytes_target=dl_bytes, timeout=40.0))
126
- up_task = asyncio.create_task(net_upload_test(bytes_target=up_bytes, timeout=40.0))
127
- p_task = asyncio.create_task(ping_ms())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
- dl, up, p = await asyncio.gather(dl_task, up_task, p_task)
130
 
131
- ping_line = f"{p:.0f} ms" if p is not None else "N/A"
132
- return (
133
- "⚡ **Server Speed Test**\n\n"
134
- f"🕒 Uptime: {human_eta(up_sec)}\n\n"
135
- "📡 Network\n"
136
- f"├ 🟢 Ping: {ping_line}\n"
137
- f"├ ⬇️ DL: {bytes_per_sec_to_mb_s(dl['bps']):.2f} MB/s\n"
138
- f"└ ⬆️ UL: {bytes_per_sec_to_mb_s(up['bps']):.2f} MB/s\n\n"
139
- "💾 Storage\n"
140
- f"├ Total: {total_gb:.1f} GB\n"
141
- f"└ Free : {free_gb:.1f} GB"
142
- )
143
 
144
 
145
- def _sanitize_filename(name: str, default_ext: str = ".mp4") -> str:
146
- name = (name or "").strip()
147
- if not name:
148
- name = "video"
149
- # remove path-ish
150
- name = name.replace("\\", "_").replace("/", "_").replace("..", "_")
151
- # force extension if missing
152
- if "." not in name:
153
- name += default_ext
154
- return name
155
 
156
 
157
- def _toggle_privacy(p: str) -> str:
158
- p = (p or "private").lower().strip()
159
- if p == "private":
160
  return "unlisted"
161
- if p == "unlisted":
162
  return "public"
163
  return "private"
164
 
165
 
166
- def _require_admin_for_links(uid: int) -> bool:
167
- # admin/owner-only for link/range uploads
168
- return is_admin_id(uid)
 
 
 
169
 
 
 
 
 
 
170
 
171
- async def _ensure_profile_and_token(uid: int):
172
- """
173
- Returns (profile_id, access_token_string)
174
- """
175
- pl = await profile_list(uid)
176
- if not pl.get("ok"):
177
- raise RuntimeError(texts.NEED_AUTH)
178
 
179
- default_id = pl.get("default_profile_id")
180
- profiles = pl.get("profiles", []) or []
181
- dp = next((p for p in profiles if p.get("profile_id") == default_id), None)
182
- if not dp:
183
- raise RuntimeError("❌ No active profile. Use /profiles and set default.")
184
 
185
- pick = await pick_profile(uid, dp["channel_id"])
186
- if not pick or not pick.get("profile_id"):
187
- raise RuntimeError(texts.PICK_FAIL.format(pick))
 
 
 
188
 
189
- tok = await access_token(uid, pick["profile_id"])
190
- if not tok.get("ok") or not tok.get("access_token"):
191
- raise RuntimeError(texts.TOKEN_FAIL.format(tok))
192
-
193
- return pick["profile_id"], tok["access_token"]
194
 
 
 
 
 
 
 
 
 
195
 
196
- def _make_throttled_progress_editor(
197
- status_msg: Message, prefix: str, min_interval_s: float = 2.5
198
- ):
199
- last_ts = 0.0
200
 
201
- async def cb(done: int, total: int):
202
- nonlocal last_ts
203
- now = time.time()
204
- if now - last_ts < min_interval_s:
205
- return
206
- last_ts = now
207
- if total > 0:
208
- pct = done * 100.0 / total
209
- await safe_edit(
210
- status_msg,
211
- f"{prefix}\n{human_bytes(done)}/{human_bytes(total)} ({pct:.1f}%)",
212
- )
213
- else:
214
- await safe_edit(status_msg, f"{prefix}\n{human_bytes(done)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
- return cb
 
 
 
 
 
 
 
 
 
217
 
218
 
219
- async def _preview_and_confirm(uid: int, status: Message):
220
- """
221
- Shows preview UI for the current pending item, using upload_confirm_keyboard.
222
- """
223
- d = _PENDING.get(uid)
224
- if not d:
225
  return
226
- title = d.get("title") or "Untitled"
227
- desc = d.get("description") or ""
228
- privacy = d.get("privacy") or "private"
229
- filename = d.get("filename") or d.get("original_name") or "video.mp4"
230
-
231
- # keep preview short
232
- desc_short = desc.strip()
233
- if len(desc_short) > 400:
234
- desc_short = desc_short[:400] + "…"
235
-
236
- src = d.get("link") or "Direct upload"
237
- await safe_edit(
238
- status,
239
- "📦 **Ready to Upload**\n\n"
240
- f"🗂 **Source:** `{src}`\n"
241
- f"📄 **File:** `{filename}`\n"
242
- f"🏷 **Title:** `{title}`\n"
243
- f"🔒 **Privacy:** `{privacy}`\n\n"
244
- f"📝 **Description (preview):**\n{desc_short or '_empty_'}\n\n"
245
- "👇 Choose action:",
246
- reply_markup=upload_confirm_keyboard(privacy),
247
- )
248
 
 
 
 
249
 
250
- async def _do_upload(uid: int):
251
- """
252
- Performs: download (if needed) -> upload to YouTube -> cleanup -> final message.
253
- """
254
- d = _PENDING.get(uid)
255
- if not d:
256
- return
257
 
258
- status: Message = d["status_msg"]
259
- msg: Message = d["src_msg"]
260
- downloader: Client = d["downloader"]
261
- filename: str = d.get("filename") or d.get("original_name") or "video.mp4"
262
- filename = _sanitize_filename(filename)
263
 
264
- # auth
265
- profile_id, access = await _ensure_profile_and_token(uid)
 
 
 
 
266
 
267
- # download progress
268
- await safe_edit(status, f"⬇️ **Downloading:** `{filename}` …")
269
- dl_cb = _make_throttled_progress_editor(status, "⬇️ **Downloading…**")
 
 
270
 
271
- path = ""
272
- try:
273
- path, _, _ = await download_to_temp(downloader, msg, progress_cb=dl_cb)
274
- if not path:
275
- raise RuntimeError("❌ Download failed.")
276
 
277
- # upload progress
278
- await safe_edit(status, "⬆️ **Uploading to YouTube…**")
279
- se = SpeedETA()
280
- last_upd = 0.0
 
 
 
 
 
 
281
 
282
- async def up_cb(done: int, total: int):
283
- nonlocal last_upd
 
 
 
 
 
284
  now = time.time()
285
- if now - last_upd < 3.0:
286
  return
287
- last_upd = now
288
- snap = se.update(done, total)
289
- await safe_edit(
290
- status,
291
- "⬆️ **Uploading…**\n"
292
- f"{human_bytes(done)}/{human_bytes(total)}\n"
293
- f" {human_bytes(snap['speed_bps'])}/s",
294
  )
 
295
 
296
- yt_url = await upload_video(
297
  access_token=access,
298
- file_path=path,
299
- title=d.get("title") or filename.rsplit(".", 1)[0],
300
- description=d.get("description") or "",
301
- privacy=(d.get("privacy") or "private"),
302
- progress_cb=up_cb,
303
  )
304
 
305
- await record_upload(uid, profile_id)
306
- await safe_edit(status, f" **Uploaded!**\n\n📺 `{filename}`\n🔗 {yt_url}")
 
 
307
 
308
- finally:
309
- if path:
310
- cleanup_file(path)
311
-
312
-
313
- # -----------------------
314
- # HANDLERS SETUP
315
- # -----------------------
316
- def setup_handlers(app: Client, user_app: Client = None):
317
- # -------------------
318
- # STANDARD COMMANDS
319
- # -------------------
320
- @app.on_message(filters.command(["start"]) & filters.private)
321
- async def start_handler(_: Client, m: Message):
322
- await safe_reply(m, texts.START_TEXT, reply_markup=main_menu_keyboard())
323
-
324
- @app.on_message(filters.command(["help"]) & filters.private)
325
- async def help_handler(_: Client, m: Message):
326
- await safe_reply(m, texts.HELP_TEXT, reply_markup=main_menu_keyboard())
327
-
328
- @app.on_message(filters.command(["ping"]) & filters.private)
329
- async def ping_handler(_: Client, m: Message):
330
- await safe_reply(m, "🏓 Pong!")
331
-
332
- @app.on_message(filters.command(["me"]) & filters.private)
333
- async def me_handler(_: Client, m: Message):
334
- await safe_reply(m, f"👤 Your ID: `{m.from_user.id}`")
335
-
336
- @app.on_message(filters.command(["cancel"]) & filters.private)
337
- async def cancel_cmd(_: Client, m: Message):
338
- uid = m.from_user.id
339
- _AWAIT_AUTH.pop(uid, None)
340
- _AWAIT_CUSTOM.pop(uid, None)
341
- _AWAIT_EDIT.pop(uid, None)
342
- _PENDING.pop(uid, None)
343
- await safe_reply(m, texts.CANCELLED, reply_markup=main_menu_keyboard())
344
-
345
- # -------------------
346
- # SPEEDTEST
347
- # -------------------
348
- @app.on_message(filters.command(["speedtest"]) & filters.private)
349
- async def speedtest_cmd(_: Client, m: Message):
350
- uid = m.from_user.id
351
- now = time.time()
352
- if now - _SPEED_COOLDOWN.get(uid, 0.0) < 30:
353
- return await safe_reply(m, "⏳ Wait 30s...")
354
- _SPEED_COOLDOWN[uid] = now
355
 
356
- msg = await safe_reply(m, "⚡ Running speedtest…")
357
- try:
358
- txt = await _render_speedtest(8)
359
- await safe_edit(msg, txt)
360
- except Exception as e:
361
- await safe_edit(msg, f"❌ Failed: {e}")
362
-
363
- # -------------------
364
- # OWNER COMMANDS
365
- # -------------------
366
- @app.on_message(filters.command(["allow", "disallow", "stats", "diag"]) & filters.private)
367
- async def owner_cmds(_: Client, m: Message):
368
- if not is_owner_id(m.from_user.id):
369
- return await safe_reply(m, texts.OWNER_ONLY)
370
-
371
- cmd = m.command[0]
372
- if cmd == "stats":
373
- return await safe_reply(m, f"📊 Stats:\n`{await stats_today()}`")
374
-
375
- if cmd == "diag":
376
- r1 = await fetch_status(Workers.WORKER1_URL)
377
- return await safe_reply(m, f"🔎 Diag:\nW1: {r1}\nDNS: {dns_check(Workers.WORKER1_URL)}")
378
-
379
- # allow/disallow
380
- if len(m.command) < 2:
381
- return await safe_reply(m, f"Usage: /{cmd} <id>")
382
- target = int(m.command[1])
383
-
384
- if cmd == "allow":
385
- res = await allow_user(target)
386
- await safe_reply(m, f"✅ Allowed {target}: {res}")
387
- else:
388
- res = await disallow_user(target)
389
- await safe_reply(m, f"🚫 Disallowed {target}: {res}")
390
-
391
- # -------------------
392
- # AUTH + PROFILES
393
- # -------------------
394
- @app.on_message(filters.command(["auth"]) & filters.private)
395
- async def auth_cmd(_: Client, m: Message):
396
- uid = m.from_user.id
397
- if not await require_allowed(uid):
398
- return await safe_reply(m, texts.NOT_ALLOWED)
399
- _AWAIT_AUTH.pop(uid, None)
400
- await safe_reply(m, "🔐 Add Profile:", reply_markup=auth_menu_keyboard())
401
 
402
- @app.on_message(filters.command(["profiles"]) & filters.private)
403
- async def profiles_cmd(_: Client, m: Message):
404
- uid = m.from_user.id
405
- if not await require_allowed(uid):
406
- return await safe_reply(m, texts.NOT_ALLOWED)
407
-
408
- j = await profile_list(uid)
409
- if not j.get("ok"):
410
- return await safe_reply(m, texts.PROFILE_LIST_FAIL.format(j))
411
-
412
- profiles = j.get("profiles") or []
413
- kb = profiles_keyboard(profiles)
414
- await safe_reply(m, f"👤 **Profiles ({len(profiles)})**", reply_markup=kb)
415
-
416
- # -------------------
417
- # LINK ARCHIVE (/archive, /yt, /dl) admin/owner only
418
- # -------------------
419
- @app.on_message(filters.command(["archive", "yt", "dl"]) & filters.private)
420
- async def archive_link_handler(c: Client, m: Message):
421
- uid = m.from_user.id
422
- if not await require_allowed(uid):
423
- return await safe_reply(m, texts.NOT_ALLOWED)
424
 
425
- if not _require_admin_for_links(uid):
426
- return await safe_reply(m, "❌ Link-archive is admin/owner only.")
 
 
 
 
 
 
 
427
 
428
- if not user_app:
429
- return await safe_reply(m, "❌ `USER_SESSION_STRING` missing. Link-archive disabled.")
430
 
431
- if len(m.command) < 2:
432
- return await safe_reply(m, "Usage: `/archive <t.me message link>`")
 
433
 
434
- link = m.command[1]
435
- ids = parse_link(link)
436
- if not ids:
437
- return await safe_reply(m, "❌ Invalid link.")
438
- chat_id, msg_id = ids
439
 
440
- status = await safe_reply(m, "🔍 **Fetching message…**")
 
 
441
 
442
- try:
443
- target_msg = await user_app.get_messages(chat_id, msg_id)
444
- if not target_msg or target_msg.empty:
445
- return await safe_edit(status, "❌ Message not found or inaccessible.")
446
-
447
- media = target_msg.video or target_msg.document
448
- if not media:
449
- return await safe_edit(status, "❌ No video/document found in that message.")
450
-
451
- original_name = getattr(media, "file_name", None) or "video.mp4"
452
- file_size = int(getattr(media, "file_size", 0) or 0)
453
- caption_base = (target_msg.caption or "video").replace("\n", " ").strip()[:60] or "video"
454
- caption_name = f"{caption_base}.mp4"
455
-
456
- _PENDING[uid] = {
457
- "mode": "link",
458
- "src_msg": target_msg,
459
- "status_msg": status,
460
- "downloader": user_app,
461
- "original_name": original_name,
462
- "caption_name": caption_name,
463
- "privacy": "private",
464
- "link": link,
465
- }
466
-
467
- size_mb = f"{file_size / 1024 / 1024:.2f} MB"
468
- await safe_edit(
469
- status,
470
- "📂 **File Found!**\n"
471
- f"📄 **Original:** `{original_name}`\n"
472
- f"📦 **Size:** `{size_mb}`\n\n"
473
- "👇 **Select filename:**",
474
- reply_markup=filename_keyboard(),
475
- )
476
 
477
- except Exception as e:
478
- await safe_edit(status, f" Error: {e}")
 
 
 
 
 
 
 
 
 
 
 
479
 
480
- # -------------------
481
- # BATCH (admin/owner only)
482
- # -------------------
483
- @app.on_message(filters.command(["batch"]) & filters.private)
484
- async def batch_handler(c: Client, m: Message):
485
  uid = m.from_user.id
486
- if not await require_allowed(uid):
487
- return await safe_reply(m, texts.NOT_ALLOWED)
 
 
 
 
 
 
 
488
 
489
- if not _require_admin_for_links(uid):
490
- return await safe_reply(m, "❌ Batch is admin/owner only.")
 
 
 
 
 
 
491
 
492
- if not user_app:
493
- return await safe_reply(m, " `USER_SESSION_STRING` missing. Batch disabled.")
 
 
 
 
494
 
495
- if len(m.command) < 3:
496
- return await safe_reply(m, "Usage: `/batch <start_link> <end_link>`")
 
 
 
 
 
 
 
 
 
 
 
 
 
497
 
498
- s_ids = parse_link(m.command[1])
499
- e_ids = parse_link(m.command[2])
500
- if not s_ids or not e_ids:
501
- return await safe_reply(m, "❌ Invalid start/end link.")
 
 
 
 
 
 
 
 
 
 
502
 
503
- chat_id1, start_id = s_ids
504
- chat_id2, end_id = e_ids
505
- if chat_id1 != chat_id2:
506
- return await safe_reply(m, "❌ Start/end must be from same chat.")
 
 
 
 
 
 
 
 
 
 
 
 
507
 
508
- if start_id > end_id:
509
- start_id, end_id = end_id, start_id
 
 
 
510
 
511
- status = await safe_reply(m, f"🔄 Batching {start_id} → {end_id}…")
512
- uploaded = 0
 
 
 
 
513
 
514
- async with BATCH_SEM:
515
- try:
516
- profile_id, access = await _ensure_profile_and_token(uid)
517
- except Exception as e:
518
- return await safe_edit(status, f"❌ Auth error: {e}")
519
-
520
- for mid in range(start_id, end_id + 1):
521
- try:
522
- await safe_edit(status, f"🔄 Processing message `{mid}`…")
523
- msg = await user_app.get_messages(chat_id1, mid)
524
- if not msg or msg.empty:
525
- continue
526
- if not (msg.video or msg.document):
527
- continue
528
-
529
- # derive basic meta
530
- t, dsc = extract_title_description(msg, default_title=f"TG {mid}")
531
- filename = getattr((msg.video or msg.document), "file_name", None) or f"{mid}.mp4"
532
-
533
- # download
534
- dl_status = _make_throttled_progress_editor(status, f"⬇️ Downloading `{filename}`…", 2.0)
535
- path, _, _ = await download_to_temp(user_app, msg, progress_cb=dl_status)
536
-
537
- # upload (no spam)
538
- await safe_edit(status, f"⬆️ Uploading `{filename}`…")
539
- await upload_video(
540
- access_token=access,
541
- file_path=path,
542
- title=t,
543
- description=dsc,
544
- privacy="private",
545
- progress_cb=None, # keep batch quiet
546
- )
547
- cleanup_file(path)
548
- uploaded += 1
549
- await asyncio.sleep(2.0)
550
- except Exception:
551
- # best-effort batch
552
- try:
553
- if "path" in locals() and path:
554
- cleanup_file(path)
555
- except Exception:
556
- pass
557
- continue
558
-
559
- await record_upload(uid, profile_id)
560
- await safe_edit(status, f"✅ Batch done. Uploaded: **{uploaded}**")
561
-
562
- # -------------------
563
- # DIRECT UPLOAD (user sends video/document in private)
564
- # -------------------
565
- @app.on_message(filters.private & (filters.video | filters.document))
566
- async def direct_upload_handler(c: Client, m: Message):
567
- uid = m.from_user.id
568
- if not await require_allowed(uid):
569
- return
570
-
571
- # prepare preview from message itself
572
- media = m.video or m.document
573
- fname = getattr(media, "file_name", None) or "video.mp4"
574
- fname = _sanitize_filename(fname)
575
-
576
- status = await safe_reply(m, "📦 Preparing preview…")
577
- title, desc = extract_title_description(m, default_title=fname.rsplit(".", 1)[0])
578
-
579
- _PENDING[uid] = {
580
- "mode": "direct",
581
- "src_msg": m,
582
- "status_msg": status,
583
- "downloader": c, # bot can download private user DM
584
- "original_name": fname,
585
- "caption_name": fname,
586
- "filename": fname,
587
- "title": title,
588
- "description": desc,
589
- "privacy": "private",
590
- "link": "Direct upload",
591
- }
592
-
593
- await _preview_and_confirm(uid, status)
594
-
595
- # -------------------
596
- # CALLBACKS (all buttons)
597
- # -------------------
598
- @app.on_callback_query()
599
- async def global_cb(c: Client, q: CallbackQuery):
600
  uid = q.from_user.id
601
- action, val = parse_cb(q.data)
602
-
603
- # ---- filename selection (link-archive) ----
604
- if action in (NAME_ORIGINAL, NAME_CAPTION, NAME_CUSTOM):
605
- d = _PENDING.get(uid)
606
- if not d:
607
- return await q.answer("❌ Expired", show_alert=True)
608
-
609
- if action == NAME_CUSTOM:
610
- _AWAIT_CUSTOM[uid] = True
611
- await safe_edit(q.message, "✍️ **Send custom filename now:**\nExample: `myvideo.mp4`")
612
- return await q.answer()
613
-
614
- # choose filename
615
- chosen = d["original_name"] if action == NAME_ORIGINAL else d["caption_name"]
616
- chosen = _sanitize_filename(chosen)
617
- d["filename"] = chosen
618
-
619
- # build preview meta now that name chosen
620
- title, desc = extract_title_description(d["src_msg"], default_title=chosen.rsplit(".", 1)[0])
621
- d["title"] = title
622
- d["description"] = desc
623
- d["privacy"] = d.get("privacy") or "private"
624
 
 
625
  await q.answer()
626
- await _preview_and_confirm(uid, d["status_msg"])
 
 
 
 
 
 
 
 
627
  return
628
 
629
- # ---- main menu callbacks ----
630
  if action == MENU_AUTH:
631
  if not await require_allowed(uid):
632
- return await q.answer("Not allowed", show_alert=True)
633
- _AWAIT_AUTH.pop(uid, None)
634
- await safe_edit(q.message, "🔐 Auth Menu", reply_markup=auth_menu_keyboard())
635
- return await q.answer()
636
 
637
  if action == MENU_PROFILES:
638
- # reuse profiles command
639
- fake = q.message
640
- await q.answer()
641
- return await profiles_cmd(c, fake)
 
 
 
 
 
 
 
 
 
642
 
643
  if action == MENU_SPEEDTEST:
644
- await q.answer("Running…")
645
- txt = await _render_speedtest(8)
646
- await safe_edit(q.message, txt)
647
- return
 
 
648
 
649
- if action == MENU_HELP:
650
- await q.answer()
651
- await safe_edit(q.message, texts.HELP_TEXT, reply_markup=main_menu_keyboard())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  return
653
 
654
- # ---- auth buttons ----
655
  if action == AUTH_JSON:
656
- _AWAIT_AUTH[uid] = "json"
657
- await q.answer()
658
- await safe_edit(q.message, texts.ASK_JSON)
 
 
 
659
  return
660
 
661
  if action == AUTH_CI:
662
- _AWAIT_AUTH[uid] = "ci"
663
- await q.answer()
664
- await safe_edit(q.message, texts.ASK_ID_SECRET)
 
 
 
665
  return
666
 
667
  if action == "setdef":
668
- try:
669
- await profile_set_default(uid, val)
670
- await q.answer("Updated!")
671
- await safe_edit(q.message, f"✅ Profile set as default: `{val}`", reply_markup=main_menu_keyboard())
672
- except Exception as e:
673
- await q.answer("Failed", show_alert=True)
674
- await safe_edit(q.message, f"❌ Failed: {e}", reply_markup=main_menu_keyboard())
 
 
 
 
 
675
  return
676
 
677
- # ---- upload confirm UI ----
678
- if action == UP_EDIT:
679
- _AWAIT_EDIT[uid] = True
680
- await q.answer()
681
- await safe_edit(q.message, texts.EDIT_PROMPT)
682
- return
683
 
684
- if action == UP_PRIV:
685
- d = _PENDING.get(uid)
686
- if not d:
687
- return await q.answer("Expired", show_alert=True)
688
- d["privacy"] = _toggle_privacy(d.get("privacy"))
689
- await q.answer("Privacy updated")
690
- await _preview_and_confirm(uid, d["status_msg"])
691
- return
692
 
693
- if action == UP_GO:
694
- await q.answer("Uploading…")
695
- try:
696
- await _do_upload(uid)
697
- except Exception as e:
698
- d = _PENDING.get(uid)
699
- if d:
700
- await safe_edit(d["status_msg"], f"❌ Error: {e}")
701
- finally:
702
- _PENDING.pop(uid, None)
703
- _AWAIT_CUSTOM.pop(uid, None)
704
  _AWAIT_EDIT.pop(uid, None)
705
- return
 
706
 
707
- if action in (UP_CANCEL, CANCEL):
708
- _AWAIT_AUTH.pop(uid, None)
709
- _AWAIT_CUSTOM.pop(uid, None)
710
- _AWAIT_EDIT.pop(uid, None)
711
- _PENDING.pop(uid, None)
712
- await q.answer("Cancelled")
713
- await safe_edit(q.message, texts.CANCELLED, reply_markup=main_menu_keyboard())
714
- return
715
 
716
- if action == BACK:
717
- await q.answer()
718
- await safe_edit(q.message, texts.START_TEXT, reply_markup=main_menu_keyboard())
719
- return
 
 
 
720
 
721
- await q.answer()
 
 
 
722
 
723
- # -------------------
724
- # TEXT INPUT handler (auth/custom/edit)
725
- # -------------------
726
  @app.on_message(filters.text & filters.private)
727
- async def text_handler(c: Client, m: Message):
728
- uid = m.from_user.id
729
- txt = (m.text or "").strip()
 
730
 
731
- # A) Custom filename input
732
- if _AWAIT_CUSTOM.get(uid):
733
- d = _PENDING.get(uid)
734
- if d:
735
- name = _sanitize_filename(txt)
736
- d["filename"] = name
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
737
 
738
- title, desc = extract_title_description(d["src_msg"], default_title=name.rsplit(".", 1)[0])
739
- d["title"] = title
740
- d["description"] = desc
741
- d["privacy"] = d.get("privacy") or "private"
742
 
743
- _AWAIT_CUSTOM.pop(uid, None)
744
- try:
745
- await m.delete()
746
- except Exception:
747
- pass
748
- await _preview_and_confirm(uid, d["status_msg"])
 
 
 
 
 
 
 
 
 
 
749
  return
750
 
751
- # B) Edit title/desc input
752
  if _AWAIT_EDIT.get(uid):
753
- d = _PENDING.get(uid)
754
- if not d:
755
  _AWAIT_EDIT.pop(uid, None)
756
  return
757
 
758
- lines = [ln.strip() for ln in (txt.splitlines() if txt else []) if ln.strip()]
759
- if not lines:
760
- return await safe_reply(m, texts.PARSE_FAIL)
761
 
762
- new_title = lines[0]
763
- new_desc = "\n".join(lines[1:]) if len(lines) > 1 else (d.get("description") or "")
764
 
765
- d["title"] = new_title
766
- d["description"] = new_desc
767
- _AWAIT_EDIT.pop(uid, None)
768
- try:
769
- await m.delete()
770
- except Exception:
771
- pass
772
- await _preview_and_confirm(uid, d["status_msg"])
773
- return
774
 
775
- # C) Auth input
776
- mode = _AWAIT_AUTH.get(uid)
777
- if mode:
778
- try:
779
- cid = sec = ""
780
- if mode == "json":
781
- data = json.loads(txt)
782
- cid = (data.get("installed", {}) or {}).get("client_id") or (data.get("web", {}) or {}).get("client_id") or ""
783
- sec = (data.get("installed", {}) or {}).get("client_secret") or (data.get("web", {}) or {}).get("client_secret") or ""
784
- else:
785
- # allow: "id | secret" OR "id secret"
786
- if "|" in txt:
787
- parts = [p.strip() for p in txt.split("|", 1)]
788
- else:
789
- parts = [p.strip() for p in txt.split() if p.strip()]
790
- if len(parts) >= 2:
791
- cid, sec = parts[0], parts[1]
792
-
793
- if cid and sec:
794
- res = await profile_add(uid, cid, sec, "main")
795
- link = _pick_login_url(res)
796
- _AWAIT_AUTH.pop(uid, None)
797
- if not link:
798
- return await safe_reply(m, f"✅ Saved. (No login url returned)\n`{res}`", reply_markup=main_menu_keyboard())
799
- return await safe_reply(m, texts.SENT_AUTH_LINK + link, reply_markup=main_menu_keyboard())
800
- else:
801
- return await safe_reply(m, "❌ Invalid format.", reply_markup=main_menu_keyboard())
802
 
803
- except Exception as e:
804
- return await safe_reply(m, f"❌ Error: {e}", reply_markup=main_menu_keyboard())
 
 
805
 
806
- # if none of the above, ignore
807
- return
 
1
+ # bot/handlers.py
2
  from __future__ import annotations
3
 
 
 
4
  import os
5
+ import json
6
  import time
7
+ import asyncio
8
+ from dataclasses import dataclass
9
+ from typing import Any, Dict, Optional, Tuple, Union
10
 
11
  from hydrogram import Client, filters
12
+ from hydrogram.types import (
13
+ Message,
14
+ CallbackQuery,
15
+ InlineKeyboardMarkup,
16
+ InlineKeyboardButton,
 
 
 
 
 
 
 
 
 
 
 
 
17
  )
18
 
19
+ from bot.core.auth import require_allowed, is_owner_id
 
20
  from bot.core.progress import SpeedETA, human_bytes, human_eta
21
+
22
+ # ✅ profile_* are ADMIN routes => cf_worker1
23
+ from bot.integrations.cf_worker1 import (
24
+ profile_add,
25
+ profile_list,
26
+ profile_set_default,
27
  )
28
+
29
+ # ✅ HF/private routes => cf_worker2
30
  from bot.integrations.cf_worker2 import (
 
 
31
  pick_profile,
32
  access_token,
33
  record_upload,
34
+ allow_user,
35
+ disallow_user,
36
  stats_today,
 
 
37
  )
 
 
38
 
 
 
39
  from bot.telegram.replies import safe_reply, safe_edit
40
+ from bot.telegram.parse import extract_title_description
41
+ from bot.telegram.media import download_to_temp
42
  from bot.temp.files import cleanup_file
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
+ from bot.ui.keyboards import (
45
+ main_menu_keyboard,
46
+ auth_menu_keyboard,
47
+ profiles_keyboard,
48
+ upload_confirm_keyboard,
49
+ )
50
+ from bot.ui.callbacks import (
51
+ parse_cb,
52
+ make,
53
+ MENU_AUTH,
54
+ MENU_PROFILES,
55
+ MENU_HELP,
56
+ MENU_SPEEDTEST,
57
+ BACK,
58
+ AUTH_JSON,
59
+ AUTH_CI,
60
+ UP_GO,
61
+ UP_EDIT,
62
+ UP_PRIV,
63
+ UP_CANCEL,
64
+ )
65
+ from bot.ui.texts import (
66
+ START_TEXT,
67
+ HELP_TEXT,
68
+ OWNER_ONLY,
69
+ NOT_ALLOWED,
70
+ CANCELLED,
71
+ NEED_AUTH,
72
+ )
73
 
 
 
 
74
 
75
+ # =========================
76
+ # In-memory state (per-user)
77
+ # =========================
78
+
79
+ @dataclass
80
+ class PendingUpload:
81
+ src_msg: Message
82
+ downloader: Client
83
+ file_name: str
84
+ title: str
85
+ description: str
86
+ privacy: str # private | unlisted | public
87
+ status_msg: Message
88
+ via_link: bool = False
89
+
90
+
91
+ _AWAIT_AUTH_MODE: Dict[int, str] = {} # uid -> "json"|"ci"
92
+ _AWAIT_EDIT: Dict[int, bool] = {} # uid -> True when waiting for title/desc text
93
+ _PENDING_UPLOAD: Dict[int, PendingUpload] = {} # uid -> pending upload
94
+ _IN_PROGRESS: Dict[int, bool] = {} # uid -> upload is running
95
+ _SPEED_COOLDOWN_UNTIL: Dict[int, float] = {} # uid -> unix time
96
+
97
+
98
+ # =========================
99
+ # Helpers
100
+ # =========================
101
+
102
+ def _admin_ids_from_env() -> set[int]:
103
+ raw = (os.getenv("ADMIN_IDS") or "").strip()
104
+ out: set[int] = set()
105
+ for part in raw.split(","):
106
+ part = part.strip()
107
+ if not part:
108
+ continue
109
+ try:
110
+ out.add(int(part))
111
+ except ValueError:
112
+ continue
113
+ return out
114
 
 
115
 
116
+ _ADMIN_IDS = _admin_ids_from_env()
 
 
 
 
 
 
 
 
 
 
 
117
 
118
 
119
+ def _is_admin_or_owner(uid: int) -> bool:
120
+ return is_owner_id(uid) or (uid in _ADMIN_IDS)
 
 
 
 
 
 
 
 
121
 
122
 
123
+ def _next_privacy(cur: str) -> str:
124
+ cur = (cur or "").lower()
125
+ if cur == "private":
126
  return "unlisted"
127
+ if cur == "unlisted":
128
  return "public"
129
  return "private"
130
 
131
 
132
+ def _media_and_filename(m: Message) -> Tuple[Optional[Any], str, int]:
133
+ if getattr(m, "video", None):
134
+ v = m.video
135
+ name = (getattr(v, "file_name", None) or "video.mp4").strip() or "video.mp4"
136
+ size = int(getattr(v, "file_size", 0) or 0)
137
+ return v, name, size
138
 
139
+ if getattr(m, "document", None):
140
+ d = m.document
141
+ name = (getattr(d, "file_name", None) or "file.bin").strip() or "file.bin"
142
+ size = int(getattr(d, "file_size", 0) or 0)
143
+ return d, name, size
144
 
145
+ return None, "file.bin", 0
 
 
 
 
 
 
146
 
 
 
 
 
 
147
 
148
+ def _render_preview(p: PendingUpload) -> str:
149
+ size = 0
150
+ if getattr(p.src_msg, "video", None):
151
+ size = int(getattr(p.src_msg.video, "file_size", 0) or 0)
152
+ elif getattr(p.src_msg, "document", None):
153
+ size = int(getattr(p.src_msg.document, "file_size", 0) or 0)
154
 
155
+ desc = p.description or ""
156
+ if len(desc) > 500:
157
+ desc = desc[:500] + "…"
 
 
158
 
159
+ return (
160
+ "✅ *Ready to upload*\n\n"
161
+ f"**File:** `{p.file_name}`\n"
162
+ f"**Size:** {human_bytes(size) if size else '—'}\n"
163
+ f"**Privacy:** `{p.privacy}`\n\n"
164
+ f"**Title:** {p.title or '—'}\n"
165
+ f"**Description:** {desc or '—'}"
166
+ )
167
 
 
 
 
 
168
 
169
+ def _pick_login_url(resp: Any) -> Optional[str]:
170
+ if not isinstance(resp, dict):
171
+ return None
172
+ if isinstance(resp.get("login_url"), str):
173
+ return resp["login_url"]
174
+ data = resp.get("data")
175
+ if isinstance(data, dict) and isinstance(data.get("login_url"), str):
176
+ return data["login_url"]
177
+ return None
178
+
179
+
180
+ def parse_telegram_link(link: str) -> Tuple[Union[int, str], int]:
181
+ s = (link or "").strip()
182
+ if not s:
183
+ raise ValueError("empty link")
184
+
185
+ s = s.replace("https://", "").replace("http://", "")
186
+ if s.startswith("t.me/"):
187
+ s = s[len("t.me/") :]
188
+
189
+ s = s.split("?", 1)[0].split("#", 1)[0]
190
+ s = s.strip("/")
191
+
192
+ parts = s.split("/")
193
+ if len(parts) < 2:
194
+ raise ValueError("link must include message id")
195
+
196
+ if parts[0] == "c":
197
+ if len(parts) < 3:
198
+ raise ValueError("invalid /c/ link")
199
+ internal_id = parts[1]
200
+ msg_id = parts[2]
201
+ if not internal_id.isdigit() or not msg_id.isdigit():
202
+ raise ValueError("invalid numeric ids in /c/ link")
203
+ chat_id = int(f"-100{internal_id}")
204
+ return chat_id, int(msg_id)
205
+
206
+ username = parts[0]
207
+ msg_id = parts[1]
208
+ if not msg_id.isdigit():
209
+ raise ValueError("invalid message id")
210
+ return username, int(msg_id)
211
+
212
+
213
+ async def _ensure_allowed(m: Message) -> bool:
214
+ uid = m.from_user.id if m.from_user else 0
215
+ if not uid:
216
+ await safe_reply(m, NOT_ALLOWED)
217
+ return False
218
+ if not await require_allowed(uid):
219
+ await safe_reply(m, NOT_ALLOWED)
220
+ return False
221
+ return True
222
+
223
+
224
+ async def _start_pending_upload(
225
+ *,
226
+ chat_msg: Message,
227
+ src_msg: Message,
228
+ downloader: Client,
229
+ privacy: str = "private",
230
+ via_link: bool = False,
231
+ ) -> None:
232
+ uid = chat_msg.from_user.id
233
+ _, file_name, _ = _media_and_filename(src_msg)
234
+ title, desc = extract_title_description(src_msg, file_name)
235
+
236
+ preview = PendingUpload(
237
+ src_msg=src_msg,
238
+ downloader=downloader,
239
+ file_name=file_name,
240
+ title=title,
241
+ description=desc,
242
+ privacy=privacy,
243
+ status_msg=chat_msg,
244
+ via_link=via_link,
245
+ )
246
+ status = await safe_reply(chat_msg, _render_preview(preview), reply_markup=upload_confirm_keyboard(privacy))
247
+ if not status:
248
+ return
249
 
250
+ _PENDING_UPLOAD[uid] = PendingUpload(
251
+ src_msg=src_msg,
252
+ downloader=downloader,
253
+ file_name=file_name,
254
+ title=title,
255
+ description=desc,
256
+ privacy=privacy,
257
+ status_msg=status,
258
+ via_link=via_link,
259
+ )
260
 
261
 
262
+ async def _run_upload(uid: int) -> None:
263
+ if _IN_PROGRESS.get(uid):
264
+ return
265
+ pending = _PENDING_UPLOAD.get(uid)
266
+ if not pending:
 
267
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
+ _IN_PROGRESS[uid] = True
270
+ st = pending.status_msg
271
+ file_path = None
272
 
273
+ try:
274
+ pl = await profile_list(uid)
275
+ if not (isinstance(pl, dict) and pl.get("ok")):
276
+ await safe_edit(st, "❌ Failed to fetch profiles. Try /profiles again.")
277
+ return
 
 
278
 
279
+ profiles = pl.get("profiles") or []
280
+ if not profiles:
281
+ await safe_edit(st, NEED_AUTH)
282
+ return
 
283
 
284
+ default_id = pl.get("default_profile_id") or profiles[0].get("profile_id")
285
+ default_profile = next((p for p in profiles if p.get("profile_id") == default_id), profiles[0])
286
+ channel_id = default_profile.get("channel_id")
287
+ if not channel_id:
288
+ await safe_edit(st, "❌ Default profile is not connected. Use /profiles → set a connected one as default.")
289
+ return
290
 
291
+ pick = await pick_profile(uid, channel_id)
292
+ if not (isinstance(pick, dict) and pick.get("ok")):
293
+ err = pick.get("err", "unknown") if isinstance(pick, dict) else "unknown"
294
+ await safe_edit(st, f"❌ Profile pick failed: `{err}`")
295
+ return
296
 
297
+ prof_id = pick.get("profile_id")
298
+ if not prof_id:
299
+ await safe_edit(st, "❌ pick_profile returned no profile_id.")
300
+ return
 
301
 
302
+ tok = await access_token(uid, prof_id)
303
+ if not (isinstance(tok, dict) and tok.get("ok") and tok.get("access_token")):
304
+ err = tok.get("err", "token_failed") if isinstance(tok, dict) else "token_failed"
305
+ await safe_edit(st, f"❌ Token failed: `{err}`")
306
+ return
307
+
308
+ access = tok["access_token"]
309
+
310
+ await safe_edit(st, "⬇️ Downloading media…")
311
+ file_path, _, _ = await download_to_temp(pending.downloader, pending.src_msg)
312
 
313
+ from bot.youtube.uploader import upload_video # local import
314
+ speed = SpeedETA()
315
+ last_ui = 0.0
316
+ start_t = time.time()
317
+
318
+ async def progress_cb(sent: int, total: int) -> None:
319
+ nonlocal last_ui
320
  now = time.time()
321
+ if now - last_ui < 0.8:
322
  return
323
+ last_ui = now
324
+ rate = speed.update(sent, total)
325
+ txt = (
326
+ "⬆️ Uploading…\n\n"
327
+ f"{human_bytes(sent)} / {human_bytes(total)}\n"
328
+ f"Speed: {human_bytes(rate)}/s\n"
329
+ f"ETA: {human_eta(speed.eta_seconds)}"
330
  )
331
+ await safe_edit(st, txt)
332
 
333
+ up = await upload_video(
334
  access_token=access,
335
+ file_path=file_path,
336
+ title=pending.title,
337
+ description=pending.description,
338
+ privacy=pending.privacy,
339
+ progress_cb=progress_cb,
340
  )
341
 
342
+ if not (isinstance(up, dict) and up.get("ok")):
343
+ err = up.get("err", "upload_failed") if isinstance(up, dict) else "upload_failed"
344
+ await safe_edit(st, f"❌ Upload failed: `{err}`")
345
+ return
346
 
347
+ url = up.get("url") or up.get("video_url") or up.get("watch_url") or ""
348
+ await record_upload(uid, prof_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
 
350
+ dur = max(1.0, time.time() - start_t)
351
+ done = "✅ Uploaded!"
352
+ if url:
353
+ done += f"\n\n{url}"
354
+ done += f"\n\nTime: {dur:.1f}s"
355
+ await safe_edit(st, done)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
 
357
+ _PENDING_UPLOAD.pop(uid, None)
358
+ _AWAIT_EDIT.pop(uid, None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
 
360
+ except Exception as e:
361
+ await safe_edit(st, f"❌ Error: `{str(e)[:180]}`")
362
+ finally:
363
+ if file_path:
364
+ try:
365
+ cleanup_file(file_path)
366
+ except Exception:
367
+ pass
368
+ _IN_PROGRESS.pop(uid, None)
369
 
 
 
370
 
371
+ # =========================
372
+ # Handlers
373
+ # =========================
374
 
375
+ def setup_handlers(app: Client, user_app: Optional[Client] = None) -> None:
376
+ @app.on_message(filters.command("start") & filters.private)
377
+ async def start_cmd(_, m: Message) -> None:
378
+ await safe_reply(m, START_TEXT, reply_markup=main_menu_keyboard())
 
379
 
380
+ @app.on_message(filters.command("help") & filters.private)
381
+ async def help_cmd(_, m: Message) -> None:
382
+ await safe_reply(m, HELP_TEXT, reply_markup=main_menu_keyboard())
383
 
384
+ @app.on_message(filters.command("cancel") & filters.private)
385
+ async def cancel_cmd(_, m: Message) -> None:
386
+ uid = m.from_user.id
387
+ _AWAIT_AUTH_MODE.pop(uid, None)
388
+ _AWAIT_EDIT.pop(uid, None)
389
+ _PENDING_UPLOAD.pop(uid, None)
390
+ await safe_reply(m, CANCELLED, reply_markup=main_menu_keyboard())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
 
392
+ # owner/admin
393
+ @app.on_message(filters.command("allow") & filters.private)
394
+ async def allow_cmd(_, m: Message) -> None:
395
+ uid = m.from_user.id
396
+ if not _is_admin_or_owner(uid):
397
+ await safe_reply(m, OWNER_ONLY)
398
+ return
399
+ parts = (m.text or "").split()
400
+ if len(parts) < 2 or not parts[1].isdigit():
401
+ await safe_reply(m, "Usage: /allow <tg_id>")
402
+ return
403
+ r = await allow_user(int(parts[1]))
404
+ await safe_reply(m, json.dumps(r, indent=2)[:3900])
405
 
406
+ @app.on_message(filters.command("disallow") & filters.private)
407
+ async def disallow_cmd(_, m: Message) -> None:
 
 
 
408
  uid = m.from_user.id
409
+ if not _is_admin_or_owner(uid):
410
+ await safe_reply(m, OWNER_ONLY)
411
+ return
412
+ parts = (m.text or "").split()
413
+ if len(parts) < 2 or not parts[1].isdigit():
414
+ await safe_reply(m, "Usage: /disallow <tg_id>")
415
+ return
416
+ r = await disallow_user(int(parts[1]))
417
+ await safe_reply(m, json.dumps(r, indent=2)[:3900])
418
 
419
+ @app.on_message(filters.command("stats") & filters.private)
420
+ async def stats_cmd(_, m: Message) -> None:
421
+ uid = m.from_user.id
422
+ if not _is_admin_or_owner(uid):
423
+ await safe_reply(m, OWNER_ONLY)
424
+ return
425
+ r = await stats_today()
426
+ await safe_reply(m, json.dumps(r, indent=2)[:3900])
427
 
428
+ # profiles/auth
429
+ @app.on_message(filters.command("auth") & filters.private)
430
+ async def auth_cmd(_, m: Message) -> None:
431
+ if not await _ensure_allowed(m):
432
+ return
433
+ await safe_reply(m, "Choose auth mode:", reply_markup=auth_menu_keyboard())
434
 
435
+ @app.on_message(filters.command("profiles") & filters.private)
436
+ async def profiles_cmd(_, m: Message) -> None:
437
+ if not await _ensure_allowed(m):
438
+ return
439
+ uid = m.from_user.id
440
+ pl = await profile_list(uid)
441
+ if not (isinstance(pl, dict) and pl.get("ok")):
442
+ err = pl.get("err", "unknown") if isinstance(pl, dict) else "unknown"
443
+ await safe_reply(m, f"❌ profile_list failed: `{err}`")
444
+ return
445
+ await safe_reply(
446
+ m,
447
+ f"Profiles for `{uid}`\nDefault: `{pl.get('default_profile_id')}`\n\nPick default:",
448
+ reply_markup=profiles_keyboard(pl),
449
+ )
450
 
451
+ # incoming media
452
+ @app.on_message((filters.video | filters.document) & filters.private)
453
+ async def incoming_media(_, m: Message) -> None:
454
+ if not await _ensure_allowed(m):
455
+ return
456
+ uid = m.from_user.id
457
+ if _IN_PROGRESS.get(uid):
458
+ await safe_reply(m, "⏳ Upload already running. Use /cancel to reset.")
459
+ return
460
+ media, _, _ = _media_and_filename(m)
461
+ if not media:
462
+ await safe_reply(m, "Send a video or a document file.")
463
+ return
464
+ await _start_pending_upload(chat_msg=m, src_msg=m, downloader=app, via_link=False)
465
 
466
+ # link/archive mode (owner/admin only)
467
+ @app.on_message(filters.command(["yt", "dl", "archive"]) & filters.private)
468
+ async def archive_cmd(_, m: Message) -> None:
469
+ if not await _ensure_allowed(m):
470
+ return
471
+ uid = m.from_user.id
472
+ if not _is_admin_or_owner(uid):
473
+ await safe_reply(m, OWNER_ONLY)
474
+ return
475
+ args = (m.text or "").split(maxsplit=1)
476
+ if len(args) < 2:
477
+ await safe_reply(m, "Send: /archive <t.me message link>")
478
+ return
479
+ if user_app is None:
480
+ await safe_reply(m, "❌ Link mode is not configured (user session missing).")
481
+ return
482
 
483
+ try:
484
+ chat_ref, msg_id = parse_telegram_link(args[1].strip())
485
+ except Exception as e:
486
+ await safe_reply(m, f"❌ Bad link: {e}")
487
+ return
488
 
489
+ await safe_reply(m, "🔎 Fetching message…")
490
+ try:
491
+ src = await user_app.get_messages(chat_ref, msg_id)
492
+ except Exception as e:
493
+ await safe_reply(m, f"❌ Cannot fetch: `{str(e)[:180]}`")
494
+ return
495
 
496
+ media, _, _ = _media_and_filename(src)
497
+ if not media:
498
+ await safe_reply(m, "❌ That message has no video/document.")
499
+ return
500
+
501
+ await _start_pending_upload(chat_msg=m, src_msg=src, downloader=user_app, via_link=True)
502
+
503
+ # callbacks
504
+ @app.on_callback_query(filters.private)
505
+ async def cb_handler(_, q: CallbackQuery) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  uid = q.from_user.id
507
+ action, value = parse_cb(q.data or "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
508
 
509
+ try:
510
  await q.answer()
511
+ except Exception:
512
+ pass
513
+
514
+ if action == BACK:
515
+ await safe_edit(q.message, START_TEXT, reply_markup=main_menu_keyboard())
516
+ return
517
+
518
+ if action == MENU_HELP:
519
+ await safe_edit(q.message, HELP_TEXT, reply_markup=main_menu_keyboard())
520
  return
521
 
 
522
  if action == MENU_AUTH:
523
  if not await require_allowed(uid):
524
+ await safe_edit(q.message, NOT_ALLOWED, reply_markup=main_menu_keyboard())
525
+ return
526
+ await safe_edit(q.message, "Choose auth mode:", reply_markup=auth_menu_keyboard())
527
+ return
528
 
529
  if action == MENU_PROFILES:
530
+ if not await require_allowed(uid):
531
+ await safe_edit(q.message, NOT_ALLOWED, reply_markup=main_menu_keyboard())
532
+ return
533
+ pl = await profile_list(uid)
534
+ if not (isinstance(pl, dict) and pl.get("ok")):
535
+ await safe_edit(q.message, "❌ Failed to load profiles.", reply_markup=main_menu_keyboard())
536
+ return
537
+ await safe_edit(
538
+ q.message,
539
+ f"Profiles for `{uid}`\nDefault: `{pl.get('default_profile_id')}`\n\nPick default:",
540
+ reply_markup=profiles_keyboard(pl),
541
+ )
542
+ return
543
 
544
  if action == MENU_SPEEDTEST:
545
+ now = time.time()
546
+ until = _SPEED_COOLDOWN_UNTIL.get(uid, 0.0)
547
+ if now < until:
548
+ await safe_edit(q.message, f"⏳ Try again in {int(until - now)}s.", reply_markup=main_menu_keyboard())
549
+ return
550
+ _SPEED_COOLDOWN_UNTIL[uid] = now + 60
551
 
552
+ from bot.core.speedtest import ping_ms, net_download_test, net_upload_test, disk_total_free
553
+
554
+ await safe_edit(q.message, "🧪 Running speed test…")
555
+ try:
556
+ p = await ping_ms()
557
+ dl = await net_download_test()
558
+ ul = await net_upload_test()
559
+ disk = disk_total_free()
560
+
561
+ dl_mbps = (float(dl.get("bps", 0) or 0) * 8.0) / 1e6 if isinstance(dl, dict) else 0.0
562
+ ul_mbps = (float(ul.get("bps", 0) or 0) * 8.0) / 1e6 if isinstance(ul, dict) else 0.0
563
+ total = int(disk.get("total", 0) or 0) if isinstance(disk, dict) else 0
564
+ free = int(disk.get("free", 0) or 0) if isinstance(disk, dict) else 0
565
+
566
+ txt = (
567
+ "📶 *Speed test*\n\n"
568
+ f"Ping: `{int(p)} ms`\n"
569
+ f"Download: `{dl_mbps:.2f} Mbps`\n"
570
+ f"Upload: `{ul_mbps:.2f} Mbps`\n"
571
+ f"Disk: `{human_bytes(free)} free / {human_bytes(total)} total`"
572
+ )
573
+ except Exception as e:
574
+ txt = f"❌ Speed test error: `{str(e)[:180]}`"
575
+ await safe_edit(q.message, txt, reply_markup=main_menu_keyboard())
576
  return
577
 
 
578
  if action == AUTH_JSON:
579
+ _AWAIT_AUTH_MODE[uid] = "json"
580
+ await safe_edit(
581
+ q.message,
582
+ "Paste credentials JSON:\n`{\"client_id\":\"...\",\"client_secret\":\"...\",\"label\":\"optional\"}`",
583
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data=make(BACK))]]),
584
+ )
585
  return
586
 
587
  if action == AUTH_CI:
588
+ _AWAIT_AUTH_MODE[uid] = "ci"
589
+ await safe_edit(
590
+ q.message,
591
+ "Send in 2–3 lines:\n`client_id`\n`client_secret`\n(optional 3rd line = label)",
592
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data=make(BACK))]]),
593
+ )
594
  return
595
 
596
  if action == "setdef":
597
+ if not await require_allowed(uid):
598
+ await safe_edit(q.message, NOT_ALLOWED, reply_markup=main_menu_keyboard())
599
+ return
600
+ prof_id = (value or "").strip()
601
+ if not prof_id:
602
+ return
603
+ r = await profile_set_default(uid, prof_id)
604
+ if isinstance(r, dict) and r.get("ok"):
605
+ await safe_edit(q.message, "✅ Default profile updated.", reply_markup=main_menu_keyboard())
606
+ else:
607
+ err = r.get("err", "unknown") if isinstance(r, dict) else "unknown"
608
+ await safe_edit(q.message, f"❌ Failed: `{err}`", reply_markup=main_menu_keyboard())
609
  return
610
 
611
+ if action in (UP_GO, UP_EDIT, UP_PRIV, UP_CANCEL):
612
+ pending = _PENDING_UPLOAD.get(uid)
613
+ if not pending:
614
+ await safe_edit(q.message, "No pending upload. Send a video/document first.", reply_markup=main_menu_keyboard())
615
+ return
 
616
 
617
+ pending.status_msg = q.message
618
+ _PENDING_UPLOAD[uid] = pending
 
 
 
 
 
 
619
 
620
+ if action == UP_CANCEL:
621
+ _PENDING_UPLOAD.pop(uid, None)
 
 
 
 
 
 
 
 
 
622
  _AWAIT_EDIT.pop(uid, None)
623
+ await safe_edit(q.message, CANCELLED, reply_markup=main_menu_keyboard())
624
+ return
625
 
626
+ if action == UP_PRIV:
627
+ pending.privacy = _next_privacy(pending.privacy)
628
+ _PENDING_UPLOAD[uid] = pending
629
+ await safe_edit(q.message, _render_preview(pending), reply_markup=upload_confirm_keyboard(pending.privacy))
630
+ return
 
 
 
631
 
632
+ if action == UP_EDIT:
633
+ _AWAIT_EDIT[uid] = True
634
+ await safe_reply(
635
+ q.message,
636
+ "✏️ Send new title + optional description.\n\nFormats:\n1) `Title | Description`\n2) `Title` then blank line then description",
637
+ )
638
+ return
639
 
640
+ if action == UP_GO:
641
+ await safe_edit(q.message, "⏳ Starting upload…")
642
+ asyncio.create_task(_run_upload(uid))
643
+ return
644
 
645
+ # text router (auth + edit)
 
 
646
  @app.on_message(filters.text & filters.private)
647
+ async def text_router(_, m: Message) -> None:
648
+ uid = m.from_user.id if m.from_user else 0
649
+ if not uid:
650
+ return
651
 
652
+ mode = _AWAIT_AUTH_MODE.get(uid)
653
+ if mode:
654
+ if not await _ensure_allowed(m):
655
+ _AWAIT_AUTH_MODE.pop(uid, None)
656
+ return
657
+
658
+ text = (m.text or "").strip()
659
+ try:
660
+ if mode == "json":
661
+ obj = json.loads(text)
662
+ client_id = str(obj.get("client_id", "")).strip()
663
+ client_secret = str(obj.get("client_secret", "")).strip()
664
+ label = str(obj.get("label", "")).strip()[:40] if obj.get("label") else ""
665
+ else:
666
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
667
+ client_id = lines[0] if len(lines) >= 1 else ""
668
+ client_secret = lines[1] if len(lines) >= 2 else ""
669
+ label = lines[2][:40] if len(lines) >= 3 else ""
670
+
671
+ if not client_id or not client_secret:
672
+ raise ValueError("missing client_id/client_secret")
673
 
674
+ _AWAIT_AUTH_MODE.pop(uid, None)
 
 
 
675
 
676
+ resp = await profile_add(uid, client_id, client_secret, label)
677
+ if not (isinstance(resp, dict) and resp.get("ok")):
678
+ err = resp.get("err", "unknown") if isinstance(resp, dict) else "unknown"
679
+ await safe_reply(m, f"❌ profile_add failed: `{err}`")
680
+ return
681
+
682
+ login_url = _pick_login_url(resp)
683
+ if not login_url:
684
+ await safe_reply(m, "✅ Profile added, but login link missing from response. Check your Pages backend.")
685
+ return
686
+
687
+ await safe_reply(m, f"✅ Profile added.\n\nOpen this link to authorize:\n{login_url}")
688
+ await safe_reply(m, "After authorizing, send a video/document here to upload.", reply_markup=main_menu_keyboard())
689
+
690
+ except Exception as e:
691
+ await safe_reply(m, f"❌ Parse error: `{str(e)[:180]}`\n\nTry /auth again.")
692
  return
693
 
 
694
  if _AWAIT_EDIT.get(uid):
695
+ pending = _PENDING_UPLOAD.get(uid)
696
+ if not pending:
697
  _AWAIT_EDIT.pop(uid, None)
698
  return
699
 
700
+ raw = (m.text or "").strip()
701
+ if not raw or raw.startswith("/"):
702
+ return
703
 
704
+ title = raw
705
+ desc = pending.description or ""
706
 
707
+ if "\n\n" in raw:
708
+ a, b = raw.split("\n\n", 1)
709
+ title = a.strip()
710
+ desc = b.strip()
711
+ elif "|" in raw:
712
+ a, b = raw.split("|", 1)
713
+ title = a.strip()
714
+ desc = b.strip()
 
715
 
716
+ title = (title or pending.title or "video").strip()[:95]
717
+ desc = (desc or "").strip()[:4900]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
718
 
719
+ pending.title = title
720
+ pending.description = desc
721
+ _PENDING_UPLOAD[uid] = pending
722
+ _AWAIT_EDIT.pop(uid, None)
723
 
724
+ await safe_edit(pending.status_msg, _render_preview(pending), reply_markup=upload_confirm_keyboard(pending.privacy))
725
+ return