akdNIKY commited on
Commit
bd021f8
·
verified ·
1 Parent(s): 303cfe1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +247 -302
app.py CHANGED
@@ -7,54 +7,70 @@ import json
7
  from io import BytesIO
8
 
9
  from flask import Flask, request, jsonify
10
- from a2wsgi import ASGIMiddleware # ->
11
- # واسط برای سازگاری با سرور
12
 
13
- from telegram import Update, InputFile, InlineKeyboardButton, InlineKeyboardMarkup, error as telegram_error
14
  from telegram.ext import (
15
  Application,
16
  CommandHandler,
17
  MessageHandler,
18
  CallbackQueryHandler,
19
  filters,
20
- ConversationHandler,
21
- ContextTypes,
22
  )
23
  from telegram.constants import ParseMode
24
  from pydub import AudioSegment
25
 
26
- # --- Environment Variables ---
27
- # These should be set as "Secrets" in your Hugging Face Space settings
28
- TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
29
- if not TOKEN:
30
- print("Error: TELEGRAM_BOT_TOKEN environment variable not set.", file=sys.stderr)
 
 
31
  sys.exit(1)
32
 
 
33
  BOT_USERNAME = os.getenv("TELEGRAM_BOT_USERNAME", "Voice2mp3_RoBot")
34
- # Ensure ADMIN_ID is an integer
35
- try:
36
- ADMIN_ID = int(os.getenv("TELEGRAM_ADMIN_ID", "0"))
37
- except ValueError:
38
- print("Error: TELEGRAM_ADMIN_ID is not a valid integer.", file=sys.stderr)
39
- ADMIN_ID = 0
40
-
41
- if ADMIN_ID == 0:
42
- print("Warning: TELEGRAM_ADMIN_ID not set or is 0. Admin features will be disabled.", file=sys.stderr)
43
-
44
- # --- Directories and Files ---
45
- # Use the /tmp directory as it is writable in Hugging Face Spaces
46
  DOWNLOAD_DIR = "/tmp/downloads"
47
  OUTPUT_DIR = "/tmp/outputs"
48
- CHANNELS_FILE = "channels.json"
49
  os.makedirs(DOWNLOAD_DIR, exist_ok=True)
50
  os.makedirs(OUTPUT_DIR, exist_ok=True)
51
 
52
- # --- Conversation States ---
53
- LANGUAGE_SELECTION, MAIN_MENU, CONVERT_AUDIO, CUT_AUDIO_FILE, CUT_AUDIO_RANGE, \
54
- VIDEO_CONVERSION_MODE, WAITING_FOR_MEMBERSHIP, \
55
- ADMIN_MENU, ADD_CHANNEL, LIST_REMOVE_CHANNELS = range(10)
56
 
57
- # --- Multi-language Messages ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  MESSAGES = {
59
  'fa': {
60
  'start_welcome': "سلام! من یک ربات تبدیل فرمت صوتی و ویدیویی هستم.\n\nبرای شروع، از منوی زیر یک قابلیت را انتخاب کنید.",
@@ -108,7 +124,7 @@ MESSAGES = {
108
  'channel_removed': "✅ کانال '{channel_id}' با موفقیت حذف شد.",
109
  'channel_not_found': "❗️ کانال مورد نظر یافت نشد.",
110
  'invalid_channel_id': "آیدی/لینک کانال نامعتبر است. لطفاً @username یا آیدی عددی (مانند -1001234567890) را ارسال کنید.",
111
- 'bot_not_admin_in_channel': "ربات ادمین کانال '{channel_id}' نیست یا مجوزهای کافی برای بررسی عضویت را ندارد. لطفاً ربات را به عنوان ادمین با مجوز 'دعوت کاربران از طریق لینک' در کانال اضافه کنید."
112
  },
113
  'en': {
114
  'start_welcome': "Hello! I am an audio and video format conversion bot.\n\nTo start, select a feature from the menu below.",
@@ -162,35 +178,22 @@ MESSAGES = {
162
  'channel_removed': "✅ Channel '{channel_id}' successfully removed.",
163
  'channel_not_found': "❗️ Channel not found.",
164
  'invalid_channel_id': "Invalid channel ID/link. Please send @username or numeric ID (e.g., -1001234567890).",
165
- 'bot_not_admin_in_channel': "The bot is not an admin in channel '{channel_id}' or does not have sufficient permissions to check membership. Please add the bot as an admin with 'Invite Users via Link' permission in the channel."
166
  }
167
  }
168
 
 
169
 
170
- # --- Channel Management Functions ---
171
- def load_required_channels():
172
- if os.path.exists(CHANNELS_FILE):
173
- try:
174
- with open(CHANNELS_FILE, 'r', encoding='utf-8') as f:
175
- return json.load(f)
176
- except json.JSONDecodeError:
177
- print(f"Error decoding JSON from {CHANNELS_FILE}.", file=sys.stderr)
178
- return []
179
-
180
- def save_required_channels(channels):
181
- with open(CHANNELS_FILE, 'w', encoding='utf-8') as f:
182
- json.dump(channels, f, indent=4, ensure_ascii=False)
183
-
184
- REQUIRED_CHANNELS = load_required_channels()
185
-
186
- # --- Helper Functions ---
187
- def get_message(context: ContextTypes.DEFAULT_TYPE, key: str, **kwargs) -> str:
188
  lang = context.user_data.get('language', 'fa')
189
- message_template = MESSAGES.get(lang, {}).get(key, MESSAGES['fa'][key])
 
190
  return message_template.format(**kwargs)
191
 
192
- def parse_time_to_ms(time_str: str) -> int:
193
- match = re.match(r'^(\d{1,2})\.(\d{2})$', time_str)
 
194
  if not match:
195
  raise ValueError("Invalid time format")
196
  minutes, seconds = int(match.group(1)), int(match.group(2))
@@ -198,77 +201,61 @@ def parse_time_to_ms(time_str: str) -> int:
198
  raise ValueError("Seconds must be between 00 and 59")
199
  return (minutes * 60 + seconds) * 1000
200
 
201
-
202
- # --- Membership Check ---
203
- async def check_user_membership(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
204
  user_id = update.effective_user.id
205
  if not REQUIRED_CHANNELS:
206
  return True
207
-
208
  for channel_id in REQUIRED_CHANNELS:
209
  try:
210
  chat_member = await context.bot.get_chat_member(chat_id=channel_id, user_id=user_id)
211
  if chat_member.status not in ['member', 'administrator', 'creator']:
212
  return False
213
- except telegram_error.BadRequest as e:
214
- # This can happen if channel_id is invalid or bot is not in the channel
215
- print(f"Error checking membership for channel {channel_id}: {e}", file=sys.stderr)
216
- return False
217
  except Exception as e:
218
- print(f"Unexpected error checking membership for {channel_id}: {e}", file=sys.stderr)
219
  return False
220
  return True
221
 
222
- async def show_membership_required_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
 
223
  keyboard = []
224
  if not REQUIRED_CHANNELS:
225
  return await show_main_menu(update, context)
226
 
227
  for channel_id in REQUIRED_CHANNELS:
228
  try:
229
- # Using a generic URL for @username and assuming invite link for others
230
- if str(channel_id).startswith('@'):
231
- url = f"https://t.me/{channel_id.lstrip('@')}"
232
- title = channel_id
233
- else:
234
- chat = await context.bot.get_chat(chat_id=channel_id)
235
- url = chat.invite_link or f"https://t.me/c/{str(channel_id).replace('-100', '')}"
236
- title = chat.title or channel_id
237
- keyboard.append([InlineKeyboardButton(f"{get_message(context, 'btn_join_channel')} {title}", url=url)])
238
  except Exception as e:
239
- print(f"Could not get chat info for {channel_id}: {e}", file=sys.stderr)
240
- # Fallback button
241
- keyboard.append([InlineKeyboardButton(f"{get_message(context, 'btn_join_channel')} {channel_id}", url=f"https://t.me/{str(channel_id).lstrip('@')}")])
242
 
243
  keyboard.append([InlineKeyboardButton(get_message(context, 'btn_check_membership'), callback_data='check_membership')])
244
  reply_markup = InlineKeyboardMarkup(keyboard)
245
 
246
- message_target = update.callback_query.message if update.callback_query else update.effective_message
247
- await message_target.reply_text(
248
- get_message(context, 'membership_required'),
249
- reply_markup=reply_markup
250
- )
251
  return WAITING_FOR_MEMBERSHIP
252
 
253
- async def process_feature_or_check_membership(update: Update, context: ContextTypes.DEFAULT_TYPE, feature_func, *args, **kwargs):
254
- user_id = update.effective_user.id
255
- # Grant access if user is admin, or no channels are required
256
- if user_id == ADMIN_ID or not REQUIRED_CHANNELS:
257
  return await feature_func(update, context, *args, **kwargs)
258
 
259
- # Allow one-time use before checking membership
260
- if context.user_data.get('used_bot_once', False):
261
- is_member = await check_user_membership(update, context)
262
- if not is_member:
263
- return await show_membership_required_message(update, context)
264
  else:
265
- context.user_data['used_bot_once'] = True
266
-
267
- return await feature_func(update, context, *args, **kwargs)
268
 
 
269
 
270
- # --- Core Bot Handlers ---
271
- async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
272
  keyboard = [
273
  [InlineKeyboardButton("فارسی 🇮🇷", callback_data='set_lang_fa')],
274
  [InlineKeyboardButton("English 🇬🇧", callback_data='set_lang_en')]
@@ -280,354 +267,316 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
280
  )
281
  return LANGUAGE_SELECTION
282
 
283
- async def set_language(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
 
284
  query = update.callback_query
285
  await query.answer()
286
- lang_code = query.data.replace('set_lang_', '')
287
- context.user_data['language'] = lang_code
288
  await query.edit_message_text(text=get_message(context, 'start_welcome'))
289
  return await show_main_menu(update, context)
290
 
291
- async def show_main_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
 
292
  keyboard = [
293
  [InlineKeyboardButton(get_message(context, 'btn_convert_format'), callback_data='select_convert_format')],
294
  [InlineKeyboardButton(get_message(context, 'btn_cut_audio'), callback_data='select_cut_audio')],
295
  [InlineKeyboardButton(get_message(context, 'btn_video_conversion'), callback_data='select_video_conversion')]
296
  ]
297
  reply_markup = InlineKeyboardMarkup(keyboard)
298
-
299
- query = update.callback_query
300
- message_target = query.message if query else update.effective_message
301
-
302
- if query:
303
- await query.edit_message_text(text=get_message(context, 'main_menu_prompt'), reply_markup=reply_markup)
304
- else:
305
- await message_target.reply_text(text=get_message(context, 'main_menu_prompt'), reply_markup=reply_markup)
306
 
 
 
307
  return MAIN_MENU
308
 
309
- async def change_format_selected(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
310
  query = update.callback_query
311
  await query.answer()
312
  await query.edit_message_text(text=get_message(context, 'convert_mode_active'))
313
  return CONVERT_AUDIO
314
 
315
- async def cut_audio_selected(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
316
  query = update.callback_query
317
  await query.answer()
318
  await query.edit_message_text(text=get_message(context, 'cut_mode_active_file'))
319
  context.user_data.pop('audio_for_cut_path', None)
320
  return CUT_AUDIO_FILE
321
 
322
- async def video_conversion_selected(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
323
  query = update.callback_query
324
  await query.answer()
325
  await query.edit_message_text(text=get_message(context, 'video_conversion_mode_active'))
326
  return VIDEO_CONVERSION_MODE
327
 
328
- # --- File Processing Handlers (Wrapped with membership check) ---
329
-
330
- async def handle_audio(update: Update, context: ContextTypes.DEFAULT_TYPE):
331
  async def _perform_conversion(update, context):
332
- file = update.message.audio
333
- processing_msg = await update.message.reply_text(get_message(context, 'file_received'))
334
-
335
- download_path = os.path.join(DOWNLOAD_DIR, f"{file.file_id}.mp3")
336
- output_ogg_path = os.path.join(OUTPUT_DIR, f"{file.file_id}.ogg")
337
 
338
  try:
339
- tg_file = await context.bot.get_file(file.file_id)
340
- await tg_file.download_to_drive(download_path)
341
-
342
- await processing_msg.edit_text(get_message(context, 'conversion_done'))
343
  audio = AudioSegment.from_file(download_path)
344
  audio.export(output_ogg_path, format="ogg", codec="libopus", parameters=["-b:a", "32k"])
345
-
346
- with open(output_ogg_path, 'rb') as voice_file:
347
- await update.message.reply_voice(voice_file, reply_to_message_id=update.message.message_id)
348
 
 
 
 
349
  except Exception as e:
350
- print(f"Error converting MP3 to voice: {e}", file=sys.stderr)
351
- await processing_msg.edit_text(get_message(context, 'error_mp3_to_voice') + str(e))
352
  finally:
353
  if os.path.exists(download_path): os.remove(download_path)
354
  if os.path.exists(output_ogg_path): os.remove(output_ogg_path)
355
- await processing_msg.delete()
356
  return CONVERT_AUDIO
357
  return await process_feature_or_check_membership(update, context, _perform_conversion)
358
 
359
- async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
 
360
  async def _perform_conversion(update, context):
361
- file = update.message.voice
362
- processing_msg = await update.message.reply_text(get_message(context, 'file_received'))
363
-
364
- download_path = os.path.join(DOWNLOAD_DIR, f"{file.file_id}.ogg")
365
- output_mp3_path = os.path.join(OUTPUT_DIR, f"@{BOT_USERNAME}_{file.file_id}.mp3")
366
 
367
  try:
368
- tg_file = await context.bot.get_file(file.file_id)
369
- await tg_file.download_to_drive(download_path)
 
370
 
371
- await processing_msg.edit_text(get_message(context, 'conversion_done'))
372
- audio = AudioSegment.from_file(download_path)
373
- audio.export(output_mp3_path, format="mp3", tags={'artist': f'@{BOT_USERNAME}'})
374
-
375
- with open(output_mp3_path, 'rb') as audio_file:
376
- await update.message.reply_audio(
377
- audio_file,
378
- caption=get_message(context, 'voice_to_mp3_caption'),
379
- reply_to_message_id=update.message.message_id
380
- )
381
  except Exception as e:
382
- print(f"Error converting voice to MP3: {e}", file=sys.stderr)
383
- await processing_msg.edit_text(get_message(context, 'error_voice_to_mp3') + str(e))
384
  finally:
385
  if os.path.exists(download_path): os.remove(download_path)
386
  if os.path.exists(output_mp3_path): os.remove(output_mp3_path)
387
- await processing_msg.delete()
388
  return CONVERT_AUDIO
389
  return await process_feature_or_check_membership(update, context, _perform_conversion)
390
 
391
- async def handle_cut_audio_file(update: Update, context: ContextTypes.DEFAULT_TYPE):
 
392
  async def _perform_file_receive(update, context):
393
  audio_file = update.message.audio or update.message.voice
394
- if not audio_file:
395
- await update.message.reply_text(get_message(context, 'no_audio_for_cut'))
396
- return CUT_AUDIO_FILE
397
-
398
- file_ext = 'mp3' if update.message.audio else 'ogg'
399
- download_path = os.path.join(DOWNLOAD_DIR, f"cut_{audio_file.file_id}.{file_ext}")
400
 
401
  try:
402
- tg_file = await context.bot.get_file(audio_file.file_id)
403
- await tg_file.download_to_drive(download_path)
404
  context.user_data['audio_for_cut_path'] = download_path
405
- context.user_data['audio_for_cut_type'] = file_ext
406
  await update.message.reply_text(get_message(context, 'cut_mode_active_range'))
407
  return CUT_AUDIO_RANGE
408
  except Exception as e:
409
- print(f"Error downloading audio for cutting: {e}", file=sys.stderr)
410
  await update.message.reply_text(get_message(context, 'general_error'))
411
  return CUT_AUDIO_FILE
412
  return await process_feature_or_check_membership(update, context, _perform_file_receive)
413
 
414
- async def handle_cut_audio_range(update: Update, context: ContextTypes.DEFAULT_TYPE):
 
415
  async def _perform_cut(update, context):
 
416
  audio_path = context.user_data.get('audio_for_cut_path')
 
 
417
  if not audio_path or not os.path.exists(audio_path):
418
  await update.message.reply_text(get_message(context, 'no_audio_for_cut'))
419
  return CUT_AUDIO_FILE
420
 
421
- processing_msg = await update.message.reply_text(get_message(context, 'cut_processing'))
422
- output_cut_path = None
 
423
  try:
424
- time_range_str = update.message.text
425
- start_str, end_str = time_range_str.split('-')
426
- start_ms = parse_time_to_ms(start_str.strip())
427
- end_ms = parse_time_to_ms(end_str.strip())
428
 
429
  if start_ms >= end_ms:
430
- await processing_msg.edit_text(get_message(context, 'invalid_time_range'))
431
  return CUT_AUDIO_RANGE
432
 
433
- audio = AudioSegment.from_file(audio_path, format=context.user_data['audio_for_cut_type'])
434
  cut_audio = audio[start_ms:end_ms]
435
-
436
- output_cut_path = os.path.join(OUTPUT_DIR, f"cut_output_{os.path.basename(audio_path)}")
437
  cut_audio.export(output_cut_path, format="mp3")
438
 
 
439
  with open(output_cut_path, 'rb') as f:
440
- await update.message.reply_audio(f, caption=f"برش از {start_str.strip()} تا {end_str.strip()}")
441
-
442
- await processing_msg.delete()
443
  return await show_main_menu(update, context)
444
-
445
- except (ValueError, IndexError):
446
- await processing_msg.edit_text(get_message(context, 'invalid_time_format'))
447
  return CUT_AUDIO_RANGE
448
  except Exception as e:
449
- print(f"Error cutting audio: {e}", file=sys.stderr)
450
- await processing_msg.edit_text(get_message(context, 'general_error'))
451
  return await show_main_menu(update, context)
452
  finally:
453
  if os.path.exists(audio_path): os.remove(audio_path)
454
- if output_cut_path and os.path.exists(output_cut_path): os.remove(output_cut_path)
455
  context.user_data.pop('audio_for_cut_path', None)
456
  context.user_data.pop('audio_for_cut_type', None)
457
-
458
  return await process_feature_or_check_membership(update, context, _perform_cut)
459
 
460
- async def handle_video_conversion(update: Update, context: ContextTypes.DEFAULT_TYPE):
 
461
  async def _perform_video_conversion(update, context):
462
  is_video_note = bool(update.message.video_note)
463
- file = update.message.video_note if is_video_note else update.message.video
464
- if not file:
465
- await update.message.reply_text(get_message(context, 'invalid_file_type_video'))
466
- return VIDEO_CONVERSION_MODE
467
-
468
- processing_msg = await update.message.reply_text(get_message(context, 'file_received_video'))
469
- download_path = os.path.join(DOWNLOAD_DIR, f"{file.file_id}.mp4")
470
- output_path = os.path.join(OUTPUT_DIR, f"converted_{file.file_id}.mp4")
471
 
 
 
 
 
 
472
  try:
473
- tg_file = await context.bot.get_file(file.file_id)
474
- await tg_file.download_to_drive(download_path)
 
475
 
476
  if is_video_note:
477
- await processing_msg.edit_text(get_message(context, 'converting_video_note_to_video'))
478
- # Just send as a regular video
479
- with open(download_path, 'rb') as video_file:
480
- await update.message.reply_video(video_file, caption=get_message(context, 'video_note_to_video_caption'))
481
  else:
482
- await processing_msg.edit_text(get_message(context, 'converting_video_to_video_note'))
483
- # Crop to square, scale, and send as video note
484
- ffmpeg_command = [
485
- 'ffmpeg', '-i', download_path,
486
- '-vf', 'crop=min(iw\\,ih):min(iw\\,ih),scale=360:360',
487
- '-c:v', 'libx264', '-crf', '28', '-preset', 'veryfast',
488
- '-c:a', 'aac', '-b:a', '64k',
489
- '-movflags', '+faststart', output_path, '-y'
490
- ]
491
- subprocess.run(ffmpeg_command, check=True, capture_output=True, text=True)
492
- with open(output_path, 'rb') as video_file:
493
- await update.message.reply_video_note(video_file)
494
-
495
  except subprocess.CalledProcessError as e:
496
- print(f"FFmpeg Error: {e.stderr}", file=sys.stderr)
497
- await processing_msg.edit_text(get_message(context, 'error_video_conversion') + "FFmpeg Error")
498
  except Exception as e:
499
- print(f"Video conversion error: {e}", file=sys.stderr)
500
- await processing_msg.edit_text(get_message(context, 'error_video_conversion') + str(e))
501
  finally:
502
  if os.path.exists(download_path): os.remove(download_path)
503
  if os.path.exists(output_path): os.remove(output_path)
504
- await processing_msg.delete()
505
  return VIDEO_CONVERSION_MODE
506
  return await process_feature_or_check_membership(update, context, _perform_video_conversion)
507
 
508
- async def check_membership_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
 
509
  query = update.callback_query
510
  await query.answer()
511
  is_member = await check_user_membership(update, context)
512
  if is_member:
513
- context.user_data['used_bot_once'] = True # Mark as verified
514
  await query.edit_message_text(get_message(context, 'membership_success'))
515
  return await show_main_menu(update, context)
516
  else:
517
  await query.answer(get_message(context, 'membership_failed'), show_alert=True)
518
  return WAITING_FOR_MEMBERSHIP
519
 
520
-
521
- # --- Admin Handlers ---
522
- async def admin_link_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
523
  if update.effective_user.id != ADMIN_ID:
524
  await update.message.reply_text(get_message(context, 'not_admin'))
525
  return ConversationHandler.END
526
-
527
  keyboard = [
528
  [InlineKeyboardButton(get_message(context, 'btn_add_channel'), callback_data='admin_add_channel')],
529
  [InlineKeyboardButton(get_message(context, 'btn_list_channels'), callback_data='admin_list_channels')]
530
  ]
531
- reply_markup = InlineKeyboardMarkup(keyboard)
532
- await update.message.reply_text(get_message(context, 'admin_menu_prompt'), reply_markup=reply_markup)
533
  return ADMIN_MENU
534
 
535
- async def admin_add_channel_prompt(update: Update, context: ContextTypes.DEFAULT_TYPE):
536
  query = update.callback_query
537
  await query.answer()
538
  await query.edit_message_text(get_message(context, 'send_channel_link'))
539
  return ADD_CHANNEL
540
 
541
- async def admin_handle_add_channel(update: Update, context: ContextTypes.DEFAULT_TYPE):
 
542
  channel_input = update.message.text.strip()
543
  if not (channel_input.startswith('@') or channel_input.startswith('-100')):
544
  await update.message.reply_text(get_message(context, 'invalid_channel_id'))
545
  return ADD_CHANNEL
546
-
547
  try:
548
- # Check if bot can get chat info, which implies it's in the channel
549
- await context.bot.get_chat(channel_input)
 
 
 
 
 
550
  except Exception as e:
551
- print(f"Error checking bot status in channel {channel_input}: {e}", file=sys.stderr)
552
- await update.message.reply_text(get_message(context, 'bot_not_admin_in_channel', channel_id=channel_input))
553
- return ADD_CHANNEL
554
-
555
- if channel_input not in REQUIRED_CHANNELS:
556
- REQUIRED_CHANNELS.append(channel_input)
557
- save_required_channels(REQUIRED_CHANNELS)
558
- await update.message.reply_text(get_message(context, 'channel_added', channel_id=channel_input))
559
- else:
560
- await update.message.reply_text(get_message(context, 'channel_already_exists'))
561
-
562
- # Go back to admin menu
563
- await admin_link_command(update, context)
564
- return ADMIN_MENU
565
 
566
- async def admin_list_channels(update: Update, context: ContextTypes.DEFAULT_TYPE):
 
567
  query = update.callback_query
568
  await query.answer()
569
  if not REQUIRED_CHANNELS:
570
  await query.edit_message_text(get_message(context, 'no_channels_configured'))
571
  return ADMIN_MENU
572
-
573
  keyboard = []
574
  for channel_id in REQUIRED_CHANNELS:
575
  keyboard.append([InlineKeyboardButton(f"{get_message(context, 'btn_remove_channel')} {channel_id}", callback_data=f'remove_channel_{channel_id}')])
576
-
577
- reply_markup = InlineKeyboardMarkup(keyboard)
578
- await query.edit_message_text(get_message(context, 'channel_list_prompt'), reply_markup=reply_markup)
579
  return LIST_REMOVE_CHANNELS
580
 
581
- async def admin_handle_remove_channel(update: Update, context: ContextTypes.DEFAULT_TYPE):
 
582
  query = update.callback_query
583
  await query.answer()
584
  channel_id_to_remove = query.data.replace('remove_channel_', '')
585
-
586
  if channel_id_to_remove in REQUIRED_CHANNELS:
587
  REQUIRED_CHANNELS.remove(channel_id_to_remove)
588
  save_required_channels(REQUIRED_CHANNELS)
589
  await query.edit_message_text(get_message(context, 'channel_removed', channel_id=channel_id_to_remove))
590
  else:
591
  await query.edit_message_text(get_message(context, 'channel_not_found'))
592
-
593
- # Go back to admin menu by showing the updated list
594
- await admin_list_channels(update, context)
595
- return LIST_REMOVE_CHANNELS
596
-
597
- async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
598
- # Clean up temp files if any
599
- audio_path = context.user_data.pop('audio_for_cut_path', None)
600
- if audio_path and os.path.exists(audio_path):
601
- os.remove(audio_path)
602
- context.user_data.pop('audio_for_cut_type', None)
603
 
604
- await update.effective_message.reply_text(get_message(context, 'cancel_message'))
 
 
 
 
605
  return await show_main_menu(update, context)
606
 
607
- async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
 
608
  print(f"Update {update} caused error {context.error}", file=sys.stderr)
609
- if isinstance(update, Update) and update.effective_message:
610
- await update.effective_message.reply_text(get_message(context, 'general_error'))
611
 
 
612
 
613
- # --- Flask & Telegram Application Setup ---
614
  app = Flask(__name__)
615
- app = ASGIMiddleware(app) # Wrap the Flask app with the ASGI middleware
616
-
617
  _application_instance = None
618
 
619
  async def get_telegram_application():
 
620
  global _application_instance
621
  if _application_instance is None:
622
  print("Initializing Telegram bot Application for this worker...")
623
- builder = Application.builder().token(TOKEN)
624
- _app = builder.build()
625
 
626
  conv_handler = ConversationHandler(
627
- entry_points=[
628
- CommandHandler("start", start),
629
- CommandHandler("link", admin_link_command)
630
- ],
631
  states={
632
  LANGUAGE_SELECTION: [CallbackQueryHandler(set_language, pattern='^set_lang_')],
633
  MAIN_MENU: [
@@ -636,12 +585,12 @@ async def get_telegram_application():
636
  CallbackQueryHandler(video_conversion_selected, pattern='^select_video_conversion$'),
637
  ],
638
  CONVERT_AUDIO: [
639
- MessageHandler(filters.AUDIO, handle_audio),
640
- MessageHandler(filters.VOICE, handle_voice),
641
  ],
642
- CUT_AUDIO_FILE: [MessageHandler(filters.AUDIO | filters.VOICE, handle_cut_audio_file)],
643
  CUT_AUDIO_RANGE: [MessageHandler(filters.TEXT & ~filters.COMMAND, handle_cut_audio_range)],
644
- VIDEO_CONVERSION_MODE: [MessageHandler(filters.VIDEO | filters.VIDEO_NOTE, handle_video_conversion)],
645
  WAITING_FOR_MEMBERSHIP: [CallbackQueryHandler(check_membership_callback, pattern='^check_membership$')],
646
  ADMIN_MENU: [
647
  CallbackQueryHandler(admin_add_channel_prompt, pattern='^admin_add_channel$'),
@@ -650,57 +599,53 @@ async def get_telegram_application():
650
  ADD_CHANNEL: [MessageHandler(filters.TEXT & ~filters.COMMAND, admin_handle_add_channel)],
651
  LIST_REMOVE_CHANNELS: [CallbackQueryHandler(admin_handle_remove_channel, pattern='^remove_channel_')],
652
  },
653
- fallbacks=[
654
- CommandHandler("cancel", cancel),
655
- CommandHandler("start", start),
656
- ],
657
  allow_reentry=True
658
  )
659
 
660
- _app.add_handler(conv_handler)
661
- _app.add_error_handler(error_handler)
662
-
663
- await _app.initialize()
664
- _application_instance = _app
665
  print("Telegram bot Application initialized.")
666
  return _application_instance
667
 
668
- # --- Flask Web Routes ---
669
  @app.route("/")
670
- async def index():
671
- return jsonify({"status": "ok", "message": "Telegram bot is running."})
 
672
 
673
  @app.route("/webhook", methods=["POST"])
674
  async def webhook():
675
- application = await get_telegram_application()
676
- update_data = await request.get_json()
677
- update = Update.de_json(update_data, application.bot)
678
- await application.process_update(update)
679
- return "ok"
 
 
 
 
680
 
681
  @app.route("/set_webhook", methods=["GET"])
682
  async def set_webhook_route():
683
- application = await get_telegram_application()
684
- # WEBHOOK_URL should be set as a Secret in Hugging Face
685
  webhook_url = os.getenv("WEBHOOK_URL")
686
  if not webhook_url:
687
- return jsonify({"status": "error", "message": "WEBHOOK_URL not set."}), 500
688
-
689
  if not webhook_url.endswith("/webhook"):
690
- webhook_url = f"{webhook_url.rstrip('/')}/webhook"
691
 
692
  try:
 
693
  await application.bot.set_webhook(url=webhook_url)
694
- print(f"Webhook successfully set to {webhook_url}")
695
  return jsonify({"status": "success", "message": f"Webhook set to {webhook_url}"})
696
  except Exception as e:
697
  print(f"Failed to set webhook: {e}", file=sys.stderr)
698
  return jsonify({"status": "error", "message": f"Failed to set webhook: {e}"}), 500
699
 
700
- # This part is for local testing and will not be used on Hugging Face Spaces
701
  if __name__ == "__main__":
702
- import asyncio
703
- print("Starting Flask app in local mode...")
704
- # This initializes the bot for local run, but on HF it's done via the web route
705
- asyncio.run(get_telegram_application())
706
- app.run(host='0.0.0.0', port=int(os.environ.get("PORT", 7860)))
 
7
  from io import BytesIO
8
 
9
  from flask import Flask, request, jsonify
 
 
10
 
11
+ from telegram import Update, InputFile, InlineKeyboardButton, InlineKeyboardMarkup
12
  from telegram.ext import (
13
  Application,
14
  CommandHandler,
15
  MessageHandler,
16
  CallbackQueryHandler,
17
  filters,
18
+ ConversationHandler
 
19
  )
20
  from telegram.constants import ParseMode
21
  from pydub import AudioSegment
22
 
23
+ # --- 1. تنظیمات و متغیرهای سراسری ---
24
+
25
+ # ⚠️⚠️⚠️ بسیار مهم: این توکن را با توکن واقعی ربات خود که از BotFather دریافت کرده‌اید جایگزین کنید.
26
+ # می‌توانید توکن را به عنوان یک متغیر محیطی (Environment Variable) به نام TELEGRAM_BOT_TOKEN نیز تعریف کنید.
27
+ TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "8035336072:AAHG9REotvM4u8DgreC7hu8gIAroxhS-N-M")
28
+ if "YOUR_TELEGRAM_BOT_TOKEN_HERE" in TOKEN:
29
+ print("خطا: لطفا توکن ربات تلگرام خود را در کد جایگزین کنید.", file=sys.stderr)
30
  sys.exit(1)
31
 
32
+ # نام کاربری ربات و آیدی ادمین
33
  BOT_USERNAME = os.getenv("TELEGRAM_BOT_USERNAME", "Voice2mp3_RoBot")
34
+ # ⚠️⚠️⚠️ این را به آیدی عددی تلگرام خودتان تغییر دهید.
35
+ ADMIN_ID = int(os.getenv("TELEGRAM_ADMIN_ID", "684173337"))
36
+
37
+ # مسیرها
38
+ # در محیط‌های سرورلس مانند Hugging Face Spaces، استفاده از /tmp توصیه می‌شود
 
 
 
 
 
 
 
39
  DOWNLOAD_DIR = "/tmp/downloads"
40
  OUTPUT_DIR = "/tmp/outputs"
41
+ CHANNELS_FILE = "/tmp/channels.json"
42
  os.makedirs(DOWNLOAD_DIR, exist_ok=True)
43
  os.makedirs(OUTPUT_DIR, exist_ok=True)
44
 
45
+ # --- 2. توابع مدیریت کانال‌های عضویت اجباری ---
 
 
 
46
 
47
+ def load_required_channels():
48
+ """کانال‌های مورد نیاز را از فایل JSON بارگذاری می‌کند."""
49
+ if os.path.exists(CHANNELS_FILE):
50
+ try:
51
+ with open(CHANNELS_FILE, 'r', encoding='utf-8') as f:
52
+ return json.load(f)
53
+ except json.JSONDecodeError:
54
+ print(f"خطا در خواندن فایل JSON کانال‌ها: {CHANNELS_FILE}", file=sys.stderr)
55
+ return []
56
+ return []
57
+
58
+ def save_required_channels(channels):
59
+ """کانال‌های مورد نیاز را در فایل JSON ذخیره می‌کند."""
60
+ with open(CHANNELS_FILE, 'w', encoding='utf-8') as f:
61
+ json.dump(channels, f, indent=4, ensure_ascii=False)
62
+
63
+ REQUIRED_CHANNELS = load_required_channels()
64
+
65
+
66
+ # --- 3. تعریف حالت‌های مکالمه و پیام‌ها ---
67
+
68
+ # تعریف حالت‌ها
69
+ (LANGUAGE_SELECTION, MAIN_MENU, CONVERT_AUDIO, CUT_AUDIO_FILE, CUT_AUDIO_RANGE,
70
+ VIDEO_CONVERSION_MODE, WAITING_FOR_MEMBERSHIP, ADMIN_MENU, ADD_CHANNEL,
71
+ LIST_REMOVE_CHANNELS) = range(10)
72
+
73
+ # دیکشنری پیام‌ها
74
  MESSAGES = {
75
  'fa': {
76
  'start_welcome': "سلام! من یک ربات تبدیل فرمت صوتی و ویدیویی هستم.\n\nبرای شروع، از منوی زیر یک قابلیت را انتخاب کنید.",
 
124
  'channel_removed': "✅ کانال '{channel_id}' با موفقیت حذف شد.",
125
  'channel_not_found': "❗️ کانال مورد نظر یافت نشد.",
126
  'invalid_channel_id': "آیدی/لینک کانال نامعتبر است. لطفاً @username یا آیدی عددی (مانند -1001234567890) را ارسال کنید.",
127
+ 'bot_not_admin_in_channel': "ربات ادمین کانال '{channel_id}' نیست یا مجوزهای کافی برای بررسی عضویت را ندارد. لطفاً ربات را به عنوان ادمین با مجوز 'بررسی وضعیت اعضا' در کانال اضافه کنید."
128
  },
129
  'en': {
130
  'start_welcome': "Hello! I am an audio and video format conversion bot.\n\nTo start, select a feature from the menu below.",
 
178
  'channel_removed': "✅ Channel '{channel_id}' successfully removed.",
179
  'channel_not_found': "❗️ Channel not found.",
180
  'invalid_channel_id': "Invalid channel ID/link. Please send @username or numeric ID (e.g., -1001234567890).",
181
+ 'bot_not_admin_in_channel': "The bot is not an admin in channel '{channel_id}' or does not have sufficient permissions to check membership. Please add the bot as an admin with 'Check members' permission in the channel."
182
  }
183
  }
184
 
185
+ # --- 4. تمام توابع کمکی و منطقی ربات ---
186
 
187
+ def get_message(context, key, **kwargs):
188
+ """پیام مناسب را بر اساس زبان کاربر برمی‌گرداند."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  lang = context.user_data.get('language', 'fa')
190
+ # Fallback to Persian if language or key is not found
191
+ message_template = MESSAGES.get(lang, MESSAGES['fa']).get(key, MESSAGES['fa'].get(key, "Message key not found"))
192
  return message_template.format(**kwargs)
193
 
194
+ def parse_time_to_ms(time_str):
195
+ """رشته 'MM.SS' را به میلی‌ثانیه تبدیل می‌کند."""
196
+ match = re.match(r'^(\d{2})\.(\d{2})$', time_str)
197
  if not match:
198
  raise ValueError("Invalid time format")
199
  minutes, seconds = int(match.group(1)), int(match.group(2))
 
201
  raise ValueError("Seconds must be between 00 and 59")
202
  return (minutes * 60 + seconds) * 1000
203
 
204
+ async def check_user_membership(update: Update, context):
205
+ """عضویت کاربر در کانال‌های ضروری را بررسی می‌کند."""
 
206
  user_id = update.effective_user.id
207
  if not REQUIRED_CHANNELS:
208
  return True
 
209
  for channel_id in REQUIRED_CHANNELS:
210
  try:
211
  chat_member = await context.bot.get_chat_member(chat_id=channel_id, user_id=user_id)
212
  if chat_member.status not in ['member', 'administrator', 'creator']:
213
  return False
 
 
 
 
214
  except Exception as e:
215
+ print(f"خطا در بررسی عضویت کانال {channel_id}: {e}", file=sys.stderr)
216
  return False
217
  return True
218
 
219
+ async def show_membership_required_message(update: Update, context):
220
+ """پیام درخواست عضویت در کانال را نمایش می‌دهد."""
221
  keyboard = []
222
  if not REQUIRED_CHANNELS:
223
  return await show_main_menu(update, context)
224
 
225
  for channel_id in REQUIRED_CHANNELS:
226
  try:
227
+ chat = await context.bot.get_chat(chat_id=channel_id)
228
+ url = chat.invite_link or (f"https://t.me/{chat.username}" if chat.username else None)
229
+ if url:
230
+ keyboard.append([InlineKeyboardButton(f"{get_message(context, 'btn_join_channel')} {chat.title or channel_id}", url=url)])
 
 
 
 
 
231
  except Exception as e:
232
+ print(f"ناتوان در دریافت اطلاعات کانال {channel_id}: {e}", file=sys.stderr)
233
+ keyboard.append([InlineKeyboardButton(f"{get_message(context, 'btn_join_channel')} {channel_id}", callback_data=f"no_link_{channel_id}")])
 
234
 
235
  keyboard.append([InlineKeyboardButton(get_message(context, 'btn_check_membership'), callback_data='check_membership')])
236
  reply_markup = InlineKeyboardMarkup(keyboard)
237
 
238
+ message_sender = update.callback_query.edit_message_text if update.callback_query else update.effective_message.reply_text
239
+ await message_sender(get_message(context, 'membership_required'), reply_markup=reply_markup)
 
 
 
240
  return WAITING_FOR_MEMBERSHIP
241
 
242
+ async def process_feature_or_check_membership(update: Update, context, feature_func, *args, **kwargs):
243
+ """یک میان‌افزار برای بررسی عضویت قبل از اجرای قابلیت‌ها."""
244
+ if update.effective_user.id == ADMIN_ID or not REQUIRED_CHANNELS:
 
245
  return await feature_func(update, context, *args, **kwargs)
246
 
247
+ is_member = await check_user_membership(update, context)
248
+ if is_member:
249
+ context.user_data['is_member'] = True
250
+ return await feature_func(update, context, *args, **kwargs)
 
251
  else:
252
+ context.user_data['is_member'] = False
253
+ return await show_membership_required_message(update, context)
 
254
 
255
+ # --- 5. تمام توابع Handler (مدیریت دستورات و پیام‌ها) ---
256
 
257
+ async def start(update: Update, context):
258
+ """دستور /start را مدیریت کرده و درخواست انتخاب زبان می‌کند."""
259
  keyboard = [
260
  [InlineKeyboardButton("فارسی 🇮🇷", callback_data='set_lang_fa')],
261
  [InlineKeyboardButton("English 🇬🇧", callback_data='set_lang_en')]
 
267
  )
268
  return LANGUAGE_SELECTION
269
 
270
+ async def set_language(update: Update, context):
271
+ """زبان کاربر را تنظیم کرده و به منوی اصلی می‌رود."""
272
  query = update.callback_query
273
  await query.answer()
274
+ context.user_data['language'] = query.data.replace('set_lang_', '')
 
275
  await query.edit_message_text(text=get_message(context, 'start_welcome'))
276
  return await show_main_menu(update, context)
277
 
278
+ async def show_main_menu(update: Update, context):
279
+ """منوی اصلی ربات را نمایش می‌دهد."""
280
  keyboard = [
281
  [InlineKeyboardButton(get_message(context, 'btn_convert_format'), callback_data='select_convert_format')],
282
  [InlineKeyboardButton(get_message(context, 'btn_cut_audio'), callback_data='select_cut_audio')],
283
  [InlineKeyboardButton(get_message(context, 'btn_video_conversion'), callback_data='select_video_conversion')]
284
  ]
285
  reply_markup = InlineKeyboardMarkup(keyboard)
 
 
 
 
 
 
 
 
286
 
287
+ message_sender = update.callback_query.edit_message_text if update.callback_query else update.message.reply_text
288
+ await message_sender(text=get_message(context, 'main_menu_prompt'), reply_markup=reply_markup)
289
  return MAIN_MENU
290
 
291
+ async def change_format_selected(update: Update, context):
292
  query = update.callback_query
293
  await query.answer()
294
  await query.edit_message_text(text=get_message(context, 'convert_mode_active'))
295
  return CONVERT_AUDIO
296
 
297
+ async def cut_audio_selected(update: Update, context):
298
  query = update.callback_query
299
  await query.answer()
300
  await query.edit_message_text(text=get_message(context, 'cut_mode_active_file'))
301
  context.user_data.pop('audio_for_cut_path', None)
302
  return CUT_AUDIO_FILE
303
 
304
+ async def video_conversion_selected(update: Update, context):
305
  query = update.callback_query
306
  await query.answer()
307
  await query.edit_message_text(text=get_message(context, 'video_conversion_mode_active'))
308
  return VIDEO_CONVERSION_MODE
309
 
310
+ async def handle_audio(update: Update, context):
311
+ """فایل MP3 را به ویس تلگرام تبدیل می‌کند."""
 
312
  async def _perform_conversion(update, context):
313
+ file_id = update.message.audio.file_id
314
+ file_name = update.message.audio.file_name or f"audio_{file_id}.mp3"
315
+ processing_message = await update.message.reply_text(get_message(context, 'processing_start'))
316
+ download_path = os.path.join(DOWNLOAD_DIR, f"in_{file_id}.mp3")
317
+ output_ogg_path = os.path.join(OUTPUT_DIR, f"out_{file_id}.ogg")
318
 
319
  try:
320
+ new_file = await context.bot.get_file(file_id)
321
+ await new_file.download_to_drive(download_path)
322
+ await processing_message.edit_text(get_message(context, 'file_received'))
323
+
324
  audio = AudioSegment.from_file(download_path)
325
  audio.export(output_ogg_path, format="ogg", codec="libopus", parameters=["-b:a", "32k"])
326
+ await processing_message.edit_text(get_message(context, 'conversion_done'))
 
 
327
 
328
+ with open(output_ogg_path, 'rb') as f:
329
+ await update.message.reply_voice(f, reply_to_message_id=update.message.message_id)
330
+ await processing_message.delete()
331
  except Exception as e:
332
+ print(f"خطا در تبدیل MP3 به ویس: {e}", file=sys.stderr)
333
+ await processing_message.edit_text(get_message(context, 'error_mp3_to_voice') + str(e))
334
  finally:
335
  if os.path.exists(download_path): os.remove(download_path)
336
  if os.path.exists(output_ogg_path): os.remove(output_ogg_path)
 
337
  return CONVERT_AUDIO
338
  return await process_feature_or_check_membership(update, context, _perform_conversion)
339
 
340
+ async def handle_voice(update: Update, context):
341
+ """ویس تلگرام را به فایل MP3 تبدیل می‌کند."""
342
  async def _perform_conversion(update, context):
343
+ file_id = update.message.voice.file_id
344
+ processing_message = await update.message.reply_text(get_message(context, 'processing_start'))
345
+ download_path = os.path.join(DOWNLOAD_DIR, f"in_{file_id}.ogg")
346
+ output_mp3_path = os.path.join(OUTPUT_DIR, f"@{BOT_USERNAME}_{file_id}.mp3")
 
347
 
348
  try:
349
+ new_file = await context.bot.get_file(file_id)
350
+ await new_file.download_to_drive(download_path)
351
+ await processing_message.edit_text(get_message(context, 'file_received'))
352
 
353
+ audio = AudioSegment.from_file(download_path, format="ogg")
354
+ audio.export(output_mp3_path, format="mp3", tags={'album': BOT_USERNAME, 'artist': BOT_USERNAME})
355
+ await processing_message.edit_text(get_message(context, 'conversion_done'))
356
+
357
+ with open(output_mp3_path, 'rb') as f:
358
+ await update.message.reply_audio(f, caption=get_message(context, 'voice_to_mp3_caption'), reply_to_message_id=update.message.message_id)
359
+ await processing_message.delete()
 
 
 
360
  except Exception as e:
361
+ print(f"خطا در تبدیل ویس به MP3: {e}", file=sys.stderr)
362
+ await processing_message.edit_text(get_message(context, 'error_voice_to_mp3') + str(e))
363
  finally:
364
  if os.path.exists(download_path): os.remove(download_path)
365
  if os.path.exists(output_mp3_path): os.remove(output_mp3_path)
 
366
  return CONVERT_AUDIO
367
  return await process_feature_or_check_membership(update, context, _perform_conversion)
368
 
369
+ async def handle_cut_audio_file(update: Update, context):
370
+ """فایل صوتی برای برش را دریافت می‌کند."""
371
  async def _perform_file_receive(update, context):
372
  audio_file = update.message.audio or update.message.voice
373
+ file_id = audio_file.file_id
374
+ ext = 'mp3' if update.message.audio else 'ogg'
375
+ download_path = os.path.join(DOWNLOAD_DIR, f"cut_in_{file_id}.{ext}")
 
 
 
376
 
377
  try:
378
+ new_file = await context.bot.get_file(file_id)
379
+ await new_file.download_to_drive(download_path)
380
  context.user_data['audio_for_cut_path'] = download_path
381
+ context.user_data['audio_for_cut_type'] = ext
382
  await update.message.reply_text(get_message(context, 'cut_mode_active_range'))
383
  return CUT_AUDIO_RANGE
384
  except Exception as e:
385
+ print(f"خطا در دریافت فایل صوتی برای برش: {e}", file=sys.stderr)
386
  await update.message.reply_text(get_message(context, 'general_error'))
387
  return CUT_AUDIO_FILE
388
  return await process_feature_or_check_membership(update, context, _perform_file_receive)
389
 
390
+ async def handle_cut_audio_range(update: Update, context):
391
+ """بازه زمانی را دریافت کرده و صدا را برش می‌دهد."""
392
  async def _perform_cut(update, context):
393
+ time_range_str = update.message.text
394
  audio_path = context.user_data.get('audio_for_cut_path')
395
+ audio_type = context.user_data.get('audio_for_cut_type')
396
+
397
  if not audio_path or not os.path.exists(audio_path):
398
  await update.message.reply_text(get_message(context, 'no_audio_for_cut'))
399
  return CUT_AUDIO_FILE
400
 
401
+ processing_message = await update.message.reply_text(get_message(context, 'cut_processing'))
402
+ output_cut_path = os.path.join(OUTPUT_DIR, f"cut_out_{os.path.basename(audio_path)}.mp3")
403
+
404
  try:
405
+ start_time_str, end_time_str = time_range_str.split('-')
406
+ start_ms = parse_time_to_ms(start_time_str.strip())
407
+ end_ms = parse_time_to_ms(end_time_str.strip())
 
408
 
409
  if start_ms >= end_ms:
410
+ await processing_message.edit_text(get_message(context, 'invalid_time_range'))
411
  return CUT_AUDIO_RANGE
412
 
413
+ audio = AudioSegment.from_file(audio_path, format=audio_type)
414
  cut_audio = audio[start_ms:end_ms]
 
 
415
  cut_audio.export(output_cut_path, format="mp3")
416
 
417
+ await processing_message.edit_text(get_message(context, 'audio_cut_success'))
418
  with open(output_cut_path, 'rb') as f:
419
+ await update.message.reply_audio(f, caption=f"برش از {start_time_str} تا {end_time_str}")
420
+ await processing_message.delete()
 
421
  return await show_main_menu(update, context)
422
+ except ValueError:
423
+ await processing_message.edit_text(get_message(context, 'invalid_time_format'))
 
424
  return CUT_AUDIO_RANGE
425
  except Exception as e:
426
+ print(f"خطا در برش صدا: {e}", file=sys.stderr)
427
+ await processing_message.edit_text(get_message(context, 'general_error'))
428
  return await show_main_menu(update, context)
429
  finally:
430
  if os.path.exists(audio_path): os.remove(audio_path)
431
+ if os.path.exists(output_cut_path): os.remove(output_cut_path)
432
  context.user_data.pop('audio_for_cut_path', None)
433
  context.user_data.pop('audio_for_cut_type', None)
 
434
  return await process_feature_or_check_membership(update, context, _perform_cut)
435
 
436
+ async def handle_video_conversion(update: Update, context):
437
+ """تبدیل بین ویدیو معمولی و دایره‌ای را انجام می‌دهد."""
438
  async def _perform_video_conversion(update, context):
439
  is_video_note = bool(update.message.video_note)
440
+ file_to_process = update.message.video_note if is_video_note else update.message.video
 
 
 
 
 
 
 
441
 
442
+ file_id = file_to_process.file_id
443
+ download_path = os.path.join(DOWNLOAD_DIR, f"vid_in_{file_id}.mp4")
444
+ output_path = os.path.join(OUTPUT_DIR, f"vid_out_{file_id}.mp4")
445
+ processing_message = await update.message.reply_text(get_message(context, 'processing_start'))
446
+
447
  try:
448
+ new_file = await context.bot.get_file(file_id)
449
+ await new_file.download_to_drive(download_path)
450
+ await processing_message.edit_text(get_message(context, 'file_received_video'))
451
 
452
  if is_video_note:
453
+ await processing_message.edit_text(get_message(context, 'converting_video_note_to_video'))
454
+ ffmpeg_command = ['ffmpeg', '-i', download_path, '-c:v', 'libx264', '-crf', '23', '-preset', 'medium', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', output_path]
 
 
455
  else:
456
+ await processing_message.edit_text(get_message(context, 'converting_video_to_video_note'))
457
+ ffmpeg_command = ['ffmpeg', '-i', download_path, '-vf', 'crop=min(iw\,ih):min(iw\,ih),scale=640:640', '-c:v', 'libx264', '-crf', '28', '-preset', 'veryfast', '-an', output_path]
458
+
459
+ subprocess.run(ffmpeg_command, check=True, capture_output=True)
460
+ await processing_message.edit_text(get_message(context, 'conversion_done_video'))
461
+
462
+ with open(output_path, 'rb') as f:
463
+ if is_video_note:
464
+ await update.message.reply_video(f, caption=get_message(context, 'video_note_to_video_caption'))
465
+ else:
466
+ await update.message.reply_video_note(f)
467
+ await processing_message.delete()
 
468
  except subprocess.CalledProcessError as e:
469
+ print(f"خطای FFmpeg: {e.stderr.decode()}", file=sys.stderr)
470
+ await processing_message.edit_text(get_message(context, 'error_video_conversion') + "خطای FFmpeg")
471
  except Exception as e:
472
+ print(f"خطای کلی تبدیل ویدیو: {e}", file=sys.stderr)
473
+ await processing_message.edit_text(get_message(context, 'error_video_conversion') + str(e))
474
  finally:
475
  if os.path.exists(download_path): os.remove(download_path)
476
  if os.path.exists(output_path): os.remove(output_path)
 
477
  return VIDEO_CONVERSION_MODE
478
  return await process_feature_or_check_membership(update, context, _perform_video_conversion)
479
 
480
+ async def check_membership_callback(update: Update, context):
481
+ """دکمه 'بررسی عضویت' را مدیریت می‌کند."""
482
  query = update.callback_query
483
  await query.answer()
484
  is_member = await check_user_membership(update, context)
485
  if is_member:
486
+ context.user_data['is_member'] = True
487
  await query.edit_message_text(get_message(context, 'membership_success'))
488
  return await show_main_menu(update, context)
489
  else:
490
  await query.answer(get_message(context, 'membership_failed'), show_alert=True)
491
  return WAITING_FOR_MEMBERSHIP
492
 
493
+ async def admin_link_command(update: Update, context):
494
+ """دستور /link برای ادمین."""
 
495
  if update.effective_user.id != ADMIN_ID:
496
  await update.message.reply_text(get_message(context, 'not_admin'))
497
  return ConversationHandler.END
 
498
  keyboard = [
499
  [InlineKeyboardButton(get_message(context, 'btn_add_channel'), callback_data='admin_add_channel')],
500
  [InlineKeyboardButton(get_message(context, 'btn_list_channels'), callback_data='admin_list_channels')]
501
  ]
502
+ await update.message.reply_text(get_message(context, 'admin_menu_prompt'), reply_markup=InlineKeyboardMarkup(keyboard))
 
503
  return ADMIN_MENU
504
 
505
+ async def admin_add_channel_prompt(update: Update, context):
506
  query = update.callback_query
507
  await query.answer()
508
  await query.edit_message_text(get_message(context, 'send_channel_link'))
509
  return ADD_CHANNEL
510
 
511
+ async def admin_handle_add_channel(update: Update, context):
512
+ """کانال جدید را از ادمین دریافت و اضافه می‌کند."""
513
  channel_input = update.message.text.strip()
514
  if not (channel_input.startswith('@') or channel_input.startswith('-100')):
515
  await update.message.reply_text(get_message(context, 'invalid_channel_id'))
516
  return ADD_CHANNEL
 
517
  try:
518
+ await context.bot.get_chat(channel_input) # Check if bot can access the channel
519
+ if channel_input not in REQUIRED_CHANNELS:
520
+ REQUIRED_CHANNELS.append(channel_input)
521
+ save_required_channels(REQUIRED_CHANNELS)
522
+ await update.message.reply_text(get_message(context, 'channel_added', channel_id=channel_input))
523
+ else:
524
+ await update.message.reply_text(get_message(context, 'channel_already_exists'))
525
  except Exception as e:
526
+ await update.message.reply_text(get_message(context, 'bot_not_admin_in_channel', channel_id=channel_input) + f"\nError: {e}")
527
+ return await admin_link_command(update, context)
 
 
 
 
 
 
 
 
 
 
 
 
528
 
529
+ async def admin_list_channels(update: Update, context):
530
+ """لیست کانال‌ها را با دکمه حذف نمایش می‌دهد."""
531
  query = update.callback_query
532
  await query.answer()
533
  if not REQUIRED_CHANNELS:
534
  await query.edit_message_text(get_message(context, 'no_channels_configured'))
535
  return ADMIN_MENU
 
536
  keyboard = []
537
  for channel_id in REQUIRED_CHANNELS:
538
  keyboard.append([InlineKeyboardButton(f"{get_message(context, 'btn_remove_channel')} {channel_id}", callback_data=f'remove_channel_{channel_id}')])
539
+ await query.edit_message_text(get_message(context, 'channel_list_prompt'), reply_markup=InlineKeyboardMarkup(keyboard))
 
 
540
  return LIST_REMOVE_CHANNELS
541
 
542
+ async def admin_handle_remove_channel(update: Update, context):
543
+ """یک کانال را از لیست حذف می‌کند."""
544
  query = update.callback_query
545
  await query.answer()
546
  channel_id_to_remove = query.data.replace('remove_channel_', '')
 
547
  if channel_id_to_remove in REQUIRED_CHANNELS:
548
  REQUIRED_CHANNELS.remove(channel_id_to_remove)
549
  save_required_channels(REQUIRED_CHANNELS)
550
  await query.edit_message_text(get_message(context, 'channel_removed', channel_id=channel_id_to_remove))
551
  else:
552
  await query.edit_message_text(get_message(context, 'channel_not_found'))
553
+ return await admin_link_command(update, context)
 
 
 
 
 
 
 
 
 
 
554
 
555
+ async def cancel(update: Update, context):
556
+ """عملیات را لغو کرده و به منوی اصلی برمی‌گردد."""
557
+ message = update.message or update.callback_query.message
558
+ await message.reply_text(get_message(context, 'cancel_message'))
559
+ context.user_data.clear()
560
  return await show_main_menu(update, context)
561
 
562
+ async def error_handler(update: object, context: object) -> None:
563
+ """لاگ کردن خطاها."""
564
  print(f"Update {update} caused error {context.error}", file=sys.stderr)
 
 
565
 
566
+ # --- 6. بخش مربوط به Flask و Webhook ---
567
 
 
568
  app = Flask(__name__)
 
 
569
  _application_instance = None
570
 
571
  async def get_telegram_application():
572
+ """اپلیکیشن تلگرام را مقداردهی اولیه کرده و ConversationHandler کامل را به آن اضافه می‌کند."""
573
  global _application_instance
574
  if _application_instance is None:
575
  print("Initializing Telegram bot Application for this worker...")
576
+ application = Application.builder().token(TOKEN).build()
 
577
 
578
  conv_handler = ConversationHandler(
579
+ entry_points=[CommandHandler("start", start), CommandHandler("link", admin_link_command)],
 
 
 
580
  states={
581
  LANGUAGE_SELECTION: [CallbackQueryHandler(set_language, pattern='^set_lang_')],
582
  MAIN_MENU: [
 
585
  CallbackQueryHandler(video_conversion_selected, pattern='^select_video_conversion$'),
586
  ],
587
  CONVERT_AUDIO: [
588
+ MessageHandler(filters.AUDIO & ~filters.COMMAND, handle_audio),
589
+ MessageHandler(filters.VOICE & ~filters.COMMAND, handle_voice),
590
  ],
591
+ CUT_AUDIO_FILE: [MessageHandler((filters.AUDIO | filters.VOICE) & ~filters.COMMAND, handle_cut_audio_file)],
592
  CUT_AUDIO_RANGE: [MessageHandler(filters.TEXT & ~filters.COMMAND, handle_cut_audio_range)],
593
+ VIDEO_CONVERSION_MODE: [MessageHandler((filters.VIDEO | filters.VIDEO_NOTE) & ~filters.COMMAND, handle_video_conversion)],
594
  WAITING_FOR_MEMBERSHIP: [CallbackQueryHandler(check_membership_callback, pattern='^check_membership$')],
595
  ADMIN_MENU: [
596
  CallbackQueryHandler(admin_add_channel_prompt, pattern='^admin_add_channel$'),
 
599
  ADD_CHANNEL: [MessageHandler(filters.TEXT & ~filters.COMMAND, admin_handle_add_channel)],
600
  LIST_REMOVE_CHANNELS: [CallbackQueryHandler(admin_handle_remove_channel, pattern='^remove_channel_')],
601
  },
602
+ fallbacks=[CommandHandler("cancel", cancel), CommandHandler("start", start)],
 
 
 
603
  allow_reentry=True
604
  )
605
 
606
+ application.add_handler(conv_handler)
607
+ application.add_error_handler(error_handler)
608
+ await application.initialize()
609
+ _application_instance = application
 
610
  print("Telegram bot Application initialized.")
611
  return _application_instance
612
 
 
613
  @app.route("/")
614
+ def index():
615
+ """یک صفحه ساده برای اطمینان از بالا بودن سرور."""
616
+ return "Hello, I am the Telegram bot server. The bot is running."
617
 
618
  @app.route("/webhook", methods=["POST"])
619
  async def webhook():
620
+ """این مسیر آپدیت‌ها را از تلگرام دریافت می‌کند."""
621
+ try:
622
+ application = await get_telegram_application()
623
+ update = Update.de_json(request.get_json(force=True), application.bot)
624
+ await application.process_update(update)
625
+ return "ok"
626
+ except Exception as e:
627
+ print(f"Error in webhook: {e}", file=sys.stderr)
628
+ return "error", 500
629
 
630
  @app.route("/set_webhook", methods=["GET"])
631
  async def set_webhook_route():
632
+ """یک مسیر برای تنظیم وبهوک به صورت خودکار."""
 
633
  webhook_url = os.getenv("WEBHOOK_URL")
634
  if not webhook_url:
635
+ return jsonify({"status": "error", "message": "WEBHOOK_URL environment variable not set."}), 500
636
+
637
  if not webhook_url.endswith("/webhook"):
638
+ webhook_url = f"{webhook_url.rstrip('/')}/webhook"
639
 
640
  try:
641
+ application = await get_telegram_application()
642
  await application.bot.set_webhook(url=webhook_url)
 
643
  return jsonify({"status": "success", "message": f"Webhook set to {webhook_url}"})
644
  except Exception as e:
645
  print(f"Failed to set webhook: {e}", file=sys.stderr)
646
  return jsonify({"status": "error", "message": f"Failed to set webhook: {e}"}), 500
647
 
 
648
  if __name__ == "__main__":
649
+ # این بخش برای اجرای محلی (local) سرور Flask است
650
+ # در محیط‌های production مانند Hugging Face، یک وب سرور مانند Gunicorn این فایل را اجرا می‌کند.
651
+ app.run(host='0.0.0.0', port=int(os.environ.get("PORT", 7860)))