no-name-here commited on
Commit
dff8287
Β·
verified Β·
1 Parent(s): 536eed8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +237 -582
app.py CHANGED
@@ -23,6 +23,7 @@ import asyncio
23
  from urllib.parse import urlparse
24
  from pathlib import Path
25
  import io
 
26
 
27
  from fastapi import FastAPI, Request, HTTPException
28
  import av
@@ -120,7 +121,7 @@ DEFAULT_USER_SETTINGS = {
120
  "stop_on_error_in_playlist": True,
121
  "reconnect_on_stream_error": True,
122
  "reconnect_delay_seconds": 5,
123
- "max_reconnect_attempts": 3,
124
 
125
  # Connection / network
126
  "open_timeout_seconds": 15,
@@ -141,9 +142,6 @@ DEFAULT_USER_SETTINGS = {
141
  "current_step_index": 0,
142
  "conversation_fields_list": [],
143
  "settings_editing_field": None,
144
-
145
- # UX mode: "send" = always new message (default), "edit" = edit-in-place
146
- "ux_mode": "send",
147
  }
148
 
149
  DEFAULT_SESSION_RUNTIME_STATE = {
@@ -167,9 +165,6 @@ DEFAULT_SESSION_RUNTIME_STATE = {
167
  "reconnect_attempt": 0,
168
  "last_frame_time": None, # for watchdog
169
  "last_notified_state": None, # for state-change notifications
170
- "status_message_id": None, # message to auto-update in real-time
171
- "status_chat_id": None,
172
- "active_output_url": None, # the URL actually being streamed to right now
173
  }
174
 
175
  # ──────────────────────────────────────────────
@@ -220,17 +215,50 @@ def append_user_live_log(chat_id: int, line: str):
220
 
221
  # ──────────────────────────────────────────────
222
  # TELEGRAM API HELPERS
223
- # (Webhook-only mode: no outbound Telegram API calls)
224
  # ──────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  def push_message(chat_id: int, text: str, reply_markup=None, parse_mode="HTML"):
226
- """
227
- In webhook-only mode outbound calls are not possible.
228
- Log the notification so it appears in /logs instead.
229
- """
230
- import html
231
- plain = html.unescape(re.sub(r'<[^>]+>', '', text))
232
- logger.info(f"[Chat {chat_id}] [push_message suppressed] {plain[:200]}")
233
- append_user_live_log(chat_id, f"[notification] {plain[:200]}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
 
236
  # ──────────────────────────────────────────────
@@ -261,91 +289,6 @@ def answer_callback_query(cq_id: str, text: str = None, show_alert: bool = False
261
  return resp
262
 
263
 
264
- # ──────────────────────────────────────────────
265
- # REAL-TIME STATUS UPDATER
266
- # ──────────────────────────────────────────────
267
- # How it works (webhook-only, no outbound calls):
268
- # 1. When the user taps πŸ“Š Status, we register their chat_id + message_id.
269
- # 2. A background thread every 5s builds fresh status text and stores it as
270
- # a "pending edit" dict keyed by chat_id.
271
- # 3. The /webhook handler, before returning its own response, checks if there
272
- # is a pending edit for this chat_id that differs from the last-sent text,
273
- # and if so returns the editMessageText as the response (Telegram applies it).
274
- # 4. A GET /status/{chat_id} endpoint lets any HTTP client poll for the latest
275
- # status text (useful for dashboards / external monitoring).
276
- _pending_status_updates: dict = {} # chat_id -> {message_id, last_sent, pending_text}
277
- _status_updater_lock = threading.Lock()
278
-
279
-
280
- def register_status_message(chat_id: int, message_id: int):
281
- """Start tracking this message for real-time updates."""
282
- session = get_user_session(chat_id)
283
- with lock_for(chat_id):
284
- session['status_message_id'] = message_id
285
- with _status_updater_lock:
286
- _pending_status_updates[chat_id] = {
287
- "message_id": message_id,
288
- "last_sent": "",
289
- "pending_text": None,
290
- }
291
- logger.info(f"[Chat {chat_id}] Real-time status registered for msg {message_id}.")
292
-
293
-
294
- def lock_for(chat_id: int):
295
- return session_locks.get(chat_id, threading.Lock())
296
-
297
-
298
- def unregister_status_message(chat_id: int):
299
- with _status_updater_lock:
300
- _pending_status_updates.pop(chat_id, None)
301
-
302
-
303
- def pop_pending_status_edit(chat_id: int):
304
- """Return an editMessageText dict if there's a fresher status, else None."""
305
- with _status_updater_lock:
306
- info = _pending_status_updates.get(chat_id)
307
- if not info or not info.get("pending_text"):
308
- return None
309
- if info["pending_text"] == info["last_sent"]:
310
- return None
311
- session = get_user_session(chat_id)
312
- kb = get_main_keyboard(session)
313
- resp = edit_message_text(chat_id, info["message_id"], info["pending_text"],
314
- reply_markup=kb)
315
- info["last_sent"] = info["pending_text"]
316
- return resp
317
-
318
-
319
- def _status_updater_loop():
320
- """Background thread: refresh pending_text every 5 seconds."""
321
- while True:
322
- time.sleep(5)
323
- try:
324
- with _status_updater_lock:
325
- items = list(_pending_status_updates.items())
326
- for chat_id, info in items:
327
- try:
328
- session = get_user_session(chat_id)
329
- state = session.get('streaming_state', 'idle')
330
- new_text = compose_status_message(chat_id, include_config=False)
331
- with _status_updater_lock:
332
- if chat_id in _pending_status_updates:
333
- _pending_status_updates[chat_id]["pending_text"] = new_text
334
- # Auto-unregister once stream fully ended
335
- if state in ('idle', 'stopped', 'completed', 'error'):
336
- # leave one final update queued, then stop
337
- unregister_status_message(chat_id)
338
- except Exception as e_inner:
339
- logger.warning(f"Status updater inner error for {chat_id}: {e_inner}")
340
- except Exception as e:
341
- logger.error(f"Status updater error: {e}")
342
-
343
-
344
- _status_updater_thread = threading.Thread(target=_status_updater_loop,
345
- name="StatusUpdater", daemon=True)
346
- _status_updater_thread.start()
347
-
348
-
349
  # ──────────────────────────────────────────────
350
  # CONTEXT-AWARE KEYBOARDS
351
  # ──────────────────────────────────────────────
@@ -404,10 +347,8 @@ def get_main_keyboard(session: dict):
404
  return {"inline_keyboard": btns}
405
 
406
 
407
- def get_settings_keyboard(session: dict = None):
408
  """Inline keyboard for settings navigation."""
409
- ux = (session or {}).get("ux_mode", "send")
410
- ux_label = "πŸ’¬ UX: New Message βœ…" if ux == "send" else "✏️ UX: Edit In-Place βœ…"
411
  return {"inline_keyboard": [
412
  [{"text": "πŸ“‘ Output URL", "callback_data": "set_output_url"},
413
  {"text": "🎬 Input Playlist", "callback_data": "view_playlist"}],
@@ -426,7 +367,6 @@ def get_settings_keyboard(session: dict = None):
426
  [{"text": "πŸ›‘ Stop on Error", "callback_data": "toggle_stop_on_error"},
427
  {"text": "⏰ Open Timeout", "callback_data": "set_open_timeout"}],
428
  [{"text": "🎨 Quality Preset", "callback_data": "pick_quality_preset"}],
429
- [{"text": ux_label, "callback_data": "toggle_ux_mode"}],
430
  [{"text": "βœ… Done", "callback_data": "settings_done"}],
431
  ]}
432
 
@@ -742,15 +682,9 @@ def stream_engine_thread_target(chat_id: int):
742
  notify_state_change(chat_id, "starting")
743
 
744
  # --- Open output container ---
745
- # Always read fresh from session so any /set output_url change takes effect.
746
- with lock:
747
- output_url = session['output_url']
748
- output_format = session.get('output_format', 'flv')
749
- open_timeout = session.get('open_timeout_seconds', 15)
750
- # Also store the active_output_url in session for status display
751
- with lock:
752
- session['active_output_url'] = output_url
753
- append_user_live_log(chat_id, f"Opening output: {output_url} [{output_format}]")
754
  try:
755
  active_output_container = av.open(
756
  output_url, mode='w', format=output_format,
@@ -797,9 +731,6 @@ def stream_engine_thread_target(chat_id: int):
797
  if session.get('stop_gracefully_flag') or session['streaming_state'] == "stopping":
798
  break
799
  current_loop_iter = session['current_loop_iteration']
800
- # Re-read total_loops and playlist each iteration so live changes are picked up
801
- total_loops = session.get('loop_count', 0)
802
- playlist = list(session.get('input_url_playlist', []))
803
 
804
  if total_loops != -1 and current_loop_iter >= max(total_loops, 1):
805
  append_user_live_log(chat_id, f"Completed all {max(total_loops, 1)} loop(s).")
@@ -825,7 +756,7 @@ def stream_engine_thread_target(chat_id: int):
825
  _input_container = None
826
  reconnect_on_error = session.get('reconnect_on_stream_error', True)
827
  reconnect_delay = session.get('reconnect_delay_seconds', 5)
828
- max_reconnects = session.get('max_reconnect_attempts', 3)
829
  read_timeout = session.get('read_timeout_seconds', 30)
830
 
831
  try:
@@ -980,7 +911,7 @@ def stream_engine_thread_target(chat_id: int):
980
  should_stop_now = session['streaming_state'] == "stopping"
981
  reconnect_on = session.get('reconnect_on_stream_error', True)
982
  cur_attempt = session.get('reconnect_attempt', 0)
983
- max_att = session.get('max_reconnect_attempts', 3)
984
  stop_on_err = session.get('stop_on_error_in_playlist', True)
985
 
986
  if should_stop_now:
@@ -1092,32 +1023,27 @@ def stream_engine_thread_target(chat_id: int):
1092
  # ──────────────────────────────────────────────
1093
  # STREAM CONTROL HANDLERS
1094
  # ──────────────────────────────────────────────
1095
- async def start_stream_handler(chat_id: int, message_id_to_edit: int = None):
1096
  session = get_user_session(chat_id)
1097
  lock = session_locks[chat_id]
1098
 
1099
- def _reply(text, kb):
1100
- if message_id_to_edit:
1101
- return edit_message_text(chat_id, message_id_to_edit, text, reply_markup=kb)
1102
- return send_message(chat_id, text, reply_markup=kb)
1103
-
1104
  with lock:
1105
  state = session['streaming_state']
1106
  if state in ("streaming", "paused", "starting", "reconnecting"):
1107
- return _reply(
1108
  f"⚠️ Stream already active (state: <code>{state}</code>).\n"
1109
  f"Use /abort to stop it first.",
1110
- get_main_keyboard(session))
1111
  if not session.get('input_url_playlist'):
1112
- return _reply(
1113
  "πŸ“œ <b>Playlist is empty.</b>\nAdd at least one URL:\n"
1114
  "<code>/playlist add &lt;url&gt;</code>",
1115
- get_main_keyboard(session))
1116
  if not session.get('output_url') or session['output_url'] == DEFAULT_USER_SETTINGS['output_url']:
1117
- return _reply(
1118
  "πŸ“‘ <b>Output URL not configured.</b>\n"
1119
  "Set it with: <code>/set output_url rtmp://your-url/key</code>",
1120
- get_main_keyboard(session))
1121
  session['streaming_state'] = "starting"
1122
  session['error_notification_user'] = ""
1123
 
@@ -1133,7 +1059,7 @@ async def start_stream_handler(chat_id: int, message_id_to_edit: int = None):
1133
  await asyncio.sleep(0.3)
1134
  t_watch.start()
1135
 
1136
- return _reply(
1137
  "πŸ”„ <b>Stream starting…</b>\n\n"
1138
  "I'll notify you when it's live.\n\n"
1139
  "<b>Commands while streaming:</b>\n"
@@ -1141,10 +1067,10 @@ async def start_stream_handler(chat_id: int, message_id_to_edit: int = None):
1141
  " /resume β€” resume\n"
1142
  " /abort β€” stop the stream\n"
1143
  " /status β€” current status",
1144
- get_main_keyboard(session))
1145
 
1146
 
1147
- async def pause_stream_handler(chat_id: int, message_id: int = None):
1148
  session = get_user_session(chat_id)
1149
  lock = session_locks[chat_id]
1150
  with lock:
@@ -1155,21 +1081,17 @@ async def pause_stream_handler(chat_id: int, message_id: int = None):
1155
  ok = False
1156
  st = session['streaming_state']
1157
 
1158
- def _reply(text, kb):
1159
- if message_id:
1160
- return edit_message_text(chat_id, message_id, text, reply_markup=kb)
1161
- return send_message(chat_id, text, reply_markup=kb)
1162
-
1163
  if ok:
1164
  append_user_live_log(chat_id, "Paused by user.")
1165
  threading.Thread(target=notify_state_change, args=(chat_id, "paused"), daemon=True).start()
1166
- return _reply("⏸ <b>Stream paused.</b>\nUse /resume to continue or /abort to stop.",
1167
- get_main_keyboard(session))
1168
  else:
1169
- return _reply(f"ℹ️ Cannot pause β€” state is <code>{st}</code>.", get_main_keyboard(session))
 
1170
 
1171
 
1172
- async def resume_stream_handler(chat_id: int, message_id: int = None):
1173
  session = get_user_session(chat_id)
1174
  lock = session_locks[chat_id]
1175
  with lock:
@@ -1180,20 +1102,16 @@ async def resume_stream_handler(chat_id: int, message_id: int = None):
1180
  ok = False
1181
  st = session['streaming_state']
1182
 
1183
- def _reply(text, kb):
1184
- if message_id:
1185
- return edit_message_text(chat_id, message_id, text, reply_markup=kb)
1186
- return send_message(chat_id, text, reply_markup=kb)
1187
-
1188
  if ok:
1189
  append_user_live_log(chat_id, "Resumed by user.")
1190
  threading.Thread(target=notify_state_change, args=(chat_id, "streaming"), daemon=True).start()
1191
- return _reply("▢️ <b>Stream resumed.</b>", get_main_keyboard(session))
1192
  else:
1193
- return _reply(f"ℹ️ Cannot resume β€” state is <code>{st}</code>.", get_main_keyboard(session))
 
1194
 
1195
 
1196
- async def abort_stream_handler(chat_id: int, message_id: int = None):
1197
  session = get_user_session(chat_id)
1198
  lock = session_locks[chat_id]
1199
  thread_ref = None
@@ -1207,13 +1125,9 @@ async def abort_stream_handler(chat_id: int, message_id: int = None):
1207
  aborted = False
1208
  st = session['streaming_state']
1209
 
1210
- def _reply(text, kb):
1211
- if message_id:
1212
- return edit_message_text(chat_id, message_id, text, reply_markup=kb)
1213
- return send_message(chat_id, text, reply_markup=kb)
1214
-
1215
  if not aborted:
1216
- return _reply(f"ℹ️ No active stream (state: <code>{st}</code>).", get_main_keyboard(session))
 
1217
 
1218
  append_user_live_log(chat_id, "Abort requested by user.")
1219
  if thread_ref and thread_ref.is_alive():
@@ -1226,8 +1140,9 @@ async def abort_stream_handler(chat_id: int, message_id: int = None):
1226
  session['streaming_state'] = "stopped"
1227
  session['stream_thread_ref'] = None
1228
 
1229
- return _reply("⏹ <b>Stream aborted.</b>\n\n" + compose_status_message(chat_id),
1230
- get_main_keyboard(session))
 
1231
 
1232
 
1233
  # ──────────────────────────────────────────────
@@ -1426,270 +1341,122 @@ async def handle_logo_upload(chat_id: int, message: dict):
1426
  if not file_id:
1427
  return send_message(chat_id, "❌ Could not get file from message.", reply_markup=get_main_keyboard(session))
1428
 
1429
- # Webhook-only mode: Telegram sends only file_id, not the actual bytes.
1430
- # Downloading requires an outbound getFile API call which is blocked on HuggingFace Spaces.
1431
- with lock:
1432
- session['current_step'] = None
1433
- return send_message(chat_id,
1434
- "⚠️ <b>Logo upload is unavailable in webhook-only mode.</b>\n\n"
1435
- "This deployment blocks outbound requests, so the bot cannot download "
1436
- "files from Telegram's servers.\n\n"
1437
- "To use a logo, host the image publicly and set it via a URL workaround, "
1438
- "or run the bot in an environment that allows outbound connections.",
1439
- reply_markup=get_main_keyboard(session))
1440
-
1441
-
1442
- # ──────────────────────────────────────────────
1443
- # SCHEDULE HANDLER
1444
- # ──────────────────────────────────────────────
1445
- # Scheduling flow (conversation steps):
1446
- # sched_when β†’ user picks "timer" or "datetime"
1447
- # sched_timer β†’ user types "in X minutes/hours"
1448
- # sched_datetime β†’ user types YYYY-MM-DD HH:MM:SS
1449
- # sched_name β†’ user types a friendly job name
1450
- # sched_rtmp β†’ user types the RTMP output URL for this job
1451
- # sched_input β†’ user types the input URL for this job
1452
- # Then job is registered with its own output+input URLs (independent of session).
1453
-
1454
- def _list_schedules(chat_id: int) -> str:
1455
- """Build schedule list text."""
1456
- jobs = scheduler.get_jobs()
1457
- user_jobs = [j for j in jobs if j.id.startswith(f"stream_{chat_id}_")]
1458
- lines = ["πŸ•’ <b>Scheduled Streams</b> <i>(in-memory, lost on restart)</i>\n"]
1459
- if user_jobs:
1460
- for j in user_jobs:
1461
- rt = j.next_run_time.strftime("%Y-%m-%d %H:%M:%S UTC") if j.next_run_time else "N/A"
1462
- meta = getattr(j, "_job_meta", {})
1463
- rtmp = meta.get("output_url", "?")
1464
- inp = meta.get("input_url", "?")
1465
- lines.append(
1466
- f" β€’ <b>{esc(j.name)}</b>\n"
1467
- f" ⏰ <code>{rt}</code>\n"
1468
- f" πŸ“‘ <code>{esc(rtmp[:60])}</code>\n"
1469
- f" 🎬 <code>{esc(inp[:60])}</code>\n"
1470
- f" Cancel: <code>/schedule cancel {esc(j.id)}</code>"
1471
- )
1472
- else:
1473
- lines.append(" No scheduled streams.")
1474
- lines += [
1475
- "",
1476
- "βž• <b>Add a schedule:</b> use <b>πŸ•’ Schedule</b> button or <code>/schedule new</code>",
1477
- "❌ <b>Cancel:</b> <code>/schedule cancel &lt;job_id&gt;</code>",
1478
- ]
1479
- return "\n".join(lines)
1480
-
1481
-
1482
- def get_schedule_when_keyboard():
1483
- return {"inline_keyboard": [
1484
- [{"text": "⏱ Timer (in X minutes)", "callback_data": "sched_pick_timer"},
1485
- {"text": "πŸ“… Date & Time (UTC)", "callback_data": "sched_pick_datetime"}],
1486
- [{"text": "❌ Cancel", "callback_data": "sched_cancel_setup"}],
1487
- ]}
1488
-
1489
-
1490
- def get_schedule_menu_keyboard(session: dict = None):
1491
- """Keyboard shown on the schedule list view."""
1492
- return {"inline_keyboard": [
1493
- [{"text": "βž• New Schedule", "callback_data": "sched_new"}],
1494
- [{"text": "πŸ”™ Back", "callback_data": "settings_done"}],
1495
- ]}
1496
-
1497
 
1498
- async def handle_schedule_command(chat_id: int, text: str):
1499
- session = get_user_session(chat_id)
1500
- lock = session_locks[chat_id]
1501
- parts = text.split(maxsplit=2)
1502
- sub = parts[1].lower() if len(parts) > 1 else ""
1503
 
1504
- # List / entry point
1505
- if not sub or sub == "list":
1506
- return send_message(chat_id, _list_schedules(chat_id),
 
 
 
1507
  reply_markup=get_main_keyboard(session))
1508
 
1509
- # Start new schedule conversation
1510
- if sub == "new":
1511
- with lock:
1512
- session["current_step"] = "sched_when"
1513
- session["_sched_draft"] = {}
1514
  return send_message(chat_id,
1515
- "πŸ•’ <b>New Scheduled Stream</b>\n\nWhen should it start?",
1516
- reply_markup=get_schedule_when_keyboard())
1517
 
1518
- # Cancel a job
1519
- if sub == "cancel":
1520
- job_id = parts[2].strip() if len(parts) > 2 else None
1521
- if not job_id:
1522
- return send_message(chat_id,
1523
- "Usage: <code>/schedule cancel &lt;job_id&gt;</code>",
1524
- reply_markup=get_main_keyboard(session))
1525
- try:
1526
- scheduler.remove_job(job_id)
1527
- return send_message(chat_id,
1528
- f"βœ… Cancelled: <code>{esc(job_id)}</code>",
1529
- reply_markup=get_main_keyboard(session))
1530
- except Exception:
1531
- return send_message(chat_id,
1532
- f"❌ Job not found: <code>{esc(job_id)}</code>",
1533
- reply_markup=get_main_keyboard(session))
1534
 
 
1535
  return send_message(chat_id,
1536
- "⚠️ Unknown sub-command.\nTry /schedule, /schedule new, or /schedule cancel &lt;id&gt;",
 
 
 
 
 
 
1537
  reply_markup=get_main_keyboard(session))
1538
 
1539
 
1540
- async def handle_schedule_conversation(chat_id: int, text: str):
1541
- """Handle text input during schedule setup conversation steps."""
 
 
1542
  session = get_user_session(chat_id)
1543
- lock = session_locks[chat_id]
1544
- step = session.get("current_step", "")
1545
- draft = session.setdefault("_sched_draft", {})
1546
-
1547
- if step == "sched_timer":
1548
- # Parse "30", "30m", "2h", "1h30m", "90 minutes" etc.
1549
- raw = text.strip().lower().replace(" ", "")
1550
- minutes = 0
1551
- import re as _re
1552
- hm = _re.match(r"^(?:(\d+)h)?(?:(\d+)m?)?$", raw)
1553
- plain = _re.match(r"^(\d+)$", raw)
1554
- if hm and (hm.group(1) or hm.group(2)):
1555
- minutes = int(hm.group(1) or 0) * 60 + int(hm.group(2) or 0)
1556
- elif plain:
1557
- minutes = int(plain.group(1))
1558
- if minutes <= 0:
1559
- return send_message(chat_id,
1560
- "❌ Could not parse duration. Try <code>30</code>, <code>2h</code>, <code>1h30m</code>\nOr /cancel.")
1561
- trigger_time = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=minutes)
1562
- draft["trigger_time"] = trigger_time
1563
- with lock:
1564
- session["current_step"] = "sched_name"
1565
- return send_message(chat_id,
1566
- f"βœ… Timer set: <b>{minutes} minute(s)</b> from now\n"
1567
- f" β†’ <code>{trigger_time.strftime('%Y-%m-%d %H:%M:%S UTC')}</code>\n\n"
1568
- f"Give this schedule a <b>name</b> (or type <code>skip</code>):")
1569
-
1570
- elif step == "sched_datetime":
1571
- raw = text.strip()
1572
- try:
1573
- # Accept both "YYYY-MM-DD HH:MM:SS" and "YYYY-MM-DD HH:MM"
1574
- fmt = "%Y-%m-%d %H:%M:%S" if len(raw) > 16 else "%Y-%m-%d %H:%M"
1575
- trigger_time = datetime.datetime.strptime(raw, fmt).replace(tzinfo=datetime.timezone.utc)
1576
- except ValueError:
1577
- return send_message(chat_id,
1578
- "❌ Invalid format. Use <code>YYYY-MM-DD HH:MM:SS</code> (UTC)\nOr /cancel.")
1579
- if trigger_time <= datetime.datetime.now(datetime.timezone.utc):
1580
- return send_message(chat_id,
1581
- "❌ That time is in the past. Enter a future time (UTC):\nOr /cancel.")
1582
- draft["trigger_time"] = trigger_time
1583
- with lock:
1584
- session["current_step"] = "sched_name"
1585
- return send_message(chat_id,
1586
- f"βœ… Time set: <code>{trigger_time.strftime('%Y-%m-%d %H:%M:%S UTC')}</code>\n\n"
1587
- f"Give this schedule a <b>name</b> (or type <code>skip</code>):")
1588
-
1589
- elif step == "sched_name":
1590
- name = text.strip()
1591
- draft["name"] = f"Scheduled Stream {chat_id}" if name.lower() == "skip" or not name else name
1592
- with lock:
1593
- session["current_step"] = "sched_rtmp"
1594
- return send_message(chat_id,
1595
- f"πŸ“‘ <b>Step: RTMP Output URL</b>\n\n"
1596
- f"Enter the RTMP URL to stream to:\n"
1597
- f"<code>rtmp://a.rtmp.youtube.com/live2/YOUR_KEY</code>\n\n"
1598
- f"Or type <code>use</code> to use your current output URL (<code>{esc(session.get('output_url', 'not set'))}</code>)\n"
1599
- f"Or /cancel.")
1600
-
1601
- elif step == "sched_rtmp":
1602
- raw = text.strip()
1603
- if raw.lower() == "use":
1604
- url = session.get("output_url", "")
1605
- else:
1606
- url = raw
1607
- if not validate_url(url):
1608
- return send_message(chat_id,
1609
- "❌ Invalid URL. Must start with rtmp/http/https/rtsp/...\nTry again or /cancel.")
1610
- draft["output_url"] = url
1611
- with lock:
1612
- session["current_step"] = "sched_input"
1613
- return send_message(chat_id,
1614
- f"🎬 <b>Step: Input URL</b>\n\n"
1615
- f"Enter the stream/video input URL:\n"
1616
- f"<code>https://example.com/video.mp4</code>\n\n"
1617
- f"Or type <code>use</code> to use your current playlist first item (<code>{esc((session.get('input_url_playlist') or ['not set'])[0])}</code>)\n"
1618
- f"Or /cancel.")
1619
 
1620
- elif step == "sched_input":
1621
- raw = text.strip()
1622
- if raw.lower() == "use":
1623
- pl = session.get("input_url_playlist", [])
1624
- url = pl[0] if pl else ""
 
 
 
 
1625
  else:
1626
- url = raw
1627
- if not validate_url(url):
1628
- return send_message(chat_id,
1629
- "❌ Invalid URL. Must start with http/https/rtmp/rtsp/...\nTry again or /cancel.")
1630
- draft["input_url"] = url
 
 
 
 
 
 
1631
 
1632
- # All info collected β€” register the job
1633
- trigger_time = draft["trigger_time"]
1634
- job_name = draft["name"]
1635
- output_url = draft["output_url"]
1636
- input_url = draft["input_url"]
 
 
 
 
 
1637
 
1638
- job_id = f"stream_{chat_id}_{int(trigger_time.timestamp())}"
1639
- try:
1640
- job = scheduler.add_job(
1641
- _scheduled_stream_runner,
1642
- "date",
1643
- run_date=trigger_time,
1644
- args=[chat_id, output_url, input_url],
1645
- id=job_id,
1646
- name=job_name,
1647
- replace_existing=True,
1648
- )
1649
- # Stash meta on job object for display (APScheduler allows arbitrary attrs)
1650
- job._job_meta = {"output_url": output_url, "input_url": input_url}
1651
- except Exception as e:
1652
- return send_message(chat_id, f"❌ Failed to register job: {esc(str(e))}",
1653
- reply_markup=get_main_keyboard(session))
1654
 
1655
- mins_away = int((trigger_time - datetime.datetime.now(datetime.timezone.utc)).total_seconds() / 60)
1656
- with lock:
1657
- session["current_step"] = None
1658
- session["_sched_draft"] = {}
1659
 
 
 
 
 
 
 
 
 
 
 
1660
  return send_message(chat_id,
1661
- f"βœ… <b>Stream Scheduled!</b>\n\n"
1662
- f" πŸ“› <b>Name:</b> {esc(job_name)}\n"
1663
- f" ⏰ <b>Time:</b> <code>{trigger_time.strftime('%Y-%m-%d %H:%M:%S UTC')}</code> (~{mins_away}m from now)\n"
1664
- f" πŸ“‘ <b>Output:</b> <code>{esc(output_url[:60])}</code>\n"
1665
- f" 🎬 <b>Input:</b> <code>{esc(input_url[:60])}</code>\n"
1666
- f" πŸ”‘ <b>ID:</b> <code>{job_id}</code>\n\n"
1667
- f"View all: /schedule\nCancel: <code>/schedule cancel {job_id}</code>",
1668
  reply_markup=get_main_keyboard(session))
1669
-
1670
- # Fallback β€” unknown step
1671
- with lock:
1672
- session["current_step"] = None
1673
- return send_message(chat_id,
1674
- "⚠️ Schedule setup cancelled (unknown step).",
1675
- reply_markup=get_main_keyboard(session))
1676
 
1677
 
1678
- def _scheduled_stream_runner(chat_id: int, output_url: str = None, input_url: str = None):
1679
- """Run by APScheduler at the scheduled time. Overrides session URLs if provided."""
1680
  logger.info(f"[Scheduler] Firing scheduled stream for chat {chat_id}")
1681
- session = get_user_session(chat_id)
1682
- lock = session_locks[chat_id]
1683
-
1684
- # Override session URLs with the per-job ones
1685
- if output_url:
1686
- with lock:
1687
- session["output_url"] = output_url
1688
- session["active_output_url"] = output_url
1689
- if input_url:
1690
- with lock:
1691
- session["input_url_playlist"] = [input_url]
1692
-
1693
  loop = _main_event_loop
1694
  if loop and loop.is_running():
1695
  asyncio.run_coroutine_threadsafe(start_stream_handler(chat_id), loop)
@@ -1728,13 +1495,9 @@ def get_help_text() -> str:
1728
  " /set logo_scale 0.1\n"
1729
  " /set logo_opacity 0.8\n\n"
1730
  "<b>━━ Schedule ━━</b>\n"
1731
- " /schedule β€” List &amp; manage schedules\n"
1732
- " /schedule new β€” Create a new schedule (conversation)\n"
1733
- " /schedule cancel &lt;id&gt; β€” Cancel a scheduled stream\n\n"
1734
- "<b>πŸ“… Scheduling supports:</b>\n"
1735
- " ⏱ Timer: <i>in X minutes/hours</i>\n"
1736
- " πŸ“… Date &amp; time: <code>YYYY-MM-DD HH:MM:SS</code> (UTC)\n"
1737
- " Each schedule has its own RTMP output + input URL\n\n"
1738
  "<b>━━ Diagnostics ━━</b>\n"
1739
  " /logs β€” Recent user logs\n"
1740
  " /globallogs β€” System logs\n\n"
@@ -1892,73 +1655,40 @@ async def _handle_update_inner(update: dict):
1892
  logger.info(f"[Chat {chat_id}] Callback: {data}")
1893
  ack = answer_callback_query(cq["id"])
1894
 
1895
- # ── UX mode helpers ──────────────────────────────────────────────────
1896
- # ux_mode "send" β†’ always sendMessage (new bubble, default)
1897
- # ux_mode "edit" β†’ editMessageText in-place (replaces the button msg)
1898
- ux = session.get("ux_mode", "send")
1899
-
1900
- def reply(text, kb=None):
1901
- """Send or edit depending on ux_mode."""
1902
- kb = kb or get_main_keyboard(session)
1903
- if ux == "edit":
1904
- return edit_message_text(chat_id, message_id, text, reply_markup=kb)
1905
- return send_message(chat_id, text, reply_markup=kb)
1906
-
1907
- def edit(text, kb=None):
1908
- """Always edit in-place (used for sub-menus that must stay in same msg)."""
1909
- return edit_message_text(chat_id, message_id, text,
1910
- reply_markup=kb or get_main_keyboard(session))
1911
-
1912
- # ─────────────────────────────────────────────────────────────────────
1913
  # Stream controls
1914
  if data == "stream_start":
1915
- mid = message_id if ux == "edit" else None
1916
- return [ack, await start_stream_handler(chat_id, message_id_to_edit=mid)]
1917
  elif data == "stream_pause":
1918
- mid = message_id if ux == "edit" else None
1919
- return [ack, await pause_stream_handler(chat_id, message_id=mid)]
1920
  elif data == "stream_resume":
1921
- mid = message_id if ux == "edit" else None
1922
- return [ack, await resume_stream_handler(chat_id, message_id=mid)]
1923
  elif data == "stream_abort":
1924
- mid = message_id if ux == "edit" else None
1925
- return [ack, await abort_stream_handler(chat_id, message_id=mid)]
1926
  elif data == "stream_status":
1927
- status_text = compose_status_message(chat_id, include_config=True)
1928
- # Register this message for real-time auto-updates
1929
- register_status_message(chat_id, message_id)
1930
- return [ack, edit(status_text)] # always edit for live status
1931
  elif data == "stream_stop_graceful":
1932
  with lock:
1933
  session['stop_gracefully_flag'] = True
1934
  append_user_live_log(chat_id, "Graceful stop requested.")
1935
- return [ack, edit("⏳ <b>Will stop after current loop finishes.</b>\n\n"
1936
- + compose_status_message(chat_id))]
 
1937
 
1938
  # Settings navigation
1939
  elif data == "open_settings":
1940
- return [ack, edit(
1941
  "βš™οΈ <b>Settings</b>\n\n" + format_settings_display(session) + "\n\n"
1942
  "Tap a parameter to change it, or use <code>/set &lt;field&gt; &lt;value&gt;</code>",
1943
- get_settings_keyboard(session))]
1944
-
1945
- elif data == "toggle_ux_mode":
1946
- with lock:
1947
- current_ux = session.get("ux_mode", "send")
1948
- session["ux_mode"] = "edit" if current_ux == "send" else "send"
1949
- new_ux = session["ux_mode"]
1950
- label = "New Message (sendMessage)" if new_ux == "send" else "Edit In-Place (editMessageText)"
1951
- return [ack, edit(
1952
- f"βœ… <b>UX Mode switched to:</b> <code>{label}</code>\n\n"
1953
- f"{'πŸ’¬ Each button press sends a new message.' if new_ux == 'send' else '✏️ Button presses edit the existing message in-place.'}\n\n"
1954
- + format_settings_display(session),
1955
- get_settings_keyboard(session))]
1956
 
1957
  elif data == "settings_done":
1958
- return [ack, reply(compose_status_message(chat_id, True))]
 
1959
 
1960
  elif data == "pick_quality_preset":
1961
- return [ack, edit("🎨 <b>Choose Quality Preset:</b>", get_quality_keyboard())]
 
1962
 
1963
  elif data.startswith("apply_quality_"):
1964
  q = data.replace("apply_quality_", "")
@@ -1967,33 +1697,39 @@ async def _handle_update_inner(update: dict):
1967
  session['quality_preset'] = q
1968
  for k, v in QUALITY_PRESETS[q].items():
1969
  session[k] = v
1970
- return [ack, edit(
1971
  f"βœ… Quality preset <code>{q}</code> applied.\n\n" + format_settings_display(session),
1972
- get_settings_keyboard(session))]
1973
 
1974
  elif data == "set_video_codec":
1975
- return [ack, edit("πŸŽ₯ <b>Choose Video Codec:</b>", get_codec_keyboard("video"))]
 
1976
 
1977
  elif data.startswith("set_vcodec_"):
1978
  codec = data.replace("set_vcodec_", "")
1979
  with lock: session['video_codec'] = codec
1980
- return [ack, edit(f"βœ… Video codec: <code>{codec}</code>", get_settings_keyboard(session))]
 
1981
 
1982
  elif data == "set_audio_codec":
1983
- return [ack, edit("πŸ”Š <b>Choose Audio Codec:</b>", get_codec_keyboard("audio"))]
 
1984
 
1985
  elif data.startswith("set_acodec_"):
1986
  codec = data.replace("set_acodec_", "")
1987
  with lock: session['audio_codec'] = codec
1988
- return [ack, edit(f"βœ… Audio codec: <code>{codec}</code>", get_settings_keyboard(session))]
 
1989
 
1990
  elif data == "set_ffmpeg_preset":
1991
- return [ack, edit("⚑ <b>Choose FFmpeg Preset:</b>", get_preset_keyboard())]
 
1992
 
1993
  elif data.startswith("set_preset_"):
1994
  preset = data.replace("set_preset_", "")
1995
  with lock: session['ffmpeg_preset'] = preset
1996
- return [ack, edit(f"βœ… Preset: <code>{preset}</code>", get_settings_keyboard(session))]
 
1997
 
1998
  # Inline-triggered field edits (ask user to type)
1999
  elif data in ("set_output_url", "set_resolution", "set_fps", "set_video_bitrate",
@@ -2017,11 +1753,12 @@ async def _handle_update_inner(update: dict):
2017
  with lock:
2018
  session['current_step'] = "editing_field"
2019
  session['settings_editing_field'] = field
2020
- return [ack, reply(
2021
  f"πŸ“ <b>Set {field}</b>\n"
2022
  f"Current: <code>{esc(str(cur))}</code>\n"
2023
  f"Expected: {esc(desc)}\n\n"
2024
- f"Type the new value now, or /cancel to abort.")]
 
2025
 
2026
  elif data in ("toggle_reconnect", "toggle_stop_on_error"):
2027
  field_map = {
@@ -2032,9 +1769,9 @@ async def _handle_update_inner(update: dict):
2032
  with lock:
2033
  session[field] = not session.get(field, True)
2034
  new_val = session[field]
2035
- return [ack, edit(
2036
  f"βœ… <b>{field}</b> β†’ <code>{'on' if new_val else 'off'}</code>",
2037
- get_settings_keyboard(session))]
2038
 
2039
  # Playlist view
2040
  elif data == "view_playlist":
@@ -2049,7 +1786,7 @@ async def _handle_update_inner(update: dict):
2049
  else:
2050
  msg = ("πŸ“œ <b>Playlist is empty.</b>\n"
2051
  "<code>/playlist add &lt;url&gt;</code>")
2052
- return [ack, reply(msg)]
2053
 
2054
  # Logo
2055
  elif data == "cfg_logo":
@@ -2076,95 +1813,69 @@ async def _handle_update_inner(update: dict):
2076
  lines.append("Change scale/opacity: <code>/set logo_scale 0.15</code>")
2077
  else:
2078
  lines.append("No logo uploaded.")
2079
- return [ack, edit("\n".join(lines), {"inline_keyboard": logo_btns})]
 
2080
 
2081
  elif data == "toggle_logo":
2082
  with lock:
2083
  session['logo_enabled'] = not session.get('logo_enabled', False)
2084
  v = session['logo_enabled']
2085
- return [ack, edit(f"πŸ–Ό Logo {'enabled βœ…' if v else 'disabled ❌'}.")]
 
2086
 
2087
  elif data == "change_logo_pos":
2088
- return [ack, edit("πŸ“ <b>Choose logo position:</b>", get_logo_pos_keyboard())]
 
2089
 
2090
  elif data.startswith("set_logo_pos_"):
2091
  pos = data.replace("set_logo_pos_", "")
2092
  with lock: session['logo_position'] = pos
2093
- return [ack, edit(f"βœ… Logo position: <code>{pos}</code>")]
 
2094
 
2095
  elif data == "upload_new_logo":
2096
  with lock: session['current_step'] = "awaiting_logo"
2097
- return [ack, reply("πŸ–Ό Send a PNG or JPG image now. /cancel to abort.")]
 
 
2098
 
2099
  # Schedule
2100
  elif data == "cfg_schedule":
2101
- return [ack, reply(_list_schedules(chat_id),
2102
- get_schedule_menu_keyboard(session))]
2103
-
2104
- elif data == "sched_new":
2105
- with lock:
2106
- session["current_step"] = "sched_when"
2107
- session["_sched_draft"] = {}
2108
- return [ack, reply(
2109
- "πŸ•’ <b>New Scheduled Stream</b>\n\nWhen should it start?",
2110
- get_schedule_when_keyboard())]
2111
-
2112
- elif data == "sched_pick_timer":
2113
- with lock:
2114
- session["current_step"] = "sched_timer"
2115
- return [ack, reply(
2116
- "\u23f1 <b>Timer Setup</b>\n\n"
2117
- "How long from now? Type a duration:\n"
2118
- " <code>30</code> \u2192 30 minutes\n"
2119
- " <code>2h</code> \u2192 2 hours\n"
2120
- " <code>1h30m</code> \u2192 1h 30min\n\n"
2121
- "Or /cancel.")]
2122
-
2123
- elif data == "sched_pick_datetime":
2124
- with lock:
2125
- session["current_step"] = "sched_datetime"
2126
- return [ack, reply(
2127
- "\U0001f4c5 <b>Date &amp; Time (UTC)</b>\n\n"
2128
- "Enter the start time:\n"
2129
- "<code>YYYY-MM-DD HH:MM:SS</code>\n"
2130
- "Example: <code>2025-12-31 23:55:00</code>\n\n"
2131
- "Or /cancel.")]
2132
-
2133
- elif data == "sched_cancel_setup":
2134
- with lock:
2135
- session["current_step"] = None
2136
- session["_sched_draft"] = {}
2137
- return [ack, reply("❌ Schedule setup cancelled.",
2138
- get_main_keyboard(session))]
2139
 
2140
  # Quick setup
2141
  elif data == "quick_setup":
2142
  with lock:
2143
  session['current_step'] = "quick_output_url"
2144
- return [ack, reply(
2145
  "βš™οΈ <b>Quick Setup</b>\n\n"
2146
  "Step 1/2: Enter your <b>RTMP Output URL</b>:\n"
2147
  "<code>rtmp://a.rtmp.youtube.com/live2/YOUR_STREAM_KEY</code>\n\n"
2148
- "Or /cancel to abort.")]
 
2149
 
2150
  # Reset
2151
  elif data == "confirm_reset":
2152
- return [ack, edit("⚠️ <b>Reset all settings to defaults?</b>",
2153
- get_reset_confirm_keyboard())]
 
2154
 
2155
  elif data == "do_reset":
2156
  reset_session_settings(chat_id)
2157
- return [ack, reply("πŸ”„ <b>Settings restored to defaults.</b>\n\n"
2158
- + compose_status_message(chat_id, True))]
 
2159
 
2160
  # Logs
2161
  elif data == "show_user_logs":
2162
  logs = session.get('live_log_lines_user', [])
2163
  last = "\n".join(logs[-20:]) if logs else "No logs yet."
2164
- return [ack, reply("πŸ“‹ <b>Stream Logs (last 20):</b>\n<pre>" + esc(last) + "</pre>")]
 
 
2165
 
2166
  elif data == "show_help":
2167
- return [ack, reply(get_help_text())]
2168
 
2169
  return [ack, answer_callback_query(cq["id"], "Unknown action", show_alert=True)]
2170
 
@@ -2237,10 +1948,6 @@ async def handle_conversation_input(chat_id: int, text: str):
2237
  return send_message(chat_id,
2238
  "πŸ–Ό Please <b>send an image file</b> (PNG/JPG), not text. /cancel to abort.")
2239
 
2240
- elif step in ("sched_when", "sched_timer", "sched_datetime",
2241
- "sched_name", "sched_rtmp", "sched_input"):
2242
- return await handle_schedule_conversation(chat_id, text)
2243
-
2244
  else:
2245
  with lock:
2246
  session['current_step'] = None
@@ -2274,45 +1981,17 @@ async def shutdown_event():
2274
  async def telegram_webhook_endpoint(request: Request):
2275
  try:
2276
  update = await request.json()
2277
-
2278
- # Identify chat_id early to drain any pending real-time status edit
2279
- chat_id = None
2280
- try:
2281
- if "message" in update:
2282
- chat_id = update["message"]["chat"]["id"]
2283
- elif "callback_query" in update:
2284
- chat_id = update["callback_query"]["message"]["chat"]["id"]
2285
- except Exception:
2286
- pass
2287
-
2288
  response_data = await handle_telegram_update(update)
2289
 
2290
- # Collect the primary response
2291
- primary = None
2292
  if isinstance(response_data, list):
2293
- items = [i for i in response_data if i and isinstance(i, dict) and i.get("method")]
2294
- for priority in ("sendMessage", "editMessageText"):
2295
- for item in items:
2296
- if item.get("method") == priority:
2297
- primary = item
2298
- break
2299
- if primary:
2300
- break
2301
- if not primary:
2302
- for item in items:
2303
- if item.get("method") == "answerCallbackQuery":
2304
- primary = item
2305
- break
2306
- elif isinstance(response_data, dict):
2307
- primary = response_data
2308
 
2309
- # Piggyback a pending real-time status edit if we have no real response
2310
- if chat_id:
2311
- pending_edit = pop_pending_status_edit(chat_id)
2312
- if pending_edit and (primary is None or primary.get("method") == "answerCallbackQuery"):
2313
- primary = pending_edit
2314
-
2315
- return primary or {"status": "ok"}
2316
 
2317
  except json.JSONDecodeError:
2318
  raise HTTPException(status_code=400, detail="Invalid JSON")
@@ -2341,30 +2020,6 @@ async def health():
2341
  }
2342
 
2343
 
2344
- @app.get("/status/{chat_id}")
2345
- async def get_status_endpoint(chat_id: int):
2346
- """HTTP polling β€” returns current stream state as JSON for external dashboards."""
2347
- if chat_id not in user_sessions:
2348
- raise HTTPException(status_code=404, detail="No session for this chat_id")
2349
- session = get_user_session(chat_id)
2350
- uptime = 0
2351
- if session.get("stream_start_time"):
2352
- try:
2353
- uptime = int((datetime.datetime.now(datetime.timezone.utc) - session["stream_start_time"]).total_seconds())
2354
- except Exception:
2355
- pass
2356
- return {
2357
- "chat_id": chat_id,
2358
- "state": session.get("streaming_state", "idle"),
2359
- "frames_encoded": session.get("frames_encoded", 0),
2360
- "bytes_sent": session.get("bytes_sent", 0),
2361
- "uptime_seconds": uptime,
2362
- "reconnect_attempt": session.get("reconnect_attempt", 0),
2363
- "error": session.get("error_notification_user", ""),
2364
- "status_text": compose_status_message(chat_id, include_config=False),
2365
- }
2366
-
2367
-
2368
  # ──────────────────────────────────────────────
2369
  # MAIN
2370
  # ──────────────────────────────────────────────
 
23
  from urllib.parse import urlparse
24
  from pathlib import Path
25
  import io
26
+ import requests # for Telegram file download
27
 
28
  from fastapi import FastAPI, Request, HTTPException
29
  import av
 
121
  "stop_on_error_in_playlist": True,
122
  "reconnect_on_stream_error": True,
123
  "reconnect_delay_seconds": 5,
124
+ "max_reconnect_attempts": 10,
125
 
126
  # Connection / network
127
  "open_timeout_seconds": 15,
 
142
  "current_step_index": 0,
143
  "conversation_fields_list": [],
144
  "settings_editing_field": None,
 
 
 
145
  }
146
 
147
  DEFAULT_SESSION_RUNTIME_STATE = {
 
165
  "reconnect_attempt": 0,
166
  "last_frame_time": None, # for watchdog
167
  "last_notified_state": None, # for state-change notifications
 
 
 
168
  }
169
 
170
  # ──────────────────────────────────────────────
 
215
 
216
  # ──────────────────────────────────────────────
217
  # TELEGRAM API HELPERS
 
218
  # ──────────────────────────────────────────────
219
+ def _tg_api_call(method: str, payload: dict) -> dict:
220
+ """Make a direct outgoing Telegram API call (for push notifications from threads)."""
221
+ if not TELEGRAM_BOT_TOKEN:
222
+ logger.warning("No TELEGRAM_BOT_TOKEN set β€” cannot make outgoing API calls.")
223
+ return {}
224
+ try:
225
+ url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/{method}"
226
+ resp = requests.post(url, json=payload, timeout=10)
227
+ return resp.json()
228
+ except Exception as e:
229
+ logger.error(f"Outgoing API call failed ({method}): {e}")
230
+ return {}
231
+
232
+
233
  def push_message(chat_id: int, text: str, reply_markup=None, parse_mode="HTML"):
234
+ """Send a message proactively from a background thread."""
235
+ payload = {"chat_id": chat_id, "text": text, "parse_mode": parse_mode}
236
+ if reply_markup:
237
+ payload["reply_markup"] = json.dumps(reply_markup)
238
+ return _tg_api_call("sendMessage", payload)
239
+
240
+
241
+ def download_telegram_file(file_id: str) -> bytes:
242
+ """Download a file from Telegram by file_id. Returns bytes or None."""
243
+ if not TELEGRAM_BOT_TOKEN:
244
+ return None
245
+ try:
246
+ resp = requests.get(
247
+ f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/getFile",
248
+ params={"file_id": file_id}, timeout=10
249
+ )
250
+ data = resp.json()
251
+ if not data.get("ok"):
252
+ return None
253
+ file_path = data["result"]["file_path"]
254
+ file_resp = requests.get(
255
+ f"https://api.telegram.org/file/bot{TELEGRAM_BOT_TOKEN}/{file_path}",
256
+ timeout=30
257
+ )
258
+ return file_resp.content
259
+ except Exception as e:
260
+ logger.error(f"Failed to download Telegram file {file_id}: {e}")
261
+ return None
262
 
263
 
264
  # ──────────────────────────────────────────────
 
289
  return resp
290
 
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  # ──────────────────────────────────────────────
293
  # CONTEXT-AWARE KEYBOARDS
294
  # ──────────────────────────────────────────────
 
347
  return {"inline_keyboard": btns}
348
 
349
 
350
+ def get_settings_keyboard():
351
  """Inline keyboard for settings navigation."""
 
 
352
  return {"inline_keyboard": [
353
  [{"text": "πŸ“‘ Output URL", "callback_data": "set_output_url"},
354
  {"text": "🎬 Input Playlist", "callback_data": "view_playlist"}],
 
367
  [{"text": "πŸ›‘ Stop on Error", "callback_data": "toggle_stop_on_error"},
368
  {"text": "⏰ Open Timeout", "callback_data": "set_open_timeout"}],
369
  [{"text": "🎨 Quality Preset", "callback_data": "pick_quality_preset"}],
 
370
  [{"text": "βœ… Done", "callback_data": "settings_done"}],
371
  ]}
372
 
 
682
  notify_state_change(chat_id, "starting")
683
 
684
  # --- Open output container ---
685
+ output_url = session['output_url']
686
+ output_format = session.get('output_format', 'flv')
687
+ open_timeout = session.get('open_timeout_seconds', 15)
 
 
 
 
 
 
688
  try:
689
  active_output_container = av.open(
690
  output_url, mode='w', format=output_format,
 
731
  if session.get('stop_gracefully_flag') or session['streaming_state'] == "stopping":
732
  break
733
  current_loop_iter = session['current_loop_iteration']
 
 
 
734
 
735
  if total_loops != -1 and current_loop_iter >= max(total_loops, 1):
736
  append_user_live_log(chat_id, f"Completed all {max(total_loops, 1)} loop(s).")
 
756
  _input_container = None
757
  reconnect_on_error = session.get('reconnect_on_stream_error', True)
758
  reconnect_delay = session.get('reconnect_delay_seconds', 5)
759
+ max_reconnects = session.get('max_reconnect_attempts', 10)
760
  read_timeout = session.get('read_timeout_seconds', 30)
761
 
762
  try:
 
911
  should_stop_now = session['streaming_state'] == "stopping"
912
  reconnect_on = session.get('reconnect_on_stream_error', True)
913
  cur_attempt = session.get('reconnect_attempt', 0)
914
+ max_att = session.get('max_reconnect_attempts', 10)
915
  stop_on_err = session.get('stop_on_error_in_playlist', True)
916
 
917
  if should_stop_now:
 
1023
  # ──────────────────────────────────────────────
1024
  # STREAM CONTROL HANDLERS
1025
  # ──────────────────────────────────────────────
1026
+ async def start_stream_handler(chat_id: int):
1027
  session = get_user_session(chat_id)
1028
  lock = session_locks[chat_id]
1029
 
 
 
 
 
 
1030
  with lock:
1031
  state = session['streaming_state']
1032
  if state in ("streaming", "paused", "starting", "reconnecting"):
1033
+ return send_message(chat_id,
1034
  f"⚠️ Stream already active (state: <code>{state}</code>).\n"
1035
  f"Use /abort to stop it first.",
1036
+ reply_markup=get_main_keyboard(session))
1037
  if not session.get('input_url_playlist'):
1038
+ return send_message(chat_id,
1039
  "πŸ“œ <b>Playlist is empty.</b>\nAdd at least one URL:\n"
1040
  "<code>/playlist add &lt;url&gt;</code>",
1041
+ reply_markup=get_main_keyboard(session))
1042
  if not session.get('output_url') or session['output_url'] == DEFAULT_USER_SETTINGS['output_url']:
1043
+ return send_message(chat_id,
1044
  "πŸ“‘ <b>Output URL not configured.</b>\n"
1045
  "Set it with: <code>/set output_url rtmp://your-url/key</code>",
1046
+ reply_markup=get_main_keyboard(session))
1047
  session['streaming_state'] = "starting"
1048
  session['error_notification_user'] = ""
1049
 
 
1059
  await asyncio.sleep(0.3)
1060
  t_watch.start()
1061
 
1062
+ return send_message(chat_id,
1063
  "πŸ”„ <b>Stream starting…</b>\n\n"
1064
  "I'll notify you when it's live.\n\n"
1065
  "<b>Commands while streaming:</b>\n"
 
1067
  " /resume β€” resume\n"
1068
  " /abort β€” stop the stream\n"
1069
  " /status β€” current status",
1070
+ reply_markup=get_main_keyboard(session))
1071
 
1072
 
1073
+ async def pause_stream_handler(chat_id: int):
1074
  session = get_user_session(chat_id)
1075
  lock = session_locks[chat_id]
1076
  with lock:
 
1081
  ok = False
1082
  st = session['streaming_state']
1083
 
 
 
 
 
 
1084
  if ok:
1085
  append_user_live_log(chat_id, "Paused by user.")
1086
  threading.Thread(target=notify_state_change, args=(chat_id, "paused"), daemon=True).start()
1087
+ return send_message(chat_id, "⏸ <b>Stream paused.</b>\nUse /resume to continue or /abort to stop.",
1088
+ reply_markup=get_main_keyboard(session))
1089
  else:
1090
+ return send_message(chat_id, f"ℹ️ Cannot pause β€” state is <code>{st}</code>.",
1091
+ reply_markup=get_main_keyboard(session))
1092
 
1093
 
1094
+ async def resume_stream_handler(chat_id: int):
1095
  session = get_user_session(chat_id)
1096
  lock = session_locks[chat_id]
1097
  with lock:
 
1102
  ok = False
1103
  st = session['streaming_state']
1104
 
 
 
 
 
 
1105
  if ok:
1106
  append_user_live_log(chat_id, "Resumed by user.")
1107
  threading.Thread(target=notify_state_change, args=(chat_id, "streaming"), daemon=True).start()
1108
+ return send_message(chat_id, "▢️ <b>Stream resumed.</b>", reply_markup=get_main_keyboard(session))
1109
  else:
1110
+ return send_message(chat_id, f"ℹ️ Cannot resume β€” state is <code>{st}</code>.",
1111
+ reply_markup=get_main_keyboard(session))
1112
 
1113
 
1114
+ async def abort_stream_handler(chat_id: int):
1115
  session = get_user_session(chat_id)
1116
  lock = session_locks[chat_id]
1117
  thread_ref = None
 
1125
  aborted = False
1126
  st = session['streaming_state']
1127
 
 
 
 
 
 
1128
  if not aborted:
1129
+ return send_message(chat_id, f"ℹ️ No active stream (state: <code>{st}</code>).",
1130
+ reply_markup=get_main_keyboard(session))
1131
 
1132
  append_user_live_log(chat_id, "Abort requested by user.")
1133
  if thread_ref and thread_ref.is_alive():
 
1140
  session['streaming_state'] = "stopped"
1141
  session['stream_thread_ref'] = None
1142
 
1143
+ return send_message(chat_id,
1144
+ "⏹ <b>Stream aborted.</b>\n\n" + compose_status_message(chat_id),
1145
+ reply_markup=get_main_keyboard(session))
1146
 
1147
 
1148
  # ──────────────────────────────────────────────
 
1341
  if not file_id:
1342
  return send_message(chat_id, "❌ Could not get file from message.", reply_markup=get_main_keyboard(session))
1343
 
1344
+ if not TELEGRAM_BOT_TOKEN:
1345
+ return send_message(chat_id,
1346
+ "⚠️ <b>TELEGRAM_BOT_TOKEN not set.</b>\nCannot download files. Set the env variable and restart.",
1347
+ reply_markup=get_main_keyboard(session))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1348
 
1349
+ logo_bytes = download_telegram_file(file_id)
1350
+ if not logo_bytes:
1351
+ return send_message(chat_id, "❌ Failed to download the file from Telegram. Try again.",
1352
+ reply_markup=get_main_keyboard(session))
 
1353
 
1354
+ # Validate it's an image
1355
+ try:
1356
+ img = Image.open(io.BytesIO(logo_bytes))
1357
+ img.verify()
1358
+ except Exception as e:
1359
+ return send_message(chat_id, f"❌ Invalid image file: {esc(str(e))}",
1360
  reply_markup=get_main_keyboard(session))
1361
 
1362
+ MAX_SIZE = 5 * 1024 * 1024 # 5 MB
1363
+ if len(logo_bytes) > MAX_SIZE:
 
 
 
1364
  return send_message(chat_id,
1365
+ f"❌ File too large ({len(logo_bytes)/1024/1024:.1f} MB). Max 5 MB.",
1366
+ reply_markup=get_main_keyboard(session))
1367
 
1368
+ with lock:
1369
+ session['logo_data_bytes'] = logo_bytes
1370
+ session['logo_mime_type'] = mime_type
1371
+ session['logo_original_filename'] = filename
1372
+ session['logo_enabled'] = True
1373
+ session['current_step'] = None
 
 
 
 
 
 
 
 
 
 
1374
 
1375
+ append_user_live_log(chat_id, f"Logo uploaded: {filename} ({len(logo_bytes)/1024:.1f} KB)")
1376
  return send_message(chat_id,
1377
+ f"βœ… <b>Logo uploaded and enabled!</b>\n"
1378
+ f"File: <code>{esc(filename)}</code> ({len(logo_bytes)/1024:.1f} KB)\n\n"
1379
+ f"Adjust logo settings:\n"
1380
+ f" <code>/set logo_position top_right</code>\n"
1381
+ f" <code>/set logo_scale 0.1</code>\n"
1382
+ f" <code>/set logo_opacity 0.8</code>\n"
1383
+ f" <code>/set logo_enabled off</code> β€” to disable",
1384
  reply_markup=get_main_keyboard(session))
1385
 
1386
 
1387
+ # ──────────────────────────────────────────────
1388
+ # SCHEDULE HANDLER
1389
+ # ──────────────────────────────────────────────
1390
+ async def handle_schedule_command(chat_id: int, text: str):
1391
  session = get_user_session(chat_id)
1392
+ parts = text.split(maxsplit=3)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1393
 
1394
+ if len(parts) < 3:
1395
+ jobs = scheduler.get_jobs()
1396
+ user_jobs = [j for j in jobs if j.id.startswith(f"stream_{chat_id}_")]
1397
+ lines = ["πŸ•’ <b>Scheduled Streams</b> <i>(in-memory, lost on restart)</i>\n"]
1398
+ if user_jobs:
1399
+ for j in user_jobs:
1400
+ rt = j.next_run_time.strftime('%Y-%m-%d %H:%M:%S UTC') if j.next_run_time else "N/A"
1401
+ lines.append(f" β€’ <b>{esc(j.name)}</b> at <code>{rt}</code>")
1402
+ lines.append(f" Cancel: <code>/schedule cancel {esc(j.id)}</code>")
1403
  else:
1404
+ lines.append(" No scheduled streams.")
1405
+ lines += [
1406
+ "",
1407
+ "<b>Schedule a stream:</b>",
1408
+ " <code>/schedule YYYY-MM-DD HH:MM:SS [Name]</code>",
1409
+ " Example: <code>/schedule 2025-12-31 23:55:00 NYE</code>",
1410
+ "",
1411
+ "<b>Cancel a schedule:</b>",
1412
+ " <code>/schedule cancel &lt;job_id&gt;</code>",
1413
+ ]
1414
+ return send_message(chat_id, "\n".join(lines), reply_markup=get_main_keyboard(session))
1415
 
1416
+ if parts[1].lower() == "cancel":
1417
+ job_id = parts[2] if len(parts) > 2 else None
1418
+ if job_id:
1419
+ try:
1420
+ scheduler.remove_job(job_id)
1421
+ return send_message(chat_id, f"βœ… Schedule <code>{esc(job_id)}</code> cancelled.",
1422
+ reply_markup=get_main_keyboard(session))
1423
+ except Exception:
1424
+ return send_message(chat_id, f"❌ Job not found: <code>{esc(job_id)}</code>",
1425
+ reply_markup=get_main_keyboard(session))
1426
 
1427
+ try:
1428
+ date_str, time_str = parts[1], parts[2]
1429
+ job_name = parts[3] if len(parts) > 3 else f"Stream for {chat_id}"
1430
+ trigger_time = datetime.datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M:%S")
1431
+ trigger_time = trigger_time.replace(tzinfo=datetime.timezone.utc)
 
 
 
 
 
 
 
 
 
 
 
1432
 
1433
+ if trigger_time <= datetime.datetime.now(datetime.timezone.utc):
1434
+ return send_message(chat_id, "❌ Scheduled time must be in the future (UTC).")
 
 
1435
 
1436
+ job_id = f"stream_{chat_id}_{int(trigger_time.timestamp())}"
1437
+ scheduler.add_job(
1438
+ _scheduled_stream_runner,
1439
+ 'date',
1440
+ run_date=trigger_time,
1441
+ args=[chat_id],
1442
+ id=job_id,
1443
+ name=job_name,
1444
+ replace_existing=True
1445
+ )
1446
  return send_message(chat_id,
1447
+ f"βœ… <b>Stream scheduled!</b>\n"
1448
+ f" Name: <b>{esc(job_name)}</b>\n"
1449
+ f" Time: <code>{trigger_time.strftime('%Y-%m-%d %H:%M:%S UTC')}</code>\n"
1450
+ f" ID: <code>{job_id}</code>\n\n"
1451
+ f"View/cancel: /schedule",
 
 
1452
  reply_markup=get_main_keyboard(session))
1453
+ except ValueError as e:
1454
+ return send_message(chat_id,
1455
+ f"❌ Invalid date/time format.\nExpected: <code>YYYY-MM-DD HH:MM:SS</code>\nError: {esc(str(e))}")
 
 
 
 
1456
 
1457
 
1458
+ def _scheduled_stream_runner(chat_id: int):
 
1459
  logger.info(f"[Scheduler] Firing scheduled stream for chat {chat_id}")
 
 
 
 
 
 
 
 
 
 
 
 
1460
  loop = _main_event_loop
1461
  if loop and loop.is_running():
1462
  asyncio.run_coroutine_threadsafe(start_stream_handler(chat_id), loop)
 
1495
  " /set logo_scale 0.1\n"
1496
  " /set logo_opacity 0.8\n\n"
1497
  "<b>━━ Schedule ━━</b>\n"
1498
+ " /schedule β€” List schedules\n"
1499
+ " /schedule YYYY-MM-DD HH:MM:SS [Name]\n"
1500
+ " /schedule cancel &lt;id&gt;\n\n"
 
 
 
 
1501
  "<b>━━ Diagnostics ━━</b>\n"
1502
  " /logs β€” Recent user logs\n"
1503
  " /globallogs β€” System logs\n\n"
 
1655
  logger.info(f"[Chat {chat_id}] Callback: {data}")
1656
  ack = answer_callback_query(cq["id"])
1657
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1658
  # Stream controls
1659
  if data == "stream_start":
1660
+ return [ack, await start_stream_handler(chat_id)]
 
1661
  elif data == "stream_pause":
1662
+ return [ack, await pause_stream_handler(chat_id)]
 
1663
  elif data == "stream_resume":
1664
+ return [ack, await resume_stream_handler(chat_id)]
 
1665
  elif data == "stream_abort":
1666
+ return [ack, await abort_stream_handler(chat_id)]
 
1667
  elif data == "stream_status":
1668
+ return [ack, send_message(chat_id, compose_status_message(chat_id, include_config=True),
1669
+ reply_markup=get_main_keyboard(session))]
 
 
1670
  elif data == "stream_stop_graceful":
1671
  with lock:
1672
  session['stop_gracefully_flag'] = True
1673
  append_user_live_log(chat_id, "Graceful stop requested.")
1674
+ return [ack, send_message(chat_id,
1675
+ "⏳ <b>Will stop after current loop finishes.</b>",
1676
+ reply_markup=get_main_keyboard(session))]
1677
 
1678
  # Settings navigation
1679
  elif data == "open_settings":
1680
+ return [ack, send_message(chat_id,
1681
  "βš™οΈ <b>Settings</b>\n\n" + format_settings_display(session) + "\n\n"
1682
  "Tap a parameter to change it, or use <code>/set &lt;field&gt; &lt;value&gt;</code>",
1683
+ reply_markup=get_settings_keyboard())]
 
 
 
 
 
 
 
 
 
 
 
 
1684
 
1685
  elif data == "settings_done":
1686
+ return [ack, send_message(chat_id, compose_status_message(chat_id, True),
1687
+ reply_markup=get_main_keyboard(session))]
1688
 
1689
  elif data == "pick_quality_preset":
1690
+ return [ack, send_message(chat_id, "🎨 <b>Choose Quality Preset:</b>",
1691
+ reply_markup=get_quality_keyboard())]
1692
 
1693
  elif data.startswith("apply_quality_"):
1694
  q = data.replace("apply_quality_", "")
 
1697
  session['quality_preset'] = q
1698
  for k, v in QUALITY_PRESETS[q].items():
1699
  session[k] = v
1700
+ return [ack, send_message(chat_id,
1701
  f"βœ… Quality preset <code>{q}</code> applied.\n\n" + format_settings_display(session),
1702
+ reply_markup=get_settings_keyboard())]
1703
 
1704
  elif data == "set_video_codec":
1705
+ return [ack, send_message(chat_id, "πŸŽ₯ <b>Choose Video Codec:</b>",
1706
+ reply_markup=get_codec_keyboard("video"))]
1707
 
1708
  elif data.startswith("set_vcodec_"):
1709
  codec = data.replace("set_vcodec_", "")
1710
  with lock: session['video_codec'] = codec
1711
+ return [ack, send_message(chat_id, f"βœ… Video codec: <code>{codec}</code>",
1712
+ reply_markup=get_settings_keyboard())]
1713
 
1714
  elif data == "set_audio_codec":
1715
+ return [ack, send_message(chat_id, "πŸ”Š <b>Choose Audio Codec:</b>",
1716
+ reply_markup=get_codec_keyboard("audio"))]
1717
 
1718
  elif data.startswith("set_acodec_"):
1719
  codec = data.replace("set_acodec_", "")
1720
  with lock: session['audio_codec'] = codec
1721
+ return [ack, send_message(chat_id, f"βœ… Audio codec: <code>{codec}</code>",
1722
+ reply_markup=get_settings_keyboard())]
1723
 
1724
  elif data == "set_ffmpeg_preset":
1725
+ return [ack, send_message(chat_id, "⚑ <b>Choose FFmpeg Preset:</b>",
1726
+ reply_markup=get_preset_keyboard())]
1727
 
1728
  elif data.startswith("set_preset_"):
1729
  preset = data.replace("set_preset_", "")
1730
  with lock: session['ffmpeg_preset'] = preset
1731
+ return [ack, send_message(chat_id, f"βœ… Preset: <code>{preset}</code>",
1732
+ reply_markup=get_settings_keyboard())]
1733
 
1734
  # Inline-triggered field edits (ask user to type)
1735
  elif data in ("set_output_url", "set_resolution", "set_fps", "set_video_bitrate",
 
1753
  with lock:
1754
  session['current_step'] = "editing_field"
1755
  session['settings_editing_field'] = field
1756
+ return [ack, send_message(chat_id,
1757
  f"πŸ“ <b>Set {field}</b>\n"
1758
  f"Current: <code>{esc(str(cur))}</code>\n"
1759
  f"Expected: {esc(desc)}\n\n"
1760
+ f"Type the new value now, or /cancel to abort.",
1761
+ reply_markup=get_main_keyboard(session))]
1762
 
1763
  elif data in ("toggle_reconnect", "toggle_stop_on_error"):
1764
  field_map = {
 
1769
  with lock:
1770
  session[field] = not session.get(field, True)
1771
  new_val = session[field]
1772
+ return [ack, send_message(chat_id,
1773
  f"βœ… <b>{field}</b> β†’ <code>{'on' if new_val else 'off'}</code>",
1774
+ reply_markup=get_settings_keyboard())]
1775
 
1776
  # Playlist view
1777
  elif data == "view_playlist":
 
1786
  else:
1787
  msg = ("πŸ“œ <b>Playlist is empty.</b>\n"
1788
  "<code>/playlist add &lt;url&gt;</code>")
1789
+ return [ack, send_message(chat_id, msg, reply_markup=get_main_keyboard(session))]
1790
 
1791
  # Logo
1792
  elif data == "cfg_logo":
 
1813
  lines.append("Change scale/opacity: <code>/set logo_scale 0.15</code>")
1814
  else:
1815
  lines.append("No logo uploaded.")
1816
+ return [ack, send_message(chat_id, "\n".join(lines),
1817
+ reply_markup={"inline_keyboard": logo_btns})]
1818
 
1819
  elif data == "toggle_logo":
1820
  with lock:
1821
  session['logo_enabled'] = not session.get('logo_enabled', False)
1822
  v = session['logo_enabled']
1823
+ return [ack, send_message(chat_id, f"πŸ–Ό Logo {'enabled βœ…' if v else 'disabled ❌'}.",
1824
+ reply_markup=get_main_keyboard(session))]
1825
 
1826
  elif data == "change_logo_pos":
1827
+ return [ack, send_message(chat_id, "πŸ“ <b>Choose logo position:</b>",
1828
+ reply_markup=get_logo_pos_keyboard())]
1829
 
1830
  elif data.startswith("set_logo_pos_"):
1831
  pos = data.replace("set_logo_pos_", "")
1832
  with lock: session['logo_position'] = pos
1833
+ return [ack, send_message(chat_id, f"βœ… Logo position: <code>{pos}</code>",
1834
+ reply_markup=get_main_keyboard(session))]
1835
 
1836
  elif data == "upload_new_logo":
1837
  with lock: session['current_step'] = "awaiting_logo"
1838
+ return [ack, send_message(chat_id,
1839
+ "πŸ–Ό Send a PNG or JPG image now. /cancel to abort.",
1840
+ reply_markup=get_main_keyboard(session))]
1841
 
1842
  # Schedule
1843
  elif data == "cfg_schedule":
1844
+ return [ack, await handle_schedule_command(chat_id, "/schedule")]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1845
 
1846
  # Quick setup
1847
  elif data == "quick_setup":
1848
  with lock:
1849
  session['current_step'] = "quick_output_url"
1850
+ return [ack, send_message(chat_id,
1851
  "βš™οΈ <b>Quick Setup</b>\n\n"
1852
  "Step 1/2: Enter your <b>RTMP Output URL</b>:\n"
1853
  "<code>rtmp://a.rtmp.youtube.com/live2/YOUR_STREAM_KEY</code>\n\n"
1854
+ "Or /cancel to abort.",
1855
+ reply_markup=get_main_keyboard(session))]
1856
 
1857
  # Reset
1858
  elif data == "confirm_reset":
1859
+ return [ack, send_message(chat_id,
1860
+ "⚠️ <b>Reset all settings to defaults?</b>",
1861
+ reply_markup=get_reset_confirm_keyboard())]
1862
 
1863
  elif data == "do_reset":
1864
  reset_session_settings(chat_id)
1865
+ return [ack, send_message(chat_id,
1866
+ "πŸ”„ <b>Settings restored to defaults.</b>\n\n" + compose_status_message(chat_id, True),
1867
+ reply_markup=get_main_keyboard(session))]
1868
 
1869
  # Logs
1870
  elif data == "show_user_logs":
1871
  logs = session.get('live_log_lines_user', [])
1872
  last = "\n".join(logs[-20:]) if logs else "No logs yet."
1873
+ return [ack, send_message(chat_id,
1874
+ "πŸ“‹ <b>Stream Logs (last 20):</b>\n<pre>" + esc(last) + "</pre>",
1875
+ reply_markup=get_main_keyboard(session))]
1876
 
1877
  elif data == "show_help":
1878
+ return [ack, send_message(chat_id, get_help_text(), reply_markup=get_main_keyboard(session))]
1879
 
1880
  return [ack, answer_callback_query(cq["id"], "Unknown action", show_alert=True)]
1881
 
 
1948
  return send_message(chat_id,
1949
  "πŸ–Ό Please <b>send an image file</b> (PNG/JPG), not text. /cancel to abort.")
1950
 
 
 
 
 
1951
  else:
1952
  with lock:
1953
  session['current_step'] = None
 
1981
  async def telegram_webhook_endpoint(request: Request):
1982
  try:
1983
  update = await request.json()
 
 
 
 
 
 
 
 
 
 
 
1984
  response_data = await handle_telegram_update(update)
1985
 
 
 
1986
  if isinstance(response_data, list):
1987
+ for item in response_data:
1988
+ if item and isinstance(item, dict) and item.get("method") in (
1989
+ "sendMessage", "editMessageText", "answerCallbackQuery"
1990
+ ):
1991
+ return item
1992
+ return response_data[0] if response_data else {"status": "ok"}
 
 
 
 
 
 
 
 
 
1993
 
1994
+ return response_data
 
 
 
 
 
 
1995
 
1996
  except json.JSONDecodeError:
1997
  raise HTTPException(status_code=400, detail="Invalid JSON")
 
2020
  }
2021
 
2022
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2023
  # ──────────────────────────────────────────────
2024
  # MAIN
2025
  # ──────────────────────────────────────────────