understanding commited on
Commit
ab20000
·
verified ·
1 Parent(s): 89c65cb

Update bot/handlers.py

Browse files
Files changed (1) hide show
  1. bot/handlers.py +645 -332
bot/handlers.py CHANGED
@@ -1,37 +1,43 @@
1
  # PATH: bot/handlers.py
 
 
2
  import asyncio
3
  import json
4
  import os
5
- import re
6
  import time
7
- from typing import Dict
8
 
9
  from hydrogram import Client, filters
10
  from hydrogram.types import Message, CallbackQuery
11
 
12
- from bot.config import Workers
13
  from bot.ui import texts
14
- from bot.ui.keyboards import profiles_keyboard, auth_menu_keyboard, main_menu_keyboard
 
 
 
 
 
 
15
  from bot.ui.callbacks import (
16
  parse_cb,
17
- AUTH_JSON, AUTH_CI, CANCEL,
18
- MENU_HELP, MENU_AUTH, MENU_PROFILES, MENU_SPEEDTEST
 
 
19
  )
20
 
21
- from bot.core.auth import is_owner_id, require_allowed
 
22
  from bot.core.progress import SpeedETA, human_bytes, human_eta
23
- from bot.core.tasks import create_task, set_task
24
-
25
  from bot.core.speedtest import (
26
  ping_ms,
27
  net_download_test,
28
  net_upload_test,
29
  disk_total_free,
30
- bytes_to_mb,
31
  bytes_per_sec_to_mb_s,
32
  )
33
-
34
- from bot.integrations.cf_worker1 import profile_add, profile_list, profile_set_default
35
  from bot.integrations.cf_worker2 import (
36
  allow_user,
37
  disallow_user,
@@ -39,24 +45,51 @@ from bot.integrations.cf_worker2 import (
39
  access_token,
40
  record_upload,
41
  stats_today,
 
 
42
  )
 
 
43
 
44
  from bot.telegram.media import download_to_temp
45
  from bot.telegram.parse import extract_title_description
46
  from bot.telegram.replies import safe_reply, safe_edit
47
  from bot.temp.files import cleanup_file
48
-
49
  from bot.youtube.uploader import upload_video
50
 
51
- from bot.integrations.diag_extra import dns_check
52
- from bot.integrations.http import fetch_status
53
-
54
- _AWAIT_AUTH: Dict[int, str] = {}
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  _STARTED_AT = time.time()
57
- _SPEED_COOLDOWN: dict[int, float] = {}
58
 
59
 
 
 
 
60
  def _pick_login_url(j: dict) -> str:
61
  if not isinstance(j, dict):
62
  return ""
@@ -81,11 +114,9 @@ def _pick_login_url(j: dict) -> str:
81
  async def _render_speedtest(mb: int) -> str:
82
  mb = max(1, min(200, int(mb)))
83
  dl_bytes = mb * 1024 * 1024
84
- up_mb = min(20, max(3, mb // 5)) # 3..20MB
85
  up_bytes = up_mb * 1024 * 1024
86
-
87
  up_sec = int(max(0, time.time() - _STARTED_AT))
88
- up_txt = human_eta(up_sec)
89
 
90
  d = disk_total_free("/")
91
  total_gb = d["total"] / (1024.0**3)
@@ -98,27 +129,194 @@ async def _render_speedtest(mb: int) -> str:
98
  dl, up, p = await asyncio.gather(dl_task, up_task, p_task)
99
 
100
  ping_line = f"{p:.0f} ms" if p is not None else "N/A"
101
- dl_mb_s = bytes_per_sec_to_mb_s(dl["bps"])
102
- up_mb_s = bytes_per_sec_to_mb_s(up["bps"])
103
-
104
  return (
105
  "⚡ **Server Speed Test**\n\n"
106
- "🕒 Uptime\n"
107
- f"└ {up_txt}\n\n"
108
  "📡 Network\n"
109
  f"├ 🟢 Ping: {ping_line}\n"
110
- f"├ ⬇️ Download: {dl_mb_s:.2f} MB/s\n"
111
- f"└ {bytes_to_mb(dl['bytes']):.2f} MB in {dl['seconds']:.2f}s\n"
112
- f"└ ⬆️ Upload: {up_mb_s:.2f} MB/s\n"
113
- f" └ {bytes_to_mb(up['bytes']):.2f} MB in {up['seconds']:.2f}s\n\n"
114
  "💾 Storage\n"
115
  f"├ Total: {total_gb:.1f} GB\n"
116
- f"└ Free : {free_gb:.1f} GB\n\n"
117
- "🧪 Measured from server (real MB/s, not Mbps)"
118
  )
119
 
120
 
121
- def setup_handlers(app: Client) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  @app.on_message(filters.command(["start"]) & filters.private)
123
  async def start_handler(_: Client, m: Message):
124
  await safe_reply(m, texts.START_TEXT, reply_markup=main_menu_keyboard())
@@ -133,362 +331,477 @@ def setup_handlers(app: Client) -> None:
133
 
134
  @app.on_message(filters.command(["me"]) & filters.private)
135
  async def me_handler(_: Client, m: Message):
136
- uid = m.from_user.id if m.from_user else None
137
- await safe_reply(m, f"👤 Your ID: `{uid}`")
 
 
 
 
 
 
 
 
138
 
139
- # -------------------- SPEEDTEST (any user) --------------------
 
 
140
  @app.on_message(filters.command(["speedtest"]) & filters.private)
141
  async def speedtest_cmd(_: Client, m: Message):
142
- uid = m.from_user.id if m.from_user else 0
143
-
144
  now = time.time()
145
- last = _SPEED_COOLDOWN.get(uid, 0.0)
146
- if now - last < 30:
147
- return await safe_reply(m, "⏳ Wait 30s then try again.")
148
  _SPEED_COOLDOWN[uid] = now
149
 
150
- parts = (m.text or "").split()
151
- try:
152
- mb = int(parts[1]) if len(parts) > 1 else 8
153
- except Exception:
154
- mb = 8
155
-
156
  msg = await safe_reply(m, "⚡ Running speedtest…")
157
  try:
158
- txt = await _render_speedtest(mb)
159
  await safe_edit(msg, txt)
160
  except Exception as e:
161
- await safe_edit(msg, f"❌ Speedtest failed: `{type(e).__name__}: {e}`")
162
-
163
- # -------- OWNER allow/disallow --------
164
- @app.on_message(filters.command(["allow"]) & filters.private)
165
- async def allow_cmd(_: Client, m: Message):
166
- if not is_owner_id(m.from_user.id if m.from_user else None):
167
- return await safe_reply(m, texts.OWNER_ONLY)
168
-
169
- target = None
170
- if m.reply_to_message and m.reply_to_message.from_user:
171
- target = m.reply_to_message.from_user.id
172
- elif m.forward_from:
173
- target = m.forward_from.id
174
- else:
175
- parts = (m.text or "").split()
176
- if len(parts) >= 2 and parts[1].isdigit():
177
- target = int(parts[1])
178
-
179
- if not target:
180
- return await safe_reply(m, "Usage:\n• Reply/forward user msg then /allow\n• or /allow <user_id>")
181
-
182
- j = await allow_user(target)
183
- await safe_reply(m, f"✅ allowed: `{target}`\n`{j}`")
184
-
185
- @app.on_message(filters.command(["disallow"]) & filters.private)
186
- async def disallow_cmd(_: Client, m: Message):
187
- if not is_owner_id(m.from_user.id if m.from_user else None):
188
  return await safe_reply(m, texts.OWNER_ONLY)
189
 
190
- parts = (m.text or "").split()
191
- if len(parts) < 2 or not parts[1].isdigit():
192
- return await safe_reply(m, "Usage: /disallow <user_id>")
193
- target = int(parts[1])
194
- j = await disallow_user(target)
195
- await safe_reply(m, f"✅ disallowed: `{target}`\n`{j}`")
196
 
197
- @app.on_message(filters.command(["stats"]) & filters.private)
198
- async def stats_cmd(_: Client, m: Message):
199
- if not is_owner_id(m.from_user.id if m.from_user else None):
200
- return await safe_reply(m, texts.OWNER_ONLY)
201
- j = await stats_today()
202
- await safe_reply(m, f"📊 Stats:\n`{j}`")
203
 
204
- @app.on_message(filters.command(["diag"]) & filters.private)
205
- async def diag_cmd(_: Client, m: Message):
206
- if not is_owner_id(m.from_user.id if m.from_user else None):
207
- return await safe_reply(m, texts.OWNER_ONLY)
208
 
209
- r1 = await fetch_status((Workers.WORKER1_URL or "") + "/")
210
- r2 = await fetch_status((Workers.WORKER2_URL or "") + "/")
211
-
212
- await safe_reply(
213
- m,
214
- "🔎 DIAG\n"
215
- f"W1={Workers.WORKER1_URL} -> {r1}\n"
216
- f"W2={Workers.WORKER2_URL} -> {r2}\n\n"
217
- f"DNS W1 -> {dns_check(Workers.WORKER1_URL)}\n"
218
- f"DNS W2 -> {dns_check(Workers.WORKER2_URL)}\n"
219
- f"DNS google -> {dns_check('https://www.google.com')}\n",
220
- )
221
 
222
- # -------- AUTH --------
 
 
223
  @app.on_message(filters.command(["auth"]) & filters.private)
224
  async def auth_cmd(_: Client, m: Message):
225
- uid = m.from_user.id if m.from_user else 0
226
  if not await require_allowed(uid):
227
  return await safe_reply(m, texts.NOT_ALLOWED)
228
-
229
  _AWAIT_AUTH.pop(uid, None)
230
- await safe_reply(m, "🔐 Add YouTube Profile\n\nChoose method:", reply_markup=auth_menu_keyboard())
231
 
232
- @app.on_message(filters.command(["cancel"]) & filters.private)
233
- async def cancel_cmd(_: Client, m: Message):
234
- _AWAIT_AUTH.pop(m.from_user.id, None)
235
- await safe_reply(m, texts.CANCELLED, reply_markup=main_menu_keyboard())
 
236
 
237
- @app.on_callback_query()
238
- async def cb(_: Client, q: CallbackQuery):
239
- uid = q.from_user.id if q.from_user else 0
240
- action, value = parse_cb(q.data or "")
241
 
242
- # main menu
243
- if action == MENU_HELP:
244
- try:
245
- await q.message.edit_text(texts.HELP_TEXT, reply_markup=main_menu_keyboard())
246
- except Exception:
247
- pass
248
- return await q.answer("Help")
 
 
 
 
 
249
 
250
- if action == MENU_SPEEDTEST:
251
- await q.answer("Use /speedtest", show_alert=False)
252
- return
253
 
254
- if action == MENU_AUTH:
255
- if not await require_allowed(uid):
256
- return await q.answer("Not allowed", show_alert=True)
257
- _AWAIT_AUTH.pop(uid, None)
258
- try:
259
- await q.message.edit_text("🔐 Add YouTube Profile\n\nChoose method:", reply_markup=auth_menu_keyboard())
260
- except Exception:
261
- pass
262
- return await q.answer("Auth")
263
 
264
- if action == MENU_PROFILES:
265
- await q.answer("Use /profiles", show_alert=False)
266
- return
267
 
268
- # auth menu
269
- if action in (AUTH_JSON, AUTH_CI, CANCEL):
270
- if action == CANCEL:
271
- _AWAIT_AUTH.pop(uid, None)
272
- return await q.answer("Cancelled")
273
- if action == AUTH_JSON:
274
- _AWAIT_AUTH[uid] = "json"
275
- await q.message.edit_text("📄 Paste your client JSON here.\n\n(You can /cancel anytime)")
276
- return await q.answer("Paste JSON")
277
- if action == AUTH_CI:
278
- _AWAIT_AUTH[uid] = "ci"
279
- await q.message.edit_text("🔑 Send Client ID and Client Secret.\n\nFormat:\n<id> | <secret>\n(or 2 lines)\n\n(/cancel anytime)")
280
- return await q.answer("Send ID & Secret")
281
- return
282
 
283
- if action == "setdef" and value:
284
- if not await require_allowed(uid):
285
- return await q.answer("Not allowed", show_alert=True)
286
 
287
- j = await profile_set_default(uid, value)
288
- if not j.get("ok"):
289
- return await q.answer("Failed", show_alert=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
- await q.answer("Current set ✅", show_alert=False)
292
- try:
293
- await q.message.edit_text(f"🟢 Current profile set:\n`{value}`", reply_markup=main_menu_keyboard())
294
- except Exception:
295
- pass
296
- return
 
 
 
 
 
297
 
298
- await q.answer("OK")
 
299
 
300
- # auth sink
301
- @app.on_message(
302
- filters.private
303
- & filters.text
304
- & ~filters.command(["start", "help", "ping", "me", "auth", "cancel", "profiles", "speedtest", "stats", "allow", "disallow", "diag"])
305
- )
306
- async def auth_sink(_: Client, m: Message):
307
- uid = m.from_user.id if m.from_user else 0
308
- mode = _AWAIT_AUTH.get(uid)
309
- if not mode:
310
- return
311
 
312
- try:
313
- if mode == "json":
314
- data = json.loads(m.text)
315
- root = data.get("installed") or data.get("web") or {}
316
- client_id = root.get("client_id") or data.get("client_id")
317
- client_secret = root.get("client_secret") or data.get("client_secret")
318
- if not client_id or not client_secret:
319
- return await safe_reply(m, texts.PARSE_FAIL)
320
- else:
321
- txt = m.text.strip()
322
- parts = [p.strip() for p in re.split(r"[|\n]+", txt) if p.strip()]
323
- if len(parts) < 2:
324
- return await safe_reply(m, texts.PARSE_FAIL)
325
- client_id, client_secret = parts[0], parts[1]
326
-
327
- j = await profile_add(uid, client_id, client_secret, label="main", ttl_sec=600)
328
- if not isinstance(j, dict):
329
- return await safe_reply(m, f"❌ profile_add bad response: `{j}`")
330
-
331
- if not j.get("ok") and not (isinstance(j.get("data"), dict) and j["data"].get("ok")):
332
- return await safe_reply(m, texts.PROFILE_ADD_FAIL.format(j))
333
-
334
- login_url = _pick_login_url(j)
335
- if not login_url:
336
- return await safe_reply(m, f"❌ profile_add OK but login link missing.\nBackend response:\n`{j}`")
337
 
338
- _AWAIT_AUTH.pop(uid, None)
339
- await safe_reply(m, texts.SENT_AUTH_LINK + login_url, reply_markup=main_menu_keyboard())
 
 
340
 
341
- except Exception as e:
342
- await safe_reply(m, f"{texts.PARSE_FAIL}\n`{type(e).__name__}: {e}`")
 
 
343
 
344
- @app.on_message(filters.command(["profiles"]) & filters.private)
345
- async def profiles_cmd(_: Client, m: Message):
346
- uid = m.from_user.id if m.from_user else 0
347
- if not await require_allowed(uid):
348
- return await safe_reply(m, texts.NOT_ALLOWED)
349
 
350
- j = await profile_list(uid)
351
- if not j.get("ok"):
352
- return await safe_reply(m, texts.PROFILE_LIST_FAIL.format(j))
353
 
354
- profiles = j.get("profiles") or []
355
- default_id = j.get("default_profile_id")
356
-
357
- txt = "👤 **Profiles**\n\n"
358
- txt += f"🟢 Current: `{default_id}`\n\n"
359
-
360
- for i, p in enumerate(profiles, start=1):
361
- pid = p.get("profile_id")
362
- ch = (p.get("channel_title") or "—").strip()
363
- cid = p.get("client_id_hint") or "—"
364
- sec = p.get("client_secret_hint") or "****…****"
365
- connected = "✅ Connected" if (p.get("has_refresh") and p.get("channel_id")) else "⏳ Pending"
366
- cur = "🟢 Current" if pid == default_id else ""
367
- txt += f"[{i}] {ch} {cur}\n"
368
- txt += f" id: {cid}\n"
369
- txt += f" secret: {sec}\n"
370
- txt += f" {connected}\n\n"
371
-
372
- kb = profiles_keyboard(profiles)
373
- await safe_reply(m, txt, reply_markup=kb)
374
 
375
- # -------- Upload --------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  @app.on_message(filters.private & (filters.video | filters.document))
377
- async def upload_handler(app_: Client, m: Message):
378
- uid = m.from_user.id if m.from_user else 0
379
  if not await require_allowed(uid):
380
- return await safe_reply(m, texts.NOT_ALLOWED)
381
 
 
382
  media = m.video or m.document
383
- if not media:
384
- return await safe_reply(m, "Send a video/document.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
 
386
- pl = await profile_list(uid)
387
- if not pl.get("ok"):
388
- return await safe_reply(m, texts.PROFILE_LIST_FAIL.format(pl))
 
 
 
 
389
 
390
- default_id = pl.get("default_profile_id")
391
- profiles = pl.get("profiles") or []
392
- dp = next((p for p in profiles if p.get("profile_id") == default_id), None)
393
- if not dp or not dp.get("has_refresh"):
394
- return await safe_reply(m, texts.NEED_AUTH)
395
 
396
- channel_id = dp.get("channel_id")
397
- if not channel_id:
398
- return await safe_reply(m, "❌ channel_id missing. Re-auth profile.")
 
 
399
 
400
- pick = await pick_profile(uid, channel_id)
401
- if not pick.get("ok"):
402
- return await safe_reply(m, texts.PICK_FAIL.format(pick))
403
- use_profile_id = pick["profile_id"]
404
 
405
- tok = await access_token(uid, use_profile_id)
406
- if not tok.get("ok"):
407
- return await safe_reply(m, texts.TOKEN_FAIL.format(tok))
408
- access_tok = tok["access_token"]
 
 
409
 
410
- status = await safe_reply(m, "⬇️ Downloading…")
411
- task_id = f"{uid}:{m.id}"
412
- create_task(task_id, uid)
 
 
413
 
414
- file_path = ""
415
- file_size = 0
416
- file_name = "video.bin"
 
 
 
 
 
 
417
 
418
- try:
419
- # ---- Download ----
420
- set_task(task_id, "downloading", "")
421
- dl_t0 = time.time()
422
- file_path, file_size, file_name = await download_to_temp(app_, m)
423
- dl_sec = max(0.001, time.time() - dl_t0)
424
 
425
- if (not file_size) and file_path and os.path.exists(file_path):
426
- try:
427
- file_size = os.path.getsize(file_path)
428
- except Exception:
429
- file_size = 0
 
 
 
430
 
431
- dl_avg_bps = int((file_size or 0) / dl_sec) if file_size else 0
 
 
 
 
 
 
 
 
 
 
 
 
432
 
433
- await safe_edit(
434
- status,
435
- "✅ Downloaded\n"
436
- f"• File: `{file_name}`\n"
437
- f"• Size: {human_bytes(file_size)}\n"
438
- f"• Time: {human_eta(int(dl_sec))}\n"
439
- f"• Avg: {human_bytes(dl_avg_bps)}/s\n\n"
440
- "⬆️ Starting upload…",
441
- )
442
 
443
- # ---- Upload ----
444
- set_task(task_id, "uploading", "")
445
- up_t0 = time.time()
446
-
447
- se = SpeedETA()
448
- last_edit = 0.0
449
-
450
- async def prog(done: int, total: int):
451
- nonlocal last_edit
452
- snap = se.update(done, total)
453
- if time.time() - last_edit < 2.0 and done < total:
454
- return
455
- last_edit = time.time()
456
- txt = (
457
- "⬆️ Uploading to YouTube…\n"
458
- f"{human_bytes(done)}/{human_bytes(total)}\n"
459
- f"speed: {human_bytes(snap['speed_bps'])}/s | eta: {human_eta(snap['eta_sec'])}"
460
- )
461
- await safe_edit(status, txt)
462
-
463
- title, desc = extract_title_description(m, file_name)
464
-
465
- yt_url = await upload_video(
466
- access_tok,
467
- file_path,
468
- title,
469
- desc,
470
- privacy="private",
471
- progress_cb=prog,
472
- )
473
 
474
- up_sec = max(0.001, time.time() - up_t0)
475
- up_avg_bps = int((file_size or 0) / up_sec) if file_size else 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
 
477
- await record_upload(uid, use_profile_id)
478
- set_task(task_id, "done", "", yt_url=yt_url)
 
 
 
 
479
 
480
- await safe_edit(
481
- status,
482
- "✅ **Uploaded!**\n\n"
483
- f"🎬 {title}\n"
484
- f"🔗 {yt_url}\n\n"
485
- f"⬇️ Download: {human_eta(int(dl_sec))} | avg {human_bytes(dl_avg_bps)}/s\n"
486
- f"⬆️ Upload: {human_eta(int(up_sec))} | avg {human_bytes(up_avg_bps)}/s",
487
- )
488
 
489
- except Exception as e:
490
- set_task(task_id, "error", str(e))
491
- await safe_edit(status, f"❌ Upload failed:\n`{type(e).__name__}: {e}`")
492
- finally:
493
- if file_path:
494
- cleanup_file(file_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
 
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 ""
 
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)
 
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())
 
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