Compare commits
2 Commits
4ec369c18e
...
b5b6135cc3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5b6135cc3 | ||
|
|
f962f16308 |
Binary file not shown.
170
bot.py
170
bot.py
@@ -1,78 +1,98 @@
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
from aiogram import Bot, Dispatcher, types, BaseMiddleware
|
||||
import time
|
||||
from aiogram import Bot, Dispatcher, types, BaseMiddleware, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import FSInputFile, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.types import FSInputFile, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.client.session.aiohttp import AiohttpSession # <-- TAMBAH INI
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from dotenv import load_dotenv
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
# Import modul lokal yang sudah kita buat
|
||||
from yt_engine import process_youtube_request
|
||||
from yt_engine import process_youtube_request, get_recommendations
|
||||
from db_manager import db
|
||||
from s3_manager import upload_audio, download_audio, delete_audio
|
||||
|
||||
# Setup Logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
load_dotenv()
|
||||
BOT_TOKEN = os.getenv("BOT_TOKEN")
|
||||
# Pastikan ini mengambil angka ID Telegram kamu
|
||||
AUTHORIZED_USER_ID = int(os.getenv("AUTHORIZED_USER_ID", 0))
|
||||
|
||||
# --- INISIALISASI BOT ---
|
||||
# Timeout diatur 300 detik (5 menit) agar Telegram tidak memutus koneksi saat mengirim file MP3 yang besar
|
||||
session = AiohttpSession(timeout=300)
|
||||
|
||||
bot = Bot(
|
||||
token=BOT_TOKEN,
|
||||
session=session,
|
||||
default=DefaultBotProperties(parse_mode=None)
|
||||
)
|
||||
# Matikan parse_mode default agar aman dari error karakter aneh di judul lagu YouTube
|
||||
bot = Bot(token=BOT_TOKEN, session=session, default=DefaultBotProperties(parse_mode=None))
|
||||
dp = Dispatcher()
|
||||
# --- MIDDLEWARE / SECURITY CHECK ---
|
||||
|
||||
# Antrean sederhana (FIFO)
|
||||
music_queue = asyncio.Queue()
|
||||
|
||||
async def queue_worker():
|
||||
"""Memproses lagu satu per satu dari antrean agar server tidak meledak"""
|
||||
while True:
|
||||
query, chat_id, msg_id = await music_queue.get()
|
||||
try:
|
||||
await process_music_request(query, chat_id, msg_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Worker Error: {e}")
|
||||
finally:
|
||||
music_queue.task_done()
|
||||
|
||||
# Satpam Bot
|
||||
class SecurityMiddleware(BaseMiddleware):
|
||||
async def __call__(self, handler, event: types.Message, data: dict):
|
||||
# Memblokir semua orang kecuali Kamu (Single-User Mode)
|
||||
if event.from_user.id != AUTHORIZED_USER_ID:
|
||||
logger.warning(f"Akses ditolak untuk User ID: {event.from_user.id}")
|
||||
return # Abaikan pesan
|
||||
async def __call__(self, handler, event: types.Message | CallbackQuery, data: dict):
|
||||
if event.from_user.id != AUTHORIZED_USER_ID: return
|
||||
return await handler(event, data)
|
||||
|
||||
# Daftarkan Satpam ke sistem
|
||||
dp.message.middleware(SecurityMiddleware())
|
||||
dp.callback_query.middleware(SecurityMiddleware())
|
||||
|
||||
# --- COMMAND HANDLERS ---
|
||||
# --- COMMANDS ---
|
||||
@dp.message(Command("start"))
|
||||
async def cmd_start(message: types.Message):
|
||||
await message.answer(
|
||||
"🎧 **Kantor-Bypass Music Bot Ready!**\n\n"
|
||||
"Kirim judul lagu atau link YouTube dengan format:\n"
|
||||
"`/play <judul/link>`\n\n"
|
||||
"Contoh: `/play Dewa 19 Kangen`"
|
||||
)
|
||||
await message.answer("🎧 **Music Bot Ready!**\nKirim format: `/play <judul lagu>`\nSetelah lagu terkirim, klik rekomendasi di bawahnya untuk melanjutkan.", parse_mode="Markdown")
|
||||
|
||||
@dp.message(Command("play"))
|
||||
async def cmd_play(message: types.Message):
|
||||
query = message.text.replace("/play", "").strip()
|
||||
if not query:
|
||||
return await message.answer("⚠️ Masukkan judul lagu. Contoh: `/play Nadin Amizah`")
|
||||
return await message.answer("⚠️ Masukkan judul lagu. Contoh: /play Dewa 19 Kangen")
|
||||
|
||||
# Kirim status loading
|
||||
status_msg = await message.answer("🔍 *Mencari dan memproses...*")
|
||||
status_msg = await message.answer("⏳ Memproses pesananmu...")
|
||||
await music_queue.put((query, message.chat.id, status_msg.message_id))
|
||||
|
||||
# Gunakan asyncio.create_task agar tidak nge-block queue bot (bisa antre banyak lagu)
|
||||
asyncio.create_task(process_music_request(query, message.chat.id, status_msg.message_id))
|
||||
# --- HANDLER TOMBOL SUGGESTION ---
|
||||
@dp.callback_query(F.data.startswith("play_"))
|
||||
async def handle_suggestion_click(callback: CallbackQuery):
|
||||
video_id = callback.data.split("_", 1)[1]
|
||||
|
||||
# --- CORE LOGIC: PROCESS MUSIC ---
|
||||
# 1. Hapus tombol di pesan sebelumnya biar chat nggak penuh tombol mati
|
||||
await callback.message.edit_reply_markup(reply_markup=None)
|
||||
|
||||
# 2. Kirim status baru
|
||||
status_msg = await bot.send_message(callback.message.chat.id, "⏳ Mengunduh lagu pilihanmu...")
|
||||
query_url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
# 3. Masukkan ke antrean
|
||||
await music_queue.put((query_url, callback.message.chat.id, status_msg.message_id))
|
||||
await callback.answer("Siapp! Diproses...")
|
||||
|
||||
# --- CORE LOGIC ---
|
||||
async def process_music_request(query: str, chat_id: int, status_msg_id: int):
|
||||
local_file = None # Mencegah error 'UnboundLocalError' jika gagal di awal
|
||||
local_file = None
|
||||
total_start_time = time.time() # ⏱️ START TIMER TOTAL
|
||||
|
||||
try:
|
||||
# 1. Download dari YouTube
|
||||
logger.info(f"▶️ MEMULAI PROSES LAGU: {query}")
|
||||
await bot.edit_message_text("🔍 Mencari ke YouTube...", chat_id, status_msg_id)
|
||||
|
||||
# --- FASE 1: YOUTUBE & FFMPEG ---
|
||||
t_yt_start = time.time()
|
||||
yt_result = await process_youtube_request(query)
|
||||
t_yt_end = time.time()
|
||||
logger.info(f"⏱️ [FASE 1] yt-dlp & FFmpeg memakan waktu: {t_yt_end - t_yt_start:.2f} detik")
|
||||
|
||||
if yt_result["status"] == "error":
|
||||
return await bot.edit_message_text(f"❌ Gagal: {yt_result['message']}", chat_id, status_msg_id)
|
||||
@@ -82,83 +102,81 @@ async def process_music_request(query: str, chat_id: int, status_msg_id: int):
|
||||
local_file = yt_result["file_path"]
|
||||
s3_object_name = f"{video_id}.mp3"
|
||||
|
||||
# 2. Cek Cache di Database
|
||||
# --- FASE 2: MINIO S3 ---
|
||||
t_s3_start = time.time()
|
||||
cached_data = await db.get_cache(video_id)
|
||||
|
||||
if cached_data:
|
||||
await bot.edit_message_text("⚡ *Mengambil dari Cache S3...*", chat_id, status_msg_id)
|
||||
# Download dari S3 ke local temporary
|
||||
await bot.edit_message_text("⚡ Mengambil dari Cache (MinIO)...", chat_id, status_msg_id)
|
||||
local_file = f"downloads/cache_{video_id}.mp3"
|
||||
await download_audio(s3_object_name, local_file)
|
||||
logger.info(f"⏱️ [FASE 2] Download dari MinIO memakan waktu: {time.time() - t_s3_start:.2f} detik")
|
||||
else:
|
||||
await bot.edit_message_text("☁️ *Mengunggah ke S3 Storage...*", chat_id, status_msg_id)
|
||||
# Upload file baru ke S3
|
||||
await bot.edit_message_text("☁️ Mengunggah ke MinIO Server...", chat_id, status_msg_id)
|
||||
if local_file and os.path.exists(local_file):
|
||||
await upload_audio(local_file, s3_object_name)
|
||||
await db.save_cache(video_id, title, s3_object_name)
|
||||
logger.info(f"⏱️ [FASE 2] Upload ke MinIO memakan waktu: {time.time() - t_s3_start:.2f} detik")
|
||||
|
||||
# 3. Kirim Audio ke Telegram
|
||||
await bot.edit_message_text("📤 *Mengirim audio ke Telegram... (Mungkin butuh waktu)*", chat_id, status_msg_id)
|
||||
# --- FASE 3: TELEGRAM UPLOAD ---
|
||||
await bot.edit_message_text("📤 Mengirim file ke Telegram...", chat_id, status_msg_id)
|
||||
|
||||
if local_file and os.path.exists(local_file):
|
||||
t_tele_start = time.time()
|
||||
audio = FSInputFile(local_file)
|
||||
await bot.send_audio(chat_id=chat_id, audio=audio, title=title, performer="Music Bot", request_timeout=300)
|
||||
t_tele_end = time.time()
|
||||
logger.info(f"⏱️ [FASE 3] Upload ke Telegram memakan waktu: {t_tele_end - t_tele_start:.2f} detik")
|
||||
|
||||
if status_msg_id:
|
||||
await bot.delete_message(chat_id, status_msg_id)
|
||||
|
||||
# --- SUGGEST 5 LAGU ---
|
||||
t_recs_start = time.time()
|
||||
recs = await get_recommendations(video_id)
|
||||
logger.info(f"⏱️ [FASE 4] Fetch Rekomendasi memakan waktu: {time.time() - t_recs_start:.2f} detik")
|
||||
|
||||
if recs:
|
||||
kb = []
|
||||
for rec in recs[:5]:
|
||||
short_title = rec['title'][:60] + "..." if len(rec['title']) > 60 else rec['title']
|
||||
kb.append([InlineKeyboardButton(text=f"▶️ {short_title}", callback_data=f"play_{rec['id']}")])
|
||||
|
||||
# Opsi: Tombol pencarian selanjutnya
|
||||
kb = [[InlineKeyboardButton(text="🎵 Putar Lagu Acak Lainnya", switch_inline_query_current_chat="")]]
|
||||
reply_markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
||||
|
||||
# Ekstra Pengaman Timeout di Request Level
|
||||
await bot.send_audio(
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
audio=audio,
|
||||
title=title,
|
||||
performer="Music Bot",
|
||||
text=f"💡 **Rekomendasi dari:**\n_{title}_",
|
||||
reply_markup=reply_markup,
|
||||
request_timeout=300
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
# Hapus pesan status yang muter-muter
|
||||
await bot.delete_message(chat_id, status_msg_id)
|
||||
else:
|
||||
await bot.edit_message_text("❌ File audio tidak ditemukan di server lokal.", chat_id, status_msg_id)
|
||||
logger.info(f"✅ TOTAL WAKTU E2E: {time.time() - total_start_time:.2f} detik\n{'-'*40}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing music: {e}")
|
||||
logger.error(f"Error: {e}")
|
||||
try:
|
||||
await bot.edit_message_text(f"❌ Terjadi kesalahan internal: {e}", chat_id, status_msg_id)
|
||||
except:
|
||||
pass # Abaikan jika pesan sudah terhapus
|
||||
|
||||
await bot.edit_message_text("❌ Terjadi kesalahan internal saat memproses lagu.", chat_id, status_msg_id)
|
||||
except: pass
|
||||
finally:
|
||||
# 4. Hapus file temporary di VPS (Hemat Disk!)
|
||||
if local_file and os.path.exists(local_file):
|
||||
os.remove(local_file)
|
||||
|
||||
# --- BACKGROUND JOB: AUTO CLEANUP (7 HARI) ---
|
||||
# --- AUTO CLEANUP CACHE ---
|
||||
async def cleanup_expired_cache():
|
||||
logger.info("🧹 Menjalankan tugas pembersihan cache otomatis...")
|
||||
expired_items = await db.get_expired_cache(days=7)
|
||||
|
||||
for item in expired_items:
|
||||
# Hapus dari S3
|
||||
await delete_audio(item['s3_object_key'])
|
||||
# Hapus dari Database
|
||||
await db.delete_cache(item['youtube_id'])
|
||||
logger.info(f"🗑️ Dihapus: {item['title']} (Usia > 7 Hari)")
|
||||
|
||||
# --- MAIN LOOP ---
|
||||
# --- MAIN RUNNER ---
|
||||
async def main():
|
||||
# Konek ke database
|
||||
await db.connect()
|
||||
asyncio.create_task(queue_worker())
|
||||
|
||||
# Jalankan Scheduler untuk bersih-bersih tiap jam 3 pagi
|
||||
scheduler = AsyncIOScheduler()
|
||||
scheduler.add_job(cleanup_expired_cache, 'cron', hour=3, minute=0)
|
||||
scheduler.start()
|
||||
|
||||
logger.info("🚀 Bot Music Started!")
|
||||
|
||||
# Hapus webhook lama (jika ada) dan mulai polling
|
||||
logger.info("🚀 Bot Music - Mode Interactive Playlist Berjalan!")
|
||||
await bot.delete_webhook(drop_pending_updates=True)
|
||||
await dp.start_polling(bot)
|
||||
|
||||
|
||||
16
cookies.txt
16
cookies.txt
@@ -3,12 +3,12 @@
|
||||
|
||||
.youtube.com TRUE / TRUE 2147483647 __Secure-1PAPISID CABFFJNnCe9Ocqz0/ANX4rXmqpbZzZy8y1
|
||||
.youtube.com TRUE / TRUE 2147483647 __Secure-1PSID g.a0008AhqPWcg3-Syf1o_JEl3LCHzWFKn_n3Y8jgh3RnwDW83PtpvDZ6ZjTqI52V0CZWm40gj1wACgYKAYYSARESFQHGX2MigEiR2_gNFFV-QFRM2YthnxoVAUF8yKp0aiEfXAKcPOTZdsLVsdG00076
|
||||
.youtube.com TRUE / TRUE 1805947740 __Secure-1PSIDCC AKEyXzUORFVg_0ZBDrcdDFVznCjghYk0kFgXPgu6_nGbfqQb8JQApHGnBqt7NkD7fgCea-TTmg
|
||||
.youtube.com TRUE / TRUE 1805951136 __Secure-1PSIDCC AKEyXzUIxLQmOaltTf1fGcQk8YPiD-LeYgDwpvoYnXyN2XpMqdyv8dRk8RMnPnEuNpcNaJpiuA
|
||||
.youtube.com TRUE / TRUE 2147483647 __Secure-1PSIDRTS sidts-CjcBWhotCb-4c6IuQXRvmRVYyon3cb4LfaOkQmhdlWz2FTjey8tLFTyGymAQr1fbHPACgLGOXKsXEAA
|
||||
.youtube.com TRUE / TRUE 2147483647 __Secure-1PSIDTS sidts-CjUBWhotCXH5m0kFaAC1lhqf8kaotcZDxFxOty6XBxNsYxySai6J15Fi8BHklGtKOGZKSNYrLxAA
|
||||
.youtube.com TRUE / TRUE 1837483738 __Secure-3PAPISID CABFFJNnCe9Ocqz0/ANX4rXmqpbZzZy8y1
|
||||
.youtube.com TRUE / TRUE 1837483738 __Secure-3PSID g.a0008AhqPWcg3-Syf1o_JEl3LCHzWFKn_n3Y8jgh3RnwDW83PtpvC14W2htdab0jQBsnf1YkqgACgYKAdcSARESFQHGX2MiTK5ZxIBltpFTHCsEt_xiuxoVAUF8yKrG5FgxNyKwsIayRODxp8Fo0076
|
||||
.youtube.com TRUE / TRUE 1805947740 __Secure-3PSIDCC AKEyXzVld0GspLJe8l6JS4cfa4h4ElnWgBoAB5kiHdSs0gCerB-quDuuc4JEn63Voja8hi9ZSQ
|
||||
.youtube.com TRUE / TRUE 1837487124 __Secure-3PAPISID CABFFJNnCe9Ocqz0/ANX4rXmqpbZzZy8y1
|
||||
.youtube.com TRUE / TRUE 1837487124 __Secure-3PSID g.a0008AhqPWcg3-Syf1o_JEl3LCHzWFKn_n3Y8jgh3RnwDW83PtpvC14W2htdab0jQBsnf1YkqgACgYKAdcSARESFQHGX2MiTK5ZxIBltpFTHCsEt_xiuxoVAUF8yKrG5FgxNyKwsIayRODxp8Fo0076
|
||||
.youtube.com TRUE / TRUE 1805951136 __Secure-3PSIDCC AKEyXzUEL9zBsIFlxJP8kImHFhOK8FA5ZzEE4j6_lcjzKUrODAt1IS4GDcyMK2tbSJFdpBMnaA
|
||||
.youtube.com TRUE / TRUE 2147483647 __Secure-3PSIDRTS sidts-CjcBWhotCb-4c6IuQXRvmRVYyon3cb4LfaOkQmhdlWz2FTjey8tLFTyGymAQr1fbHPACgLGOXKsXEAA
|
||||
.youtube.com TRUE / TRUE 2147483647 __Secure-3PSIDTS sidts-CjUBWhotCXH5m0kFaAC1lhqf8kaotcZDxFxOty6XBxNsYxySai6J15Fi8BHklGtKOGZKSNYrLxAA
|
||||
.youtube.com TRUE / TRUE 2147483647 __Secure-BUCKET CJsE
|
||||
@@ -19,7 +19,7 @@
|
||||
.youtube.com TRUE / TRUE 2147483647 APISID PbWG-XrqTeJJFiPn/ACQ0hTzgL5lCMuKP5
|
||||
.youtube.com TRUE / FALSE 0 PREF hl=en&tz=UTC
|
||||
.youtube.com TRUE / TRUE 0 SOCS CAI
|
||||
.youtube.com TRUE / TRUE 0 YSC a2WtW-hP4Ck
|
||||
.youtube.com TRUE / TRUE 1789963740 VISITOR_INFO1_LIVE BIF7sNwBpLI
|
||||
.youtube.com TRUE / TRUE 1789963740 VISITOR_PRIVACY_METADATA CgJJRBIEGgAgFA%3D%3D
|
||||
.youtube.com TRUE / TRUE 1837483740 LOGIN_INFO AFmmF2swRgIhAMFCn3UGB5h-2Wf8Rnv4LIWp0LiVwf6ibiPVQhmFufMQAiEA-q7C22E801epMI4Lk54Xb9uNUHWVKkjNLHJ-auj0p7c:QUQ3MjNmelp0RkxSamFrTTZnOVBLelN1ZjVpeVRBWC1TTUc1d0NPUm9PXzNPQmx0dUNEdjBDOEF0cGsxc3p1SWhwMWtIZDdyMjRxYVFKZWRIWnkwMkxzLWVnakVCdFlxcVpFclc3N3VfNmpIWWh2aUd2eUxydzJMekZ6eFo4MzZUMDJROVRtTHZXdjFyZGlqV3dWVFZnUTlTWWJvbTZHRHRR
|
||||
.youtube.com TRUE / TRUE 0 YSC 1xjiT5Oikbs
|
||||
.youtube.com TRUE / TRUE 1837487125 LOGIN_INFO AFmmF2swRQIhAL69BVhiHN7pI8NRR4HKWf4y1nB2NymBs8b4SapeMuYBAiBXnlXmRsm7fl_vzwvgkCRSs-81PNyApm33E11JW8RoJA:QUQ3MjNmejFnV0xvdHdQeXktQUJoY1pZZ3lDT1N4U1ZMUUNYVmdDNG02YzZTSjM2ejltODhFWFBjTXduNXNyamQ5OEZRRzU3RzVpWTJVM051T3pwbHEzZkZTcjJtS1RTcXFoMVpDLWRiR1g0YVN6VnpBQTFUOUxhSTZiMUlMdzB4bWtjaVV2dklrcW05TlVKOE0tcGpRVnRrSE9rX1czUE9B
|
||||
.youtube.com TRUE / TRUE 1789967136 VISITOR_INFO1_LIVE 68v_ALXnekk
|
||||
.youtube.com TRUE / TRUE 1789967136 VISITOR_PRIVACY_METADATA CgJJRBIEGgAgIA%3D%3D
|
||||
|
||||
BIN
downloads/7SqNVv98e8Q.mp3
Normal file
BIN
downloads/7SqNVv98e8Q.mp3
Normal file
Binary file not shown.
BIN
downloads/grp6FCnioMM.mp3
Normal file
BIN
downloads/grp6FCnioMM.mp3
Normal file
Binary file not shown.
BIN
downloads/gvunApwKIiY.mp3
Normal file
BIN
downloads/gvunApwKIiY.mp3
Normal file
Binary file not shown.
BIN
downloads/gvunApwKIiY.webm
Normal file
BIN
downloads/gvunApwKIiY.webm
Normal file
Binary file not shown.
43
yt_engine.py
43
yt_engine.py
@@ -106,3 +106,46 @@ async def process_youtube_request(query: str):
|
||||
saat menunggu proses download selesai.
|
||||
"""
|
||||
return await asyncio.to_thread(search_and_download, query)
|
||||
|
||||
# ==========================================
|
||||
# FITUR TAMBAHAN: YOUTUBE MIX RECOMMENDATION
|
||||
# ==========================================
|
||||
def fetch_recommendations_sync(video_id: str, limit: int = 5):
|
||||
"""
|
||||
Mengambil rekomendasi lagu dari YouTube Mix algoritma (Vibes/Genre serupa)
|
||||
"""
|
||||
# Gunakan settingan ringan karena kita cuma mau ambil teks judul, BUKAN download
|
||||
ydl_opts_recs = {
|
||||
'quiet': True,
|
||||
'extract_flat': True,
|
||||
'playlistend': limit + 1, # +1 karena urutan pertama biasanya lagu aslinya
|
||||
'extractor_args': {'youtube': ['player_client=android,ios']},
|
||||
}
|
||||
|
||||
# Sisipkan cookie jika ada
|
||||
if os.path.exists("cookies.txt"):
|
||||
ydl_opts_recs['cookiefile'] = 'cookies.txt'
|
||||
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts_recs) as ydl:
|
||||
# RD = Playlist YouTube Mix khusus untuk video tersebut
|
||||
mix_url = f"https://www.youtube.com/watch?v={video_id}&list=RD{video_id}"
|
||||
info = ydl.extract_info(mix_url, download=False)
|
||||
|
||||
recs = []
|
||||
if 'entries' in info:
|
||||
# Loop dan ambil lagunya (Lewati index 0 karena itu lagu yg sedang diputar)
|
||||
for entry in info['entries'][1:]:
|
||||
if entry and entry.get('id') and entry.get('title'):
|
||||
recs.append({
|
||||
'id': entry['id'],
|
||||
'title': entry['title']
|
||||
})
|
||||
return recs
|
||||
except Exception as e:
|
||||
print(f"Gagal mengambil rekomendasi: {e}")
|
||||
return []
|
||||
|
||||
async def get_recommendations(video_id: str):
|
||||
"""Bungkus Async agar bot tidak hang"""
|
||||
return await asyncio.to_thread(fetch_recommendations_sync, video_id)
|
||||
|
||||
Reference in New Issue
Block a user