akdNIKY commited on
Commit
409c69b
·
verified ·
1 Parent(s): 529f69a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +831 -31
app.py CHANGED
@@ -4,10 +4,8 @@ import re
4
  import subprocess
5
  import json
6
  from io import BytesIO
7
-
8
  from flask import Flask, request, jsonify
9
-
10
- from telegram import Update, InputFile, InlineKeyboardButton, InlineKeyboardMarkup
11
  from telegram.ext import (
12
  Application,
13
  CommandHandler,
@@ -16,8 +14,9 @@ from telegram.ext import (
16
  filters,
17
  ConversationHandler
18
  )
19
- from telegram.constants import ParseMode
20
  from pydub import AudioSegment
 
21
 
22
  # Environment variables
23
  TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
@@ -28,27 +27,28 @@ if not TOKEN:
28
  BOT_USERNAME = os.getenv("TELEGRAM_BOT_USERNAME", "Voice2mp3_RoBot")
29
  ADMIN_ID = int(os.getenv("TELEGRAM_ADMIN_ID", "0"))
30
  if ADMIN_ID == 0:
31
- print("Warning: TELEGRAM_ADMIN_ID not set or 0.", file=sys.stderr)
32
 
33
  DOWNLOAD_DIR = "/tmp/downloads"
34
  OUTPUT_DIR = "/tmp/outputs"
35
  CHANNELS_FILE = "channels.json"
 
36
  os.makedirs(DOWNLOAD_DIR, exist_ok=True)
37
  os.makedirs(OUTPUT_DIR, exist_ok=True)
38
 
39
  # Telegram states
40
- LANGUAGE_SELECTION, MAIN_MENU, CONVERT_AUDIO, CUT_AUDIO_FILE, CUT_AUDIO_RANGE, \
41
- VIDEO_CONVERSION_MODE, WAITING_FOR_MEMBERSHIP, ADMIN_MENU, ADD_CHANNEL, LIST_REMOVE_CHANNELS = range(10)
 
42
 
43
  # Load and save channels
44
-
45
  def load_required_channels():
46
  if os.path.exists(CHANNELS_FILE):
47
  try:
48
  with open(CHANNELS_FILE, 'r', encoding='utf-8') as f:
49
  return json.load(f)
50
  except json.JSONDecodeError:
51
- print(f"Error decoding JSON from {CHANNELS_FILE}.", file=sys.stderr)
52
  return []
53
 
54
  def save_required_channels(channels):
@@ -57,7 +57,736 @@ def save_required_channels(channels):
57
 
58
  REQUIRED_CHANNELS = load_required_channels()
59
 
60
- # Telegram application init
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  app = Flask(__name__)
62
  _application_instance = None
63
 
@@ -67,22 +796,97 @@ async def get_telegram_application():
67
  print("Initializing Telegram bot Application for this worker...")
68
  _app = Application.builder().token(TOKEN).build()
69
 
 
70
  conv_handler = ConversationHandler(
71
  entry_points=[CommandHandler("start", start)],
72
- states={},
73
- fallbacks=[CommandHandler("cancel", cancel), CommandHandler("start", start)],
74
- allow_reentry=True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  )
76
-
77
  _app.add_handler(conv_handler)
78
  _app.add_error_handler(error_handler)
79
- await _app.initialize() # <-- Fix: proper initialization
80
- _application_instance = _app
 
 
 
 
 
 
 
81
  print("Telegram bot Application initialized.")
82
  return _application_instance
83
 
84
  # Flask routes
85
-
86
  @app.route("/")
87
  async def index():
88
  return jsonify({"status": "ok", "message": "Telegram bot is running."})
@@ -100,10 +904,8 @@ async def set_webhook_route():
100
  webhook_url = os.getenv("WEBHOOK_URL")
101
  if not webhook_url:
102
  return jsonify({"status": "error", "message": "WEBHOOK_URL not set."}), 500
103
-
104
  if not webhook_url.endswith("/webhook"):
105
  webhook_url = f"{webhook_url.rstrip('/')}/webhook"
106
-
107
  try:
108
  await application.bot.set_webhook(url=webhook_url)
109
  return jsonify({"status": "success", "message": f"Webhook set to {webhook_url}"})
@@ -115,15 +917,13 @@ async def set_webhook_route():
115
  if __name__ == "__main__":
116
  import asyncio
117
  print("Starting Flask app in local mode...")
118
- asyncio.run(get_telegram_application())
119
- app.run(host='0.0.0.0', port=int(os.environ.get("PORT", 7860)))
120
-
121
- # Placeholder handlers (implement as needed)
122
- async def start(update, context):
123
- await update.message.reply_text("Bot started!")
124
-
125
- async def cancel(update, context):
126
- await update.message.reply_text("Operation cancelled.")
127
-
128
- async def error_handler(update, context):
129
- print(f"Error: {context.error}", file=sys.stderr)
 
4
  import subprocess
5
  import json
6
  from io import BytesIO
 
7
  from flask import Flask, request, jsonify
8
+ from telegram import Update, InputFile, InlineKeyboardButton, InlineKeyboardMarkup, BotCommand
 
9
  from telegram.ext import (
10
  Application,
11
  CommandHandler,
 
14
  filters,
15
  ConversationHandler
16
  )
17
+ from telegram.constants import ParseMode, ChatMemberStatus
18
  from pydub import AudioSegment
19
+ from pydub.silence import split_on_silence # Optional: for advanced audio processing if needed
20
 
21
  # Environment variables
22
  TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
 
27
  BOT_USERNAME = os.getenv("TELEGRAM_BOT_USERNAME", "Voice2mp3_RoBot")
28
  ADMIN_ID = int(os.getenv("TELEGRAM_ADMIN_ID", "0"))
29
  if ADMIN_ID == 0:
30
+ print("Warning: TELEGRAM_ADMIN_ID not set or 0. Admin features will be disabled.", file=sys.stderr)
31
 
32
  DOWNLOAD_DIR = "/tmp/downloads"
33
  OUTPUT_DIR = "/tmp/outputs"
34
  CHANNELS_FILE = "channels.json"
35
+
36
  os.makedirs(DOWNLOAD_DIR, exist_ok=True)
37
  os.makedirs(OUTPUT_DIR, exist_ok=True)
38
 
39
  # Telegram states
40
+ LANGUAGE_SELECTION, MAIN_MENU, CONVERT_AUDIO_CHOICE, CONVERT_VOICE_TO_MP3, CONVERT_MP3_TO_VOICE, \
41
+ CUT_AUDIO_FILE, CUT_AUDIO_RANGE, VIDEO_CONVERSION_MODE, CONVERT_VIDEO_TO_NOTE, CONVERT_NOTE_TO_VIDEO, \
42
+ WAITING_FOR_MEMBERSHIP, ADMIN_MENU, ADD_CHANNEL, LIST_REMOVE_CHANNELS = range(14)
43
 
44
  # Load and save channels
 
45
  def load_required_channels():
46
  if os.path.exists(CHANNELS_FILE):
47
  try:
48
  with open(CHANNELS_FILE, 'r', encoding='utf-8') as f:
49
  return json.load(f)
50
  except json.JSONDecodeError:
51
+ print(f"Error decoding JSON from {CHANNELS_FILE}. Returning empty list.", file=sys.stderr)
52
  return []
53
 
54
  def save_required_channels(channels):
 
57
 
58
  REQUIRED_CHANNELS = load_required_channels()
59
 
60
+ # --- Language & Messages ---
61
+ MESSAGES = {
62
+ "en": {
63
+ "welcome": "Hello! Please choose your language:",
64
+ "language_chosen": "Language set to English.",
65
+ "main_menu": "Welcome to the Audio/Video Converter Bot!\nWhat would you like to do?",
66
+ "audio_menu_text": "Choose an audio conversion option:",
67
+ "cut_audio_intro": "Please send me an audio file (MP3 or Voice message) you want to cut.",
68
+ "cut_audio_range_prompt": "Now, send me the time range in 'MM.SS-MM.SS' format (e.g., 00.21-00.54).\n\nMax duration for cutting is 5 minutes for MP3s and 2 minutes for Voice messages. If your file is longer, only the first part will be processed.",
69
+ "invalid_time_format": "Invalid time format. Please use 'MM.SS-MM.SS' (e.g., 00.21-00.54).",
70
+ "processing": "Processing your request, please wait...",
71
+ "audio_converted": "Here is your converted audio:",
72
+ "audio_cut_success": "Here is your trimmed audio:",
73
+ "no_audio_file": "Please send an audio file (MP3 or Voice message) to proceed.",
74
+ "video_menu_text": "Choose a video conversion option:",
75
+ "not_a_video": "Please send a video file to convert.",
76
+ "video_note_converted": "Here is your converted video note:",
77
+ "video_converted": "Here is your converted video:",
78
+ "membership_required": "To use this bot without limitations, please join the following channels:",
79
+ "membership_check_button": "I have joined!",
80
+ "admin_menu": "Welcome to the Admin Panel! What do you want to do?",
81
+ "add_channel_prompt": "Please send me the **username** or **ID** of the channel you want to add (e.g., `@mychannel` or `-1001234567890`).\n\n**Important:** The bot must be an admin in this channel with 'Invite Users' and 'Get Chat Member' permissions to verify membership.",
82
+ "channel_already_added": "This channel is already in the list.",
83
+ "channel_added_success": "Channel '{channel_name}' added successfully! ID: `{channel_id}`\n\n**Bot's Admin Status:** {status}",
84
+ "channel_not_found": "Could not find the channel. Please ensure the username/ID is correct and the bot is a member of the channel.",
85
+ "channel_permission_error": "Bot is not an admin or lacks necessary permissions (Invite Users, Get Chat Member) in this channel.",
86
+ "list_channels": "Current required channels:\n{channels_list}",
87
+ "no_channels_added": "No required channels added yet.",
88
+ "select_channel_to_remove": "Select a channel to remove:",
89
+ "channel_removed_success": "Channel '{channel_name}' removed successfully.",
90
+ "cancel_operation": "Operation cancelled. Returning to main menu.",
91
+ "unknown_command": "Unknown command. Please use the menu buttons or /start.",
92
+ "error_occurred": "An error occurred while processing your request. Please try again later.",
93
+ "not_admin": "You are not authorized to access the admin panel.",
94
+ "admin_permission_check": "Checking bot's admin status in channel {channel_name}...",
95
+ "bot_is_admin": "✅ Bot is an admin with required permissions.",
96
+ "bot_not_admin": "❌ Bot is not an admin or lacks 'Invite Users' and 'Get Chat Member' permissions.",
97
+ "bot_cannot_check_admin": "⚠️ Could not verify bot's admin status in this channel. Please ensure the bot is an admin with 'Invite Users' and 'Get Chat Member' permissions."
98
+ },
99
+ "fa": {
100
+ "welcome": "سلام! لطفاً زبان خود را انتخاب کنید:",
101
+ "language_chosen": "زبان به فارسی تنظیم شد.",
102
+ "main_menu": "به ربات تبدیل صدا/ویدیو خوش آمدید!\nچه کاری می‌خواهید انجام دهید؟",
103
+ "audio_menu_text": "یک گزینه برای تبدیل صدا انتخاب کنید:",
104
+ "cut_audio_intro": "لطفاً یک فایل صوتی (MP3 یا ویس) که می‌خواهید برش دهید را برای من بفرستید.",
105
+ "cut_audio_range_prompt": "اکنون، محدوده زمانی مورد نظر را با فرمت 'دقیقه.ثانیه-دقیقه.ثانیه' (مثال: 00.21-00.54) ارسال کنید.\n\nحداکثر زمان برش برای فایل‌های MP3 پنج دقیقه و برای ویس‌های تلگرام دو دقیقه است. اگر فایل شما طولانی‌تر باشد، فقط بخش ابتدایی آن پردازش می‌شود.",
106
+ "invalid_time_format": "فرمت زمان نامعتبر است. لطفاً از فرمت 'MM.SS-MM.SS' (مثال: 00.21-00.54) استفاده کنید.",
107
+ "processing": "در حال پردازش درخواست شما، لطفاً منتظر بمانید...",
108
+ "audio_converted": "فایل صوتی تبدیل شده شما:",
109
+ "audio_cut_success": "فایل صوتی برش خورده شما:",
110
+ "no_audio_file": "لطفاً یک فایل صوتی (MP3 یا ویس) برای ادامه ارسال کنید.",
111
+ "video_menu_text": "یک گزینه برای تبدیل ویدیو انتخاب کنید:",
112
+ "not_a_video": "لطفاً یک فایل ویدیویی برای تبدیل ارسال کنید.",
113
+ "video_note_converted": "ویدیوی دایره‌ای تبدیل شده شما:",
114
+ "video_converted": "ویدیوی تبدیل شده شما:",
115
+ "membership_required": "برای استفاده نامحدود از ربات، لطفاً در کانال‌های زیر عضو شوید:",
116
+ "membership_check_button": "عضو شدم!",
117
+ "admin_menu": "به پنل مدیریت خوش آمدید! چه کاری می‌خواهید انجام دهید؟",
118
+ "add_channel_prompt": "لطفاً **یوزرنیم** یا **آیدی** کانالی که می‌خواهید اضافه کنید را ارسال کنید (مثال: `@mychannel` یا `-1001234567890`).\n\n**مهم:** ربات باید در این کانال ادمین باشد و دسترسی‌های 'دعوت کاربر' و 'دریافت اطلاعات اعضا' را داشته باشد تا بتواند عضویت را بررسی کند.",
119
+ "channel_already_added": "این کانال از قبل اضافه شده است.",
120
+ "channel_added_success": "کانال '{channel_name}' با موفقیت اضافه شد! آیدی: `{channel_id}`\n\n**وضعیت ادمین ربات:** {status}",
121
+ "channel_not_found": "کانال پیدا نشد. لطفاً مطمئن شوید یوزرنیم/آیدی صحیح است و ربات عضو کانال است.",
122
+ "channel_permission_error": "ربات ادمین نیست یا دسترسی‌های لازم (دعوت کاربر، دریافت اطلاعات اعضا) را در این کانال ندارد.",
123
+ "list_channels": "کانال‌های اجباری فعلی:\n{channels_list}",
124
+ "no_channels_added": "هنوز کانالی برای عضویت اجباری اضافه نشده است.",
125
+ "select_channel_to_remove": "یک کانال برای حذف انتخاب کنید:",
126
+ "channel_removed_success": "کانال '{channel_name}' با موفقیت حذف شد.",
127
+ "cancel_operation": "عملیات لغو شد. بازگشت به منوی اصلی.",
128
+ "unknown_command": "دستور نامشخص. لطفاً از دکمه‌های منو یا /start استفاده کنید.",
129
+ "error_occurred": "خطایی در پردازش درخواست شما رخ داد. لطفاً بعداً امتحان کنید.",
130
+ "not_admin": "شما اجازه دسترسی به پنل ادمین را ندارید.",
131
+ "admin_permission_check": "در حال بررسی وضعیت ادمین ربا�� در کانال {channel_name}...",
132
+ "bot_is_admin": "✅ ربات ادمین است و دسترسی‌های لازم را دارد.",
133
+ "bot_not_admin": "❌ ربات ادمین نیست یا دسترسی‌های 'دعوت کاربر' و 'دریافت اطلاعات اعضا' را ندارد.",
134
+ "bot_cannot_check_admin": "⚠️ امکان بررسی وضعیت ادمین ربات در این کانال وجود ندارد. لطفاً اطمینان حاصل کنید که ربات ادمین است و دسترسی‌های 'دعوت کاربر' و 'دریافت اطلاعات اعضا' را دارد."
135
+ }
136
+ }
137
+
138
+ # --- Utility Functions ---
139
+ async def get_user_language(user_id, context):
140
+ user_data = context.user_data
141
+ if 'language' not in user_data:
142
+ # Default to Persian if not set
143
+ user_data['language'] = 'fa'
144
+ return user_data['language']
145
+
146
+ def get_message(user_lang, key, **kwargs):
147
+ return MESSAGES.get(user_lang, MESSAGES["en"]).get(key, MESSAGES["en"][key]).format(**kwargs)
148
+
149
+ async def check_membership(update, context):
150
+ user_id = update.effective_user.id
151
+ user_lang = await get_user_language(user_id, context)
152
+
153
+ if not REQUIRED_CHANNELS:
154
+ return True # No channels required, proceed
155
+
156
+ is_member = True
157
+ not_joined_channels = []
158
+
159
+ for channel_info in REQUIRED_CHANNELS:
160
+ channel_id = channel_info['id']
161
+ try:
162
+ member = await context.bot.get_chat_member(chat_id=channel_id, user_id=user_id)
163
+ if member.status not in [ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER]:
164
+ is_member = False
165
+ not_joined_channels.append(channel_info)
166
+ except Exception as e:
167
+ print(f"Error checking membership for channel {channel_id}: {e}", file=sys.stderr)
168
+ # If there's an error (e.g., bot not in channel, channel not found), assume not joined
169
+ is_member = False
170
+ not_joined_channels.append(channel_info)
171
+
172
+ if not is_member:
173
+ keyboard_buttons = []
174
+ message_text = get_message(user_lang, "membership_required") + "\n"
175
+ for i, channel in enumerate(not_joined_channels):
176
+ channel_name = channel.get('name', channel['id'])
177
+ message_text += f"- {channel_name}: {channel['link']}\n"
178
+
179
+ keyboard_buttons.append([InlineKeyboardButton(get_message(user_lang, "membership_check_button"), callback_data="check_membership_again")])
180
+
181
+ reply_markup = InlineKeyboardMarkup(keyboard_buttons)
182
+ await update.message.reply_text(message_text, reply_markup=reply_markup, parse_mode=ParseMode.HTML)
183
+ return False
184
+ return True
185
+
186
+ async def send_main_menu(update, context):
187
+ user_lang = await get_user_language(update.effective_user.id, context)
188
+ keyboard = [
189
+ [InlineKeyboardButton(get_message(user_lang, "audio_menu"), callback_data="audio_menu")],
190
+ [InlineKeyboardButton(get_message(user_lang, "video_menu"), callback_data="video_menu")]
191
+ ]
192
+ reply_markup = InlineKeyboardMarkup(keyboard)
193
+ await update.message.reply_text(get_message(user_lang, "main_menu"), reply_markup=reply_markup)
194
+ return MAIN_MENU
195
+
196
+ # --- Audio Processing ---
197
+ async def convert_ogg_to_mp3(ogg_file_path):
198
+ output_path = os.path.join(OUTPUT_DIR, os.path.basename(ogg_file_path).replace(".ogg", ".mp3"))
199
+ audio = AudioSegment.from_ogg(ogg_file_path)
200
+ audio.export(output_path, format="mp3")
201
+ return output_path
202
+
203
+ async def convert_mp3_to_ogg_opus(mp3_file_path):
204
+ output_path = os.path.join(OUTPUT_DIR, os.path.basename(mp3_file_path).replace(".mp3", ".ogg"))
205
+ audio = AudioSegment.from_mp3(mp3_file_path)
206
+ # Telegram voice notes are OGG Opus. Use a reasonable bitrate.
207
+ # pydub's default ogg export uses libvorbis. For opus, ffmpeg might be needed directly.
208
+ # Let's use ffmpeg via subprocess for explicit Opus encoding.
209
+ command = [
210
+ "ffmpeg", "-i", mp3_file_path,
211
+ "-c:a", "libopus", "-b:a", "32k", # Typical Opus bitrate for voice notes
212
+ "-vbr", "on", "-compression_level", "10", # VBR on, max compression
213
+ "-map_metadata", "-1", # Remove metadata
214
+ output_path
215
+ ]
216
+ try:
217
+ subprocess.run(command, check=True, capture_output=True)
218
+ return output_path
219
+ except subprocess.CalledProcessError as e:
220
+ print(f"Error converting MP3 to OGG Opus with ffmpeg: {e.stderr.decode()}", file=sys.stderr)
221
+ raise
222
+
223
+ async def cut_audio_segment(input_file_path, start_ms, end_ms):
224
+ output_path = os.path.join(OUTPUT_DIR, f"cut_{os.path.basename(input_file_path)}")
225
+ audio = AudioSegment.from_file(input_file_path)
226
+ cut_audio = audio[start_ms:end_ms]
227
+ cut_audio.export(output_path, format="mp3") # Always export cut audio as MP3
228
+ return output_path
229
+
230
+ # --- Video Processing ---
231
+ async def convert_video_to_note(input_file_path):
232
+ output_path = os.path.join(OUTPUT_DIR, f"note_{os.path.basename(input_file_path).replace('.', '_note.')}")
233
+ # Convert to circle video (video note). Requires specific resolution (e.g., 640x640), no audio, and short duration.
234
+ # Telegram usually expects square video for notes, no audio track, and specific bitrate/codec.
235
+ # Assuming input might be non-square, we can crop/scale.
236
+ # For simplicity, let's just convert format and ensure it's square and short (e.g., < 1 min)
237
+ # We will use ffmpeg directly for better control.
238
+
239
+ # Try to get input video duration
240
+ try:
241
+ probe_command = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", input_file_path]
242
+ duration_str = subprocess.check_output(probe_command, universal_newlines=True).strip()
243
+ duration_seconds = float(duration_str)
244
+ if duration_seconds > 60: # Limit to 60 seconds for video notes
245
+ print("Video too long for note, trimming to 60 seconds.")
246
+ trim_duration = 60
247
+ else:
248
+ trim_duration = duration_seconds
249
+ except Exception as e:
250
+ print(f"Could not get video duration, assuming 60s max: {e}", file=sys.stderr)
251
+ trim_duration = 60 # Default to 60s max if duration cannot be determined
252
+
253
+ command = [
254
+ "ffmpeg", "-i", input_file_path,
255
+ "-vf", "scale='min(iw,ih):min(iw,ih)',crop='min(iw,ih):min(iw,ih)'", # Crop to square
256
+ "-t", str(trim_duration), # Trim to max duration
257
+ "-c:v", "libx264", "-b:v", "800k", # H.264, target bitrate
258
+ "-preset", "ultrafast", # Faster encoding
259
+ "-an", # No audio for video notes
260
+ "-f", "mp4", # Ensure MP4 format
261
+ output_path
262
+ ]
263
+ try:
264
+ subprocess.run(command, check=True, capture_output=True)
265
+ return output_path
266
+ except subprocess.CalledProcessError as e:
267
+ print(f"Error converting video to note with ffmpeg: {e.stderr.decode()}", file=sys.stderr)
268
+ raise
269
+
270
+ async def convert_note_to_video(input_file_path):
271
+ # A video note is essentially a video with specific Telegram metadata.
272
+ # To convert to "normal" video, often just changing container/codec or removing some metadata is enough.
273
+ # For simplicity, we'll convert it to a standard MP4 with a common codec.
274
+ output_path = os.path.join(OUTPUT_DIR, f"video_{os.path.basename(input_file_path).replace('.', '_video.')}.mp4")
275
+ command = [
276
+ "ffmpeg", "-i", input_file_path,
277
+ "-c:v", "libx264", "-c:a", "aac", # Common video/audio codecs
278
+ "-b:a", "128k", "-b:v", "1500k", # Common bitrates
279
+ "-movflags", "faststart", # For web streaming optimization
280
+ "-f", "mp4", # Ensure MP4 format
281
+ output_path
282
+ ]
283
+ try:
284
+ subprocess.run(command, check=True, capture_output=True)
285
+ return output_path
286
+ except subprocess.CalledProcessError as e:
287
+ print(f"Error converting note to video with ffmpeg: {e.stderr.decode()}", file=sys.stderr)
288
+ raise
289
+
290
+ # --- Telegram Bot Handlers ---
291
+
292
+ # Entry point for the bot: Language selection
293
+ async def start(update: Update, context):
294
+ user_lang = await get_user_language(update.effective_user.id, context) # Get current lang, default if not set
295
+ if 'language_chosen' not in context.user_data: # Only show language selection on first start
296
+ keyboard = [
297
+ [InlineKeyboardButton("English 🇬🇧", callback_data="set_lang_en")],
298
+ [InlineKeyboardButton("فارسی 🇮🇷", callback_data="set_lang_fa")]
299
+ ]
300
+ reply_markup = InlineKeyboardMarkup(keyboard)
301
+ await update.message.reply_text(get_message(user_lang, "welcome"), reply_markup=reply_markup)
302
+ return LANGUAGE_SELECTION
303
+ else:
304
+ # If language is already set, check membership and go to main menu
305
+ if await check_membership(update, context):
306
+ return await send_main_menu(update, context)
307
+ return WAITING_FOR_MEMBERSHIP
308
+
309
+ async def set_language(update: Update, context):
310
+ query = update.callback_query
311
+ await query.answer()
312
+ lang = query.data.split('_')[2]
313
+ context.user_data['language'] = lang
314
+ context.user_data['language_chosen'] = True # Mark language as chosen
315
+
316
+ user_lang = await get_user_language(query.from_user.id, context)
317
+ await query.edit_message_text(get_message(user_lang, "language_chosen"))
318
+
319
+ # After setting language, check membership
320
+ if await check_membership(query, context): # Pass query for message update
321
+ return await send_main_menu(query, context)
322
+ return WAITING_FOR_MEMBERSHIP
323
+
324
+ async def check_membership_callback(update: Update, context):
325
+ query = update.callback_query
326
+ await query.answer()
327
+
328
+ if await check_membership(query, context):
329
+ await query.edit_message_text(get_message(await get_user_language(query.from_user.id, context), "main_menu"),
330
+ reply_markup=InlineKeyboardMarkup([
331
+ [InlineKeyboardButton(get_message(await get_user_language(query.from_user.id, context), "audio_menu"), callback_data="audio_menu")],
332
+ [InlineKeyboardButton(get_message(await get_user_language(query.from_user.id, context), "video_menu"), callback_data="video_menu")]
333
+ ]))
334
+ return MAIN_MENU
335
+ return WAITING_FOR_MEMBERSHIP # Still waiting if not joined
336
+
337
+ async def main_menu_callback(update: Update, context):
338
+ query = update.callback_query
339
+ await query.answer()
340
+ user_lang = await get_user_language(query.from_user.id, context)
341
+
342
+ if not await check_membership(query, context):
343
+ return WAITING_FOR_MEMBERSHIP
344
+
345
+ if query.data == "audio_menu":
346
+ keyboard = [
347
+ [InlineKeyboardButton(get_message(user_lang, "convert_voice_to_mp3"), callback_data="convert_voice_to_mp3")],
348
+ [InlineKeyboardButton(get_message(user_lang, "convert_mp3_to_voice"), callback_data="convert_mp3_to_voice")],
349
+ [InlineKeyboardButton(get_message(user_lang, "cut_audio"), callback_data="cut_audio")],
350
+ [InlineKeyboardButton(get_message(user_lang, "back_to_main_menu"), callback_data="main_menu")]
351
+ ]
352
+ reply_markup = InlineKeyboardMarkup(keyboard)
353
+ await query.edit_message_text(get_message(user_lang, "audio_menu_text"), reply_markup=reply_markup)
354
+ return CONVERT_AUDIO_CHOICE
355
+
356
+ elif query.data == "video_menu":
357
+ keyboard = [
358
+ [InlineKeyboardButton(get_message(user_lang, "convert_video_to_note"), callback_data="convert_video_to_note")],
359
+ [InlineKeyboardButton(get_message(user_lang, "convert_note_to_video"), callback_data="convert_note_to_video")],
360
+ [InlineKeyboardButton(get_message(user_lang, "back_to_main_menu"), callback_data="main_menu")]
361
+ ]
362
+ reply_markup = InlineKeyboardMarkup(keyboard)
363
+ await query.edit_message_text(get_message(user_lang, "video_menu_text"), reply_markup=reply_markup)
364
+ return VIDEO_CONVERSION_MODE
365
+
366
+ elif query.data == "main_menu":
367
+ await send_main_menu(query, context)
368
+ return MAIN_MENU
369
+
370
+ return MAIN_MENU
371
+
372
+ # --- Audio Conversion Handlers ---
373
+ async def start_convert_voice_to_mp3(update: Update, context):
374
+ query = update.callback_query
375
+ await query.answer()
376
+ user_lang = await get_user_language(query.from_user.id, context)
377
+
378
+ if not await check_membership(query, context):
379
+ return WAITING_FOR_MEMBERSHIP
380
+
381
+ await query.edit_message_text(get_message(user_lang, "send_voice_for_mp3"))
382
+ return CONVERT_VOICE_TO_MP3
383
+
384
+ async def handle_voice_to_mp3(update: Update, context):
385
+ user_lang = await get_user_language(update.effective_user.id, context)
386
+ if update.message.voice:
387
+ file_id = update.message.voice.file_id
388
+ file = await context.bot.get_file(file_id)
389
+ input_file_path = os.path.join(DOWNLOAD_DIR, f"{file_id}.ogg")
390
+ await file.download_to_drive(input_file_path)
391
+
392
+ await update.message.reply_text(get_message(user_lang, "processing"))
393
+ try:
394
+ output_mp3_path = await convert_ogg_to_mp3(input_file_path)
395
+ await update.message.reply_audio(audio=open(output_mp3_path, 'rb'), caption=get_message(user_lang, "audio_converted"))
396
+ except Exception as e:
397
+ print(f"Error converting voice to mp3: {e}", file=sys.stderr)
398
+ await update.message.reply_text(get_message(user_lang, "error_occurred"))
399
+ finally:
400
+ os.remove(input_file_path)
401
+ if 'output_mp3_path' in locals() and os.path.exists(output_mp3_path):
402
+ os.remove(output_mp3_path)
403
+
404
+ return await send_main_menu(update, context) # Go back to main menu
405
+ else:
406
+ await update.message.reply_text(get_message(user_lang, "no_voice_file"))
407
+ return CONVERT_VOICE_TO_MP3
408
+
409
+ async def start_convert_mp3_to_voice(update: Update, context):
410
+ query = update.callback_query
411
+ await query.answer()
412
+ user_lang = await get_user_language(query.from_user.id, context)
413
+
414
+ if not await check_membership(query, context):
415
+ return WAITING_FOR_MEMBERSHIP
416
+
417
+ await query.edit_message_text(get_message(user_lang, "send_mp3_for_voice"))
418
+ return CONVERT_MP3_TO_VOICE
419
+
420
+ async def handle_mp3_to_voice(update: Update, context):
421
+ user_lang = await get_user_language(update.effective_user.id, context)
422
+ if update.message.audio and update.message.audio.mime_type == 'audio/mpeg':
423
+ file_id = update.message.audio.file_id
424
+ file = await context.bot.get_file(file_id)
425
+ input_file_path = os.path.join(DOWNLOAD_DIR, f"{file_id}.mp3")
426
+ await file.download_to_drive(input_file_path)
427
+
428
+ await update.message.reply_text(get_message(user_lang, "processing"))
429
+ try:
430
+ output_ogg_path = await convert_mp3_to_ogg_opus(input_file_path)
431
+ # Send as voice message
432
+ await update.message.reply_voice(voice=open(output_ogg_path, 'rb'), caption=get_message(user_lang, "audio_converted"))
433
+ except Exception as e:
434
+ print(f"Error converting mp3 to voice: {e}", file=sys.stderr)
435
+ await update.message.reply_text(get_message(user_lang, "error_occurred"))
436
+ finally:
437
+ os.remove(input_file_path)
438
+ if 'output_ogg_path' in locals() and os.path.exists(output_ogg_path):
439
+ os.remove(output_ogg_path)
440
+
441
+ return await send_main_menu(update, context) # Go back to main menu
442
+ else:
443
+ await update.message.reply_text(get_message(user_lang, "no_mp3_file"))
444
+ return CONVERT_MP3_TO_VOICE
445
+
446
+ # --- Audio Cutting Handlers ---
447
+ async def start_cut_audio(update: Update, context):
448
+ query = update.callback_query
449
+ await query.answer()
450
+ user_lang = await get_user_language(query.from_user.id, context)
451
+
452
+ if not await check_membership(query, context):
453
+ return WAITING_FOR_MEMBERSHIP
454
+
455
+ await query.edit_message_text(get_message(user_lang, "cut_audio_intro"))
456
+ return CUT_AUDIO_FILE
457
+
458
+ async def handle_cut_audio_file(update: Update, context):
459
+ user_lang = await get_user_language(update.effective_user.id, context)
460
+ file_id = None
461
+ file_extension = None
462
+
463
+ if update.message.audio and update.message.audio.mime_type == 'audio/mpeg':
464
+ file_id = update.message.audio.file_id
465
+ file_extension = "mp3"
466
+ context.user_data['audio_type'] = 'mp3'
467
+ elif update.message.voice:
468
+ file_id = update.message.voice.file_id
469
+ file_extension = "ogg"
470
+ context.user_data['audio_type'] = 'voice'
471
+ else:
472
+ await update.message.reply_text(get_message(user_lang, "no_audio_file"))
473
+ return CUT_AUDIO_FILE # Stay in this state, waiting for audio file
474
+
475
+ file = await context.bot.get_file(file_id)
476
+ input_file_path = os.path.join(DOWNLOAD_DIR, f"{file_id}.{file_extension}")
477
+ await file.download_to_drive(input_file_path)
478
+ context.user_data['input_file_path'] = input_file_path
479
+
480
+ await update.message.reply_text(get_message(user_lang, "cut_audio_range_prompt"))
481
+ return CUT_AUDIO_RANGE
482
+
483
+ async def handle_cut_audio_range(update: Update, context):
484
+ user_lang = await get_user_language(update.effective_user.id, context)
485
+ time_range_str = update.message.text
486
+
487
+ match = re.match(r"^(\d{2})\.(\d{2})-(\d{2})\.(\d{2})$", time_range_str)
488
+ if not match:
489
+ await update.message.reply_text(get_message(user_lang, "invalid_time_format"))
490
+ return CUT_AUDIO_RANGE # Stay in this state, waiting for correct format
491
+
492
+ start_min, start_sec, end_min, end_sec = map(int, match.groups())
493
+
494
+ start_ms = (start_min * 60 + start_sec) * 1000
495
+ end_ms = (end_min * 60 + end_sec) * 1000
496
+
497
+ input_file_path = context.user_data.get('input_file_path')
498
+ if not input_file_path or not os.path.exists(input_file_path):
499
+ await update.message.reply_text(get_message(user_lang, "error_occurred"))
500
+ return await send_main_menu(update, context)
501
+
502
+ try:
503
+ audio_info = AudioSegment.from_file(input_file_path)
504
+ audio_duration_ms = len(audio_info)
505
+
506
+ # Apply max duration limits
507
+ max_duration_ms = 5 * 60 * 1000 if context.user_data.get('audio_type') == 'mp3' else 2 * 60 * 1000
508
+ if audio_duration_ms > max_duration_ms:
509
+ # If file is longer than max allowed, cut only from the start to max_duration
510
+ audio_duration_ms = max_duration_ms
511
+
512
+ if end_ms > audio_duration_ms:
513
+ end_ms = audio_duration_ms # Cap end time to actual (or limited) duration
514
+
515
+ if start_ms >= end_ms:
516
+ await update.message.reply_text(get_message(user_lang, "invalid_time_range")) # e.g. start > end
517
+ return CUT_AUDIO_RANGE
518
+
519
+ await update.message.reply_text(get_message(user_lang, "processing"))
520
+
521
+ output_cut_path = await cut_audio_segment(input_file_path, start_ms, end_ms)
522
+ await update.message.reply_audio(audio=open(output_cut_path, 'rb'), caption=get_message(user_lang, "audio_cut_success"))
523
+
524
+ except Exception as e:
525
+ print(f"Error cutting audio: {e}", file=sys.stderr)
526
+ await update.message.reply_text(get_message(user_lang, "error_occurred"))
527
+ finally:
528
+ if input_file_path and os.path.exists(input_file_path):
529
+ os.remove(input_file_path)
530
+ if 'output_cut_path' in locals() and os.path.exists(output_cut_path):
531
+ os.remove(output_cut_path)
532
+ if 'input_file_path' in context.user_data:
533
+ del context.user_data['input_file_path']
534
+ if 'audio_type' in context.user_data:
535
+ del context.user_data['audio_type']
536
+
537
+ return await send_main_menu(update, context)
538
+
539
+ # --- Video Conversion Handlers ---
540
+ async def start_convert_video_to_note(update: Update, context):
541
+ query = update.callback_query
542
+ await query.answer()
543
+ user_lang = await get_user_language(query.from_user.id, context)
544
+
545
+ if not await check_membership(query, context):
546
+ return WAITING_FOR_MEMBERSHIP
547
+
548
+ await query.edit_message_text(get_message(user_lang, "send_video_for_note"))
549
+ return CONVERT_VIDEO_TO_NOTE
550
+
551
+ async def handle_video_to_note(update: Update, context):
552
+ user_lang = await get_user_language(update.effective_user.id, context)
553
+ if update.message.video:
554
+ file_id = update.message.video.file_id
555
+ file = await context.bot.get_file(file_id)
556
+ input_file_path = os.path.join(DOWNLOAD_DIR, f"{file_id}.mp4") # Assume mp4, or get actual extension
557
+ await file.download_to_drive(input_file_path)
558
+
559
+ await update.message.reply_text(get_message(user_lang, "processing"))
560
+ try:
561
+ output_note_path = await convert_video_to_note(input_file_path)
562
+ # Send as video note
563
+ await update.message.reply_video_note(video_note=open(output_note_path, 'rb'))
564
+ await update.message.reply_text(get_message(user_lang, "video_note_converted"))
565
+ except Exception as e:
566
+ print(f"Error converting video to note: {e}", file=sys.stderr)
567
+ await update.message.reply_text(get_message(user_lang, "error_occurred"))
568
+ finally:
569
+ os.remove(input_file_path)
570
+ if 'output_note_path' in locals() and os.path.exists(output_note_path):
571
+ os.remove(output_note_path)
572
+
573
+ return await send_main_menu(update, context)
574
+ else:
575
+ await update.message.reply_text(get_message(user_lang, "not_a_video"))
576
+ return CONVERT_VIDEO_TO_NOTE
577
+
578
+ async def start_convert_note_to_video(update: Update, context):
579
+ query = update.callback_query
580
+ await query.answer()
581
+ user_lang = await get_user_language(query.from_user.id, context)
582
+
583
+ if not await check_membership(query, context):
584
+ return WAITING_FOR_MEMBERSHIP
585
+
586
+ await query.edit_message_text(get_message(user_lang, "send_note_for_video"))
587
+ return CONVERT_NOTE_TO_VIDEO
588
+
589
+ async def handle_note_to_video(update: Update, context):
590
+ user_lang = await get_user_language(update.effective_user.id, context)
591
+ if update.message.video_note:
592
+ file_id = update.message.video_note.file_id
593
+ file = await context.bot.get_file(file_id)
594
+ input_file_path = os.path.join(DOWNLOAD_DIR, f"{file_id}.mp4") # Video notes are mp4
595
+ await file.download_to_drive(input_file_path)
596
+
597
+ await update.message.reply_text(get_message(user_lang, "processing"))
598
+ try:
599
+ output_video_path = await convert_note_to_video(input_file_path)
600
+ await update.message.reply_video(video=open(output_video_path, 'rb'), caption=get_message(user_lang, "video_converted"))
601
+ except Exception as e:
602
+ print(f"Error converting note to video: {e}", file=sys.stderr)
603
+ await update.message.reply_text(get_message(user_lang, "error_occurred"))
604
+ finally:
605
+ os.remove(input_file_path)
606
+ if 'output_video_path' in locals() and os.path.exists(output_video_path):
607
+ os.remove(output_video_path)
608
+
609
+ return await send_main_menu(update, context)
610
+ else:
611
+ await update.message.reply_text(get_message(user_lang, "not_a_video_note"))
612
+ return CONVERT_NOTE_TO_VIDEO
613
+
614
+ # --- Admin Panel Handlers ---
615
+ async def admin_link(update: Update, context):
616
+ user_lang = await get_user_language(update.effective_user.id, context)
617
+ if update.effective_user.id != ADMIN_ID:
618
+ await update.message.reply_text(get_message(user_lang, "not_admin"))
619
+ return ConversationHandler.END # End conversation if not admin
620
+
621
+ keyboard = [
622
+ [InlineKeyboardButton(get_message(user_lang, "add_channel"), callback_data="admin_add_channel")],
623
+ [InlineKeyboardButton(get_message(user_lang, "list_remove_channels"), callback_data="admin_list_remove_channels")],
624
+ [InlineKeyboardButton(get_message(user_lang, "back_to_main_menu"), callback_data="main_menu")]
625
+ ]
626
+ reply_markup = InlineKeyboardMarkup(keyboard)
627
+ await update.message.reply_text(get_message(user_lang, "admin_menu"), reply_markup=reply_markup)
628
+ return ADMIN_MENU
629
+
630
+ async def admin_menu_callback(update: Update, context):
631
+ query = update.callback_query
632
+ await query.answer()
633
+ user_lang = await get_user_language(query.from_user.id, context)
634
+
635
+ if query.from_user.id != ADMIN_ID:
636
+ await query.edit_message_text(get_message(user_lang, "not_admin"))
637
+ return ConversationHandler.END
638
+
639
+ if query.data == "admin_add_channel":
640
+ await query.edit_message_text(get_message(user_lang, "add_channel_prompt"), parse_mode=ParseMode.MARKDOWN)
641
+ return ADD_CHANNEL
642
+ elif query.data == "admin_list_remove_channels":
643
+ await list_remove_channels(update, context) # Re-use the list function
644
+ return LIST_REMOVE_CHANNELS
645
+ elif query.data == "main_menu":
646
+ return await send_main_menu(query, context)
647
+
648
+ return ADMIN_MENU
649
+
650
+ async def add_channel_handler(update: Update, context):
651
+ user_lang = await get_user_language(update.effective_user.id, context)
652
+ channel_identifier = update.message.text.strip()
653
+
654
+ channel_id = None
655
+ channel_name = None
656
+
657
+ await update.message.reply_text(get_message(user_lang, "admin_permission_check").format(channel_name=channel_identifier))
658
+
659
+ try:
660
+ # Try to get chat object using identifier
661
+ chat = await context.bot.get_chat(channel_identifier)
662
+ channel_id = chat.id
663
+ channel_name = chat.title if chat.title else chat.username if chat.username else str(chat.id)
664
+
665
+ # Check if already added
666
+ for c in REQUIRED_CHANNELS:
667
+ if c['id'] == channel_id:
668
+ await update.message.reply_text(get_message(user_lang, "channel_already_added"))
669
+ return ADMIN_MENU # Go back to admin menu
670
+
671
+ # Check bot's admin status in the channel
672
+ bot_member = await context.bot.get_chat_member(chat_id=channel_id, user_id=context.bot.id)
673
+ is_bot_admin = bot_member.status in [ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER]
674
+
675
+ can_invite_users = False
676
+ can_get_chat_members = False
677
+
678
+ if is_bot_admin:
679
+ if bot_member.can_invite_users and bot_member.can_post_messages and bot_member.can_manage_chat and bot_member.can_delete_messages: # Check required permissions
680
+ can_invite_users = True # Can manage chat and post means can invite
681
+ if bot_member.can_manage_chat: # can_manage_chat often implies get_chat_member
682
+ can_get_chat_members = True
683
+
684
+ bot_status_message = ""
685
+ if is_bot_admin and can_invite_users and can_get_chat_members:
686
+ bot_status_message = get_message(user_lang, "bot_is_admin")
687
+ elif is_bot_admin:
688
+ bot_status_message = get_message(user_lang, "bot_not_admin") # Admin but missing specific perms
689
+ else:
690
+ bot_status_message = get_message(user_lang, "bot_not_admin") # Not admin at all
691
+
692
+ REQUIRED_CHANNELS.append({
693
+ "id": channel_id,
694
+ "name": channel_name,
695
+ "link": chat.invite_link if chat.invite_link else f"https://t.me/{chat.username}" if chat.username else f"https://t.me/c/{str(channel_id).replace('-100', '')}",
696
+ "bot_is_admin": is_bot_admin,
697
+ "bot_can_invite_users": can_invite_users,
698
+ "bot_can_get_chat_members": can_get_chat_members
699
+ })
700
+ save_required_channels(REQUIRED_CHANNELS)
701
+
702
+ await update.message.reply_text(get_message(user_lang, "channel_added_success").format(channel_name=channel_name, channel_id=channel_id, status=bot_status_message), parse_mode=ParseMode.MARKDOWN)
703
+
704
+ except Exception as e:
705
+ print(f"Error adding channel {channel_identifier}: {e}", file=sys.stderr)
706
+ if "chat not found" in str(e).lower() or "bad request: chat_id is empty" in str(e).lower():
707
+ await update.message.reply_text(get_message(user_lang, "channel_not_found"))
708
+ elif "bot is not a member of the channel" in str(e).lower():
709
+ await update.message.reply_text(get_message(user_lang, "channel_not_found"))
710
+ else:
711
+ await update.message.reply_text(get_message(user_lang, "error_occurred"))
712
+
713
+ # Return to admin menu after adding
714
+ keyboard = [
715
+ [InlineKeyboardButton(get_message(user_lang, "add_channel"), callback_data="admin_add_channel")],
716
+ [InlineKeyboardButton(get_message(user_lang, "list_remove_channels"), callback_data="admin_list_remove_channels")],
717
+ [InlineKeyboardButton(get_message(user_lang, "back_to_main_menu"), callback_data="main_menu")]
718
+ ]
719
+ reply_markup = InlineKeyboardMarkup(keyboard)
720
+ await update.message.reply_text(get_message(user_lang, "admin_menu"), reply_markup=reply_markup)
721
+ return ADMIN_MENU
722
+
723
+ async def list_remove_channels(update: Update, context):
724
+ user_lang = await get_user_language(update.effective_user.id, context)
725
+
726
+ if not REQUIRED_CHANNELS:
727
+ message_text = get_message(user_lang, "no_channels_added")
728
+ keyboard = [[InlineKeyboardButton(get_message(user_lang, "back_to_admin_menu"), callback_data="admin_menu")]]
729
+ reply_markup = InlineKeyboardMarkup(keyboard)
730
+ if update.callback_query:
731
+ await update.callback_query.edit_message_text(message_text, reply_markup=reply_markup)
732
+ else:
733
+ await update.message.reply_text(message_text, reply_markup=reply_markup)
734
+ return ADMIN_MENU
735
+
736
+ message_text = get_message(user_lang, "list_channels").format(channels_list="")
737
+ keyboard_buttons = []
738
+
739
+ for i, channel in enumerate(REQUIRED_CHANNELS):
740
+ channel_name = channel.get('name', channel['id'])
741
+ message_text += f"{i+1}. {channel_name} (ID: `{channel['id']}`)\n"
742
+ keyboard_buttons.append([InlineKeyboardButton(f"❌ {channel_name}", callback_data=f"remove_channel_{i}")])
743
+
744
+ keyboard_buttons.append([InlineKeyboardButton(get_message(user_lang, "back_to_admin_menu"), callback_data="admin_menu")])
745
+ reply_markup = InlineKeyboardMarkup(keyboard_buttons)
746
+
747
+ if update.callback_query:
748
+ await update.callback_query.edit_message_text(message_text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN)
749
+ else:
750
+ await update.message.reply_text(message_text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN)
751
+
752
+ return LIST_REMOVE_CHANNELS
753
+
754
+ async def remove_channel_handler(update: Update, context):
755
+ query = update.callback_query
756
+ await query.answer()
757
+ user_lang = await get_user_language(query.from_user.id, context)
758
+
759
+ if query.from_user.id != ADMIN_ID:
760
+ await query.edit_message_text(get_message(user_lang, "not_admin"))
761
+ return ConversationHandler.END
762
+
763
+ index_to_remove = int(query.data.split('_')[2])
764
+
765
+ if 0 <= index_to_remove < len(REQUIRED_CHANNELS):
766
+ removed_channel = REQUIRED_CHANNELS.pop(index_to_remove)
767
+ save_required_channels(REQUIRED_CHANNELS)
768
+ await query.edit_message_text(get_message(user_lang, "channel_removed_success").format(channel_name=removed_channel.get('name', removed_channel['id'])))
769
+ else:
770
+ await query.edit_message_text(get_message(user_lang, "error_occurred"))
771
+
772
+ # After removal, re-list channels or go back to admin menu
773
+ await list_remove_channels(update, context) # Show updated list
774
+ return LIST_REMOVE_CHANNELS
775
+
776
+
777
+ # --- General Handlers ---
778
+ async def cancel(update: Update, context):
779
+ user_lang = await get_user_language(update.effective_user.id, context)
780
+ await update.message.reply_text(get_message(user_lang, "cancel_operation"))
781
+ return await send_main_menu(update, context)
782
+
783
+ async def error_handler(update: Update, context):
784
+ print(f"Error: {context.error} in update {update}", file=sys.stderr)
785
+ if update.effective_message:
786
+ user_lang = await get_user_language(update.effective_user.id, context)
787
+ await update.effective_message.reply_text(get_message(user_lang, "error_occurred"))
788
+
789
+ # Flask application init
790
  app = Flask(__name__)
791
  _application_instance = None
792
 
 
796
  print("Initializing Telegram bot Application for this worker...")
797
  _app = Application.builder().token(TOKEN).build()
798
 
799
+ # Define ConversationHandler with all states
800
  conv_handler = ConversationHandler(
801
  entry_points=[CommandHandler("start", start)],
802
+ states={
803
+ LANGUAGE_SELECTION: [
804
+ CallbackQueryHandler(set_language, pattern=r"^set_lang_")
805
+ ],
806
+ WAITING_FOR_MEMBERSHIP: [
807
+ CallbackQueryHandler(check_membership_callback, pattern="^check_membership_again$"),
808
+ MessageHandler(filters.ALL & ~filters.COMMAND, lambda u, c: u.message.reply_text(get_message(c.user_data.get('language', 'fa'), "membership_required_stay"))) # Keep waiting
809
+ ],
810
+ MAIN_MENU: [
811
+ CallbackQueryHandler(main_menu_callback, pattern="^(audio_menu|video_menu|main_menu)$")
812
+ ],
813
+ CONVERT_AUDIO_CHOICE: [
814
+ CallbackQueryHandler(start_convert_voice_to_mp3, pattern="^convert_voice_to_mp3$"),
815
+ CallbackQueryHandler(start_convert_mp3_to_voice, pattern="^convert_mp3_to_voice$"),
816
+ CallbackQueryHandler(start_cut_audio, pattern="^cut_audio$"),
817
+ CallbackQueryHandler(main_menu_callback, pattern="^main_menu$") # Back to main menu
818
+ ],
819
+ CONVERT_VOICE_TO_MP3: [
820
+ MessageHandler(filters.VOICE, handle_voice_to_mp3),
821
+ CommandHandler("cancel", cancel),
822
+ MessageHandler(filters.ALL & ~filters.COMMAND, lambda u, c: u.message.reply_text(get_message(c.user_data.get('language', 'fa'), "no_voice_file"))) # Repeat prompt
823
+ ],
824
+ CONVERT_MP3_TO_VOICE: [
825
+ MessageHandler(filters.AUDIO & filters.Document.MimeType("audio/mpeg"), handle_mp3_to_voice),
826
+ CommandHandler("cancel", cancel),
827
+ MessageHandler(filters.ALL & ~filters.COMMAND, lambda u, c: u.message.reply_text(get_message(c.user_data.get('language', 'fa'), "no_mp3_file"))) # Repeat prompt
828
+ ],
829
+ CUT_AUDIO_FILE: [
830
+ MessageHandler(filters.AUDIO | filters.VOICE, handle_cut_audio_file),
831
+ CommandHandler("cancel", cancel),
832
+ MessageHandler(filters.ALL & ~filters.COMMAND, lambda u, c: u.message.reply_text(get_message(c.user_data.get('language', 'fa'), "no_audio_file"))) # Repeat prompt
833
+ ],
834
+ CUT_AUDIO_RANGE: [
835
+ MessageHandler(filters.TEXT & ~filters.COMMAND, handle_cut_audio_range),
836
+ CommandHandler("cancel", cancel),
837
+ MessageHandler(filters.ALL & ~filters.COMMAND, lambda u, c: u.message.reply_text(get_message(c.user_data.get('language', 'fa'), "invalid_time_format"))) # Repeat prompt
838
+ ],
839
+ VIDEO_CONVERSION_MODE: [
840
+ CallbackQueryHandler(start_convert_video_to_note, pattern="^convert_video_to_note$"),
841
+ CallbackQueryHandler(start_convert_note_to_video, pattern="^convert_note_to_video$"),
842
+ CallbackQueryHandler(main_menu_callback, pattern="^main_menu$") # Back to main menu
843
+ ],
844
+ CONVERT_VIDEO_TO_NOTE: [
845
+ MessageHandler(filters.VIDEO, handle_video_to_note),
846
+ CommandHandler("cancel", cancel),
847
+ MessageHandler(filters.ALL & ~filters.COMMAND, lambda u, c: u.message.reply_text(get_message(c.user_data.get('language', 'fa'), "not_a_video")))
848
+ ],
849
+ CONVERT_NOTE_TO_VIDEO: [
850
+ MessageHandler(filters.VIDEO_NOTE, handle_note_to_video),
851
+ CommandHandler("cancel", cancel),
852
+ MessageHandler(filters.ALL & ~filters.COMMAND, lambda u, c: u.message.reply_text(get_message(c.user_data.get('language', 'fa'), "not_a_video_note")))
853
+ ],
854
+ ADMIN_MENU: [
855
+ CallbackQueryHandler(admin_menu_callback, pattern="^admin_(add_channel|list_remove_channels)$"),
856
+ CallbackQueryHandler(main_menu_callback, pattern="^main_menu$")
857
+ ],
858
+ ADD_CHANNEL: [
859
+ MessageHandler(filters.TEXT & ~filters.COMMAND, add_channel_handler),
860
+ CommandHandler("cancel", cancel),
861
+ CommandHandler("link", admin_link) # Allow re-entry to admin menu
862
+ ],
863
+ LIST_REMOVE_CHANNELS: [
864
+ CallbackQueryHandler(remove_channel_handler, pattern="^remove_channel_\d+$"),
865
+ CallbackQueryHandler(admin_menu_callback, pattern="^admin_menu$") # Back to admin menu
866
+ ]
867
+ },
868
+ fallbacks=[
869
+ CommandHandler("cancel", cancel),
870
+ CommandHandler("start", start),
871
+ CommandHandler("link", admin_link) # Link for admin access
872
+ ],
873
+ allow_reentry=True # Allow re-entry into the same conversation
874
  )
 
875
  _app.add_handler(conv_handler)
876
  _app.add_error_handler(error_handler)
877
+
878
+ # Admin command list
879
+ await _app.bot.set_my_commands([
880
+ BotCommand("start", "Start the bot"),
881
+ BotCommand("cancel", "Cancel current operation"),
882
+ BotCommand("link", "Admin panel (Admin only)")
883
+ ])
884
+
885
+ await _app.initialize()
886
  print("Telegram bot Application initialized.")
887
  return _application_instance
888
 
889
  # Flask routes
 
890
  @app.route("/")
891
  async def index():
892
  return jsonify({"status": "ok", "message": "Telegram bot is running."})
 
904
  webhook_url = os.getenv("WEBHOOK_URL")
905
  if not webhook_url:
906
  return jsonify({"status": "error", "message": "WEBHOOK_URL not set."}), 500
 
907
  if not webhook_url.endswith("/webhook"):
908
  webhook_url = f"{webhook_url.rstrip('/')}/webhook"
 
909
  try:
910
  await application.bot.set_webhook(url=webhook_url)
911
  return jsonify({"status": "success", "message": f"Webhook set to {webhook_url}"})
 
917
  if __name__ == "__main__":
918
  import asyncio
919
  print("Starting Flask app in local mode...")
920
+ # Initialize the application in a separate asyncio task if running locally
921
+ # Otherwise, it will be initialized by the webhook call in a production environment
922
+ try:
923
+ asyncio.run(get_telegram_application())
924
+ except RuntimeError as e:
925
+ if "cannot run loop while another loop is running" in str(e):
926
+ print("Loop already running, likely in an IDE. Skipping explicit asyncio.run.")
927
+ else:
928
+ raise
929
+ app.run(host='0.0.0.0', port=int(os.environ.get("PORT", 7860)))