Reooiq commited on
Commit
8a52868
·
verified ·
1 Parent(s): 04b39a1

Update bot.py

Browse files
Files changed (1) hide show
  1. bot.py +455 -314
bot.py CHANGED
@@ -3,6 +3,7 @@ import asyncio
3
  import logging
4
  import time
5
  import threading
 
6
  import gradio as gr
7
  from collections import defaultdict
8
  from pyrogram import Client, filters
@@ -31,8 +32,10 @@ API_HASH = os.environ.get("API_HASH", "")
31
  SESSION_STRING = os.environ.get("SESSION_STRING", "")
32
  TARGET_CHAT_RAW = os.environ.get("TARGET_CHAT", "me")
33
 
34
- DATA_DIR = "./data"
 
35
  DOWNLOAD_DIR = os.path.join(DATA_DIR, "downloads")
 
36
  os.makedirs(DOWNLOAD_DIR, exist_ok=True)
37
 
38
  # ============ العميل ============
@@ -51,20 +54,200 @@ app = Client(
51
 
52
  TARGET_CHAT = None
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  # ============ Semaphores ============
55
- # Semaphore منفصل للألبومات والرسائل المنفردة لعدم التأثير على بعض
56
- album_sem = asyncio.Semaphore(1)
57
- single_sem = asyncio.Semaphore(2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
- # ============ نظام الألبومات ============
60
- albums_storage = defaultdict(lambda: defaultdict(list))
61
- albums_lock = asyncio.Lock()
62
- albums_timers = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
- # ============ Deduplication (منع التكرار) ============
65
- sent_messages = set() # تتبع (chat_id, message_id) المرسلة
66
- dedup_lock = asyncio.Lock()
 
 
 
 
 
 
 
 
 
 
 
 
67
 
 
68
  def get_media_info(message):
69
  if message.photo:
70
  return "photo", ".jpg"
@@ -92,38 +275,75 @@ def get_source_name(message):
92
  return chat.first_name or chat.username or "خاص"
93
  return "مصدر"
94
 
95
- def create_input_media(message, caption=None):
96
- """إنشاء InputMedia للألبوم — يدعم جميع الأنواع المسموح بها في send_media_group"""
97
- cap = caption or message.caption or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
- if message.photo:
100
- return InputMediaPhoto(
101
- media=message.photo.file_id,
102
- caption=cap,
103
- caption_entities=message.caption_entities
104
- )
105
- elif message.video:
106
- return InputMediaVideo(
107
- media=message.video.file_id,
108
- caption=cap,
109
- caption_entities=message.caption_entities,
110
- supports_streaming=message.video.supports_streaming
111
- )
112
- elif message.document:
113
- return InputMediaDocument(
114
- media=message.document.file_id,
115
- caption=cap,
116
- caption_entities=message.caption_entities
117
- )
118
- elif message.audio:
119
- return InputMediaAudio(
120
- media=message.audio.file_id,
121
- caption=cap,
122
- caption_entities=message.caption_entities
123
  )
124
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
- # ============ دالة إرسال متقدمة مع Tenacity ============
127
  @retry(
128
  stop=stop_after_attempt(5),
129
  wait=wait_exponential_jitter(initial=1, max=60),
@@ -131,338 +351,252 @@ def create_input_media(message, caption=None):
131
  reraise=True
132
  )
133
  async def safe_send(send_func):
134
- """إرسال مع exponential backoff + jitter + 5 محاولات"""
135
  return await send_func()
136
 
137
- async def send_with_retry(send_func, max_retries=5):
138
- """نسخة يدوية إذا أردت التحكم — لكن safe_send أفضل"""
139
- last_error = None
140
- for attempt in range(max_retries):
141
- try:
142
- return await send_func()
143
- except FloodWait as e:
144
- wait = min(e.value, 60) # كاب 60 ثانية
145
- logger.warning(f"⏳ FloodWait: انتظار {wait} ثانية (محاولة {attempt+1}/{max_retries})")
146
- await asyncio.sleep(wait)
147
- last_error = e
148
- except (ConnectionError, TimeoutError) as e:
149
- wait = min(2 ** attempt, 60)
150
- logger.warning(f"🌐 Connection error: انتظار {wait} ثانية (محاولة {attempt+1}/{max_retries})")
151
- await asyncio.sleep(wait)
152
- last_error = e
153
- except Exception as e:
154
- logger.error(f"❌ خطأ غير متوقع: {e}")
155
- raise
156
- raise last_error or Exception("Failed after retries")
157
-
158
- # ============ إرسال الألبوم ============
159
- async def send_album(chat_id, media_group_id):
160
- """إرسال الألبوم مع الحفاظ على الترتيب وعدم فقدان أي ملف"""
161
- await asyncio.sleep(2.5) # انتظار أطول قليلاً لجمع كل الرسائل
162
 
163
- async with albums_lock:
164
- key = (chat_id, media_group_id)
165
- if key not in albums_timers:
166
- return
167
- albums_timers.pop(key, None)
168
- messages = albums_storage[chat_id].pop(media_group_id, [])
169
 
170
- if not messages:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  return
172
 
173
- messages.sort(key=lambda m: m.id)
174
- total = len(messages)
175
- logger.info(f"📦 ألبوم {media_group_id} | {total} رسائل | ترتيب: {[m.id for m in messages]}")
 
 
176
 
177
- src_name = get_source_name(messages[0])
 
178
 
179
- # فصل الرسائل: المدعومة في الألبوم vs المنفردة
180
- album_media = []
181
- album_messages = []
182
- standalone_msgs = []
183
 
184
- for i, msg in enumerate(messages):
185
  if i == 0:
186
- raw_cap = msg.caption or ""
187
- final_cap = f"{raw_cap}\n\n📌 المصدر: {src_name}".strip() if raw_cap else f"📌 المصدر: {src_name}"
188
  else:
189
- final_cap = None
190
 
191
- input_media = create_input_media(msg, final_cap)
192
- if input_media:
193
- album_media.append(input_media)
194
- album_messages.append(msg)
 
 
 
 
 
 
 
 
 
 
 
195
  else:
196
- standalone_msgs.append(msg)
197
- logger.info(f"📎 رسالة {msg.id} من نوع {get_media_info(msg)[0]} ستُرسل منفردة")
198
 
199
- # 🔑 إرسال الألبوم الرئيسي (المدعوم) — تقسيم إذا >10
200
- if album_media:
201
- async with album_sem:
202
- # تقسيم الألبوم إلى مجموعات من 10 (حد Telegram)
203
- chunk_size = 10
204
- for chunk_idx in range(0, len(album_media), chunk_size):
205
- chunk_media = album_media[chunk_idx:chunk_idx + chunk_size]
206
- chunk_msgs = album_messages[chunk_idx:chunk_idx + chunk_size]
207
-
208
- try:
209
- if len(chunk_media) == 1:
210
- await safe_send(lambda: chunk_msgs[0].copy(chat_id=TARGET_CHAT))
211
- logger.info(f"✅ ألبوم {media_group_id} | جزء {chunk_idx//chunk_size + 1} | 1 ملف تم النسخ")
212
- else:
213
- await safe_send(
214
- lambda: app.send_media_group(
215
- chat_id=TARGET_CHAT,
216
- media=chunk_media,
217
- disable_notification=True
218
- )
219
- )
220
- logger.info(f"✅ ألبوم {media_group_id} | جزء {chunk_idx//chunk_size + 1} | {len(chunk_media)} ملفات (file_id)")
221
- except Exception as e:
222
- logger.warning(f"⚠️ فشل النسخ السريع للجزء {chunk_idx//chunk_size + 1}: {e}")
223
- await download_and_send_album(chunk_msgs, src_name)
224
-
225
- # 🔑 إرسال الرسائل المنفردة (voice, video_note, animation)
226
- for msg in standalone_msgs:
227
- async with single_sem:
228
  try:
229
- await safe_send(lambda: msg.copy(chat_id=TARGET_CHAT))
230
- logger.info(f"✅ رسالة منفردة {msg.id} تم النسخ")
 
 
 
 
 
 
 
 
 
 
231
  except Exception as e:
232
- logger.warning(f"⚠️ فشل نسخ منفرد {msg.id}: {e}")
233
- await download_and_send_single(msg)
 
 
 
234
 
235
- async def download_and_send_album(messages, src_name=None):
236
- """تحميل وإرسال الألبوم — مع FloodWait handling"""
237
- if not messages:
238
- return
239
-
240
- messages.sort(key=lambda m: m.id)
241
- if src_name is None:
242
- src_name = get_source_name(messages[0])
243
-
244
- downloaded_files = []
245
- media_inputs = []
246
- msg_map = []
247
-
248
- try:
249
- for i, msg in enumerate(messages):
250
- m_type, ext = get_media_info(msg)
251
- if not m_type:
252
  continue
253
 
254
- file_path = await app.download_media(
255
- msg,
256
- file_name=f"{DOWNLOAD_DIR}/album_{msg.chat.id}_{msg.media_group_id}_{msg.id}{ext}"
257
- )
 
258
 
259
- if file_path and os.path.exists(file_path) and os.path.getsize(file_path) > 0:
260
- downloaded_files.append(file_path)
261
- msg_map.append(msg)
262
-
263
- if i == 0:
264
- raw_cap = msg.caption or ""
265
- final_cap = f"{raw_cap}\n\n📌 المصدر: {src_name}".strip() if raw_cap else f"📌 المصدر: {src_name}"
266
- else:
267
- final_cap = None
268
-
269
- if m_type == "photo":
270
- media_inputs.append(InputMediaPhoto(media=file_path, caption=final_cap))
271
- elif m_type == "video":
272
- media_inputs.append(InputMediaVideo(media=file_path, caption=final_cap, supports_streaming=True))
273
- elif m_type == "doc":
274
- media_inputs.append(InputMediaDocument(media=file_path, caption=final_cap))
275
- elif m_type == "audio":
276
- media_inputs.append(InputMediaAudio(media=file_path, caption=final_cap))
277
-
278
- # تقسيم إلى مجموعات من 10
279
- chunk_size = 10
280
- for chunk_idx in range(0, len(media_inputs), chunk_size):
281
- chunk_inputs = media_inputs[chunk_idx:chunk_idx + chunk_size]
282
 
283
- if len(chunk_inputs) >= 2:
284
- await safe_send(
285
- lambda ci=chunk_inputs: app.send_media_group(
286
- chat_id=TARGET_CHAT,
287
- media=ci,
288
- disable_notification=True
289
- )
290
- )
291
- logger.info(f"✅ ألبوم (تحميل) | جزء {chunk_idx//chunk_size + 1} | {len(chunk_inputs)} ملفات")
292
- elif len(chunk_inputs) == 1:
293
- msg = msg_map[chunk_idx]
294
- m_type, _ = get_media_info(msg)
295
- file_path = downloaded_files[chunk_idx]
296
- raw_cap = msg.caption or ""
297
- final_cap = f"{raw_cap}\n\n📌 المصدر: {src_name}".strip() if raw_cap else f"📌 المصدر: {src_name}"
298
-
299
- if m_type == "photo":
300
- await safe_send(lambda: app.send_photo(TARGET_CHAT, photo=file_path, caption=final_cap))
301
- elif m_type == "video":
302
- await safe_send(lambda: app.send_video(TARGET_CHAT, video=file_path, caption=final_cap, supports_streaming=True))
303
- elif m_type == "doc":
304
- await safe_send(lambda: app.send_document(TARGET_CHAT, document=file_path, caption=final_cap))
305
- elif m_type == "audio":
306
- await safe_send(lambda: app.send_audio(TARGET_CHAT, audio=file_path, caption=final_cap))
307
- elif m_type == "voice":
308
- await safe_send(lambda: app.send_voice(TARGET_CHAT, voice=file_path, caption=final_cap))
309
- elif m_type == "video_note":
310
- await safe_send(lambda: app.send_video_note(TARGET_CHAT, video_note=file_path))
311
- elif m_type == "animation":
312
- await safe_send(lambda: app.send_animation(TARGET_CHAT, animation=file_path, caption=final_cap))
313
-
314
- except Exception as e:
315
- logger.error(f"❌ فشل إرسال الألبوم: {e}")
316
- finally:
317
- for f in downloaded_files:
318
- try:
319
- if os.path.exists(f):
320
- os.remove(f)
321
- except:
322
- pass
323
 
324
- async def download_and_send_single(message):
325
- """تحميل وإرسال رسالة واحدة — مع FloodWait handling"""
326
- m_type, ext = get_media_info(message)
327
- if not m_type:
328
- return
329
-
330
- src_name = get_source_name(message)
331
- raw_cap = message.caption or ""
332
- final_cap = f"{raw_cap}\n\n📌 المصدر: {src_name}".strip() if raw_cap else f"📌 المصدر: {src_name}"
333
- file_path = None
334
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  try:
336
- file_path = await app.download_media(
337
- message,
338
- file_name=f"{DOWNLOAD_DIR}/{message.chat.id}_{message.id}{ext}"
339
- )
340
- if not file_path or not os.path.exists(file_path) or os.path.getsize(file_path) == 0:
341
- logger.error(f"❌ فشل تحميل الرسالة {message.id}")
342
- return
343
-
344
- if m_type == "photo":
345
- await safe_send(lambda: app.send_photo(TARGET_CHAT, photo=file_path, caption=final_cap))
346
- elif m_type == "video":
347
- await safe_send(lambda: app.send_video(TARGET_CHAT, video=file_path, caption=final_cap, supports_streaming=True))
348
- elif m_type == "doc":
349
- await safe_send(lambda: app.send_document(TARGET_CHAT, document=file_path, caption=final_cap))
350
- elif m_type == "audio":
351
- await safe_send(lambda: app.send_audio(TARGET_CHAT, audio=file_path, caption=final_cap))
352
- elif m_type == "voice":
353
- await safe_send(lambda: app.send_voice(TARGET_CHAT, voice=file_path, caption=final_cap))
354
- elif m_type == "video_note":
355
- await safe_send(lambda: app.send_video_note(TARGET_CHAT, video_note=file_path))
356
- elif m_type == "animation":
357
- await safe_send(lambda: app.send_animation(TARGET_CHAT, animation=file_path, caption=final_cap))
358
-
359
- logger.info(f"✅ تم الإرسال (تحميل): {message.id}")
360
  except Exception as e:
361
- logger.error(f"❌ فشل الإرسال النهائي {message.id}: {e}")
362
- finally:
363
- if file_path and os.path.exists(file_path):
364
- try:
365
- os.remove(file_path)
366
- except:
367
- pass
368
 
 
369
  @app.on_message(filters.incoming & ~filters.me & filters.media)
370
  async def on_media(client, message):
371
  chat_id = message.chat.id
372
- msg_id = message.id
373
-
374
- # Deduplication
375
- dedup_key = (chat_id, msg_id)
376
- async with dedup_lock:
377
- if dedup_key in sent_messages:
378
- logger.debug(f"⏭️ تخطي رسالة مكررة: {msg_id}")
379
- return
380
- sent_messages.add(dedup_key)
381
-
382
  if str(chat_id) == str(TARGET_CHAT):
383
  return
384
 
385
  src_name = get_source_name(message)
386
  m_type, _ = get_media_info(message)
387
- logger.info(f"📥 جديد من {chat_id} | {src_name} | رسالة {msg_id} | نوع: {m_type} | ألبوم: {message.media_group_id is not None}")
388
 
389
- # إذا ألبوم
390
  if message.media_group_id:
391
- mg_id = message.media_group_id
392
- async with albums_lock:
393
- albums_storage[chat_id][mg_id].append(message)
394
- key = (chat_id, mg_id)
395
- if key in albums_timers:
396
- albums_timers[key].cancel()
397
-
398
- async def delayed_send():
399
- await asyncio.sleep(2.5)
400
- await send_album(chat_id, mg_id)
401
-
402
- timer_task = asyncio.create_task(delayed_send())
403
- albums_timers[key] = timer_task
404
- return
405
-
406
- # رسالة منفردة
407
- async with single_sem:
408
- try:
409
- await safe_send(lambda: message.copy(chat_id=TARGET_CHAT))
410
- logger.info(f"✅ تم النسخ: {msg_id}")
411
- return
412
- except Exception as e:
413
- logger.warning(f"⚠️ فشل النسخ {msg_id}: {e}")
414
-
415
- # Fallback: تحميل وإرسال
416
- await download_and_send_single(message)
417
 
418
  @app.on_message(filters.incoming & ~filters.me)
419
  async def monitor_all(client, message):
420
  if str(message.chat.id) == str(TARGET_CHAT):
421
  return
422
- src_name = get_source_name(message)
423
- logger.info(f"👁️ رسالة من {message.chat.id} | نوع: {message.chat.type} | اسم: {src_name}")
424
 
425
  async def resolve_target_chat():
426
  global TARGET_CHAT
427
  raw = TARGET_CHAT_RAW
428
  if raw.lower() == "me":
429
  TARGET_CHAT = "me"
430
- logger.info("🎯 الهدف: Saved Messages (me)")
431
  return
432
  try:
433
  chat_id_int = int(raw)
434
  chat = await app.get_chat(chat_id_int)
435
  TARGET_CHAT = chat_id_int
436
- logger.info(f"🎯 تم التحقق من القناة: {chat.title} | id: {chat_id_int}")
437
  except Exception as e:
438
- logger.error(f"❌ فشل التحقق من القناة {raw}: {e}")
439
  TARGET_CHAT = "me"
440
 
441
- async def self_ping():
442
- while True:
443
- try:
444
- await asyncio.sleep(600)
445
- await app.invoke(pyrogram.raw.functions.Ping(ping_id=0))
446
- logger.debug("🏓 Self-ping")
447
- except Exception as e:
448
- logger.debug(f"Self-ping error: {e}")
449
-
450
  # ============ Gradio + FastAPI ============
451
  def run_gradio():
452
- with gr.Blocks(title="Telegram Archiver") as demo:
453
- gr.Markdown("# 📡 Telegram Archiver")
454
- gr.Markdown("🟢 **الحالة:** البوت يعمل في الخلفية")
455
- gr.Textbox(label="الحالة", value="✅ نشط", interactive=False)
456
 
457
  fastapi_app = FastAPI()
458
 
459
  @fastapi_app.get("/health")
460
- @fastapi_app.head("/health")
461
  async def health_check():
462
  return {"status": "ok", "timestamp": time.time()}
463
 
464
  @fastapi_app.get("/ping")
465
- @fastapi_app.head("/ping")
466
  async def ping():
467
  return {"status": "alive"}
468
 
@@ -471,17 +605,24 @@ def run_gradio():
471
  import uvicorn
472
  uvicorn.run(fastapi_app, host="0.0.0.0", port=7860)
473
 
 
474
  async def main():
475
  await app.start()
476
  me = await app.get_me()
477
  await resolve_target_chat()
478
- asyncio.create_task(self_ping())
 
 
 
 
479
 
480
- logger.info("=" * 50)
481
- logger.info(f"🚀 البوت يعمل!")
482
- logger.info(f"👤 الحساب: {me.first_name} (@{me.username})")
483
- logger.info(f"🎯 القناة الهدف: {TARGET_CHAT}")
484
- logger.info("=" * 50)
 
 
485
 
486
  stop_event = asyncio.Event()
487
  await stop_event.wait()
 
3
  import logging
4
  import time
5
  import threading
6
+ import aiosqlite
7
  import gradio as gr
8
  from collections import defaultdict
9
  from pyrogram import Client, filters
 
32
  SESSION_STRING = os.environ.get("SESSION_STRING", "")
33
  TARGET_CHAT_RAW = os.environ.get("TARGET_CHAT", "me")
34
 
35
+ # 🔑 استخدام /data للـ persistent storage (1TB)
36
+ DATA_DIR = "/data"
37
  DOWNLOAD_DIR = os.path.join(DATA_DIR, "downloads")
38
+ DB_PATH = os.path.join(DATA_DIR, "queue.db")
39
  os.makedirs(DOWNLOAD_DIR, exist_ok=True)
40
 
41
  # ============ العميل ============
 
54
 
55
  TARGET_CHAT = None
56
 
57
+ # ============ Throttle Controller (منع FloodWait) ============
58
+ class ThrottleController:
59
+ """تنظيم الإرسال: 1 رسالة/ثانية لكل دردشة"""
60
+ def __init__(self, rate=1.0):
61
+ self.rate = rate
62
+ self.last_send = defaultdict(float)
63
+ self.lock = asyncio.Lock()
64
+
65
+ async def wait(self, chat_id):
66
+ async with self.lock:
67
+ now = time.time()
68
+ elapsed = now - self.last_send[chat_id]
69
+ if elapsed < self.rate:
70
+ wait_time = self.rate - elapsed
71
+ logger.debug(f"⏳ Throttle: انتظار {wait_time:.2f}s للدردشة {chat_id}")
72
+ await asyncio.sleep(wait_time)
73
+ self.last_send[chat_id] = time.time()
74
+
75
+ throttle = ThrottleController(rate=1.2) # 1 رسالة كل 1.2 ثانية
76
+
77
  # ============ Semaphores ============
78
+ download_sem = asyncio.Semaphore(3) # 3 تحميلات متوازية كحد أقصى
79
+ send_sem = asyncio.Semaphore(2) # 2 إرساليات متوازية
80
+
81
+ # ============ قاعدة البيانات ============
82
+ async def init_db():
83
+ """إنشاء جداول SQLite في /data (persistent)"""
84
+ async with aiosqlite.connect(DB_PATH) as db:
85
+ await db.execute("""
86
+ CREATE TABLE IF NOT EXISTS jobs (
87
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
88
+ chat_id INTEGER NOT NULL,
89
+ message_id INTEGER NOT NULL,
90
+ media_group_id TEXT,
91
+ media_type TEXT,
92
+ file_id TEXT,
93
+ file_name TEXT,
94
+ file_size INTEGER DEFAULT 0,
95
+ file_path TEXT,
96
+ caption TEXT,
97
+ source_name TEXT,
98
+ status TEXT DEFAULT 'pending',
99
+ attempts INTEGER DEFAULT 0,
100
+ error TEXT,
101
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
102
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
103
+ UNIQUE(chat_id, message_id)
104
+ )
105
+ """)
106
+ await db.execute("""
107
+ CREATE TABLE IF NOT EXISTS albums (
108
+ media_group_id TEXT PRIMARY KEY,
109
+ chat_id INTEGER,
110
+ total_expected INTEGER DEFAULT 0,
111
+ collected INTEGER DEFAULT 0,
112
+ status TEXT DEFAULT 'collecting',
113
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
114
+ )
115
+ """)
116
+ await db.execute("""
117
+ CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)
118
+ """)
119
+ await db.execute("""
120
+ CREATE INDEX IF NOT EXISTS idx_jobs_media_group ON jobs(media_group_id)
121
+ """)
122
+ await db.commit()
123
+ logger.info("✅ قاعدة البيانات جاهزة")
124
+
125
+ async def add_job(message):
126
+ """إضافة مهمة جديدة للـ queue"""
127
+ m_type, ext = get_media_info(message)
128
+ if not m_type:
129
+ return
130
+
131
+ # الحصول على file_id و file_size
132
+ file_id = None
133
+ file_size = 0
134
+ file_name = None
135
+
136
+ if message.photo:
137
+ file_id = message.photo.file_id
138
+ file_size = message.photo.file_size or 0
139
+ elif message.video:
140
+ file_id = message.video.file_id
141
+ file_size = message.video.file_size or 0
142
+ file_name = message.video.file_name
143
+ elif message.document:
144
+ file_id = message.document.file_id
145
+ file_size = message.document.file_size or 0
146
+ file_name = message.document.file_name
147
+ elif message.audio:
148
+ file_id = message.audio.file_id
149
+ file_size = message.audio.file_size or 0
150
+ file_name = message.audio.file_name
151
+ elif message.voice:
152
+ file_id = message.voice.file_id
153
+ file_size = message.voice.file_size or 0
154
+ elif message.video_note:
155
+ file_id = message.video_note.file_id
156
+ file_size = message.video_note.file_size or 0
157
+ elif message.animation:
158
+ file_id = message.animation.file_id
159
+ file_size = message.animation.file_size or 0
160
+
161
+ src_name = get_source_name(message)
162
+ raw_cap = message.caption or ""
163
+ final_cap = f"{raw_cap}\n\n📌 المصدر: {src_name}".strip() if raw_cap else f"📌 المصدر: {src_name}"
164
+
165
+ async with aiosqlite.connect(DB_PATH) as db:
166
+ try:
167
+ await db.execute("""
168
+ INSERT OR IGNORE INTO jobs
169
+ (chat_id, message_id, media_group_id, media_type, file_id, file_name, file_size, caption, source_name, status)
170
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
171
+ """, (
172
+ message.chat.id, message.id, message.media_group_id,
173
+ m_type, file_id, file_name, file_size, final_cap, src_name
174
+ ))
175
+ await db.commit()
176
+ except Exception as e:
177
+ logger.error(f"❌ خطأ في إضافة المهمة: {e}")
178
 
179
+ async def update_job_status(chat_id, message_id, status, file_path=None, error=None):
180
+ """تحديث حالة المهمة"""
181
+ async with aiosqlite.connect(DB_PATH) as db:
182
+ if file_path:
183
+ await db.execute("""
184
+ UPDATE jobs SET status=?, file_path=?, updated_at=CURRENT_TIMESTAMP
185
+ WHERE chat_id=? AND message_id=?
186
+ """, (status, file_path, chat_id, message_id))
187
+ elif error:
188
+ await db.execute("""
189
+ UPDATE jobs SET status=?, error=?, attempts=attempts+1, updated_at=CURRENT_TIMESTAMP
190
+ WHERE chat_id=? AND message_id=?
191
+ """, (status, error, chat_id, message_id))
192
+ else:
193
+ await db.execute("""
194
+ UPDATE jobs SET status=?, updated_at=CURRENT_TIMESTAMP
195
+ WHERE chat_id=? AND message_id=?
196
+ """, (status, chat_id, message_id))
197
+ await db.commit()
198
+
199
+ async def get_pending_jobs(limit=5):
200
+ """جلب المهام المعلقة"""
201
+ async with aiosqlite.connect(DB_PATH) as db:
202
+ db.row_factory = aiosqlite.Row
203
+ cursor = await db.execute("""
204
+ SELECT * FROM jobs
205
+ WHERE status='pending' AND attempts < 10
206
+ ORDER BY created_at
207
+ LIMIT ?
208
+ """, (limit,))
209
+ rows = await cursor.fetchall()
210
+ return [dict(row) for row in rows]
211
+
212
+ async def get_downloaded_album_jobs(media_group_id):
213
+ """جلب كل ملفات ألبوم محملة وجاهزة للإرسال"""
214
+ async with aiosqlite.connect(DB_PATH) as db:
215
+ db.row_factory = aiosqlite.Row
216
+ cursor = await db.execute("""
217
+ SELECT * FROM jobs
218
+ WHERE media_group_id=? AND status='downloaded'
219
+ ORDER BY message_id
220
+ """, (media_group_id,))
221
+ rows = await cursor.fetchall()
222
+ return [dict(row) for row in rows]
223
+
224
+ async def get_album_pending_count(media_group_id):
225
+ """عدد الملفات المتبقية في الألبوم"""
226
+ async with aiosqlite.connect(DB_PATH) as db:
227
+ cursor = await db.execute("""
228
+ SELECT COUNT(*) FROM jobs
229
+ WHERE media_group_id=? AND status IN ('pending', 'downloading')
230
+ """, (media_group_id,))
231
+ row = await cursor.fetchone()
232
+ return row[0]
233
 
234
+ async def delete_job(chat_id, message_id):
235
+ """حذف مهمة ناجحة + تنظيف الملف"""
236
+ async with aiosqlite.connect(DB_PATH) as db:
237
+ cursor = await db.execute("""
238
+ SELECT file_path FROM jobs WHERE chat_id=? AND message_id=?
239
+ """, (chat_id, message_id))
240
+ row = await cursor.fetchone()
241
+ if row and row[0] and os.path.exists(row[0]):
242
+ try:
243
+ os.remove(row[0])
244
+ logger.info(f"🗑️ ملف محذوف: {row[0]}")
245
+ except:
246
+ pass
247
+ await db.execute("DELETE FROM jobs WHERE chat_id=? AND message_id=?", (chat_id, message_id))
248
+ await db.commit()
249
 
250
+ # ============ معلومات الملف ============
251
  def get_media_info(message):
252
  if message.photo:
253
  return "photo", ".jpg"
 
275
  return chat.first_name or chat.username or "خاص"
276
  return "مصدر"
277
 
278
+ def get_file_id_from_job(job):
279
+ """استخراج file_id من الرسالة الأصلية"""
280
+ # نستخدم app.download_media مباشرة بالـ message_id
281
+ return job['file_id']
282
+
283
+ # ============ التحميل مع التحقق ============
284
+ async def verify_download(file_path, expected_size):
285
+ """التحقق من اكتمال التحميل"""
286
+ if not file_path or not os.path.exists(file_path):
287
+ return False
288
+ actual_size = os.path.getsize(file_path)
289
+ if expected_size > 0 and actual_size < expected_size:
290
+ logger.warning(f"⚠️ ملف ناقص: {actual_size}/{expected_size} بايت")
291
+ return False
292
+ if actual_size == 0:
293
+ logger.warning(f"⚠️ ملف بحجم صفر: {file_path}")
294
+ return False
295
+ return True
296
+
297
+ async def download_job(job):
298
+ """تحميل ملف مهمة مع التحقق من الاكتمال"""
299
+ chat_id = job['chat_id']
300
+ message_id = job['message_id']
301
+ expected_size = job['file_size'] or 0
302
 
303
+ ext = {
304
+ 'photo': '.jpg', 'video': '.mp4', 'doc': '',
305
+ 'audio': '.mp3', 'voice': '.ogg',
306
+ 'video_note': '.mp4', 'animation': '.mp4'
307
+ }.get(job['media_type'], '')
308
+
309
+ file_path = os.path.join(DOWNLOAD_DIR, f"{chat_id}_{message_id}{ext}")
310
+
311
+ try:
312
+ # تحديث الحالة إلى downloading
313
+ await update_job_status(chat_id, message_id, 'downloading')
314
+
315
+ # 🔑 التحميل باستخدام file_id
316
+ downloaded = await app.download_media(
317
+ job['file_id'],
318
+ file_name=file_path
 
 
 
 
 
 
 
 
319
  )
320
+
321
+ if not downloaded:
322
+ raise Exception("download_media returned None")
323
+
324
+ # 🔑 التحقق من اكتمال الملف
325
+ if not await verify_download(downloaded, expected_size):
326
+ # إعادة المحاولة مرة واحدة
327
+ logger.info(f"🔄 إعادة تحميل {message_id}")
328
+ if os.path.exists(downloaded):
329
+ os.remove(downloaded)
330
+ downloaded = await app.download_media(
331
+ job['file_id'],
332
+ file_name=file_path
333
+ )
334
+ if not await verify_download(downloaded, expected_size):
335
+ raise Exception(f"File incomplete after retry: {os.path.getsize(downloaded) if downloaded else 0}/{expected_size}")
336
+
337
+ await update_job_status(chat_id, message_id, 'downloaded', file_path=downloaded)
338
+ logger.info(f"✅ تحميل مكتمل: {message_id} | {os.path.getsize(downloaded)} بايت")
339
+ return True
340
+
341
+ except Exception as e:
342
+ await update_job_status(chat_id, message_id, 'pending', error=str(e))
343
+ logger.error(f"❌ فشل تحميل {message_id}: {e}")
344
+ return False
345
 
346
+ # ============ الإرسال مع Tenacity ============
347
  @retry(
348
  stop=stop_after_attempt(5),
349
  wait=wait_exponential_jitter(initial=1, max=60),
 
351
  reraise=True
352
  )
353
  async def safe_send(send_func):
 
354
  return await send_func()
355
 
356
+ async def send_job(job):
357
+ """إرسال مهمة محملة"""
358
+ chat_id = job['chat_id']
359
+ message_id = job['message_id']
360
+ file_path = job['file_path']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
 
362
+ if not file_path or not os.path.exists(file_path):
363
+ logger.error(f"❌ ملف غير موجود: {file_path}")
364
+ await update_job_status(chat_id, message_id, 'pending', error="File not found")
365
+ return False
 
 
366
 
367
+ try:
368
+ await throttle.wait(TARGET_CHAT)
369
+
370
+ m_type = job['media_type']
371
+ caption = job['caption'] or ""
372
+
373
+ if m_type == 'photo':
374
+ await safe_send(lambda: app.send_photo(TARGET_CHAT, photo=file_path, caption=caption))
375
+ elif m_type == 'video':
376
+ await safe_send(lambda: app.send_video(TARGET_CHAT, video=file_path, caption=caption, supports_streaming=True))
377
+ elif m_type == 'doc':
378
+ await safe_send(lambda: app.send_document(TARGET_CHAT, document=file_path, caption=caption))
379
+ elif m_type == 'audio':
380
+ await safe_send(lambda: app.send_audio(TARGET_CHAT, audio=file_path, caption=caption))
381
+ elif m_type == 'voice':
382
+ await safe_send(lambda: app.send_voice(TARGET_CHAT, voice=file_path, caption=caption))
383
+ elif m_type == 'video_note':
384
+ await safe_send(lambda: app.send_video_note(TARGET_CHAT, video_note=file_path))
385
+ elif m_type == 'animation':
386
+ await safe_send(lambda: app.send_animation(TARGET_CHAT, animation=file_path, caption=caption))
387
+
388
+ logger.info(f"✅ إرسال ناجح: {message_id}")
389
+ await delete_job(chat_id, message_id)
390
+ return True
391
+
392
+ except Exception as e:
393
+ logger.error(f"❌ فشل إرسال {message_id}: {e}")
394
+ await update_job_status(chat_id, message_id, 'downloaded', error=str(e))
395
+ return False
396
+
397
+ # ============ إرسال الألبوم ============
398
+ async def send_album_group(media_group_id):
399
+ """إرسال ألبوم كامل بعد تحميل كل ملفاته"""
400
+ jobs = await get_downloaded_album_jobs(media_group_id)
401
+ if not jobs:
402
  return
403
 
404
+ # التحقق من عدم وجود ملفات معلقة
405
+ pending = await get_album_pending_count(media_group_id)
406
+ if pending > 0:
407
+ logger.info(f"⏳ ألبوم {media_group_id} ينتظر {pending} ملفات")
408
+ return
409
 
410
+ # ترتيب الملفات
411
+ jobs.sort(key=lambda j: j['message_id'])
412
 
413
+ # فصل المدعوم في الألبوم عن المنفرد
414
+ album_inputs = []
415
+ album_jobs = []
416
+ standalone_jobs = []
417
 
418
+ for i, job in enumerate(jobs):
419
  if i == 0:
420
+ cap = job['caption']
 
421
  else:
422
+ cap = None
423
 
424
+ m_type = job['media_type']
425
+ file_path = job['file_path']
426
+
427
+ if m_type == 'photo':
428
+ album_inputs.append(InputMediaPhoto(media=file_path, caption=cap))
429
+ album_jobs.append(job)
430
+ elif m_type == 'video':
431
+ album_inputs.append(InputMediaVideo(media=file_path, caption=cap, supports_streaming=True))
432
+ album_jobs.append(job)
433
+ elif m_type == 'doc':
434
+ album_inputs.append(InputMediaDocument(media=file_path, caption=cap))
435
+ album_jobs.append(job)
436
+ elif m_type == 'audio':
437
+ album_inputs.append(InputMediaAudio(media=file_path, caption=cap))
438
+ album_jobs.append(job)
439
  else:
440
+ standalone_jobs.append(job)
 
441
 
442
+ async with send_sem:
443
+ # إرسال الألبوم المدعوم (تقسيم إلى 10)
444
+ chunk_size = 10
445
+ for idx in range(0, len(album_inputs), chunk_size):
446
+ chunk = album_inputs[idx:idx+chunk_size]
447
+ chunk_jobs = album_jobs[idx:idx+chunk_size]
448
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
  try:
450
+ if len(chunk) == 1:
451
+ await safe_send(lambda: app.send_photo(
452
+ TARGET_CHAT, photo=chunk[0].media, caption=chunk[0].caption
453
+ ))
454
+ else:
455
+ await safe_send(lambda: app.send_media_group(
456
+ TARGET_CHAT, media=chunk, disable_notification=True
457
+ ))
458
+
459
+ for j in chunk_jobs:
460
+ await delete_job(j['chat_id'], j['message_id'])
461
+ logger.info(f"✅ ألبوم {media_group_id} | جزء {idx//chunk_size + 1} تم الإرسال")
462
  except Exception as e:
463
+ logger.error(f"❌ فشل إرسال جزء من الألبوم: {e}")
464
+
465
+ # إرسال المنفردات
466
+ for job in standalone_jobs:
467
+ await send_job(job)
468
 
469
+ # ============ Workers ============
470
+ async def download_worker():
471
+ """معالج التحميل المستمر"""
472
+ logger.info("🔄 Download Worker started")
473
+ while True:
474
+ try:
475
+ jobs = await get_pending_jobs(limit=5)
476
+ if not jobs:
477
+ await asyncio.sleep(2)
 
 
 
 
 
 
 
 
478
  continue
479
 
480
+ tasks = []
481
+ for job in jobs:
482
+ if len(tasks) >= 3:
483
+ break
484
+ tasks.append(download_job(job))
485
 
486
+ if tasks:
487
+ await asyncio.gather(*tasks, return_exceptions=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
 
489
+ except Exception as e:
490
+ logger.error(f"❌ خطأ في Download Worker: {e}")
491
+ await asyncio.sleep(5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
 
493
+ async def send_worker():
494
+ """معالج الإرسال المستمر"""
495
+ logger.info("📤 Send Worker started")
496
+ while True:
497
+ try:
498
+ # جلب المهام المحملة (غير ألبومات)
499
+ async with aiosqlite.connect(DB_PATH) as db:
500
+ db.row_factory = aiosqlite.Row
501
+ cursor = await db.execute("""
502
+ SELECT * FROM jobs
503
+ WHERE status='downloaded' AND (media_group_id IS NULL OR media_group_id='')
504
+ ORDER BY created_at LIMIT 5
505
+ """)
506
+ rows = await cursor.fetchall()
507
+ single_jobs = [dict(row) for row in rows]
508
+
509
+ # جلب الألبومات الجاهزة
510
+ cursor = await db.execute("""
511
+ SELECT media_group_id FROM jobs
512
+ WHERE status='downloaded' AND media_group_id IS NOT NULL AND media_group_id!=''
513
+ GROUP BY media_group_id
514
+ """)
515
+ album_rows = await cursor.fetchall()
516
+
517
+ # إرسال المنفردات
518
+ for job in single_jobs:
519
+ async with send_sem:
520
+ await send_job(job)
521
+
522
+ # معالجة الألبومات
523
+ for (mg_id,) in album_rows:
524
+ await send_album_group(mg_id)
525
+
526
+ await asyncio.sleep(1)
527
+
528
+ except Exception as e:
529
+ logger.error(f"❌ خطأ في Send Worker: {e}")
530
+ await asyncio.sleep(5)
531
+
532
+ # ============ استخدام get_media_group للألبومات ============
533
+ async def fetch_full_album(message):
534
+ """جلب كل رسائل الألبوم مرة واحدة باستخدام get_media_group"""
535
  try:
536
+ messages = await app.get_media_group(message.chat.id, message.id)
537
+ logger.info(f"📦 get_media_group: {len(messages)} رسائل")
538
+ for msg in messages:
539
+ await add_job(msg)
540
+ return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  except Exception as e:
542
+ logger.warning(f"⚠️ فشل get_media_group: {e}")
543
+ return False
 
 
 
 
 
544
 
545
+ # ============ المعالج الرئيسي ============
546
  @app.on_message(filters.incoming & ~filters.me & filters.media)
547
  async def on_media(client, message):
548
  chat_id = message.chat.id
 
 
 
 
 
 
 
 
 
 
549
  if str(chat_id) == str(TARGET_CHAT):
550
  return
551
 
552
  src_name = get_source_name(message)
553
  m_type, _ = get_media_info(message)
554
+ logger.info(f"📥 جديد: {chat_id}/{message.id} | {m_type} | ألبوم: {message.media_group_id is not None}")
555
 
 
556
  if message.media_group_id:
557
+ # 🔑 محاولة جلب الألبوم كاملاً
558
+ success = await fetch_full_album(message)
559
+ if not success:
560
+ # Fallback: إضافة الرسالة الحالية فقط
561
+ await add_job(message)
562
+ else:
563
+ await add_job(message)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
 
565
  @app.on_message(filters.incoming & ~filters.me)
566
  async def monitor_all(client, message):
567
  if str(message.chat.id) == str(TARGET_CHAT):
568
  return
569
+ logger.info(f"👁️ رسالة نصية من {message.chat.id}")
 
570
 
571
  async def resolve_target_chat():
572
  global TARGET_CHAT
573
  raw = TARGET_CHAT_RAW
574
  if raw.lower() == "me":
575
  TARGET_CHAT = "me"
 
576
  return
577
  try:
578
  chat_id_int = int(raw)
579
  chat = await app.get_chat(chat_id_int)
580
  TARGET_CHAT = chat_id_int
581
+ logger.info(f"🎯 الهدف: {chat.title}")
582
  except Exception as e:
583
+ logger.error(f"❌ فشل التحقق: {e}")
584
  TARGET_CHAT = "me"
585
 
 
 
 
 
 
 
 
 
 
586
  # ============ Gradio + FastAPI ============
587
  def run_gradio():
588
+ with gr.Blocks(title="Telegram Archiver Pro") as demo:
589
+ gr.Markdown("# 📡 Telegram Archiver Pro")
590
+ gr.Markdown("🟢 **الحالة:** نظام Queue مضبوط 100%")
591
+ gr.Textbox(label="الحالة", value="✅ نشط | SQLite Queue | 3 Workers", interactive=False)
592
 
593
  fastapi_app = FastAPI()
594
 
595
  @fastapi_app.get("/health")
 
596
  async def health_check():
597
  return {"status": "ok", "timestamp": time.time()}
598
 
599
  @fastapi_app.get("/ping")
 
600
  async def ping():
601
  return {"status": "alive"}
602
 
 
605
  import uvicorn
606
  uvicorn.run(fastapi_app, host="0.0.0.0", port=7860)
607
 
608
+ # ============ التشغيل الرئيسي ============
609
  async def main():
610
  await app.start()
611
  me = await app.get_me()
612
  await resolve_target_chat()
613
+ await init_db()
614
+
615
+ # تشغيل Workers
616
+ asyncio.create_task(download_worker())
617
+ asyncio.create_task(send_worker())
618
 
619
+ logger.info("=" * 60)
620
+ logger.info(f"🚀 Archiver Pro يعمل!")
621
+ logger.info(f"👤 الحساب: {me.first_name}")
622
+ logger.info(f"🎯 الهدف: {TARGET_CHAT}")
623
+ logger.info(f"💾 قاعدة البيانات: {DB_PATH}")
624
+ logger.info(f"📁 مجلد التحميل: {DOWNLOAD_DIR}")
625
+ logger.info("=" * 60)
626
 
627
  stop_event = asyncio.Event()
628
  await stop_event.wait()