fmab777 commited on
Commit
8b4d47c
·
verified ·
1 Parent(s): 6a18fe1

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +114 -149
main.py CHANGED
@@ -1,4 +1,4 @@
1
- # main.py (Revised: Starlette Lifespan for PTB Initialization)
2
  import os
3
  import re
4
  import logging
@@ -24,7 +24,7 @@ from telegram.ext import (
24
  CallbackQueryHandler,
25
  )
26
  from telegram.constants import ParseMode
27
- from telegram.error import NetworkError # For specific error handling if needed
28
 
29
  # --- Other Libraries ---
30
  from youtube_transcript_api import YouTubeTranscriptApi
@@ -47,8 +47,8 @@ logging.getLogger("telegram.ext").setLevel(logging.INFO)
47
  logging.getLogger('telegram.bot').setLevel(logging.INFO)
48
  logging.getLogger("urllib3").setLevel(logging.INFO)
49
  logging.getLogger('gunicorn.error').setLevel(logging.INFO)
50
- logging.getLogger('uvicorn').setLevel(logging.INFO) # Uvicorn logs (incl. access)
51
- logging.getLogger('starlette').setLevel(logging.INFO) # Starlette logs
52
  logger = logging.getLogger(__name__)
53
  logger.info("Logging configured.")
54
 
@@ -180,7 +180,6 @@ async def get_transcript_via_apify(video_url: str, api_token: str):
180
  item = results[0]
181
  content = item.get("text") or item.get("transcript") or item.get("captions_concatenated")
182
 
183
- # --- !!! FIXED FALLBACK LOGIC !!! ---
184
  if not content and item.get("captions"):
185
  captions_data = item["captions"]
186
  if isinstance(captions_data, str):
@@ -202,179 +201,135 @@ async def get_transcript_via_apify(video_url: str, api_token: str):
202
  content = html.unescape(content) # Use imported html module
203
  except Exception as unescape_err:
204
  logger.warning(f"[Apify] Error during html unescaping: {unescape_err}")
205
- # --- !!! END FIXED FALLBACK LOGIC !!! ---
206
 
207
  if content and isinstance(content, str):
208
  logger.info(f"[Apify] Successfully fetched transcript via run-sync for {video_url} (Status: {response.status_code}). Length: {len(content)}")
209
- return content # Already stripped
210
  else:
211
- # Log more clearly why content is None here
212
- if item.get("text") or item.get("transcript") or item.get("captions_concatenated"):
213
- logger.warning(f"[Apify] Actor run successful ({response.status_code}) but primary content fields were empty/None for {video_url}.")
214
- elif not item.get("captions"):
215
- logger.warning(f"[Apify] Actor run successful ({response.status_code}) but no text/transcript/captions_concatenated/captions field found for {video_url}. Item: {item}")
216
- else:
217
- logger.warning(f"[Apify] Actor run successful ({response.status_code}), 'captions' field found but fallback parsing failed to extract content for {video_url}.")
218
  return None
219
  else:
220
- logger.warning(f"[Apify] Actor run successful ({response.status_code}) but dataset result list empty for {video_url}. Response: {results}")
221
  return None
222
  except json.JSONDecodeError:
223
- logger.error(f"[Apify] Failed to decode JSON response for {video_url}. Status: {response.status_code}. Resp: {response.text[:200]}...")
224
  return None
225
  except Exception as e:
226
  logger.error(f"[Apify] Error processing successful response ({response.status_code}) for {video_url}: {e}", exc_info=True)
227
  return None
228
- elif response.status_code == 400:
229
- logger.error(f"[Apify] Bad Request (400) for {video_url}. Check payload. Response: {response.text[:200]}...")
230
- return None
231
- elif response.status_code == 401:
232
- logger.error("[Apify] Authentication error (401). Check API token.")
233
- return None
234
- else:
235
- logger.error(f"[Apify] Unexpected status code {response.status_code} from Apify API for {video_url}. Response: {response.text[:200]}...")
236
- return None
237
 
238
- except requests.exceptions.Timeout:
239
- logger.error(f"[Apify] Timeout error running actor for {video_url}")
240
- return None
241
- except requests.exceptions.RequestException as e:
242
- logger.error(f"[Apify] Request error running actor for {video_url}: {e}")
243
- return None
244
- except Exception as e:
245
- logger.error(f"[Apify] Unexpected error during Apify call for {video_url}: {e}", exc_info=True)
246
- return None
247
 
248
- # Combined YouTube Transcript Function (with Fallbacks)
249
  async def get_youtube_transcript(video_id: str, video_url: str, supadata_key: str | None, apify_token: str | None):
250
  """Fetches YouTube transcript using library, then Supadata, then Apify."""
251
  if not video_id: logger.error("get_youtube_transcript called with no video_id"); return None
252
  logger.info(f"Fetching transcript for video ID: {video_id} (URL: {video_url})")
253
  transcript_text = None
254
- try: # Wrap primary method in try/except
255
  logger.info("[Primary YT] Attempting youtube-transcript-api...")
256
- transcript_list = await asyncio.to_thread(
257
- YouTubeTranscriptApi.get_transcript,
258
- video_id,
259
- languages=['en', 'en-GB', 'en-US']
260
- )
261
  if transcript_list:
262
  transcript_text = " ".join([item['text'] for item in transcript_list if 'text' in item])
263
  transcript_text = re.sub(r'\s+', ' ', transcript_text).strip()
264
- if transcript_text:
265
- logger.info(f"[Primary YT] Successfully fetched transcript via library for {video_id} (length: {len(transcript_text)})")
266
- return transcript_text
267
- else:
268
- logger.warning(f"[Primary YT] Joined transcript text is empty after cleaning for {video_id}")
269
- transcript_text = None
270
- else:
271
- logger.warning(f"[Primary YT] Transcript list was empty for {video_id}")
272
- transcript_text = None
273
  except Exception as e:
274
- logger.warning(f"[Primary YT] Error getting transcript via library for {video_id}: {type(e).__name__} - {e}")
275
- if "YouTube is blocking requests" in str(e) or "HTTP Error 429" in str(e): logger.warning("[Primary YT] IP likely blocked by YouTube (Rate Limit / Cloud IP).")
276
- elif "No transcript found" in str(e): logger.warning(f"[Primary YT] No transcript available in specified languages for {video_id}.")
277
- elif "TranscriptsDisabled" in str(e) or "disabled" in str(e): logger.warning(f"[Primary YT] Transcripts are disabled for {video_id}.")
278
  transcript_text = None
279
 
280
  if transcript_text is None: # Fallback 1: Supadata
281
- logger.info("[Fallback YT 1] Primary method failed or yielded no text. Trying Supadata API...")
282
  if supadata_key:
283
  transcript_text = await get_transcript_via_supadata(video_id, supadata_key)
284
- if transcript_text:
285
- logger.info(f"[Fallback YT 1] Successfully fetched transcript via Supadata for {video_id} (length: {len(transcript_text)})")
286
- return transcript_text
287
- else: logger.warning(f"[Fallback YT 1] Supadata API failed or returned no content for {video_id}.")
288
- else: logger.warning("[Fallback YT 1] Supadata API key not available. Skipping.")
289
 
290
  if transcript_text is None: # Fallback 2: Apify
291
- logger.info("[Fallback YT 2] Primary & Supadata failed or yielded no text. Trying Apify API...")
292
  if apify_token:
293
  transcript_text = await get_transcript_via_apify(video_url, apify_token)
294
- if transcript_text:
295
- logger.info(f"[Fallback YT 2] Successfully fetched transcript via Apify for {video_url} (length: {len(transcript_text)})")
296
- return transcript_text
297
- else: logger.warning(f"[Fallback YT 2] Apify API failed or returned no content for {video_url}.")
298
- else: logger.warning("[Fallback YT 2] Apify API token not available. Skipping.")
299
-
300
- if transcript_text is None:
301
- logger.error(f"All methods failed to fetch transcript for video ID: {video_id}")
302
- return None
303
- return transcript_text # Should technically be unreachable if logic is correct
304
 
305
  # Website Content via Requests/BS4
306
  async def get_website_content_via_requests(url):
307
  """Attempts to scrape website content using requests/BeautifulSoup."""
308
- if not url: logger.error("[Web Scraper - Requests/BS4] called with no URL"); return None
309
- logger.info(f"[Web Scraper - Requests/BS4] Fetching website content for: {url}")
310
  try:
311
  headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8','Accept-Language': 'en-US,en;q=0.9','Connection': 'keep-alive','DNT': '1','Upgrade-Insecure-Requests': '1'}
312
- logger.debug(f"[Web Scraper - Requests/BS4] Sending GET request to {url}")
313
  response = await asyncio.to_thread(requests.get, url, headers=headers, timeout=25, allow_redirects=True)
314
  response.raise_for_status()
315
- logger.debug(f"[Web Scraper - Requests/BS4] Received response {response.status_code} from {url}")
316
  content_type = response.headers.get('content-type', '').lower()
317
  if 'html' not in content_type:
318
- logger.warning(f"[Web Scraper - Requests/BS4] Non-HTML content type received: {content_type}. Trying plain text.")
319
  if 'text/plain' in content_type and response.text: return response.text.strip()
320
  return None
321
  soup = BeautifulSoup(response.text, 'html.parser')
322
  for element in soup(["script", "style", "header", "footer", "nav", "aside", "form", "button", "input", "textarea", "select", "option", "label", "iframe", "img", "svg", "link", "meta", "noscript", "figure", "figcaption", "video", "audio"]): element.extract()
323
  main_content = soup.find('main') or soup.find('article') or soup.find(id='content') or soup.find(class_='content') or soup.find(id='main-content') or soup.find(class_='main-content') or soup.find(role='main')
324
  target_element = main_content if main_content else soup.body
325
- if not target_element:
326
- logger.warning(f"[Web Scraper - Requests/BS4] Could not find body or main content for {url}")
327
- return None
328
  lines = [line.strip() for line in target_element.get_text(separator='\n', strip=True).splitlines() if line.strip()]
329
  text = "\n".join(lines)
330
  MIN_TEXT_LENGTH = 50
331
- if not text or len(text) < MIN_TEXT_LENGTH:
332
- logger.warning(f"[Web Scraper - Requests/BS4] Extracted text is very short (<{MIN_TEXT_LENGTH} chars) for {url} (Length: {len(text)})")
333
- logger.info(f"[Web Scraper - Requests/BS4] Successfully scraped content for {url} (final length: {len(text)})")
334
  return text
335
- except requests.exceptions.Timeout: logger.error(f"[Web Scraper - Requests/BS4] Timeout error scraping {url}"); return None
336
- except requests.exceptions.TooManyRedirects: logger.error(f"[Web Scraper - Requests/BS4] Too many redirects for {url}"); return None
337
- except requests.exceptions.RequestException as e: logger.error(f"[Web Scraper - Requests/BS4] Request error scraping {url}: {e}"); return None
338
- except Exception as e: logger.error(f"[Web Scraper - Requests/BS4] Error scraping or parsing {url}: {e}", exc_info=True); return None
339
 
340
  # Website Content via URLToText API
341
  async def get_website_content_via_urltotext_api(url: str, api_key: str):
342
  """Fetches website content using the URLToText API."""
343
- if not url: logger.error("[Web Scraper - URLToText API] called with no URL"); return None
344
- if not api_key: logger.error("[Web Scraper - URLToText API] API key is missing."); return None
345
- logger.info(f"[Web Scraper - URLToText API] Attempting fetch for: {url}")
346
  api_endpoint = "https://urltotext.com/api/v1/urltotext/"
347
  payload = json.dumps({"url": url, "output_format": "text", "extract_main_content": True, "render_javascript": True, "residential_proxy": False})
348
  headers = {"Authorization": f"Token {api_key}", "Content-Type": "application/json"}
349
  try:
350
- logger.debug(f"[Web Scraper - URLToText API] Sending POST request for {url}")
351
  response = await asyncio.to_thread(requests.post, api_endpoint, headers=headers, data=payload, timeout=45)
352
- logger.debug(f"[Web Scraper - URLToText API] Received status code {response.status_code} for {url}")
353
  if response.status_code == 200:
354
  try:
355
  data = response.json()
356
- content = data.get("data", {}).get("content")
357
- credits = data.get("credits_used", "N/A")
358
- warning = data.get("data", {}).get("warning")
359
  if warning: logger.warning(f"[Web Scraper - URLToText API] Warning for {url}: {warning}")
360
- if content:
361
- logger.info(f"[Web Scraper - URLToText API] Successfully fetched content via API for {url}. Length: {len(content)}. Credits: {credits}")
362
- return content.strip()
363
- else:
364
- logger.warning(f"[Web Scraper - URLToText API] API success (200) but content empty for {url}. Response: {data}")
365
- return None
366
- except json.JSONDecodeError: logger.error(f"[Web Scraper - URLToText API] Failed JSON decode for {url}. Status: {response.status_code}. Resp: {response.text[:500]}..."); return None
367
- except Exception as e: logger.error(f"[Web Scraper - URLToText API] Error processing successful API response for {url}: {e}", exc_info=True); return None
368
- elif response.status_code == 400: logger.error(f"[Web Scraper - URLToText API] Bad Request (400) for {url}. Resp: {response.text[:200]}...")
369
- elif response.status_code == 401: logger.error(f"[Web Scraper - URLToText API] Unauthorized (401) for {url}. Check Key. Resp: {response.text[:200]}...")
370
- elif response.status_code == 402: logger.error(f"[Web Scraper - URLToText API] Payment Required (402) for {url}. Check credits. Resp: {response.text[:200]}...")
371
- elif response.status_code == 422: logger.warning(f"[Web Scraper - URLToText API] Unprocessable URL (422) for {url}. Resp: {response.text[:200]}...")
372
- elif response.status_code >= 500: logger.error(f"[Web Scraper - URLToText API] Server Error ({response.status_code}) from API for {url}. Resp: {response.text[:200]}...")
373
- else: logger.error(f"[Web Scraper - URLToText API] Unexpected status {response.status_code} from API for {url}. Resp: {response.text[:200]}...")
374
  return None
375
- except requests.exceptions.Timeout: logger.error(f"[Web Scraper - URLToText API] Timeout error connecting for {url}"); return None
376
- except requests.exceptions.RequestException as e: logger.error(f"[Web Scraper - URLToText API] Request error connecting for {url}: {e}"); return None
377
- except Exception as e: logger.error(f"[Web Scraper - URLToText API] Unexpected error during API call for {url}: {e}", exc_info=True); return None
378
 
379
  # DeepSeek Summary Function
380
  async def generate_summary(text: str, summary_type: str, api_key: str) -> str:
@@ -382,23 +337,21 @@ async def generate_summary(text: str, summary_type: str, api_key: str) -> str:
382
  logger.info(f"Generating '{summary_type}' summary. Input length: {len(text)}")
383
  if not api_key: logger.error("OpenRouter API key missing."); return "Error: AI config key missing."
384
  if not text: logger.warning("generate_summary called with empty text."); return "Error: No content to summarize."
385
- openrouter_api_endpoint = "https://openrouter.ai/api/v1/chat/completions"
386
- model_name = "deepseek/deepseek-chat:free"
387
  if summary_type == "paragraph": prompt = "Please provide a concise, well-written paragraph summarizing the key information and main points of the following text. Focus on capturing the essence of the content accurately."
388
  elif summary_type == "points": prompt = "Please summarize the following text into clear, distinct bullet points. Each point should highlight a key piece of information, finding, or main topic discussed. Aim for clarity and conciseness."
389
  else: logger.error(f"Invalid summary_type '{summary_type}'."); return f"Error: Invalid summary type ('{summary_type}')."
390
  MAX_INPUT_LENGTH = 500000
391
  if len(text) > MAX_INPUT_LENGTH: logger.warning(f"Input text ({len(text)}) > limit ({MAX_INPUT_LENGTH}). Truncating."); text = text[:MAX_INPUT_LENGTH] + "... (Truncated)"
392
  full_prompt = f"{prompt}\n\n--- Start of Text ---\n\n{text}\n\n--- End of Text ---"
393
- # Try getting space host for referer header
394
- space_host = os.environ.get("SPACE_HOST", "huggingface.co/spaces/YOUR_SPACE_NAME") # Default if env var not set
395
  referer_url = f"https://{space_host}" if not space_host.startswith("http") else space_host
396
  headers = {"Authorization": f"Bearer {api_key}","Content-Type": "application/json","HTTP-Referer": referer_url,"X-Title": "Telegram URL Summarizer Bot"}
397
  payload = json.dumps({"model": model_name, "messages": [{"role": "user", "content": full_prompt}]})
398
  try:
399
  logger.debug(f"Sending request to OpenRouter (Model: {model_name})...")
400
  response = await asyncio.to_thread(requests.post, openrouter_api_endpoint, headers=headers, data=payload, timeout=90)
401
- logger.debug(f"Received status code {response.status_code} from OpenRouter.")
402
  if response.status_code == 200:
403
  try:
404
  data = response.json()
@@ -413,7 +366,7 @@ async def generate_summary(text: str, summary_type: str, api_key: str) -> str:
413
  else: logger.error(f"Unexpected OpenRouter choices structure. Resp: {data}"); return "Sorry, could not parse AI response (choices)."
414
  except json.JSONDecodeError: logger.error(f"Failed JSON decode from OpenRouter. Status: {response.status_code}. Resp: {response.text[:500]}..."); return "Sorry, failed to understand AI response format."
415
  except Exception as e: logger.error(f"Error processing OpenRouter success resp: {e}", exc_info=True); return "Sorry, error processing AI response."
416
- elif response.status_code == 401: logger.error("OpenRouter API key invalid (401)."); return "Error: AI model config key invalid."
417
  elif response.status_code == 402: logger.error("OpenRouter Payment Required (402)."); return "Sorry, issue with AI service limits/payment."
418
  elif response.status_code == 429: logger.warning("OpenRouter Rate Limit (429)."); return "Sorry, AI model busy. Try again."
419
  elif response.status_code >= 500: logger.error(f"OpenRouter Internal Error ({response.status_code}). Resp: {response.text[:500]}..."); return "Sorry, AI model service error. Try again later."
@@ -429,43 +382,37 @@ async def generate_summary(text: str, summary_type: str, api_key: str) -> str:
429
  # --- Telegram Bot Handlers ---
430
 
431
  async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
432
- """Handles the /start command."""
433
- user = update.effective_user
434
  if not user: return
435
- logger.info(f"User {user.id} ({user.username or 'NoUsername'}) triggered /start.")
436
  mention = user.mention_html() if user.username else user.first_name
437
- await update.message.reply_html(f"👋 Hello {mention}! Send a YouTube or website URL to summarize.")
438
 
439
  async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
440
- """Handles the /help command."""
441
- user = update.effective_user
442
- logger.info(f"User {user.id if user else 'Unknown'} triggered /help.")
443
- help_text = ( "**How I Work:**\n\n" # ... (keep your detailed help text) ...
444
- "1. Send URL.\n2. Choose format (Paragraph/Points).\n3. Get summary!\n\n"
445
- "**Troubleshooting:**\n- YT transcripts might fail.\n- Complex websites hard to scrape.\n- AI errors possible.\n\nSend link to start!" )
446
  await update.message.reply_text(help_text, parse_mode=ParseMode.MARKDOWN)
447
 
448
  async def handle_potential_url(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
449
- """Handles messages containing potential URLs."""
450
  if not update.message or not update.message.text: return
451
  message_text = update.message.text.strip(); user = update.effective_user;
452
  if not user: return
453
  url_pattern = r'https?://[^\s/$.?#].[^\s]*'; match = re.search(url_pattern, message_text)
454
  if match:
455
- url = match.group(0); logger.info(f"User {user.id} sent potential URL: {url}")
456
  context.user_data['url_to_summarize'] = url; logger.debug(f"Stored URL '{url}' for user {user.id}")
457
- keyboard = [[ InlineKeyboardButton("Paragraph", callback_data="paragraph"), InlineKeyboardButton("Points", callback_data="points")]]
458
  reply_markup = InlineKeyboardMarkup(keyboard)
459
- await update.message.reply_text(f"Link detected:\n{url}\n\nChoose summary type:", reply_markup=reply_markup, link_preview_options={'is_disabled': True})
460
- else: logger.debug(f"Ignoring non-URL message from {user.id}: {message_text[:100]}")
461
 
462
  async def handle_summary_type_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
463
- """Handles button presses for choosing the summary type."""
464
  query = update.callback_query; user = query.from_user
465
- try: await query.answer(); logger.debug(f"Answered callback query {query.id}")
466
- except Exception as e: logger.error(f"Failed to answer callback query {query.id}: {e}")
467
  summary_type = query.data; url = context.user_data.get('url_to_summarize')
468
- logger.info(f"User {user.id} chose '{summary_type}'. URL in context: '{url}'.")
469
  if not url:
470
  logger.warning(f"User {user.id} pressed button, NO URL in context.");
471
  try: await query.edit_message_text(text="Context lost. Send link again.")
@@ -474,10 +421,10 @@ async def handle_summary_type_callback(update: Update, context: ContextTypes.DEF
474
  context.user_data.pop('url_to_summarize', None); logger.debug(f"Cleared URL {url} for user {user.id}")
475
  current_openrouter_key = os.environ.get('OPENROUTER_API_KEY'); current_urltotext_key = os.environ.get('URLTOTEXT_API_KEY')
476
  current_supadata_key = os.environ.get('SUPADATA_API_KEY'); current_apify_token = os.environ.get('APIFY_API_TOKEN')
477
- logger.debug(f"Keys read: OR={'Y' if current_openrouter_key else 'N'}, UTT={'Y' if current_urltotext_key else 'N'}, SD={'Y' if current_supadata_key else 'N'}, AP={'Y' if current_apify_token else 'N'}")
478
  if not current_openrouter_key:
479
  logger.error("OpenRouter key missing.");
480
- try: await query.edit_message_text(text="⚠️ AI service config error (key missing). Contact admin.")
481
  except Exception as edit_err: logger.error(f"Failed edit on missing OR key: {edit_err}")
482
  return
483
  processing_message_text = f"Working on '{summary_type}' summary for:\n{url}\n..."; message_to_delete_later_id = None
@@ -514,9 +461,10 @@ async def handle_summary_type_callback(update: Update, context: ContextTypes.DEF
514
  else: logger.info("Summary generated OK. Sending."); await context.bot.send_message(chat_id=user.id, text=summary, parse_mode=ParseMode.MARKDOWN, link_preview_options={'is_disabled': True}); success = True; user_feedback_message = None
515
  elif not user_feedback_message: user_feedback_message = "Sorry, couldn't retrieve content from link."
516
  if user_feedback_message and not success: logger.warning(f"Sending failure feedback: {user_feedback_message}"); await context.bot.send_message(chat_id=user.id, text=user_feedback_message)
517
- except Exception as e: logger.error(f"Unexpected error in callback processing: {e}", exc_info=True);
518
- try: await context.bot.send_message(chat_id=user.id, text="Oops! Internal error processing request.")
519
- except Exception as final_err: logger.error(f"Failed sending final error message: {final_err}")
 
520
  finally:
521
  logger.debug("Cleaning up status message...");
522
  try:
@@ -564,14 +512,20 @@ async def lifespan(app: Starlette): # app argument is the Starlette instance
564
  WEBHOOK_URL_BASE = os.environ.get("SPACE_HOST")
565
  if WEBHOOK_URL_BASE:
566
  if not WEBHOOK_URL_BASE.startswith("https://"): WEBHOOK_URL_BASE = f"https://{WEBHOOK_URL_BASE}"
567
- webhook_path = "/webhook" # Matches Flask route
568
  full_webhook_url = f"{WEBHOOK_URL_BASE.rstrip('/')}{webhook_path}"
569
  logger.info(f"Attempting to set webhook to: {full_webhook_url}")
570
  try:
 
 
 
571
  await ptb_app.bot.set_webhook(url=full_webhook_url, allowed_updates=Update.ALL_TYPES)
572
  webhook_info = await ptb_app.bot.get_webhook_info()
573
  logger.info(f"Webhook set successfully! Info: {webhook_info}")
574
- except Exception as e: logger.error(f"Failed to set webhook: {e}", exc_info=True)
 
 
 
575
  else: logger.warning("SPACE_HOST env variable not found. Skipping webhook setup.")
576
 
577
  logger.info("ASGI Lifespan: Startup complete. Application ready.")
@@ -579,12 +533,12 @@ async def lifespan(app: Starlette): # app argument is the Starlette instance
579
 
580
  except Exception as startup_err:
581
  logger.critical(f"CRITICAL ERROR during ASGI startup: {startup_err}", exc_info=True)
582
- # Optionally re-raise or handle to prevent server from potentially running in bad state
583
  raise
584
  finally:
585
  # --- Shutdown ---
586
  logger.info("ASGI Lifespan: Shutdown commencing...")
587
- if ptb_app and ptb_app.is_running:
 
588
  try:
589
  logger.info("Stopping PTB App...")
590
  await ptb_app.stop()
@@ -610,15 +564,26 @@ def index():
610
  """Basic health check endpoint."""
611
  logger.debug("Health check '/' accessed.")
612
  bot_status = "UNKNOWN"
613
- if ptb_app: bot_status = "Running" if ptb_app.is_running else "Initialized/Stopped/Starting/Error"
614
- else: bot_status = "Not Initialized"
 
 
 
 
615
  return f"Telegram Bot Webhook Listener ({bot_status}) running via Starlette."
616
 
617
  @flask_core_app.route('/webhook', methods=['POST'])
618
  async def webhook() -> Response:
619
  """Webhook endpoint for Telegram updates."""
620
- if not ptb_app or not ptb_app.is_running:
621
- status = "Not Initialized" if not ptb_app else "Not Running"
 
 
 
 
 
 
 
622
  logger.error(f"Webhook triggered, but PTB Application is {status}.")
623
  return Response('Bot service not ready.', status=503)
624
 
 
1
+ # main.py (Revised: Starlette Lifespan + ptb_app._running fix)
2
  import os
3
  import re
4
  import logging
 
24
  CallbackQueryHandler,
25
  )
26
  from telegram.constants import ParseMode
27
+ from telegram.error import NetworkError, RetryAfter # Import RetryAfter
28
 
29
  # --- Other Libraries ---
30
  from youtube_transcript_api import YouTubeTranscriptApi
 
47
  logging.getLogger('telegram.bot').setLevel(logging.INFO)
48
  logging.getLogger("urllib3").setLevel(logging.INFO)
49
  logging.getLogger('gunicorn.error').setLevel(logging.INFO)
50
+ logging.getLogger('uvicorn').setLevel(logging.INFO)
51
+ logging.getLogger('starlette').setLevel(logging.INFO)
52
  logger = logging.getLogger(__name__)
53
  logger.info("Logging configured.")
54
 
 
180
  item = results[0]
181
  content = item.get("text") or item.get("transcript") or item.get("captions_concatenated")
182
 
 
183
  if not content and item.get("captions"):
184
  captions_data = item["captions"]
185
  if isinstance(captions_data, str):
 
201
  content = html.unescape(content) # Use imported html module
202
  except Exception as unescape_err:
203
  logger.warning(f"[Apify] Error during html unescaping: {unescape_err}")
 
204
 
205
  if content and isinstance(content, str):
206
  logger.info(f"[Apify] Successfully fetched transcript via run-sync for {video_url} (Status: {response.status_code}). Length: {len(content)}")
207
+ return content
208
  else:
209
+ if item.get("text") or item.get("transcript") or item.get("captions_concatenated"): logger.warning(f"[Apify] Actor success ({response.status_code}) but primary fields empty for {video_url}.")
210
+ elif not item.get("captions"): logger.warning(f"[Apify] Actor success ({response.status_code}) but no relevant fields found for {video_url}. Item: {item}")
211
+ else: logger.warning(f"[Apify] Actor success ({response.status_code}), 'captions' found but fallback parsing failed for {video_url}.")
 
 
 
 
212
  return None
213
  else:
214
+ logger.warning(f"[Apify] Actor success ({response.status_code}) but dataset result list empty for {video_url}. Response: {results}")
215
  return None
216
  except json.JSONDecodeError:
217
+ logger.error(f"[Apify] Failed JSON decode for {video_url}. Status: {response.status_code}. Resp: {response.text[:200]}...")
218
  return None
219
  except Exception as e:
220
  logger.error(f"[Apify] Error processing successful response ({response.status_code}) for {video_url}: {e}", exc_info=True)
221
  return None
222
+ elif response.status_code == 400: logger.error(f"[Apify] Bad Request (400) for {video_url}. Resp: {response.text[:200]}..."); return None
223
+ elif response.status_code == 401: logger.error("[Apify] Auth error (401). Check token."); return None
224
+ else: logger.error(f"[Apify] Unexpected status {response.status_code} for {video_url}. Resp: {response.text[:200]}..."); return None
 
 
 
 
 
 
225
 
226
+ except requests.exceptions.Timeout: logger.error(f"[Apify] Timeout error running actor for {video_url}"); return None
227
+ except requests.exceptions.RequestException as e: logger.error(f"[Apify] Request error running actor for {video_url}: {e}"); return None
228
+ except Exception as e: logger.error(f"[Apify] Unexpected error during Apify call for {video_url}: {e}", exc_info=True); return None
 
 
 
 
 
 
229
 
230
+ # Combined YouTube Transcript Function
231
  async def get_youtube_transcript(video_id: str, video_url: str, supadata_key: str | None, apify_token: str | None):
232
  """Fetches YouTube transcript using library, then Supadata, then Apify."""
233
  if not video_id: logger.error("get_youtube_transcript called with no video_id"); return None
234
  logger.info(f"Fetching transcript for video ID: {video_id} (URL: {video_url})")
235
  transcript_text = None
236
+ try: # Primary: Library
237
  logger.info("[Primary YT] Attempting youtube-transcript-api...")
238
+ transcript_list = await asyncio.to_thread(YouTubeTranscriptApi.get_transcript, video_id, languages=['en', 'en-GB', 'en-US'])
 
 
 
 
239
  if transcript_list:
240
  transcript_text = " ".join([item['text'] for item in transcript_list if 'text' in item])
241
  transcript_text = re.sub(r'\s+', ' ', transcript_text).strip()
242
+ if transcript_text: logger.info(f"[Primary YT] Success via library. Length: {len(transcript_text)}"); return transcript_text
243
+ else: logger.warning("[Primary YT] Joined text empty after cleaning."); transcript_text = None
244
+ else: logger.warning("[Primary YT] Transcript list empty."); transcript_text = None
 
 
 
 
 
 
245
  except Exception as e:
246
+ logger.warning(f"[Primary YT] Error via library: {type(e).__name__} - {e}")
247
+ if "YouTube is blocking requests" in str(e) or "HTTP Error 429" in str(e): logger.warning("[Primary YT] IP likely blocked.")
248
+ elif "No transcript found" in str(e): logger.warning("[Primary YT] No transcript in languages.")
249
+ elif "TranscriptsDisabled" in str(e) or "disabled" in str(e): logger.warning("[Primary YT] Transcripts disabled.")
250
  transcript_text = None
251
 
252
  if transcript_text is None: # Fallback 1: Supadata
253
+ logger.info("[Fallback YT 1] Trying Supadata API...")
254
  if supadata_key:
255
  transcript_text = await get_transcript_via_supadata(video_id, supadata_key)
256
+ if transcript_text: logger.info(f"[Fallback YT 1] Success via Supadata. Length: {len(transcript_text)}"); return transcript_text
257
+ else: logger.warning("[Fallback YT 1] Supadata failed or no content.")
258
+ else: logger.warning("[Fallback YT 1] Supadata key not available.")
 
 
259
 
260
  if transcript_text is None: # Fallback 2: Apify
261
+ logger.info("[Fallback YT 2] Trying Apify API...")
262
  if apify_token:
263
  transcript_text = await get_transcript_via_apify(video_url, apify_token)
264
+ if transcript_text: logger.info(f"[Fallback YT 2] Success via Apify. Length: {len(transcript_text)}"); return transcript_text
265
+ else: logger.warning("[Fallback YT 2] Apify failed or no content.")
266
+ else: logger.warning("[Fallback YT 2] Apify token not available.")
267
+
268
+ if transcript_text is None: logger.error(f"All methods failed for video ID: {video_id}")
269
+ return transcript_text
 
 
 
 
270
 
271
  # Website Content via Requests/BS4
272
  async def get_website_content_via_requests(url):
273
  """Attempts to scrape website content using requests/BeautifulSoup."""
274
+ if not url: logger.error("[Web Scraper - Requests/BS4] no URL"); return None
275
+ logger.info(f"[Web Scraper - Requests/BS4] Fetching: {url}")
276
  try:
277
  headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8','Accept-Language': 'en-US,en;q=0.9','Connection': 'keep-alive','DNT': '1','Upgrade-Insecure-Requests': '1'}
 
278
  response = await asyncio.to_thread(requests.get, url, headers=headers, timeout=25, allow_redirects=True)
279
  response.raise_for_status()
280
+ logger.debug(f"[Web Scraper - Requests/BS4] Status {response.status_code} for {url}")
281
  content_type = response.headers.get('content-type', '').lower()
282
  if 'html' not in content_type:
283
+ logger.warning(f"[Web Scraper - Requests/BS4] Non-HTML: {content_type}. Plain text?")
284
  if 'text/plain' in content_type and response.text: return response.text.strip()
285
  return None
286
  soup = BeautifulSoup(response.text, 'html.parser')
287
  for element in soup(["script", "style", "header", "footer", "nav", "aside", "form", "button", "input", "textarea", "select", "option", "label", "iframe", "img", "svg", "link", "meta", "noscript", "figure", "figcaption", "video", "audio"]): element.extract()
288
  main_content = soup.find('main') or soup.find('article') or soup.find(id='content') or soup.find(class_='content') or soup.find(id='main-content') or soup.find(class_='main-content') or soup.find(role='main')
289
  target_element = main_content if main_content else soup.body
290
+ if not target_element: logger.warning(f"[Web Scraper - Requests/BS4] No body/main content for {url}"); return None
 
 
291
  lines = [line.strip() for line in target_element.get_text(separator='\n', strip=True).splitlines() if line.strip()]
292
  text = "\n".join(lines)
293
  MIN_TEXT_LENGTH = 50
294
+ if not text or len(text) < MIN_TEXT_LENGTH: logger.warning(f"[Web Scraper - Requests/BS4] Text too short (<{MIN_TEXT_LENGTH} chars) for {url} (Len: {len(text)})")
295
+ logger.info(f"[Web Scraper - Requests/BS4] Success scraping {url} (Len: {len(text)})")
 
296
  return text
297
+ except requests.exceptions.Timeout: logger.error(f"[Web Scraper - Requests/BS4] Timeout: {url}"); return None
298
+ except requests.exceptions.TooManyRedirects: logger.error(f"[Web Scraper - Requests/BS4] Redirects: {url}"); return None
299
+ except requests.exceptions.RequestException as e: logger.error(f"[Web Scraper - Requests/BS4] Request error {url}: {e}"); return None
300
+ except Exception as e: logger.error(f"[Web Scraper - Requests/BS4] Parsing error {url}: {e}", exc_info=True); return None
301
 
302
  # Website Content via URLToText API
303
  async def get_website_content_via_urltotext_api(url: str, api_key: str):
304
  """Fetches website content using the URLToText API."""
305
+ if not url: logger.error("[Web Scraper - URLToText API] no URL"); return None
306
+ if not api_key: logger.error("[Web Scraper - URLToText API] API key missing."); return None
307
+ logger.info(f"[Web Scraper - URLToText API] Attempting fetch: {url}")
308
  api_endpoint = "https://urltotext.com/api/v1/urltotext/"
309
  payload = json.dumps({"url": url, "output_format": "text", "extract_main_content": True, "render_javascript": True, "residential_proxy": False})
310
  headers = {"Authorization": f"Token {api_key}", "Content-Type": "application/json"}
311
  try:
 
312
  response = await asyncio.to_thread(requests.post, api_endpoint, headers=headers, data=payload, timeout=45)
313
+ logger.debug(f"[Web Scraper - URLToText API] Status {response.status_code} for {url}")
314
  if response.status_code == 200:
315
  try:
316
  data = response.json()
317
+ content = data.get("data", {}).get("content"); credits = data.get("credits_used", "N/A"); warning = data.get("data", {}).get("warning")
 
 
318
  if warning: logger.warning(f"[Web Scraper - URLToText API] Warning for {url}: {warning}")
319
+ if content: logger.info(f"[Web Scraper - URLToText API] Success via API. Length: {len(content)}. Credits: {credits}"); return content.strip()
320
+ else: logger.warning(f"[Web Scraper - URLToText API] API success (200) but content empty. Resp: {data}"); return None
321
+ except json.JSONDecodeError: logger.error(f"[Web Scraper - URLToText API] Failed JSON decode. Status: {response.status_code}. Resp: {response.text[:500]}..."); return None
322
+ except Exception as e: logger.error(f"[Web Scraper - URLToText API] Error processing API response: {e}", exc_info=True); return None
323
+ elif response.status_code == 400: logger.error(f"[Web Scraper - URLToText API] Bad Request (400). Resp: {response.text[:200]}...")
324
+ elif response.status_code == 401: logger.error(f"[Web Scraper - URLToText API] Unauthorized (401). Check Key. Resp: {response.text[:200]}...")
325
+ elif response.status_code == 402: logger.error(f"[Web Scraper - URLToText API] Payment Required (402). Check credits. Resp: {response.text[:200]}...")
326
+ elif response.status_code == 422: logger.warning(f"[Web Scraper - URLToText API] Unprocessable URL (422). Resp: {response.text[:200]}...")
327
+ elif response.status_code >= 500: logger.error(f"[Web Scraper - URLToText API] Server Error ({response.status_code}). Resp: {response.text[:200]}...")
328
+ else: logger.error(f"[Web Scraper - URLToText API] Unexpected status {response.status_code}. Resp: {response.text[:200]}...")
 
 
 
 
329
  return None
330
+ except requests.exceptions.Timeout: logger.error(f"[Web Scraper - URLToText API] Timeout: {url}"); return None
331
+ except requests.exceptions.RequestException as e: logger.error(f"[Web Scraper - URLToText API] Request error: {e}"); return None
332
+ except Exception as e: logger.error(f"[Web Scraper - URLToText API] Unexpected error: {e}", exc_info=True); return None
333
 
334
  # DeepSeek Summary Function
335
  async def generate_summary(text: str, summary_type: str, api_key: str) -> str:
 
337
  logger.info(f"Generating '{summary_type}' summary. Input length: {len(text)}")
338
  if not api_key: logger.error("OpenRouter API key missing."); return "Error: AI config key missing."
339
  if not text: logger.warning("generate_summary called with empty text."); return "Error: No content to summarize."
340
+ openrouter_api_endpoint = "https://openrouter.ai/api/v1/chat/completions"; model_name = "deepseek/deepseek-chat:free"
 
341
  if summary_type == "paragraph": prompt = "Please provide a concise, well-written paragraph summarizing the key information and main points of the following text. Focus on capturing the essence of the content accurately."
342
  elif summary_type == "points": prompt = "Please summarize the following text into clear, distinct bullet points. Each point should highlight a key piece of information, finding, or main topic discussed. Aim for clarity and conciseness."
343
  else: logger.error(f"Invalid summary_type '{summary_type}'."); return f"Error: Invalid summary type ('{summary_type}')."
344
  MAX_INPUT_LENGTH = 500000
345
  if len(text) > MAX_INPUT_LENGTH: logger.warning(f"Input text ({len(text)}) > limit ({MAX_INPUT_LENGTH}). Truncating."); text = text[:MAX_INPUT_LENGTH] + "... (Truncated)"
346
  full_prompt = f"{prompt}\n\n--- Start of Text ---\n\n{text}\n\n--- End of Text ---"
347
+ space_host = os.environ.get("SPACE_HOST", "huggingface.co/spaces/YOUR_SPACE_NAME")
 
348
  referer_url = f"https://{space_host}" if not space_host.startswith("http") else space_host
349
  headers = {"Authorization": f"Bearer {api_key}","Content-Type": "application/json","HTTP-Referer": referer_url,"X-Title": "Telegram URL Summarizer Bot"}
350
  payload = json.dumps({"model": model_name, "messages": [{"role": "user", "content": full_prompt}]})
351
  try:
352
  logger.debug(f"Sending request to OpenRouter (Model: {model_name})...")
353
  response = await asyncio.to_thread(requests.post, openrouter_api_endpoint, headers=headers, data=payload, timeout=90)
354
+ logger.debug(f"Received status {response.status_code} from OpenRouter.")
355
  if response.status_code == 200:
356
  try:
357
  data = response.json()
 
366
  else: logger.error(f"Unexpected OpenRouter choices structure. Resp: {data}"); return "Sorry, could not parse AI response (choices)."
367
  except json.JSONDecodeError: logger.error(f"Failed JSON decode from OpenRouter. Status: {response.status_code}. Resp: {response.text[:500]}..."); return "Sorry, failed to understand AI response format."
368
  except Exception as e: logger.error(f"Error processing OpenRouter success resp: {e}", exc_info=True); return "Sorry, error processing AI response."
369
+ elif response.status_code == 401: logger.error("OpenRouter key invalid (401)."); return "Error: AI model config key invalid."
370
  elif response.status_code == 402: logger.error("OpenRouter Payment Required (402)."); return "Sorry, issue with AI service limits/payment."
371
  elif response.status_code == 429: logger.warning("OpenRouter Rate Limit (429)."); return "Sorry, AI model busy. Try again."
372
  elif response.status_code >= 500: logger.error(f"OpenRouter Internal Error ({response.status_code}). Resp: {response.text[:500]}..."); return "Sorry, AI model service error. Try again later."
 
382
  # --- Telegram Bot Handlers ---
383
 
384
  async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
385
+ user = update.effective_user;
 
386
  if not user: return
387
+ logger.info(f"User {user.id} ({user.username or 'NoUsername'}) /start.")
388
  mention = user.mention_html() if user.username else user.first_name
389
+ await update.message.reply_html(f"👋 Hello {mention}! Send a URL to summarize.")
390
 
391
  async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
392
+ user = update.effective_user; logger.info(f"User {user.id if user else '?'} /help.")
393
+ help_text = ("**How:**\n1. Send URL.\n2. Choose Paragraph/Points.\n3. Get summary!\n\n"
394
+ "**Notes:**\n- YT transcripts can fail.\n- Complex sites hard to scrape.\n- AI errors possible.")
 
 
 
395
  await update.message.reply_text(help_text, parse_mode=ParseMode.MARKDOWN)
396
 
397
  async def handle_potential_url(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
 
398
  if not update.message or not update.message.text: return
399
  message_text = update.message.text.strip(); user = update.effective_user;
400
  if not user: return
401
  url_pattern = r'https?://[^\s/$.?#].[^\s]*'; match = re.search(url_pattern, message_text)
402
  if match:
403
+ url = match.group(0); logger.info(f"User {user.id} sent URL: {url}")
404
  context.user_data['url_to_summarize'] = url; logger.debug(f"Stored URL '{url}' for user {user.id}")
405
+ keyboard = [[InlineKeyboardButton("Paragraph", callback_data="paragraph"), InlineKeyboardButton("Points", callback_data="points")]]
406
  reply_markup = InlineKeyboardMarkup(keyboard)
407
+ await update.message.reply_text(f"Link:\n{url}\n\nChoose summary type:", reply_markup=reply_markup, link_preview_options={'is_disabled': True})
408
+ else: logger.debug(f"Ignoring non-URL from {user.id}: {message_text[:100]}")
409
 
410
  async def handle_summary_type_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
 
411
  query = update.callback_query; user = query.from_user
412
+ try: await query.answer(); logger.debug(f"Answered query {query.id}")
413
+ except Exception as e: logger.error(f"Failed answer query {query.id}: {e}")
414
  summary_type = query.data; url = context.user_data.get('url_to_summarize')
415
+ logger.info(f"User {user.id} chose '{summary_type}'. URL: '{url}'.")
416
  if not url:
417
  logger.warning(f"User {user.id} pressed button, NO URL in context.");
418
  try: await query.edit_message_text(text="Context lost. Send link again.")
 
421
  context.user_data.pop('url_to_summarize', None); logger.debug(f"Cleared URL {url} for user {user.id}")
422
  current_openrouter_key = os.environ.get('OPENROUTER_API_KEY'); current_urltotext_key = os.environ.get('URLTOTEXT_API_KEY')
423
  current_supadata_key = os.environ.get('SUPADATA_API_KEY'); current_apify_token = os.environ.get('APIFY_API_TOKEN')
424
+ logger.debug(f"Keys: OR={'Y' if current_openrouter_key else 'N'}, UTT={'Y' if current_urltotext_key else 'N'}, SD={'Y' if current_supadata_key else 'N'}, AP={'Y' if current_apify_token else 'N'}")
425
  if not current_openrouter_key:
426
  logger.error("OpenRouter key missing.");
427
+ try: await query.edit_message_text(text="⚠️ AI service config error (key missing).")
428
  except Exception as edit_err: logger.error(f"Failed edit on missing OR key: {edit_err}")
429
  return
430
  processing_message_text = f"Working on '{summary_type}' summary for:\n{url}\n..."; message_to_delete_later_id = None
 
461
  else: logger.info("Summary generated OK. Sending."); await context.bot.send_message(chat_id=user.id, text=summary, parse_mode=ParseMode.MARKDOWN, link_preview_options={'is_disabled': True}); success = True; user_feedback_message = None
462
  elif not user_feedback_message: user_feedback_message = "Sorry, couldn't retrieve content from link."
463
  if user_feedback_message and not success: logger.warning(f"Sending failure feedback: {user_feedback_message}"); await context.bot.send_message(chat_id=user.id, text=user_feedback_message)
464
+ except Exception as e:
465
+ logger.error(f"Unexpected error in callback processing: {e}", exc_info=True);
466
+ try: await context.bot.send_message(chat_id=user.id, text="Oops! Internal error processing request.")
467
+ except Exception as final_err: logger.error(f"Failed sending final error message: {final_err}")
468
  finally:
469
  logger.debug("Cleaning up status message...");
470
  try:
 
512
  WEBHOOK_URL_BASE = os.environ.get("SPACE_HOST")
513
  if WEBHOOK_URL_BASE:
514
  if not WEBHOOK_URL_BASE.startswith("https://"): WEBHOOK_URL_BASE = f"https://{WEBHOOK_URL_BASE}"
515
+ webhook_path = "/webhook"
516
  full_webhook_url = f"{WEBHOOK_URL_BASE.rstrip('/')}{webhook_path}"
517
  logger.info(f"Attempting to set webhook to: {full_webhook_url}")
518
  try:
519
+ # Add a small delay before setting webhook, especially with multiple workers
520
+ # This might help avoid the initial rate limit error, although one worker succeeding is enough.
521
+ await asyncio.sleep(1.5) # Wait 1.5 seconds
522
  await ptb_app.bot.set_webhook(url=full_webhook_url, allowed_updates=Update.ALL_TYPES)
523
  webhook_info = await ptb_app.bot.get_webhook_info()
524
  logger.info(f"Webhook set successfully! Info: {webhook_info}")
525
+ except RetryAfter as e:
526
+ logger.warning(f"Webhook set failed due to rate limit (RetryAfter: {e.retry_after}s). Another worker likely succeeded.")
527
+ except Exception as e:
528
+ logger.error(f"Failed to set webhook: {e}", exc_info=True)
529
  else: logger.warning("SPACE_HOST env variable not found. Skipping webhook setup.")
530
 
531
  logger.info("ASGI Lifespan: Startup complete. Application ready.")
 
533
 
534
  except Exception as startup_err:
535
  logger.critical(f"CRITICAL ERROR during ASGI startup: {startup_err}", exc_info=True)
 
536
  raise
537
  finally:
538
  # --- Shutdown ---
539
  logger.info("ASGI Lifespan: Shutdown commencing...")
540
+ # Use the correct attribute to check if running before stopping/shutting down
541
+ if ptb_app and ptb_app._running: # <--- Use _running here too
542
  try:
543
  logger.info("Stopping PTB App...")
544
  await ptb_app.stop()
 
564
  """Basic health check endpoint."""
565
  logger.debug("Health check '/' accessed.")
566
  bot_status = "UNKNOWN"
567
+ if ptb_app:
568
+ # --- CORRECTED CHECK ---
569
+ bot_status = "Running" if ptb_app._running else "Initialized/Stopped/Starting/Error"
570
+ # --- END CORRECTION ---
571
+ else:
572
+ bot_status = "Not Initialized"
573
  return f"Telegram Bot Webhook Listener ({bot_status}) running via Starlette."
574
 
575
  @flask_core_app.route('/webhook', methods=['POST'])
576
  async def webhook() -> Response:
577
  """Webhook endpoint for Telegram updates."""
578
+ if not ptb_app: # Check if instance exists first
579
+ logger.error("Webhook triggered, but PTB Application instance (ptb_app) is None.")
580
+ return Response('Bot service not configured.', status=503)
581
+
582
+ # --- CORRECTED CHECK ---
583
+ # Use the internal _running attribute as suggested by the AttributeError
584
+ if not ptb_app._running:
585
+ # --- END CORRECTION ---
586
+ status = "Not Running" # If instance exists but not running
587
  logger.error(f"Webhook triggered, but PTB Application is {status}.")
588
  return Response('Bot service not ready.', status=503)
589