akdNIKY commited on
Commit
e50270d
·
verified ·
1 Parent(s): 0f9dcf0

Update app.py

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