Update bot.py
Browse files
bot.py
CHANGED
|
@@ -6,105 +6,87 @@ import threading
|
|
| 6 |
import aiosqlite
|
| 7 |
import gradio as gr
|
| 8 |
from hydrogram import Client, filters
|
| 9 |
-
from hydrogram.errors import FloodWait
|
| 10 |
from hydrogram.types import (
|
| 11 |
InputMediaPhoto, InputMediaVideo, InputMediaDocument,
|
| 12 |
InputMediaAudio
|
| 13 |
)
|
| 14 |
from fastapi import FastAPI
|
|
|
|
| 15 |
|
| 16 |
-
# ============
|
| 17 |
-
import hydrogram
|
| 18 |
-
import hydrogram.errors.rpc_error as _rpc_error
|
| 19 |
-
|
| 20 |
-
if "FLOOD_PREMIUM_WAIT" not in [k for d in _rpc_error.exceptions.values() for k in d.keys()]:
|
| 21 |
-
class FloodPremiumWait(FloodWait):
|
| 22 |
-
CODE = 420
|
| 23 |
-
NAME = "FLOOD_PREMIUM_WAIT"
|
| 24 |
-
setattr(hydrogram.errors, "FloodPremiumWait", FloodPremiumWait)
|
| 25 |
-
_rpc_error.exceptions.setdefault(420, {})["FLOOD_PREMIUM_WAIT"] = "FloodPremiumWait"
|
| 26 |
-
logging.getLogger(__name__).warning("Patched missing FloodPremiumWait into Hydrogram")
|
| 27 |
-
# ===================================================================
|
| 28 |
-
|
| 29 |
logging.basicConfig(
|
| 30 |
level=logging.INFO,
|
| 31 |
format='%(asctime)s - %(levelname)s - %(message)s'
|
| 32 |
)
|
| 33 |
-
logger = logging.getLogger(
|
| 34 |
|
| 35 |
-
# ============ الإعدادات ============
|
| 36 |
API_ID = int(os.environ.get("API_ID", 0))
|
| 37 |
API_HASH = os.environ.get("API_HASH", "")
|
| 38 |
SESSION_STRING = os.environ.get("SESSION_STRING", "")
|
| 39 |
TARGET_CHAT_RAW = os.environ.get("TARGET_CHAT", "me")
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
DATA_DIR = "/data"
|
| 42 |
DOWNLOAD_DIR = os.path.join(DATA_DIR, "downloads")
|
| 43 |
-
DB_PATH = os.path.join(DATA_DIR, "
|
| 44 |
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
| 45 |
|
| 46 |
-
# ============ العميل (Hydrogram) ============
|
| 47 |
app = Client(
|
| 48 |
-
"
|
| 49 |
api_id=API_ID,
|
| 50 |
api_hash=API_HASH,
|
| 51 |
session_string=SESSION_STRING,
|
| 52 |
in_memory=True,
|
| 53 |
-
workers=
|
| 54 |
-
device_model="
|
| 55 |
-
system_version="
|
| 56 |
-
app_version="
|
| 57 |
-
lang_code="ar"
|
| 58 |
-
takeout=False,
|
| 59 |
)
|
| 60 |
|
| 61 |
-
TARGET_CHAT =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
# ============ Global Flood Control ============
|
| 64 |
-
class GlobalFloodControl:
|
| 65 |
-
def __init__(self):
|
| 66 |
-
self.until = 0
|
| 67 |
-
self._lock = asyncio.Lock()
|
| 68 |
-
|
| 69 |
-
async def hit(self, seconds):
|
| 70 |
-
async with self._lock:
|
| 71 |
-
target = time.time() + seconds
|
| 72 |
-
if target > self.until:
|
| 73 |
-
self.until = target
|
| 74 |
-
logger.warning(f"Global flood control activated: {seconds}s")
|
| 75 |
-
|
| 76 |
async def wait(self):
|
| 77 |
-
async with self._lock:
|
| 78 |
-
remaining = self.until - time.time()
|
| 79 |
-
if remaining > 0:
|
| 80 |
-
logger.info(f"Global flood pause: {remaining:.0f}s remaining")
|
| 81 |
-
await asyncio.sleep(remaining)
|
| 82 |
-
|
| 83 |
-
flood_control = GlobalFloodControl()
|
| 84 |
-
|
| 85 |
-
# ============ Throttle Controller ============
|
| 86 |
-
class ThrottleController:
|
| 87 |
-
def __init__(self, rate=3.0):
|
| 88 |
-
self.rate = rate
|
| 89 |
-
self.last_send = {}
|
| 90 |
-
self.lock = asyncio.Lock()
|
| 91 |
-
|
| 92 |
-
async def wait(self, chat_id):
|
| 93 |
async with self.lock:
|
|
|
|
| 94 |
now = time.time()
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
await asyncio.sleep(
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
-
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
|
| 104 |
-
download_lock = asyncio.Lock()
|
| 105 |
-
send_sem = asyncio.Semaphore(1)
|
| 106 |
|
| 107 |
-
# ============ قاعدة البيانات ============
|
| 108 |
async def init_db():
|
| 109 |
async with aiosqlite.connect(DB_PATH) as db:
|
| 110 |
await db.execute("""
|
|
@@ -114,606 +96,377 @@ async def init_db():
|
|
| 114 |
message_id INTEGER NOT NULL,
|
| 115 |
media_group_id TEXT,
|
| 116 |
media_type TEXT,
|
| 117 |
-
file_id TEXT,
|
| 118 |
-
file_name TEXT,
|
| 119 |
-
file_size INTEGER DEFAULT 0,
|
| 120 |
-
file_path TEXT,
|
| 121 |
caption TEXT,
|
| 122 |
source_name TEXT,
|
| 123 |
-
|
|
|
|
|
|
|
| 124 |
attempts INTEGER DEFAULT 0,
|
| 125 |
error TEXT,
|
| 126 |
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 127 |
-
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 128 |
UNIQUE(chat_id, message_id)
|
| 129 |
)
|
| 130 |
""")
|
| 131 |
await db.execute("CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)")
|
| 132 |
await db.execute("CREATE INDEX IF NOT EXISTS idx_jobs_media_group ON jobs(media_group_id)")
|
| 133 |
await db.commit()
|
| 134 |
-
logger.info("
|
| 135 |
|
| 136 |
-
async def
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
return
|
| 140 |
-
|
| 141 |
-
file_id = None
|
| 142 |
-
file_size = 0
|
| 143 |
-
file_name = None
|
| 144 |
-
|
| 145 |
-
if message.photo:
|
| 146 |
-
file_id = message.photo.file_id
|
| 147 |
-
file_size = message.photo.file_size or 0
|
| 148 |
-
elif message.video:
|
| 149 |
-
file_id = message.video.file_id
|
| 150 |
-
file_size = message.video.file_size or 0
|
| 151 |
-
file_name = message.video.file_name
|
| 152 |
-
elif message.document:
|
| 153 |
-
file_id = message.document.file_id
|
| 154 |
-
file_size = message.document.file_size or 0
|
| 155 |
-
file_name = message.document.file_name
|
| 156 |
-
elif message.audio:
|
| 157 |
-
file_id = message.audio.file_id
|
| 158 |
-
file_size = message.audio.file_size or 0
|
| 159 |
-
file_name = message.audio.file_name
|
| 160 |
-
elif message.voice:
|
| 161 |
-
file_id = message.voice.file_id
|
| 162 |
-
file_size = message.voice.file_size or 0
|
| 163 |
-
elif message.video_note:
|
| 164 |
-
file_id = message.video_note.file_id
|
| 165 |
-
file_size = message.video_note.file_size or 0
|
| 166 |
-
elif message.animation:
|
| 167 |
-
file_id = message.animation.file_id
|
| 168 |
-
file_size = message.animation.file_size or 0
|
| 169 |
|
| 170 |
chat_id = int(message.chat.id)
|
| 171 |
message_id = int(message.id)
|
| 172 |
-
file_size = int(file_size) if file_size else 0
|
| 173 |
-
file_id = str(file_id) if file_id else None
|
| 174 |
-
file_name = str(file_name) if file_name else None
|
| 175 |
media_group_id = str(message.media_group_id) if message.media_group_id else None
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
async with aiosqlite.connect(DB_PATH) as db:
|
| 182 |
try:
|
| 183 |
await db.execute("""
|
| 184 |
INSERT OR IGNORE INTO jobs
|
| 185 |
-
(chat_id, message_id, media_group_id, media_type,
|
| 186 |
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
|
| 187 |
-
""", (
|
| 188 |
-
chat_id, message_id, media_group_id,
|
| 189 |
-
m_type, file_id, file_name, file_size, final_cap, src_name
|
| 190 |
-
))
|
| 191 |
await db.commit()
|
|
|
|
|
|
|
| 192 |
except Exception as e:
|
| 193 |
-
logger.error(f"
|
| 194 |
-
|
| 195 |
-
async def update_job_status(chat_id, message_id, status, file_path=None, error=None):
|
| 196 |
-
chat_id = int(chat_id)
|
| 197 |
-
message_id = int(message_id)
|
| 198 |
-
|
| 199 |
-
async with aiosqlite.connect(DB_PATH) as db:
|
| 200 |
-
if file_path:
|
| 201 |
-
await db.execute("""
|
| 202 |
-
UPDATE jobs SET status=?, file_path=?, updated_at=CURRENT_TIMESTAMP
|
| 203 |
-
WHERE chat_id=? AND message_id=?
|
| 204 |
-
""", (status, str(file_path), chat_id, message_id))
|
| 205 |
-
elif error:
|
| 206 |
-
await db.execute("""
|
| 207 |
-
UPDATE jobs SET status=?, error=?, attempts=attempts+1, updated_at=CURRENT_TIMESTAMP
|
| 208 |
-
WHERE chat_id=? AND message_id=?
|
| 209 |
-
""", (status, str(error)[:500], chat_id, message_id))
|
| 210 |
-
else:
|
| 211 |
-
await db.execute("""
|
| 212 |
-
UPDATE jobs SET status=?, updated_at=CURRENT_TIMESTAMP
|
| 213 |
-
WHERE chat_id=? AND message_id=?
|
| 214 |
-
""", (status, chat_id, message_id))
|
| 215 |
-
await db.commit()
|
| 216 |
-
|
| 217 |
-
async def get_pending_jobs(limit=1):
|
| 218 |
-
async with aiosqlite.connect(DB_PATH) as db:
|
| 219 |
-
db.row_factory = aiosqlite.Row
|
| 220 |
-
cursor = await db.execute("""
|
| 221 |
-
SELECT * FROM jobs
|
| 222 |
-
WHERE status='pending' AND attempts < 10
|
| 223 |
-
ORDER BY created_at
|
| 224 |
-
LIMIT ?
|
| 225 |
-
""", (int(limit),))
|
| 226 |
-
rows = await cursor.fetchall()
|
| 227 |
-
return [dict(row) for row in rows]
|
| 228 |
-
|
| 229 |
-
async def get_downloaded_album_jobs(media_group_id):
|
| 230 |
-
async with aiosqlite.connect(DB_PATH) as db:
|
| 231 |
-
db.row_factory = aiosqlite.Row
|
| 232 |
-
cursor = await db.execute("""
|
| 233 |
-
SELECT * FROM jobs
|
| 234 |
-
WHERE media_group_id=? AND status='downloaded'
|
| 235 |
-
ORDER BY message_id
|
| 236 |
-
""", (str(media_group_id),))
|
| 237 |
-
rows = await cursor.fetchall()
|
| 238 |
-
return [dict(row) for row in rows]
|
| 239 |
-
|
| 240 |
-
async def get_album_pending_count(media_group_id):
|
| 241 |
-
async with aiosqlite.connect(DB_PATH) as db:
|
| 242 |
-
cursor = await db.execute("""
|
| 243 |
-
SELECT COUNT(*) FROM jobs
|
| 244 |
-
WHERE media_group_id=? AND status IN ('pending', 'downloading')
|
| 245 |
-
""", (str(media_group_id),))
|
| 246 |
-
row = await cursor.fetchone()
|
| 247 |
-
return row[0]
|
| 248 |
-
|
| 249 |
-
async def delete_job(chat_id, message_id):
|
| 250 |
-
chat_id = int(chat_id)
|
| 251 |
-
message_id = int(message_id)
|
| 252 |
-
async with aiosqlite.connect(DB_PATH) as db:
|
| 253 |
-
cursor = await db.execute("""
|
| 254 |
-
SELECT file_path FROM jobs WHERE chat_id=? AND message_id=?
|
| 255 |
-
""", (chat_id, message_id))
|
| 256 |
-
row = await cursor.fetchone()
|
| 257 |
-
if row and row[0] and os.path.exists(row[0]):
|
| 258 |
-
try:
|
| 259 |
-
os.remove(row[0])
|
| 260 |
-
except:
|
| 261 |
-
pass
|
| 262 |
-
await db.execute("DELETE FROM jobs WHERE chat_id=? AND message_id=?", (chat_id, message_id))
|
| 263 |
-
await db.commit()
|
| 264 |
-
|
| 265 |
-
# ============ معلومات الملف ============
|
| 266 |
-
def get_media_info(message):
|
| 267 |
-
if message.photo:
|
| 268 |
-
return "photo", ".jpg"
|
| 269 |
-
elif message.video:
|
| 270 |
-
return "video", ".mp4"
|
| 271 |
-
elif message.document:
|
| 272 |
-
ext = os.path.splitext(message.document.file_name or "")[1] or ""
|
| 273 |
-
return "doc", ext
|
| 274 |
-
elif message.audio:
|
| 275 |
-
ext = os.path.splitext(message.audio.file_name or "")[1] or ".mp3"
|
| 276 |
-
return "audio", ext
|
| 277 |
-
elif message.voice:
|
| 278 |
-
return "voice", ".ogg"
|
| 279 |
-
elif message.video_note:
|
| 280 |
-
return "video_note", ".mp4"
|
| 281 |
-
elif message.animation:
|
| 282 |
-
return "animation", ".mp4"
|
| 283 |
-
return None, ""
|
| 284 |
-
|
| 285 |
-
def get_source_name(message):
|
| 286 |
-
chat = message.chat
|
| 287 |
-
if chat.type in ["channel", "supergroup", "group"]:
|
| 288 |
-
return chat.title or "Channel/Group"
|
| 289 |
-
elif chat.type == "private":
|
| 290 |
-
return chat.first_name or chat.username or "Private"
|
| 291 |
-
return "Source"
|
| 292 |
-
|
| 293 |
-
# ============ التحميل المُصلح ============
|
| 294 |
-
async def verify_download(file_path, expected_size):
|
| 295 |
-
if not file_path or not os.path.exists(file_path):
|
| 296 |
-
return False
|
| 297 |
-
actual_size = os.path.getsize(file_path)
|
| 298 |
-
if expected_size > 0 and actual_size < expected_size:
|
| 299 |
-
logger.warning(f"Incomplete file: {actual_size}/{expected_size} bytes")
|
| 300 |
-
return False
|
| 301 |
-
if actual_size == 0:
|
| 302 |
-
logger.warning(f"Zero-byte file: {file_path}")
|
| 303 |
-
return False
|
| 304 |
-
return True
|
| 305 |
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
downloaded = await app.download_media(
|
| 314 |
-
message,
|
| 315 |
-
file_name=file_path
|
| 316 |
-
)
|
| 317 |
-
return downloaded
|
| 318 |
-
except Exception as e:
|
| 319 |
-
logger.error(f"Re-fetch download failed: {e}")
|
| 320 |
-
return None
|
| 321 |
-
|
| 322 |
-
async def download_job(job):
|
| 323 |
-
chat_id = int(job['chat_id'])
|
| 324 |
-
message_id = int(job['message_id'])
|
| 325 |
-
expected_size = int(job['file_size'] or 0)
|
| 326 |
-
file_id = str(job['file_id']) if job['file_id'] else None
|
| 327 |
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
'video_note': '.mp4', 'animation': '.mp4'
|
| 332 |
-
}.get(job['media_type'], '')
|
| 333 |
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
| 341 |
|
| 342 |
-
|
|
|
|
|
|
|
| 343 |
|
|
|
|
| 344 |
try:
|
| 345 |
-
await
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
except FloodWait as e:
|
| 353 |
-
await flood_control.hit(e.value)
|
| 354 |
-
raise
|
| 355 |
-
except Exception as e:
|
| 356 |
-
logger.warning(f"file_id failed: {e}")
|
| 357 |
-
|
| 358 |
-
if not await verify_download(downloaded, expected_size):
|
| 359 |
-
logger.info(f"Attempt 2 (message): {message_id}")
|
| 360 |
-
downloaded = await download_with_message_object(chat_id, message_id, file_path)
|
| 361 |
-
|
| 362 |
-
if not await verify_download(downloaded, expected_size):
|
| 363 |
-
logger.info(f"Attempt 3 (retry): {message_id}")
|
| 364 |
-
await asyncio.sleep(5)
|
| 365 |
-
if file_id:
|
| 366 |
-
try:
|
| 367 |
-
downloaded = await app.download_media(file_id, file_name=file_path)
|
| 368 |
-
except FloodWait as e:
|
| 369 |
-
await flood_control.hit(e.value)
|
| 370 |
-
raise
|
| 371 |
-
except:
|
| 372 |
-
pass
|
| 373 |
-
if not await verify_download(downloaded, expected_size):
|
| 374 |
-
downloaded = await download_with_message_object(chat_id, message_id, file_path)
|
| 375 |
-
|
| 376 |
-
if not await verify_download(downloaded, expected_size):
|
| 377 |
-
raise Exception(f"All attempts failed")
|
| 378 |
-
|
| 379 |
-
await update_job_status(chat_id, message_id, 'downloaded', file_path=downloaded)
|
| 380 |
-
logger.info(f"Download complete: {message_id} | {os.path.getsize(downloaded)} bytes")
|
| 381 |
return True
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
|
|
|
|
|
|
| 388 |
except Exception as e:
|
| 389 |
-
|
| 390 |
-
await update_job_status(chat_id, message_id, 'pending', error=err_str)
|
| 391 |
-
logger.error(f"Download failed {message_id}: {err_str}")
|
| 392 |
-
return False
|
| 393 |
|
| 394 |
-
#
|
| 395 |
-
async def safe_send(send_func):
|
| 396 |
-
for attempt in range(5):
|
| 397 |
-
try:
|
| 398 |
-
return await send_func()
|
| 399 |
-
except FloodWait as e:
|
| 400 |
-
wait_time = getattr(e, 'value', getattr(e, 'x', 30))
|
| 401 |
-
logger.warning(f"FloodWait {wait_time}s on attempt {attempt+1}/5")
|
| 402 |
-
await flood_control.hit(wait_time)
|
| 403 |
-
await asyncio.sleep(min(wait_time, 300))
|
| 404 |
-
except (ConnectionError, TimeoutError) as e:
|
| 405 |
-
wait_time = min(2 ** attempt, 60)
|
| 406 |
-
logger.warning(f"Connection error, retry in {wait_time}s: {e}")
|
| 407 |
-
await asyncio.sleep(wait_time)
|
| 408 |
-
except Exception as e:
|
| 409 |
-
if attempt == 4:
|
| 410 |
-
raise
|
| 411 |
-
logger.warning(f"Send error (attempt {attempt+1}): {e}")
|
| 412 |
-
await asyncio.sleep(min(2 ** attempt, 30))
|
| 413 |
-
return None
|
| 414 |
-
|
| 415 |
-
async def send_job(job):
|
| 416 |
-
chat_id = int(job['chat_id'])
|
| 417 |
-
message_id = int(job['message_id'])
|
| 418 |
-
file_path = job['file_path']
|
| 419 |
-
|
| 420 |
-
if not file_path or not os.path.exists(file_path):
|
| 421 |
-
logger.error(f"File not found: {file_path}")
|
| 422 |
-
await update_job_status(chat_id, message_id, 'pending', error="File not found")
|
| 423 |
-
return False
|
| 424 |
-
|
| 425 |
-
await flood_control.wait()
|
| 426 |
-
|
| 427 |
try:
|
| 428 |
-
await
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
await safe_send(lambda: app.send_photo(TARGET_CHAT, photo=file_path, caption=caption))
|
| 435 |
-
elif m_type == 'video':
|
| 436 |
-
await safe_send(lambda: app.send_video(TARGET_CHAT, video=file_path, caption=caption, supports_streaming=True))
|
| 437 |
-
elif m_type == 'doc':
|
| 438 |
-
await safe_send(lambda: app.send_document(TARGET_CHAT, document=file_path, caption=caption))
|
| 439 |
-
elif m_type == 'audio':
|
| 440 |
-
await safe_send(lambda: app.send_audio(TARGET_CHAT, audio=file_path, caption=caption))
|
| 441 |
-
elif m_type == 'voice':
|
| 442 |
-
await safe_send(lambda: app.send_voice(TARGET_CHAT, voice=file_path, caption=caption))
|
| 443 |
-
elif m_type == 'video_note':
|
| 444 |
-
await safe_send(lambda: app.send_video_note(TARGET_CHAT, video_note=file_path))
|
| 445 |
-
elif m_type == 'animation':
|
| 446 |
-
await safe_send(lambda: app.send_animation(TARGET_CHAT, animation=file_path, caption=caption))
|
| 447 |
|
| 448 |
-
|
| 449 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
return True
|
| 451 |
-
|
|
|
|
|
|
|
| 452 |
except Exception as e:
|
| 453 |
-
logger.error(f"
|
| 454 |
-
await update_job_status(chat_id, message_id, 'downloaded', error=str(e))
|
| 455 |
return False
|
| 456 |
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
return
|
| 461 |
-
|
| 462 |
-
pending = await get_album_pending_count(media_group_id)
|
| 463 |
-
if pending > 0:
|
| 464 |
-
logger.info(f"Album {media_group_id} waiting for {pending} files")
|
| 465 |
-
return
|
| 466 |
-
|
| 467 |
-
jobs.sort(key=lambda j: int(j['message_id']))
|
| 468 |
-
|
| 469 |
-
album_inputs = []
|
| 470 |
-
album_jobs = []
|
| 471 |
-
standalone_jobs = []
|
| 472 |
-
|
| 473 |
-
for i, job in enumerate(jobs):
|
| 474 |
-
cap = job['caption'] if i == 0 else None
|
| 475 |
-
m_type = job['media_type']
|
| 476 |
-
file_path = job['file_path']
|
| 477 |
-
|
| 478 |
-
if m_type == 'photo':
|
| 479 |
-
album_inputs.append(InputMediaPhoto(media=file_path, caption=cap))
|
| 480 |
-
album_jobs.append(job)
|
| 481 |
-
elif m_type == 'video':
|
| 482 |
-
album_inputs.append(InputMediaVideo(media=file_path, caption=cap, supports_streaming=True))
|
| 483 |
-
album_jobs.append(job)
|
| 484 |
-
elif m_type == 'doc':
|
| 485 |
-
album_inputs.append(InputMediaDocument(media=file_path, caption=cap))
|
| 486 |
-
album_jobs.append(job)
|
| 487 |
-
elif m_type == 'audio':
|
| 488 |
-
album_inputs.append(InputMediaAudio(media=file_path, caption=cap))
|
| 489 |
-
album_jobs.append(job)
|
| 490 |
-
else:
|
| 491 |
-
standalone_jobs.append(job)
|
| 492 |
-
|
| 493 |
-
await flood_control.wait()
|
| 494 |
-
|
| 495 |
-
async with send_sem:
|
| 496 |
-
chunk_size = 10
|
| 497 |
-
for idx in range(0, len(album_inputs), chunk_size):
|
| 498 |
-
chunk = album_inputs[idx:idx+chunk_size]
|
| 499 |
-
chunk_jobs = album_jobs[idx:idx+chunk_size]
|
| 500 |
-
|
| 501 |
-
try:
|
| 502 |
-
if len(chunk) == 1:
|
| 503 |
-
await safe_send(lambda: app.send_photo(
|
| 504 |
-
TARGET_CHAT, photo=chunk[0].media, caption=chunk[0].caption
|
| 505 |
-
))
|
| 506 |
-
else:
|
| 507 |
-
await safe_send(lambda: app.send_media_group(
|
| 508 |
-
TARGET_CHAT, media=chunk, disable_notification=True
|
| 509 |
-
))
|
| 510 |
-
|
| 511 |
-
for j in chunk_jobs:
|
| 512 |
-
await delete_job(j['chat_id'], j['message_id'])
|
| 513 |
-
logger.info(f"Album {media_group_id} part {idx//chunk_size + 1} sent")
|
| 514 |
-
except Exception as e:
|
| 515 |
-
logger.error(f"Album send failed: {e}")
|
| 516 |
-
|
| 517 |
-
for job in standalone_jobs:
|
| 518 |
-
await send_job(job)
|
| 519 |
-
|
| 520 |
-
# ============ Workers ============
|
| 521 |
-
async def download_worker():
|
| 522 |
-
logger.info("Download Worker started")
|
| 523 |
-
await asyncio.sleep(5)
|
| 524 |
-
|
| 525 |
while True:
|
| 526 |
try:
|
| 527 |
if not app.is_connected:
|
| 528 |
-
|
| 529 |
-
await asyncio.sleep(10)
|
| 530 |
continue
|
| 531 |
-
|
| 532 |
-
await flood_control.wait()
|
| 533 |
-
|
| 534 |
-
jobs = await get_pending_jobs(limit=1)
|
| 535 |
-
if not jobs:
|
| 536 |
-
await asyncio.sleep(2)
|
| 537 |
-
continue
|
| 538 |
-
|
| 539 |
-
job = jobs[0]
|
| 540 |
-
|
| 541 |
-
async with download_lock:
|
| 542 |
-
success = await download_job(job)
|
| 543 |
-
if success:
|
| 544 |
-
await asyncio.sleep(3)
|
| 545 |
-
else:
|
| 546 |
-
await asyncio.sleep(5)
|
| 547 |
-
|
| 548 |
-
except Exception as e:
|
| 549 |
-
logger.error(f"Download Worker error: {e}")
|
| 550 |
-
await asyncio.sleep(10)
|
| 551 |
|
| 552 |
-
async def send_worker():
|
| 553 |
-
logger.info("Send Worker started")
|
| 554 |
-
await asyncio.sleep(7)
|
| 555 |
-
|
| 556 |
-
while True:
|
| 557 |
-
try:
|
| 558 |
-
if not app.is_connected:
|
| 559 |
-
logger.warning("Not connected, waiting...")
|
| 560 |
-
await asyncio.sleep(10)
|
| 561 |
-
continue
|
| 562 |
-
|
| 563 |
-
await flood_control.wait()
|
| 564 |
-
|
| 565 |
async with aiosqlite.connect(DB_PATH) as db:
|
| 566 |
db.row_factory = aiosqlite.Row
|
| 567 |
-
|
| 568 |
cursor = await db.execute("""
|
| 569 |
SELECT * FROM jobs
|
| 570 |
-
WHERE status='
|
| 571 |
-
ORDER BY
|
| 572 |
""")
|
| 573 |
-
|
| 574 |
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
""
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
await send_job(job)
|
| 588 |
-
await asyncio.sleep(3)
|
| 589 |
-
|
| 590 |
-
for (mg_id,) in album_rows:
|
| 591 |
-
if not app.is_connected:
|
| 592 |
-
break
|
| 593 |
-
await send_album_group(mg_id)
|
| 594 |
-
await asyncio.sleep(3)
|
| 595 |
-
|
| 596 |
-
await asyncio.sleep(2)
|
| 597 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
except Exception as e:
|
| 599 |
-
logger.error(f"
|
| 600 |
-
await asyncio.sleep(
|
| 601 |
|
| 602 |
-
# ============
|
| 603 |
-
async def
|
|
|
|
|
|
|
|
|
|
| 604 |
try:
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
except Exception as e:
|
| 611 |
-
logger.
|
| 612 |
-
return
|
| 613 |
|
| 614 |
-
# ============
|
| 615 |
@app.on_message(filters.incoming & ~filters.me & filters.media)
|
| 616 |
-
async def
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
|
|
|
|
|
|
|
|
|
| 637 |
|
| 638 |
-
|
|
|
|
| 639 |
global TARGET_CHAT
|
| 640 |
-
raw = TARGET_CHAT_RAW
|
| 641 |
-
if raw.lower()
|
| 642 |
TARGET_CHAT = "me"
|
|
|
|
| 643 |
return
|
| 644 |
try:
|
| 645 |
chat_id_int = int(raw)
|
| 646 |
chat = await app.get_chat(chat_id_int)
|
| 647 |
TARGET_CHAT = chat_id_int
|
| 648 |
-
logger.info(f"
|
| 649 |
-
except
|
| 650 |
-
|
| 651 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 652 |
|
| 653 |
-
# ============
|
| 654 |
-
def
|
| 655 |
with gr.Blocks(title="Telegram Archiver Pro") as demo:
|
| 656 |
-
gr.Markdown("#
|
| 657 |
-
|
| 658 |
-
gr.
|
| 659 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
fastapi_app = FastAPI()
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
async def health_check():
|
| 664 |
-
return {"status": "ok", "timestamp": time.time()}
|
| 665 |
|
| 666 |
@fastapi_app.get("/ping")
|
| 667 |
async def ping():
|
| 668 |
-
return {"status": "alive"}
|
| 669 |
-
|
| 670 |
-
gr.mount_gradio_app(fastapi_app, demo, path="/")
|
| 671 |
-
|
| 672 |
-
import uvicorn
|
| 673 |
uvicorn.run(fastapi_app, host="0.0.0.0", port=7860)
|
| 674 |
|
| 675 |
-
# ============ Main ============
|
| 676 |
async def main():
|
| 677 |
-
|
| 678 |
-
if app.is_connected:
|
| 679 |
-
await app.stop()
|
| 680 |
-
await asyncio.sleep(2)
|
| 681 |
-
except:
|
| 682 |
-
pass
|
| 683 |
-
|
| 684 |
await app.start()
|
| 685 |
|
| 686 |
-
retries = 0
|
| 687 |
-
while not app.is_connected and retries < 10:
|
| 688 |
-
await asyncio.sleep(1)
|
| 689 |
-
retries += 1
|
| 690 |
-
logger.info(f"Waiting for connection... ({retries}/10)")
|
| 691 |
-
|
| 692 |
-
if not app.is_connected:
|
| 693 |
-
logger.error("Failed to connect")
|
| 694 |
-
return
|
| 695 |
-
|
| 696 |
me = await app.get_me()
|
| 697 |
-
|
|
|
|
|
|
|
| 698 |
await init_db()
|
| 699 |
|
| 700 |
-
|
| 701 |
-
asyncio.create_task(
|
| 702 |
-
await asyncio.sleep(2)
|
| 703 |
-
asyncio.create_task(send_worker())
|
| 704 |
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
|
|
|
|
|
|
| 710 |
|
|
|
|
| 711 |
stop_event = asyncio.Event()
|
| 712 |
await stop_event.wait()
|
| 713 |
|
| 714 |
if __name__ == "__main__":
|
| 715 |
-
|
| 716 |
-
|
|
|
|
| 717 |
|
| 718 |
loop = asyncio.get_event_loop()
|
| 719 |
try:
|
|
@@ -721,7 +474,5 @@ if __name__ == "__main__":
|
|
| 721 |
except KeyboardInterrupt:
|
| 722 |
pass
|
| 723 |
finally:
|
| 724 |
-
|
| 725 |
-
loop.run_until_complete(app.stop())
|
| 726 |
-
except:
|
| 727 |
-
pass
|
|
|
|
| 6 |
import aiosqlite
|
| 7 |
import gradio as gr
|
| 8 |
from hydrogram import Client, filters
|
| 9 |
+
from hydrogram.errors import FloodWait, FloodPremiumWait, ChatForwardsRestricted, MessageIdInvalid
|
| 10 |
from hydrogram.types import (
|
| 11 |
InputMediaPhoto, InputMediaVideo, InputMediaDocument,
|
| 12 |
InputMediaAudio
|
| 13 |
)
|
| 14 |
from fastapi import FastAPI
|
| 15 |
+
import uvicorn
|
| 16 |
|
| 17 |
+
# ============ إعداد السجلات (Logging) ============
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
logging.basicConfig(
|
| 19 |
level=logging.INFO,
|
| 20 |
format='%(asctime)s - %(levelname)s - %(message)s'
|
| 21 |
)
|
| 22 |
+
logger = logging.getLogger("ArchiverPro")
|
| 23 |
|
| 24 |
+
# ============ الإعدادات والمتغيرات ============
|
| 25 |
API_ID = int(os.environ.get("API_ID", 0))
|
| 26 |
API_HASH = os.environ.get("API_HASH", "")
|
| 27 |
SESSION_STRING = os.environ.get("SESSION_STRING", "")
|
| 28 |
TARGET_CHAT_RAW = os.environ.get("TARGET_CHAT", "me")
|
| 29 |
+
# يمكنك وضع قنوات مفصولة بفواصل مثل: "@channel1, -100123456789, @channel2"
|
| 30 |
+
CHANNELS_TO_SCRAPE = os.environ.get("CHANNELS_TO_SCRAPE", "")
|
| 31 |
|
| 32 |
+
DATA_DIR = "/tmp/data" if not os.path.exists("/data") else "/data"
|
| 33 |
DOWNLOAD_DIR = os.path.join(DATA_DIR, "downloads")
|
| 34 |
+
DB_PATH = os.path.join(DATA_DIR, "queue_v2.db")
|
| 35 |
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
| 36 |
|
| 37 |
+
# ============ إنشاء العميل (Hydrogram Client) ============
|
| 38 |
app = Client(
|
| 39 |
+
"archiver_pro",
|
| 40 |
api_id=API_ID,
|
| 41 |
api_hash=API_HASH,
|
| 42 |
session_string=SESSION_STRING,
|
| 43 |
in_memory=True,
|
| 44 |
+
workers=4,
|
| 45 |
+
device_model="Desktop Archiver",
|
| 46 |
+
system_version="Linux Archiver 64bit",
|
| 47 |
+
app_version="2.0.0",
|
| 48 |
+
lang_code="ar"
|
|
|
|
| 49 |
)
|
| 50 |
|
| 51 |
+
TARGET_CHAT = "me"
|
| 52 |
+
stats = {
|
| 53 |
+
"total_queued": 0,
|
| 54 |
+
"total_transferred": 0,
|
| 55 |
+
"total_failed": 0,
|
| 56 |
+
"status": "Starting..."
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
# ============ إدارة الـ FloodWait والتحكم بالسرعة ============
|
| 60 |
+
class RateLimiter:
|
| 61 |
+
def __init__(self, delay=2.5):
|
| 62 |
+
self.delay = delay
|
| 63 |
+
self.last_call = 0
|
| 64 |
+
self.lock = asyncio.Lock()
|
| 65 |
+
self.flood_until = 0
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
async def wait(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
async with self.lock:
|
| 69 |
+
# التحقق من وجود حظر FloodWait
|
| 70 |
now = time.time()
|
| 71 |
+
if self.flood_until > now:
|
| 72 |
+
wait_sec = self.flood_until - now
|
| 73 |
+
logger.warning(f"⏳ FloodWait فعال، انتظار {wait_sec:.1f} ثانية...")
|
| 74 |
+
await asyncio.sleep(wait_sec)
|
| 75 |
+
|
| 76 |
+
# التحقق من الفاصل الزمني العادي لتفادي الحظر
|
| 77 |
+
now = time.time()
|
| 78 |
+
elapsed = now - self.last_call
|
| 79 |
+
if elapsed < self.delay:
|
| 80 |
+
await asyncio.sleep(self.delay - elapsed)
|
| 81 |
+
self.last_call = time.time()
|
| 82 |
|
| 83 |
+
def set_flood(self, seconds):
|
| 84 |
+
self.flood_until = time.time() + seconds + 2
|
| 85 |
+
logger.warning(f"🚨 تم استلام FloodWait: {seconds}s")
|
| 86 |
|
| 87 |
+
rate_limiter = RateLimiter(delay=2.0)
|
|
|
|
|
|
|
| 88 |
|
| 89 |
+
# ============ قاعدة البيانات وحفظ الطابور ============
|
| 90 |
async def init_db():
|
| 91 |
async with aiosqlite.connect(DB_PATH) as db:
|
| 92 |
await db.execute("""
|
|
|
|
| 96 |
message_id INTEGER NOT NULL,
|
| 97 |
media_group_id TEXT,
|
| 98 |
media_type TEXT,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
caption TEXT,
|
| 100 |
source_name TEXT,
|
| 101 |
+
album_total INTEGER DEFAULT 1,
|
| 102 |
+
album_index INTEGER DEFAULT 1,
|
| 103 |
+
status TEXT DEFAULT 'pending', -- pending, processing, completed, failed
|
| 104 |
attempts INTEGER DEFAULT 0,
|
| 105 |
error TEXT,
|
| 106 |
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
| 107 |
UNIQUE(chat_id, message_id)
|
| 108 |
)
|
| 109 |
""")
|
| 110 |
await db.execute("CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)")
|
| 111 |
await db.execute("CREATE INDEX IF NOT EXISTS idx_jobs_media_group ON jobs(media_group_id)")
|
| 112 |
await db.commit()
|
| 113 |
+
logger.info("✅ تم تهيئة قاعدة البيانات بنجاح.")
|
| 114 |
|
| 115 |
+
async def add_message_to_queue(message, album_info=None):
|
| 116 |
+
if not message.media:
|
| 117 |
+
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
chat_id = int(message.chat.id)
|
| 120 |
message_id = int(message.id)
|
|
|
|
|
|
|
|
|
|
| 121 |
media_group_id = str(message.media_group_id) if message.media_group_id else None
|
| 122 |
|
| 123 |
+
# تحديد نوع الميديا
|
| 124 |
+
media_type = "unknown"
|
| 125 |
+
if message.photo: media_type = "photo"
|
| 126 |
+
elif message.video: media_type = "video"
|
| 127 |
+
elif message.document: media_type = "document"
|
| 128 |
+
elif message.audio: media_type = "audio"
|
| 129 |
+
elif message.voice: media_type = "voice"
|
| 130 |
+
elif message.video_note: media_type = "video_note"
|
| 131 |
+
elif message.animation: media_type = "animation"
|
| 132 |
+
|
| 133 |
+
# اسم القناة أو المجموعة
|
| 134 |
+
chat = message.chat
|
| 135 |
+
source_name = chat.title or chat.first_name or chat.username or f"Chat_{chat_id}"
|
| 136 |
+
|
| 137 |
+
raw_caption = message.caption or ""
|
| 138 |
|
| 139 |
+
# تفاصيل الألبوم إن وجدت
|
| 140 |
+
album_total = 1
|
| 141 |
+
album_index = 1
|
| 142 |
+
if album_info:
|
| 143 |
+
album_total = album_info.get("total", 1)
|
| 144 |
+
album_index = album_info.get("index", 1)
|
| 145 |
+
|
| 146 |
async with aiosqlite.connect(DB_PATH) as db:
|
| 147 |
try:
|
| 148 |
await db.execute("""
|
| 149 |
INSERT OR IGNORE INTO jobs
|
| 150 |
+
(chat_id, message_id, media_group_id, media_type, caption, source_name, album_total, album_index, status)
|
| 151 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')
|
| 152 |
+
""", (chat_id, message_id, media_group_id, media_type, raw_caption, source_name, album_total, album_index))
|
|
|
|
|
|
|
|
|
|
| 153 |
await db.commit()
|
| 154 |
+
stats["total_queued"] += 1
|
| 155 |
+
return True
|
| 156 |
except Exception as e:
|
| 157 |
+
logger.error(f"خطأ في إضافة الرسالة لقاعدة البيانات: {e}")
|
| 158 |
+
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
+
# ============ إعداد الكابشن الذكي ============
|
| 161 |
+
def format_caption(raw_cap, source_name, album_index, album_total):
|
| 162 |
+
lines = []
|
| 163 |
+
if raw_cap and raw_cap.strip():
|
| 164 |
+
lines.append(raw_cap.strip())
|
| 165 |
+
lines.append("")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
+
lines.append(f"📢 **المصدر:** {source_name}")
|
| 168 |
+
if album_total > 1:
|
| 169 |
+
lines.append(f"🔢 **الجزء:** {album_index} من {album_total}")
|
|
|
|
|
|
|
| 170 |
|
| 171 |
+
return "\n".join(lines)
|
| 172 |
+
|
| 173 |
+
# ============ النقل الذكي (Fast Copy مع Fallback للتحميل) ============
|
| 174 |
+
async def process_single_job(job):
|
| 175 |
+
chat_id = job["chat_id"]
|
| 176 |
+
msg_id = job["message_id"]
|
| 177 |
+
raw_caption = job["caption"]
|
| 178 |
+
source_name = job["source_name"]
|
| 179 |
+
album_index = job["album_index"]
|
| 180 |
+
album_total = job["album_total"]
|
| 181 |
|
| 182 |
+
caption = format_caption(raw_caption, source_name, album_index, album_total)
|
| 183 |
+
|
| 184 |
+
await rate_limiter.wait()
|
| 185 |
|
| 186 |
+
# 1. المحاولة الأولى: النسخ المباشر فائق السرعة بدون تحميل
|
| 187 |
try:
|
| 188 |
+
await app.copy_message(
|
| 189 |
+
chat_id=TARGET_CHAT,
|
| 190 |
+
from_chat_id=chat_id,
|
| 191 |
+
message_id=msg_id,
|
| 192 |
+
caption=caption
|
| 193 |
+
)
|
| 194 |
+
logger.info(f"⚡ تم النقل السريع بنجاح للرسالة {msg_id} من {source_name}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
return True
|
| 196 |
+
except (FloodWait, FloodPremiumWait) as e:
|
| 197 |
+
rate_limiter.set_flood(e.value)
|
| 198 |
+
raise e
|
| 199 |
+
except ChatForwardsRestricted:
|
| 200 |
+
logger.warning(f"⚠️ القناة محمية المحتوى! التحويل إلى وضع التحميل والرفع للرسالة {msg_id}...")
|
| 201 |
+
except MessageIdInvalid:
|
| 202 |
+
logger.error(f"❌ الرسالة {msg_id} محذوفة أو غير صالحة.")
|
| 203 |
+
return "DELETED"
|
| 204 |
except Exception as e:
|
| 205 |
+
logger.warning(f"⚠️ فشل النسخ المباشر للرسالة {msg_id} ({e})، جاري محاولة التحميل والرفع...")
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
+
# 2. المحاولة الثانية: التحميل والرفع (في حال كانت القناة تمنع النقل المباشر)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
try:
|
| 209 |
+
msg = await app.get_messages(chat_id, msg_id)
|
| 210 |
+
if not msg or not msg.media:
|
| 211 |
+
return "DELETED"
|
| 212 |
+
|
| 213 |
+
file_path = os.path.join(DOWNLOAD_DIR, f"temp_{chat_id}_{msg_id}")
|
| 214 |
+
downloaded = await app.download_media(msg, file_name=file_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
+
if not downloaded or not os.path.exists(downloaded):
|
| 217 |
+
raise Exception("فشل تنزيل الملف")
|
| 218 |
+
|
| 219 |
+
# إعادة الإرسال حسب النوع
|
| 220 |
+
mtype = job["media_type"]
|
| 221 |
+
if mtype == "photo":
|
| 222 |
+
await app.send_photo(TARGET_CHAT, photo=downloaded, caption=caption)
|
| 223 |
+
elif mtype == "video":
|
| 224 |
+
await app.send_video(TARGET_CHAT, video=downloaded, caption=caption, supports_streaming=True)
|
| 225 |
+
elif mtype == "document":
|
| 226 |
+
await app.send_document(TARGET_CHAT, document=downloaded, caption=caption)
|
| 227 |
+
elif mtype == "audio":
|
| 228 |
+
await app.send_audio(TARGET_CHAT, audio=downloaded, caption=caption)
|
| 229 |
+
elif mtype == "voice":
|
| 230 |
+
await app.send_voice(TARGET_CHAT, voice=downloaded, caption=caption)
|
| 231 |
+
elif mtype == "video_note":
|
| 232 |
+
await app.send_video_note(TARGET_CHAT, video_note=downloaded)
|
| 233 |
+
elif mtype == "animation":
|
| 234 |
+
await app.send_animation(TARGET_CHAT, animation=downloaded, caption=caption)
|
| 235 |
+
else:
|
| 236 |
+
await app.send_document(TARGET_CHAT, document=downloaded, caption=caption)
|
| 237 |
+
|
| 238 |
+
# تنظيف الملف المؤقت فوراً لتوفير المساحة
|
| 239 |
+
if os.path.exists(downloaded):
|
| 240 |
+
os.remove(downloaded)
|
| 241 |
+
|
| 242 |
+
logger.info(f"📥 تم التحميل والرفع بنجاح للرسالة {msg_id}")
|
| 243 |
return True
|
| 244 |
+
except (FloodWait, FloodPremiumWait) as e:
|
| 245 |
+
rate_limiter.set_flood(e.value)
|
| 246 |
+
raise e
|
| 247 |
except Exception as e:
|
| 248 |
+
logger.error(f"❌ فشل تام في معالجة الرسالة {msg_id}: {e}")
|
|
|
|
| 249 |
return False
|
| 250 |
|
| 251 |
+
# ============ عامل معالجة الطابور (Worker) ============
|
| 252 |
+
async def queue_worker():
|
| 253 |
+
logger.info("🚀 بدء تشغيل عامل الطابور...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
while True:
|
| 255 |
try:
|
| 256 |
if not app.is_connected:
|
| 257 |
+
await asyncio.sleep(5)
|
|
|
|
| 258 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
async with aiosqlite.connect(DB_PATH) as db:
|
| 261 |
db.row_factory = aiosqlite.Row
|
|
|
|
| 262 |
cursor = await db.execute("""
|
| 263 |
SELECT * FROM jobs
|
| 264 |
+
WHERE status = 'pending' AND attempts < 5
|
| 265 |
+
ORDER BY id ASC LIMIT 1
|
| 266 |
""")
|
| 267 |
+
job_row = await cursor.fetchone()
|
| 268 |
|
| 269 |
+
if not job_row:
|
| 270 |
+
await asyncio.sleep(2)
|
| 271 |
+
continue
|
| 272 |
+
|
| 273 |
+
job = dict(job_row)
|
| 274 |
+
job_id = job["id"]
|
| 275 |
+
|
| 276 |
+
await db.execute("UPDATE jobs SET status='processing' WHERE id=?", (job_id,))
|
| 277 |
+
await db.commit()
|
| 278 |
+
|
| 279 |
+
# المعالجة
|
| 280 |
+
result = await process_single_job(job)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
|
| 282 |
+
async with aiosqlite.connect(DB_PATH) as db:
|
| 283 |
+
if result is True:
|
| 284 |
+
await db.execute("UPDATE jobs SET status='completed' WHERE id=?", (job_id,))
|
| 285 |
+
stats["total_transferred"] += 1
|
| 286 |
+
elif result == "DELETED":
|
| 287 |
+
await db.execute("UPDATE jobs SET status='failed', error='Message Deleted/Invalid' WHERE id=?", (job_id,))
|
| 288 |
+
stats["total_failed"] += 1
|
| 289 |
+
else:
|
| 290 |
+
await db.execute("""
|
| 291 |
+
UPDATE jobs SET status='pending', attempts=attempts+1, error='Retry later' WHERE id=?
|
| 292 |
+
""", (job_id,))
|
| 293 |
+
await db.commit()
|
| 294 |
+
|
| 295 |
+
except (FloodWait, FloodPremiumWait) as e:
|
| 296 |
+
logger.warning(f"انتظار FloodWait: {e.value}s")
|
| 297 |
+
await asyncio.sleep(e.value + 1)
|
| 298 |
except Exception as e:
|
| 299 |
+
logger.error(f"خطأ غير متوقع في الـ Worker: {e}")
|
| 300 |
+
await asyncio.sleep(5)
|
| 301 |
|
| 302 |
+
# ============ ماسح الرسائل القديمة (History Scraper) ============
|
| 303 |
+
async def scrape_channel_history(chat_identifier, limit=0):
|
| 304 |
+
"""
|
| 305 |
+
يقوم بجلب كافة الرسائل القديمة من قناة أو مجموعة وترتيبها في الطابور
|
| 306 |
+
"""
|
| 307 |
try:
|
| 308 |
+
chat = await app.get_chat(chat_identifier)
|
| 309 |
+
chat_id = chat.id
|
| 310 |
+
chat_title = chat.title or chat.first_name or chat_identifier
|
| 311 |
+
logger.info(f"🔄 بدء جلب الرسائل القديمة من: {chat_title} ({chat_id})")
|
| 312 |
+
|
| 313 |
+
count = 0
|
| 314 |
+
media_groups = {}
|
| 315 |
+
|
| 316 |
+
# قراءة الأرشيف من الأقدم للأحدث أو من الأحدث للأقدم
|
| 317 |
+
async for message in app.get_chat_history(chat_id, limit=limit if limit > 0 else 0):
|
| 318 |
+
if message.media:
|
| 319 |
+
# إذا كان ألبوم
|
| 320 |
+
if message.media_group_id:
|
| 321 |
+
mg_id = str(message.media_group_id)
|
| 322 |
+
media_groups.setdefault(mg_id, []).append(message)
|
| 323 |
+
else:
|
| 324 |
+
await add_message_to_queue(message)
|
| 325 |
+
count += 1
|
| 326 |
+
|
| 327 |
+
if count % 50 == 0 and count > 0:
|
| 328 |
+
logger.info(f"📦 تم جدولة {count} رسالة من {chat_title}...")
|
| 329 |
+
await asyncio.sleep(0.1)
|
| 330 |
+
|
| 331 |
+
# إضافة الألبومات مع حساب عدد المقاطع لكل ألبوم
|
| 332 |
+
for mg_id, msgs in media_groups.items():
|
| 333 |
+
# ترتيب رسائل الألبوم بالترتيب الزمني الصحيح
|
| 334 |
+
msgs.sort(key=lambda m: m.id)
|
| 335 |
+
total = len(msgs)
|
| 336 |
+
for idx, msg in enumerate(msgs, start=1):
|
| 337 |
+
await add_message_to_queue(msg, album_info={"total": total, "index": idx})
|
| 338 |
+
count += 1
|
| 339 |
+
|
| 340 |
+
logger.info(f"✅ تم الانتهاء من فحص {chat_title}! إجمالي الوسائط المجدولة: {count}")
|
| 341 |
+
return f"تم فحص {chat_title} بنجاح وجدولة {count} ملف."
|
| 342 |
except Exception as e:
|
| 343 |
+
logger.error(f"❌ فشل فحص القناة {chat_identifier}: {e}")
|
| 344 |
+
return f"خطأ في فحص {chat_identifier}: {str(e)}"
|
| 345 |
|
| 346 |
+
# ============ رصد الرسائل الجديدة لحظياً (Real-time Listener) ============
|
| 347 |
@app.on_message(filters.incoming & ~filters.me & filters.media)
|
| 348 |
+
async def realtime_media_handler(client, message):
|
| 349 |
+
try:
|
| 350 |
+
# تجنب رصد الرسائل التي ترسلها الوجهة
|
| 351 |
+
if str(message.chat.id) == str(TARGET_CHAT):
|
| 352 |
+
return
|
| 353 |
+
|
| 354 |
+
if message.media_group_id:
|
| 355 |
+
# إذا كان ألبوم، انتظر ثانية واحدة لتكتمل حزمة الألبوم ثم جدولها
|
| 356 |
+
await asyncio.sleep(1.5)
|
| 357 |
+
try:
|
| 358 |
+
group_messages = await client.get_media_group(message.chat.id, message.id)
|
| 359 |
+
group_messages.sort(key=lambda m: m.id)
|
| 360 |
+
total = len(group_messages)
|
| 361 |
+
for idx, msg in enumerate(group_messages, start=1):
|
| 362 |
+
await add_message_to_queue(msg, album_info={"total": total, "index": idx})
|
| 363 |
+
except Exception:
|
| 364 |
+
# إذا فشل جلب المجموعة كاملة، أضف الرسالة مفردة
|
| 365 |
+
await add_message_to_queue(message)
|
| 366 |
+
else:
|
| 367 |
+
await add_message_to_queue(message)
|
| 368 |
+
|
| 369 |
+
logger.info(f"✨ رصد رسالة جديدة من {message.chat.title or message.chat.id}")
|
| 370 |
+
except Exception as e:
|
| 371 |
+
logger.error(f"خطأ في الرصد اللحظي: {e}")
|
| 372 |
|
| 373 |
+
# ============ تهيئة الهدف (TARGET_CHAT) ============
|
| 374 |
+
async def resolve_target():
|
| 375 |
global TARGET_CHAT
|
| 376 |
+
raw = TARGET_CHAT_RAW.strip()
|
| 377 |
+
if raw.lower() in ["me", "saved", ""]:
|
| 378 |
TARGET_CHAT = "me"
|
| 379 |
+
logger.info("🎯 الوجهة المحددة: الرسائل المحفوظة (Saved Messages)")
|
| 380 |
return
|
| 381 |
try:
|
| 382 |
chat_id_int = int(raw)
|
| 383 |
chat = await app.get_chat(chat_id_int)
|
| 384 |
TARGET_CHAT = chat_id_int
|
| 385 |
+
logger.info(f"🎯 الوجهة المحددة: {chat.title} ({TARGET_CHAT})")
|
| 386 |
+
except ValueError:
|
| 387 |
+
try:
|
| 388 |
+
chat = await app.get_chat(raw)
|
| 389 |
+
TARGET_CHAT = chat.id
|
| 390 |
+
logger.info(f"🎯 الوجهة المحددة: {chat.title} ({TARGET_CHAT})")
|
| 391 |
+
except Exception as e:
|
| 392 |
+
logger.error(f"فشل التحقق من الوجهة ({raw}): {e}. تم التحويل للرسائل المحفوظة.")
|
| 393 |
+
TARGET_CHAT = "me"
|
| 394 |
|
| 395 |
+
# ============ واجهة التحكم السريعة (Gradio UI) ============
|
| 396 |
+
def create_ui():
|
| 397 |
with gr.Blocks(title="Telegram Archiver Pro") as demo:
|
| 398 |
+
gr.Markdown("## 🚀 محول وأرشيف تيليجرام الاحترافي (بدون ضياع ملفات)")
|
| 399 |
+
|
| 400 |
+
with gr.Row():
|
| 401 |
+
status_box = gr.Textbox(label="حالة النظام", value="جاري التشغيل...", interactive=False)
|
| 402 |
+
queued_box = gr.Number(label="الملفات في الطابور", value=0, interactive=False)
|
| 403 |
+
transferred_box = gr.Number(label="الملفات المنقولة بنجاح", value=0, interactive=False)
|
| 404 |
+
|
| 405 |
+
gr.Markdown("---")
|
| 406 |
+
gr.Markdown("### 📥 سحب الأرشيف القديم من قناة أو كروب:")
|
| 407 |
+
with gr.Row():
|
| 408 |
+
channel_input = gr.Textbox(
|
| 409 |
+
label="معرف أو رابط القناة / الكروب",
|
| 410 |
+
placeholder="@my_channel أو -100123456789 أو رابط القناة"
|
| 411 |
+
)
|
| 412 |
+
limit_input = gr.Number(label="أقصى عدد رسائل (0 = الكل)", value=0)
|
| 413 |
+
scrape_btn = gr.Button("بدء السحب الآن 📥", variant="primary")
|
| 414 |
+
|
| 415 |
+
scrape_output = gr.Textbox(label="نتيجة الفحص", interactive=False)
|
| 416 |
+
|
| 417 |
+
def do_scrape(ch, lim):
|
| 418 |
+
if not ch:
|
| 419 |
+
return "يرجى كتابة معرف القناة أولاً"
|
| 420 |
+
loop = asyncio.new_event_loop()
|
| 421 |
+
res = loop.run_until_complete(scrape_channel_history(ch.strip(), int(lim)))
|
| 422 |
+
loop.close()
|
| 423 |
+
return res
|
| 424 |
+
|
| 425 |
+
scrape_btn.click(fn=do_scrape, inputs=[channel_input, limit_input], outputs=[scrape_output])
|
| 426 |
+
|
| 427 |
+
return demo
|
| 428 |
+
|
| 429 |
+
# ============ تشغيل خادم الويب و Hydrogram ============
|
| 430 |
+
def run_web():
|
| 431 |
fastapi_app = FastAPI()
|
| 432 |
+
demo = create_ui()
|
| 433 |
+
gr.mount_gradio_app(fastapi_app, demo, path="/")
|
|
|
|
|
|
|
| 434 |
|
| 435 |
@fastapi_app.get("/ping")
|
| 436 |
async def ping():
|
| 437 |
+
return {"status": "alive", "stats": stats}
|
| 438 |
+
|
|
|
|
|
|
|
|
|
|
| 439 |
uvicorn.run(fastapi_app, host="0.0.0.0", port=7860)
|
| 440 |
|
|
|
|
| 441 |
async def main():
|
| 442 |
+
logger.info("جاري تسجيل الدخول إلى تيليجرام...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
await app.start()
|
| 444 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
me = await app.get_me()
|
| 446 |
+
logger.info(f"✅ تم الدخول بحساب: {me.first_name} (@{me.username or me.id})")
|
| 447 |
+
|
| 448 |
+
await resolve_target()
|
| 449 |
await init_db()
|
| 450 |
|
| 451 |
+
# تشغيل العامل في الخلفية
|
| 452 |
+
asyncio.create_task(queue_worker())
|
|
|
|
|
|
|
| 453 |
|
| 454 |
+
# فحص القنوات المحددة في المتغيرات البيئية إن وجدت
|
| 455 |
+
if CHANNELS_TO_SCRAPE:
|
| 456 |
+
channels = [c.strip() for c in CHANNELS_TO_SCRAPE.split(",") if c.strip()]
|
| 457 |
+
for ch in channels:
|
| 458 |
+
asyncio.create_task(scrape_channel_history(ch))
|
| 459 |
+
|
| 460 |
+
stats["status"] = "يعمل بكفاءة ✅"
|
| 461 |
|
| 462 |
+
# الإبقاء قيد التشغيل
|
| 463 |
stop_event = asyncio.Event()
|
| 464 |
await stop_event.wait()
|
| 465 |
|
| 466 |
if __name__ == "__main__":
|
| 467 |
+
# تشغيل واجهة الويب في Thread مستقل لمنع تعليق البوت وتلبية متطلبات Hugging Face
|
| 468 |
+
web_thread = threading.Thread(target=run_web, daemon=True)
|
| 469 |
+
web_thread.start()
|
| 470 |
|
| 471 |
loop = asyncio.get_event_loop()
|
| 472 |
try:
|
|
|
|
| 474 |
except KeyboardInterrupt:
|
| 475 |
pass
|
| 476 |
finally:
|
| 477 |
+
if app.is_connected:
|
| 478 |
+
loop.run_until_complete(app.stop())
|
|
|
|
|
|