arena commited on
Commit
eef656f
·
1 Parent(s): 13a0927

تنبيه بدء البثّ المباشر إلى تيليغرام + حذف الحسابين غير الموجودين

Browse files

- telegram_api: notify_live_start()/notify_targets()/live_start_text() — رسالة باسم
المذيع والتاريخ والوقت إلى القناة والحساب الشخصي، مرة لكل بثّ (منع التكرار بـroom_id
في السجلّ الدائم)، وأي فشل يُسجَّل ولا يرمي استثناءً.
- app.py: خطّاف في حلقة المراقبة قبل أول اتصال بالمصدر + POST /api/telegram/notify-test.
- index.html: زرّ تجربة التنبيه وسطر حالة 🔔 في لوحة تيليغرام.
- Dockerfile/variables.env: LIVE_NOTIFY=on, LIVE_NOTIFY_TARGETS=both, LIVE_NOTIFY_TEXT.
- اختبارات §18 (33) ⇒ 159/159.

Files changed (8) hide show
  1. Dockerfile +9 -0
  2. README.md +31 -0
  3. VARIABLES.md +5 -0
  4. app.py +25 -0
  5. index.html +9 -2
  6. telegram_api.py +99 -0
  7. test_telegram_archive.py +130 -1
  8. variables.env +7 -0
Dockerfile CHANGED
@@ -98,6 +98,12 @@ ENV TIKTOK_TT_TARGET_IDC=useast1a
98
  # مع سبب واضح في الطابور، فلا تضيع رفعة غيغابايت لتُرفض في النهاية.
99
  # ARCHIVE_PART_MB=0 ⇒ تلقائي: أي ملف يتجاوز 95% من سقف الرفع الحالي يُقسَّم
100
  # بـ ffmpeg -c copy (بلا إعادة ترميز) وتُرسل أجزاؤه ألبومًا واحدًا (sendMediaGroup).
 
 
 
 
 
 
101
  ENV ARCHIVE_ENABLED=on \
102
  ARCHIVE_DELETE_AFTER_UPLOAD=on \
103
  ARCHIVE_PROTECT_QUEUE=on \
@@ -112,6 +118,9 @@ ENV ARCHIVE_ENABLED=on \
112
  TELEGRAM_REQUIRE_BACKUP=on \
113
  TELEGRAM_SILENT=off \
114
  TELEGRAM_MIN_CHAT_INTERVAL=4 \
 
 
 
115
  TELEGRAM_LOCAL_PORT=8081 \
116
  TELEGRAM_LOCAL_URL=http://127.0.0.1:8081 \
117
  TZ=Asia/Baghdad
 
98
  # مع سبب واضح في الطابور، فلا تضيع رفعة غيغابايت لتُرفض في النهاية.
99
  # ARCHIVE_PART_MB=0 ⇒ تلقائي: أي ملف يتجاوز 95% من سقف الرفع الحالي يُقسَّم
100
  # بـ ffmpeg -c copy (بلا إعادة ترميز) وتُرسل أجزاؤه ألبومًا واحدًا (sendMediaGroup).
101
+ # تنبيه بدء البثّ: حين يكتشف المراقب أنّ مذيعًا صار مباشرًا تصل رسالة إلى تيليغرام
102
+ # باسم المذيع وتاريخ اليوم ووقته (منطقة TZ). تُبعث **مرة واحدة لكل بثّ** لا لكل
103
+ # مقطع، ولا تكرار بعد إعادة الإقلاع (معرّف الغرفة محفوظ في السجلّ الدائم).
104
+ # LIVE_NOTIFY_TARGETS: both = القناة والحساب الشخصي | channel | backup.
105
+ # LIVE_NOTIFY_TEXT: قالب اختياري بالمفاتيح {name} {username} {nickname} {live_title}
106
+ # {date} {time} {room_id} — فارغ ⇒ النصّ الافتراضي.
107
  ENV ARCHIVE_ENABLED=on \
108
  ARCHIVE_DELETE_AFTER_UPLOAD=on \
109
  ARCHIVE_PROTECT_QUEUE=on \
 
118
  TELEGRAM_REQUIRE_BACKUP=on \
119
  TELEGRAM_SILENT=off \
120
  TELEGRAM_MIN_CHAT_INTERVAL=4 \
121
+ LIVE_NOTIFY=on \
122
+ LIVE_NOTIFY_TARGETS=both \
123
+ LIVE_NOTIFY_TEXT= \
124
  TELEGRAM_LOCAL_PORT=8081 \
125
  TELEGRAM_LOCAL_URL=http://127.0.0.1:8081 \
126
  TZ=Asia/Baghdad
README.md CHANGED
@@ -52,6 +52,34 @@ pinned: false
52
  * ملاحظة تقنية مُثبتة بالتجربة: حقل الملف داخل `InputMedia` يجب أن يكون `media` لا `video`،
53
  وإلا ردّ الخادم `can't parse InputMedia: media not found`.
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  ## V15: اعتماد وجهة جديدة دون ضياع الأرشيف
56
 
57
  1. اضغط **فحص الوجهة والبوت** لعرض الوجهة الموجودة في Secrets والوجهة المعتمدة السابقة. الفحص للقراءة فقط ولا يغيّر الربط.
@@ -220,6 +248,9 @@ ffmpeg -i in.flv -map 0:v:0 -map 0:a:0? -c copy -tag:v avc3 -movflags +faststart
220
  | `TELEGRAM_SEND_AS` | `auto` | `auto` فيديو ثم ملف · `video` · `document` |
221
  | `TELEGRAM_SILENT` | `off` | `on` = إرسال بلا إشعار للمشتركين |
222
  | `TELEGRAM_MIN_CHAT_INTERVAL` | `4` | ثوانٍ بين إرسالين للمحادثة نفسها |
 
 
 
223
  | `ARCHIVE_PROTECT_QUEUE` | `on` | منع التنظيف من حذف ملف لم يُرسل |
224
  | `ARCHIVE_MIN_MB` | `5` | أصغر حجم يستحق الإرسال |
225
  | `ARCHIVE_MAX_ATTEMPTS` | `6` | عدد المحاولات قبل التوقف مع إبقاء الملف |
 
52
  * ملاحظة تقنية مُثبتة بالتجربة: حقل الملف داخل `InputMedia` يجب أن يكون `media` لا `video`،
53
  وإلا ردّ الخادم `can't parse InputMedia: media not found`.
54
 
55
+ ## تنبيه بدء البثّ المباشر
56
+
57
+ حين يكتشف المراقب أنّ أحد المذيعين صار **مباشرًا** تصل رسالة إلى تيليغرام فورًا،
58
+ قبل أول محاولة اتصال بمصدر الفيديو:
59
+
60
+ ```
61
+ 🔴 أحمد (@ahmedbarbooka2) بدأ بثًّا مباشرًا
62
+ 📅 10-10-2026 · ⏰ 20:30
63
+ 📝 سهرة الليلة
64
+ ```
65
+
66
+ * **الوجهة:** القناة **و** الحساب الشخصي معًا (`LIVE_NOTIFY_TARGETS=both` الافتراضي؛
67
+ `channel` أو `backup` لوجهة واحدة).
68
+ * **المحتوى:** لقب المذيع كما يظهر في تيك توك (وإلا اسم المستخدم)، ‏`@username`،
69
+ تاريخ اليوم ووقته بمنطقة `TZ=Asia/Baghdad`، وعنوان البثّ إن وُجد.
70
+ * **مرة واحدة لكل بثّ:** لا لكل مقطع (البثّ يتجزّأ عند انقطاع المصدر)، ولا بعد إعادة
71
+ الإقلاع (معرّف الغرفة محفوظ في `telegram-api-state.json`). حين يعود المذيع إلى
72
+ «البث منتهٍ» ثم يبدأ بثًّا جديدًا يُعلَن من جديد.
73
+ * **بلا مخاطرة:** التنبيه خدمة إضافية — أي فشل إرسال يُسجَّل في سجلّ التطبيق ولا
74
+ يوقف التسجيل ولا الأرشفة، ولا تُعاد المحاولة (حتى لا تتكرّر الرسالة).
75
+ * **بلا رسالة انتهاء:** المطلوب كان إعلان البدء فقط، فلا رسالة عند انتهاء البثّ ولا
76
+ عند جهوز الأرشيف (الأرشيف يصل كالمعتاد بملفه أو ألبومه).
77
+ * **القالب:** `LIVE_NOTIFY_TEXT` بمفاتيح `{name} {nickname} {username} {live_title}
78
+ {title} {date} {time} {room_id}`، و`LIVE_NOTIFY=off` يطفئ التنبيه.
79
+ * **التجربة:** زرّ **تجربة تنبيه بدء البثّ** في لوحة تيليغرام (أو
80
+ `POST /api/telegram/notify-test`) يرسل نموذجًا مُعلَّمًا كرسالة تجريبية إلى الوجهات
81
+ المضبوطة، ولا يلوّث مانع التكرار.
82
+
83
  ## V15: اعتماد وجهة جديدة دون ضياع الأرشيف
84
 
85
  1. اضغط **فحص الوجهة والبوت** لعرض الوجهة الموجودة في Secrets والوجهة المعتمدة السابقة. الفحص للقراءة فقط ولا يغيّر الربط.
 
248
  | `TELEGRAM_SEND_AS` | `auto` | `auto` فيديو ثم ملف · `video` · `document` |
249
  | `TELEGRAM_SILENT` | `off` | `on` = إرسال بلا إشعار للمشتركين |
250
  | `TELEGRAM_MIN_CHAT_INTERVAL` | `4` | ثوانٍ بين إرسالين للمحادثة نفسها |
251
+ | `LIVE_NOTIFY` | `on` | تنبيه «بدأ بثًّا مباشرًا» بالاسم والتاريخ والوقت |
252
+ | `LIVE_NOTIFY_TARGETS` | `both` | `both` = القناة والحساب الشخصي / `channel` / `backup` |
253
+ | `LIVE_NOTIFY_TEXT` | _(فارغ)_ | قالب رسالة التنبيه (`{name} {username} {date} {time} …`) |
254
  | `ARCHIVE_PROTECT_QUEUE` | `on` | منع التنظيف من حذف ملف لم يُرسل |
255
  | `ARCHIVE_MIN_MB` | `5` | أصغر حجم يستحق الإرسال |
256
  | `ARCHIVE_MAX_ATTEMPTS` | `6` | عدد المحاولات قبل التوقف مع إبقاء الملف |
VARIABLES.md CHANGED
@@ -51,6 +51,11 @@
51
  | `TELEGRAM_SEND_AS` | `auto` | `auto`: `sendVideo` أولًا (مشغّل داخل القناة) ثم `sendDocument` عند الرفض. `video` أو `document` لإجبار أحدهما. |
52
  | `TELEGRAM_SILENT` | `off` | `on` = إرسال بلا إشعار للمشتركين (`disable_notification`). |
53
  | `TELEGRAM_MIN_CHAT_INTERVAL` | `4` | أقل فاصلة بالثواني بين إرسالين للمحادثة نفسها. حارس معدل يمنع 429 عند جهوزية عدة ملفات معًا. |
 
 
 
 
 
54
  | `TELEGRAM_LOCAL_URL` | `http://127.0.0.1:8081` | عنوان خادم Bot API المحلي. التطبيق يفحصه كل 60 ثانية ويستعمله إن أجاب. |
55
  | `TELEGRAM_LOCAL_PORT` | `8081` | المنفذ الذي يشغّل عليه `entrypoint.sh` الخادم المحلي. |
56
  | `TELEGRAM_UPLOAD_TIMEOUT` | *(محسوب)* | مهلة الإرسال بالثواني. تُحسب تلقائيًا من الحجم (~4 دقائق/غيغابايت + هامش). |
 
51
  | `TELEGRAM_SEND_AS` | `auto` | `auto`: `sendVideo` أولًا (مشغّل داخل القناة) ثم `sendDocument` عند الرفض. `video` أو `document` لإجبار أحدهما. |
52
  | `TELEGRAM_SILENT` | `off` | `on` = إرسال بلا إشعار للمشتركين (`disable_notification`). |
53
  | `TELEGRAM_MIN_CHAT_INTERVAL` | `4` | أقل فاصلة بالثواني بين إرسالين للمحادثة نفسها. حارس معدل يمنع 429 عند جهوزية عدة ملفات معًا. |
54
+ | `LIVE_NOTIFY` | `on` | تنبيه «بدأ بثًّا مباشرًا» إلى تيليغرام باسم المذيع وتاريخ اليوم ووقته. ‏`off` يوقفه **بلا أي أثر على التسجيل أو الأرشفة**. |
55
+ | `LIVE_NOTIFY_TARGETS` | `both` | وجهة التنبيه: `both` = القناة **و** الحساب الشخصي (`TELEGRAM_CHAT_ID` + `TELEGRAM_BACKUP_CHAT_ID`)، أو `channel`، أو `backup`. |
56
+ | `LIVE_NOTIFY_TEXT` | _(فارغ)_ | قالب اختياري بمفاتيح `{name}` `{nickname}` `{username}` `{live_title}` `{title}` `{date}` `{time}` `{room_id}`. فارغ ⇒ النصّ الافتراضي: «🔴 الاسم (@المستخدم) بدأ بثًّا مباشرًا / 📅 التاريخ · ⏰ الوقت» ويُضاف سطر العنوان إن وُجد. مفتاح غير معروف يبقى كما هو ولا يُسقط الرسالة. |
57
+
58
+ **كيف لا يتكرّر التنبيه:** يُبعث **مرة واحدة لكل بثّ** لا لكل مقطع — البثّ الواحد يتجزّأ إلى عدة ملفات عند انقطاع المصدر ثم عودته، وإعادة الإعلان إزعاج. المنع على طبقتين: في ذاكرة المراقب (يُمسح حين يعود المذيع إلى «البث منتهٍ» فيُعلَن بثّه القادم)، وفي السجلّ الدائم `telegram-api-state.json` بمعرّف الغرفة `room_id` (فلا تكرار بعد إعادة إقلاع أثناء بثّ قائم). ومفتاح غير معروف في القالب لا يُسقط الرسالة، وأي فشل إرسال يُسجَّل في سجلّ التطبيق ولا يوقف التسجيل أبدًا. للتجربة من اللوحة: زرّ **تجربة تنبيه بدء البثّ** (أو `POST /api/telegram/notify-test`) يرسل نموذجًا مُعلَّمًا كرسالة تجريبية.
59
  | `TELEGRAM_LOCAL_URL` | `http://127.0.0.1:8081` | عنوان خادم Bot API المحلي. التطبيق يفحصه كل 60 ثانية ويستعمله إن أجاب. |
60
  | `TELEGRAM_LOCAL_PORT` | `8081` | المنفذ الذي يشغّل عليه `entrypoint.sh` الخادم المحلي. |
61
  | `TELEGRAM_UPLOAD_TIMEOUT` | *(محسوب)* | مهلة الإرسال بالثواني. تُحسب تلقائيًا من الحجم (~4 دقائق/غيغابايت + هامش). |
app.py CHANGED
@@ -347,6 +347,9 @@ PROTECT_QUEUE=os.getenv('ARCHIVE_PROTECT_QUEUE','on').strip().lower() in ('1','o
347
  # 0 يُعطّل الضمّ ويرفع كل ملف فور جهوزه.
348
  JOIN_WINDOW=max(0,int(os.getenv('ARCHIVE_JOIN_WINDOW','300') or 0))
349
  _live_meta={}
 
 
 
350
  _NICKS=ROOT/'nicknames.json'
351
 
352
  def load_nicknames():
@@ -1426,7 +1429,18 @@ def worker(user,stop):
1426
  remember_nickname(user,known.get('nickname'))
1427
  except Exception:pass
1428
  if not urls:
 
1429
  update(user,status='البث منتهٍ');stop.wait(INTERVAL);continue
 
 
 
 
 
 
 
 
 
 
1430
  update(user,status='جاري الاتصال بمصدر الفيديو');ok=False
1431
  for url in urls:
1432
  if stop.is_set():break
@@ -1475,6 +1489,17 @@ def telegram_check():
1475
  msg=str(e) if isinstance(e,(APIError,RuntimeError,ValueError)) else 'تعذر فحص وجهة تيليغرام'
1476
  raise HTTPException(400,msg)
1477
 
 
 
 
 
 
 
 
 
 
 
 
1478
  class TelegramChannelConfirmation(BaseModel):
1479
  channel_id:str
1480
  @app.post('/api/telegram/channel')
 
347
  # 0 يُعطّل الضمّ ويرفع كل ملف فور جهوزه.
348
  JOIN_WINDOW=max(0,int(os.getenv('ARCHIVE_JOIN_WINDOW','300') or 0))
349
  _live_meta={}
350
+ # تنبيه «بدأ بثًّا مباشرًا»: مرة واحدة لكل بثّ. المفتاح اسم المذيع والقيمة معرّف
351
+ # الغرفة (أو True إن لم يُعرف بعد)؛ يُمسح حين ينتهي البثّ فيُعلَن البثّ القادم.
352
+ _live_seen={}
353
  _NICKS=ROOT/'nicknames.json'
354
 
355
  def load_nicknames():
 
1429
  remember_nickname(user,known.get('nickname'))
1430
  except Exception:pass
1431
  if not urls:
1432
+ _live_seen.pop(user,None)
1433
  update(user,status='البث منتهٍ');stop.wait(INTERVAL);continue
1434
+ # تنبيه بدء البثّ قبل أول محاولة اتصال: مرة لكل بثّ لا لكل مقطع (البثّ
1435
+ # الواحد يتجزّأ عند انقطاع المصدر)، ومنع التكرار قائم أيضًا في
1436
+ # telegram_api بمعرّف الغرفة حتى لو أُعيد إقلاع البرنامج أثناء البثّ.
1437
+ # أي فشل هنا لا يوقف التسجيل: التنبيه خدمة إضافية لا شرط.
1438
+ if not _live_seen.get(user):
1439
+ meta=_live_meta.get(user) or {}
1440
+ room=str(meta.get('room_id') or '')
1441
+ _live_seen[user]=room or True
1442
+ try:telegram.notify_live_start(user,meta,room)
1443
+ except Exception:log(user,'تعذّر إرسال تنبيه بدء البثّ')
1444
  update(user,status='جاري الاتصال بمصدر الفيديو');ok=False
1445
  for url in urls:
1446
  if stop.is_set():break
 
1489
  msg=str(e) if isinstance(e,(APIError,RuntimeError,ValueError)) else 'تعذر فحص وجهة تيليغرام'
1490
  raise HTTPException(400,msg)
1491
 
1492
+ @app.post('/api/telegram/notify-test')
1493
+ def telegram_notify_test():
1494
+ """زرّ تجربة: يرسل نموذج تنبيه بدء البثّ إلى الوجهات المضبوطة (بلا بثّ فعلي)."""
1495
+ try:
1496
+ ok=telegram.notify_live_start('example',{'nickname':'مثال','live_title':'تجربة تنبيه بدء البثّ'},test=True)
1497
+ return {'ok':bool(ok),'targets':telegram.notify_targets()}
1498
+ except Exception as e:
1499
+ from telegram_api import APIError
1500
+ msg=str(e) if isinstance(e,(APIError,RuntimeError,ValueError)) else 'تعذر إرسال تنبيه تجريبي'
1501
+ raise HTTPException(400,msg)
1502
+
1503
  class TelegramChannelConfirmation(BaseModel):
1504
  channel_id:str
1505
  @app.post('/api/telegram/channel')
index.html CHANGED
@@ -30,7 +30,7 @@ textarea,select{font:inherit;background:#0c1220;color:#e4eaf7;border:1px solid #
30
  </section>
31
  <section class="panel"><h2>ربط قناة تيليغرام</h2>
32
  <p>يوتيوب أُلغي من هذا المشروع نهائيًا. الوجهة الآن <b>تيليغرام</b>: تُقرأ من السرّين <code dir="ltr">TELEGRAM_CHAT_ID</code> (القناة) و<code dir="ltr">TELEGRAM_BACKUP_CHAT_ID</code> (حسابك الشخصي). الفحص يقرأ الوجهة من تيليغرام نفسه ولا يغيّر الربط.</p>
33
- <div class="row"><button id="tgcheck" class="secondary" type="button">فحص الوجهة والبوت</button><button id="tgadopt" type="button" hidden>اعتماد الوجهة الجديدة فقط</button></div>
34
  <p id="tgchannelnotice" class="notice" role="status" aria-live="polite" hidden></p>
35
  <p id="channel">لم يتم فحص الوجهة بعد.</p><p id="tgstate">تحميل الحالة…</p>
36
  <small id="budget"></small>
@@ -61,7 +61,7 @@ function error(text){$('error').textContent=text;$('error').hidden=!text}
61
  function channelSwitchPending(){return !!(checkedChannel?.previous&&checkedChannel.previous.id!==checkedChannel.id)}
62
  function renderCheckedChannel(){
63
  const pending=channelSwitchPending(),needsAdoption=!!(checkedChannel&&String(checkedChannel.id)!==String(lastT?.channel?.id));
64
- $('tgadopt').hidden=!needsAdoption;$('tgadopt').disabled=tgBusy;$('tgcheck').disabled=tgBusy;
65
  $('tgchannelnotice').hidden=!checkedChannel;
66
  $('tgchannelnotice').textContent=!checkedChannel?'':pending
67
  ? 'الوجهة المتاحة في الأسرار: «'+checkedChannel.title+'» ('+checkedChannel.id+') بدل «'+checkedChannel.previous.title+'». الفحص لم يبدّل الربط. اعتمد الجديدة بتأكيدك؛ يُحفظ طابور الوجهة السابقة وتُوقف الأرشفة حتى تفعيلها من جديد.'
@@ -79,6 +79,7 @@ $('capacity').textContent=`${s.version} • حد الحسابات: ${s.max_accou
79
  $('channel').textContent=y.channel?`الوجهة المعتمدة: ${y.channel.title} · ${y.channel.id}${y.channel.username?' · @'+y.channel.username:''}`:'اضبط TELEGRAM_CHAT_ID ثم اضغط فحص الوجهة.';
80
  renderCheckedChannel();
81
  $('tgstate').textContent=channelSwitchPending()?'الوجهة في الأسرار مختلفة؛ اعتمد الجديدة قبل تفعيل الأرشفة.':y.configured?`البوت ${y.bot_username||'مضبوط'} — سقف الرفع ${y.max_upload_mb}MB عبر ${y.local_server?'خادم Bot API المحلي':'الواجهة العامة (50MB فقط؛ أضف TELEGRAM_API_ID/HASH لرفعه)'}. النسخة الاحتياطية: ${y.backup_configured?'مضبوطة ✓':'غير مضبوطة'}.${y.local_server||!y.local_error||y.local_error.indexOf('TELEGRAM_API_ID')===0?'':'\n⚠ الخادم المحلي لا يستجيب: '+y.local_error}`:'TELEGRAM_BOT_TOKEN أو TELEGRAM_CHAT_ID ناقص — لن يُرسل أي تسجيل.';
 
82
  $('budget').textContent=`أُرسل اليوم: ${y.budget?.sent||0} ملف · ${Math.round((y.budget?.bytes||0)/1048576)}MB`;
83
  $('tasks').innerHTML=s.tasks.length?s.tasks.map(t=>`<article class="card"><h3>@${esc(t.username)}</h3><p>${esc(t.status)}</p><small>${esc(t.quality||'الجودة لم تحدد بعد')} · ${t.received_mb||0} MB · ${Math.floor((t.elapsed_seconds||0)/60)} دقيقة</small><p>${t.enabled?'المراقبة مفعّلة':'المراقبة معطّلة'} · توكن الجلسة: ${t.has_token?'موجود ✓':'غير مضاف'}</p><div class="row"><button data-user="${esc(t.username)}" data-action="start">تشغيل</button><button class="secondary" data-user="${esc(t.username)}" data-action="stop">إيقاف</button><button class="secondary" data-user="${esc(t.username)}" data-action="token">توكن</button><button class="danger" data-user="${esc(t.username)}" data-action="delete">حذف</button></div></article>`).join(''):'<p>أضف حسابات المذيع المطلوبة فقط.</p>';
84
  $('files').innerHTML=s.files.length?s.files.map(f=>`<div class="file"><div><code>${esc(f.name)}</code><br><small>${f.mb} MB</small></div><div class="row"><a href="/api/files/${encodeURIComponent(f.name)}">تنزيل</a><button class="danger" data-file="${esc(f.name)}">حذف الملف</button></div></div>`).join(''):'<p>لا توجد ملفات مكتملة بعد.</p>';
@@ -127,6 +128,12 @@ $('tgcheck').onclick=async()=>{
127
  alert('الوجهة المتاحة في الأسرار: «'+channel.title+'» ('+channel.id+').'+(channelSwitchPending()?' تحتاج تأكيد اعتمادها؛ الفحص لم يغيّر الربط.':' الفحص للقراءة فقط.'));
128
  }catch(e){error(e.message)}finally{tgBusy=false;renderCheckedChannel()}
129
  };
 
 
 
 
 
 
130
  $('tgadopt').onclick=async()=>{
131
  if(tgBusy)return;tgBusy=true;renderCheckedChannel();
132
  try{
 
30
  </section>
31
  <section class="panel"><h2>ربط قناة تيليغرام</h2>
32
  <p>يوتيوب أُلغي من هذا المشروع نهائيًا. الوجهة الآن <b>تيليغرام</b>: تُقرأ من السرّين <code dir="ltr">TELEGRAM_CHAT_ID</code> (القناة) و<code dir="ltr">TELEGRAM_BACKUP_CHAT_ID</code> (حسابك الشخصي). الفحص يقرأ الوجهة من تيليغرام نفسه ولا يغيّر الربط.</p>
33
+ <div class="row"><button id="tgcheck" class="secondary" type="button">فحص الوجهة والبوت</button><button id="tgnotify" class="secondary" type="button">تجربة تنبيه بدء البثّ</button><button id="tgadopt" type="button" hidden>اعتماد الوجهة الجديدة فقط</button></div>
34
  <p id="tgchannelnotice" class="notice" role="status" aria-live="polite" hidden></p>
35
  <p id="channel">لم يتم فحص الوجهة بعد.</p><p id="tgstate">تحميل الحالة…</p>
36
  <small id="budget"></small>
 
61
  function channelSwitchPending(){return !!(checkedChannel?.previous&&checkedChannel.previous.id!==checkedChannel.id)}
62
  function renderCheckedChannel(){
63
  const pending=channelSwitchPending(),needsAdoption=!!(checkedChannel&&String(checkedChannel.id)!==String(lastT?.channel?.id));
64
+ $('tgadopt').hidden=!needsAdoption;$('tgadopt').disabled=tgBusy;$('tgcheck').disabled=tgBusy;$('tgnotify').disabled=tgBusy;
65
  $('tgchannelnotice').hidden=!checkedChannel;
66
  $('tgchannelnotice').textContent=!checkedChannel?'':pending
67
  ? 'الوجهة المتاحة في الأسرار: «'+checkedChannel.title+'» ('+checkedChannel.id+') بدل «'+checkedChannel.previous.title+'». الفحص لم يبدّل الربط. اعتمد الجديدة بتأكيدك؛ يُحفظ طابور الوجهة السابقة وتُوقف الأرشفة حتى تفعيلها من جديد.'
 
79
  $('channel').textContent=y.channel?`الوجهة المعتمدة: ${y.channel.title} · ${y.channel.id}${y.channel.username?' · @'+y.channel.username:''}`:'اضبط TELEGRAM_CHAT_ID ثم اضغط فحص الوجهة.';
80
  renderCheckedChannel();
81
  $('tgstate').textContent=channelSwitchPending()?'الوجهة في الأسرار مختلفة؛ اعتمد الجديدة قبل تفعيل الأرشفة.':y.configured?`البوت ${y.bot_username||'مضبوط'} — سقف الرفع ${y.max_upload_mb}MB عبر ${y.local_server?'خادم Bot API المحلي':'الواجهة العامة (50MB فقط؛ أضف TELEGRAM_API_ID/HASH لرفعه)'}. النسخة الاحتياطية: ${y.backup_configured?'مضبوطة ✓':'غير مضبوطة'}.${y.local_server||!y.local_error||y.local_error.indexOf('TELEGRAM_API_ID')===0?'':'\n⚠ الخادم المحلي لا يستجيب: '+y.local_error}`:'TELEGRAM_BOT_TOKEN أو TELEGRAM_CHAT_ID ناقص — لن يُرسل أي تسجيل.';
82
+ if(y.configured)$('tgstate').textContent+='\n'+(y.live_notify?'🔔 تنبيه بدء البثّ مفعّل → '+((y.live_notify_targets||[]).length>1?'القناة والحساب الشخصي':((y.live_notify_targets||[]).length===1?'وجهة واحدة':'لا وجهة مضبوطة')):'🔕 تنبيه بدء البثّ مُطفأ (LIVE_NOTIFY=off)');
83
  $('budget').textContent=`أُرسل اليوم: ${y.budget?.sent||0} ملف · ${Math.round((y.budget?.bytes||0)/1048576)}MB`;
84
  $('tasks').innerHTML=s.tasks.length?s.tasks.map(t=>`<article class="card"><h3>@${esc(t.username)}</h3><p>${esc(t.status)}</p><small>${esc(t.quality||'الجودة لم تحدد بعد')} · ${t.received_mb||0} MB · ${Math.floor((t.elapsed_seconds||0)/60)} دقيقة</small><p>${t.enabled?'المراقبة مفعّلة':'المراقبة معطّلة'} · توكن الجلسة: ${t.has_token?'موجود ✓':'غير مضاف'}</p><div class="row"><button data-user="${esc(t.username)}" data-action="start">تشغيل</button><button class="secondary" data-user="${esc(t.username)}" data-action="stop">إيقاف</button><button class="secondary" data-user="${esc(t.username)}" data-action="token">توكن</button><button class="danger" data-user="${esc(t.username)}" data-action="delete">حذف</button></div></article>`).join(''):'<p>أضف حسابات المذيع المطلوبة فقط.</p>';
85
  $('files').innerHTML=s.files.length?s.files.map(f=>`<div class="file"><div><code>${esc(f.name)}</code><br><small>${f.mb} MB</small></div><div class="row"><a href="/api/files/${encodeURIComponent(f.name)}">تنزيل</a><button class="danger" data-file="${esc(f.name)}">حذف الملف</button></div></div>`).join(''):'<p>لا توجد ملفات مكتملة بعد.</p>';
 
128
  alert('الوجهة المتاحة في الأسرار: «'+channel.title+'» ('+channel.id+').'+(channelSwitchPending()?' تحتاج تأكيد اعتمادها؛ الفحص لم يغيّر الربط.':' الفحص للقراءة فقط.'));
129
  }catch(e){error(e.message)}finally{tgBusy=false;renderCheckedChannel()}
130
  };
131
+ $('tgnotify').onclick=async()=>{
132
+ if(tgBusy)return;tgBusy=true;renderCheckedChannel();
133
+ try{error('');const r=await api('/api/telegram/notify-test','POST');
134
+ alert(r.ok?('أُرسل نموذج التنبيه إلى '+((r.targets||[]).length)+' وجهة — تفقّد القناة ورسائل البوت الخاصة.'):'لم يُرسل التنبيه: تحقّق من LIVE_NOTIFY ومن ضبط القناة والنسخة الاحتياطية.');
135
+ }catch(e){error(e.message)}finally{tgBusy=false;renderCheckedChannel()}
136
+ };
137
  $('tgadopt').onclick=async()=>{
138
  if(tgBusy)return;tgBusy=true;renderCheckedChannel();
139
  try{
telegram_api.py CHANGED
@@ -36,6 +36,17 @@ ABSOLUTE_MAX_MB = 2000
36
 
37
  # حاشية الوسائط في تيليغرام: 1024 حرفًا (لا 4900 كوصف يوتيوب).
38
  CAPTION_LIMIT = 1024
 
 
 
 
 
 
 
 
 
 
 
39
  # حدّ تيليغرام لعناصر الألبوم الواحد في sendMediaGroup.
40
  MAX_ALBUM_ITEMS = 100
41
 
@@ -564,10 +575,98 @@ class TelegramCore:
564
  'local_error': self.api.local_error(),
565
  'max_upload_mb': self.api.max_upload_mb(),
566
  'budget': budget,
 
 
 
567
  'live_relay': 'removed',
568
  'youtube': 'removed',
569
  }
570
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
571
  # ---------- دورة حياة ----------
572
  def restore(self):
573
  """إقلاع: ينبّه على بقايا يوتيوب وعلى نقص الأسرار، ولا يرسل شيئًا."""
 
36
 
37
  # حاشية الوسائط في تيليغرام: 1024 حرفًا (لا 4900 كوصف يوتيوب).
38
  CAPTION_LIMIT = 1024
39
+ # حدّ تيليغرام لنصّ الرسالة (الحاشية 1024، النصّ 4096).
40
+ MESSAGE_LIMIT = 4096
41
+ DEFAULT_LIVE_TEXT = ('\U0001f534 {name} (@{username}) بدأ بثًّا مباشرًا\n'
42
+ '\U0001f4c5 {date} · \u23f0 {time}')
43
+
44
+
45
+ class _Safe(dict):
46
+ '''قالب لا يسقط بمفتاح ناقص: يترك {المفتاح} كما هو.'''
47
+
48
+ def __missing__(self, key):
49
+ return '{' + key + '}'
50
  # حدّ تيليغرام لعناصر الألبوم الواحد في sendMediaGroup.
51
  MAX_ALBUM_ITEMS = 100
52
 
 
575
  'local_error': self.api.local_error(),
576
  'max_upload_mb': self.api.max_upload_mb(),
577
  'budget': budget,
578
+ 'live_notify': str(os.getenv('LIVE_NOTIFY', 'on')).strip().lower()
579
+ in ('1', 'on', 'true', 'yes'),
580
+ 'live_notify_targets': self.notify_targets(),
581
  'live_relay': 'removed',
582
  'youtube': 'removed',
583
  }
584
 
585
+ # ---------- تنبيه بدء البثّ ----------
586
+ def notify_targets(self, which=None):
587
+ """إلى أين يذهب تنبيه بدء البثّ: `both` (افتراضي) أو `channel` أو `backup`."""
588
+ mode = str(which or os.getenv('LIVE_NOTIFY_TARGETS', 'both') or 'both').strip().lower()
589
+ out = []
590
+ if mode in ('both', 'channel') and self.api.chat_id():
591
+ out.append(self.api.chat_id())
592
+ if mode in ('both', 'backup') and self.api.backup_chat_id():
593
+ out.append(self.api.backup_chat_id())
594
+ return out
595
+
596
+ def live_start_text(self, user, meta=None, when=None):
597
+ """نصّ التنبيه: اسم المذيع وتاريخ اليوم ووقته (منطقة `TZ`)."""
598
+ from datetime import datetime
599
+ tz = None
600
+ try:
601
+ from zoneinfo import ZoneInfo
602
+ tz = ZoneInfo(os.getenv('TZ', 'Asia/Baghdad') or 'UTC')
603
+ except Exception:
604
+ tz = None
605
+ now = when or (datetime.now(tz) if tz else datetime.now())
606
+ meta = dict(meta or {})
607
+ nickname = str(meta.get('nickname') or '').strip()
608
+ title = str(meta.get('live_title') or '').strip()
609
+ template = str(os.getenv('LIVE_NOTIFY_TEXT', '') or DEFAULT_LIVE_TEXT)
610
+ values = _Safe({'name': nickname or user, 'nickname': nickname or user,
611
+ 'username': user, 'title': title or (nickname or user),
612
+ 'live_title': title, 'date': now.strftime('%d-%m-%Y'),
613
+ 'time': now.strftime('%H:%M'),
614
+ 'room_id': str(meta.get('room_id') or '')})
615
+ try:
616
+ text = template.format_map(values)
617
+ except Exception:
618
+ text = DEFAULT_LIVE_TEXT.format_map(values)
619
+ if title and '{live_title}' not in template:
620
+ text = f'{text}\n\U0001f4dd {title}'
621
+ return text.strip()[:MESSAGE_LIMIT]
622
+
623
+ def notify_live_start(self, user, meta=None, room_id='', when=None, test=False):
624
+ """رسالة «بدأ بثًّا مباشرًا». ← هل وصلت إلى وجهة واحدة على الأقل.
625
+
626
+ * مرة واحدة لكل بثّ: `app.py` يمنع التكرار بين مقاطع البثّ الواحد، والسجلّ
627
+ الدائم يمنعه بعد إعادة التشغيل (بمعرّف الغرفة).
628
+ * **لا يعطّل التسجيل أبدًا**: أي فشل يُسجَّل ويعيد `False`.
629
+ """
630
+ if str(os.getenv('LIVE_NOTIFY', 'on')).strip().lower() not in ('1', 'on', 'true', 'yes'):
631
+ return False
632
+ if not self.api.token():
633
+ return False
634
+ targets = self.notify_targets()
635
+ if not targets:
636
+ return False
637
+ meta = dict(meta or {})
638
+ key = str(user or '').lower()
639
+ room = str(room_id or meta.get('room_id') or '').strip()
640
+ if room and not test:
641
+ seen = self.journal.read().get('notified') or {}
642
+ if str(seen.get(key) or '') == room:
643
+ return False # هذا البثّ أُعلن من قبل، حتى لو أُعيد الإقلاع
644
+ text = self.live_start_text(user, meta, when)
645
+ if test:
646
+ text = ('\U0001f9ea ' + text +
647
+ '\n\n(رسالة تجريبية من اللوحة — لا يوجد بثّ فعلي)')[:MESSAGE_LIMIT]
648
+ sent = []
649
+ for chat in targets:
650
+ try:
651
+ self.api.call('sendMessage', {'chat_id': str(chat), 'text': text,
652
+ 'disable_web_page_preview': True})
653
+ sent.append(str(chat))
654
+ except Exception as exc:
655
+ self.log('system', f'تعذّر تنبيه بدء بثّ {user} إلى {chat} '
656
+ f'({type(exc).__name__})')
657
+ if not sent:
658
+ return False
659
+ if room and not test:
660
+ def mutate(d):
661
+ seen = d.setdefault('notified', {})
662
+ seen[key] = room
663
+ if len(seen) > 200: # لا يكبر السجلّ بلا حدّ
664
+ d['notified'] = dict(list(seen.items())[-200:])
665
+ self.journal.change(mutate)
666
+ self.log(user, f'أُرسل تنبيه «بدأ بثًّا مباشرًا» إلى {len(sent)} وجهة'
667
+ + (' (تجربة)' if test else ''))
668
+ return True
669
+
670
  # ---------- دورة حياة ----------
671
  def restore(self):
672
  """إقلاع: ينبّه على بقايا يوتيوب وعلى نقص الأسرار، ولا يرسل شيئًا."""
test_telegram_archive.py CHANGED
@@ -40,6 +40,7 @@ class FakeBot:
40
  self._mid = 100
41
  self.albums = [] # (chat_id, file_ids, caption)
42
  self.fail_album = None # APIError تُرمى عند أول ألبوم
 
43
 
44
  # --- أسرار/إعداد ---
45
  def token(self): return os.environ.get('TELEGRAM_BOT_TOKEN', '')
@@ -91,8 +92,13 @@ class FakeBot:
91
  'width': 1080, 'height': 1920, 'duration': 60,
92
  'file_size': Path(path).stat().st_size}}
93
 
94
- def call(self, method, data=None, timeout=60):
95
  self.calls.append((method, dict(data or {})))
 
 
 
 
 
96
  if method == 'sendVideo' or method == 'sendDocument':
97
  if self.fail_backup:
98
  raise self.fail_backup # يبقى قائمًا: يفشل video وdocument معًا
@@ -693,6 +699,129 @@ try:
693
  finally:
694
  TA.shutil.which, TA.subprocess.run = real_w2, real_r2
695
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
696
  # --------------------------------------------------------------------------- #
697
  failed = [r for r in RESULTS if not r[1]]
698
  print('\n' + '=' * 62)
 
40
  self._mid = 100
41
  self.albums = [] # (chat_id, file_ids, caption)
42
  self.fail_album = None # APIError تُرمى عند أول ألبوم
43
+ self.fail_send = None # APIError تُرمى عند أي sendMessage
44
 
45
  # --- أسرار/إعداد ---
46
  def token(self): return os.environ.get('TELEGRAM_BOT_TOKEN', '')
 
92
  'width': 1080, 'height': 1920, 'duration': 60,
93
  'file_size': Path(path).stat().st_size}}
94
 
95
+ def call(self, method, data=None, timeout=60, retries=0):
96
  self.calls.append((method, dict(data or {})))
97
+ if method == 'sendMessage':
98
+ if self.fail_send:
99
+ raise self.fail_send
100
+ self._mid += 1
101
+ return {'message_id': self._mid}
102
  if method == 'sendVideo' or method == 'sendDocument':
103
  if self.fail_backup:
104
  raise self.fail_backup # يبقى قائمًا: يفشل video وdocument معًا
 
699
  finally:
700
  TA.shutil.which, TA.subprocess.run = real_w2, real_r2
701
 
702
+ print('\n=== 18) تنبيه «بدأ بثًّا مباشرًا» إلى تيليغرام ===')
703
+ # المطلوب: اسم المذيع + التاريخ + الوقت، إلى القناة والحساب الشخصي معًا، مرة واحدة
704
+ # لكل بثّ (لا لكل مقطع، ولا بعد إعادة الإقلاع)، وبلا تعطيل التسجيل عند الفشل.
705
+ from datetime import datetime
706
+
707
+ with tempfile.TemporaryDirectory() as tmp:
708
+ arc, core, files = make_archive(tmp, LIVE_NOTIFY='on', LIVE_NOTIFY_TARGETS='both',
709
+ LIVE_NOTIFY_TEXT='')
710
+ bot = core.api
711
+ texts = lambda api: [d['text'] for m, d in api.calls if m == 'sendMessage']
712
+ chats = lambda api: [str(d['chat_id']) for m, d in api.calls if m == 'sendMessage']
713
+
714
+ # 1) بدء البثّ: رسالة إلى الوجهتين تحمل الاسم والتاريخ والوقت
715
+ ok = core.notify_live_start('ahmedbarbooka2', {'nickname': 'أحمد', 'live_title': 'سهرة',
716
+ 'room_id': '74'},
717
+ when=datetime(2026, 10, 10, 8, 5))
718
+ check('التنبيه نجح', ok is True)
719
+ check('إلى القناة والحساب الشخصي معًا',
720
+ chats(bot) == ['-1001234567890', '8482164917'], str(chats(bot)))
721
+ t1 = texts(bot)[0]
722
+ check('يحمل اسم المذيع', 'أحمد' in t1, t1)
723
+ check('يحمل اسم المستخدم', '@ahmedbarbooka2' in t1, t1)
724
+ check('يحمل التاريخ', '10-10-2026' in t1, t1)
725
+ check('يحمل الوقت', '08:05' in t1, t1)
726
+ check('يحمل عنوان البثّ', 'سهرة' in t1, t1)
727
+ check('يقول إنّه بدأ بثًّا مباشرًا', 'بثًّا مباشرًا' in t1, t1)
728
+ check('بلا معاينة رابط (لا بطاقة مزعجة)',
729
+ all(d.get('disable_web_page_preview') is True for m, d in bot.calls
730
+ if m == 'sendMessage'))
731
+
732
+ # 2) مقطع ثانٍ من البثّ نفسه (انقطاع مصدر ثم عودة): لا تكرار
733
+ n0 = len(bot.calls)
734
+ check('المقطع الثاني من البثّ نفسه لا يُعلن مرتين',
735
+ core.notify_live_start('ahmedbarbooka2', {'nickname': 'أحمد', 'room_id': '74'}) is False
736
+ and len(bot.calls) == n0, str(len(bot.calls) - n0))
737
+
738
+ # 3) إعادة إقلاع أثناء البثّ: السجلّ الدائم يمنع التكرار
739
+ jpath = Path(tmp) / 'telegram-api-state.json'
740
+ core2 = TelegramCore(lambda *a, **k: None, jpath)
741
+ core2.api = FakeBot(core2.journal)
742
+ check('مانع التكرار محفوظ في السجلّ',
743
+ (core2.journal.read().get('notified') or {}).get('ahmedbarbooka2') == '74',
744
+ str(core2.journal.read().get('notified')))
745
+ check('بعد إعادة الإقلاع لا يُعلن البثّ نفسه مرّة أخرى',
746
+ core2.notify_live_start('ahmedbarbooka2', {'nickname': 'أحمد', 'room_id': '74'}) is False
747
+ and not chats(core2.api))
748
+
749
+ # 4) بثّ جديد (غرفة جديدة) ⇒ تنبيه جديد بتاريخه ووقته
750
+ check('بثّ جديد بغرفة جديدة يُعلن',
751
+ core2.notify_live_start('ahmedbarbooka2', {'nickname': 'أحمد', 'room_id': '99'},
752
+ when=datetime(2026, 10, 11, 20, 30)) is True)
753
+ t2 = texts(core2.api)[-1]
754
+ check('التنبيه الجديد يحمل تاريخه ووقته', '11-10-2026' in t2 and '20:30' in t2, t2)
755
+
756
+ # 5) بلا لقب ⇒ يُذكر اسم المستخدم؛ بلا عنوان ⇒ لا سطر عنوان
757
+ core2.notify_live_start('strongarmor', {}, when=datetime(2026, 10, 10, 9, 0))
758
+ t3 = texts(core2.api)[-1]
759
+ check('بلا لقب يُذكر اسم المستخدم', '@strongarmor' in t3 and 'strongarmor (' in t3, t3)
760
+ check('بلا عنوان لا يُضاف سطر العنوان', '\U0001f4dd' not in t3, t3)
761
+ check('لا يبقى قوس قالب غير مُعبَّأ', '{' not in t3, t3)
762
+
763
+ # 6) الإطفاء
764
+ os.environ['LIVE_NOTIFY'] = 'off'
765
+ n1 = len(core2.api.calls)
766
+ check('LIVE_NOTIFY=off يوقف التنبيه',
767
+ core2.notify_live_start('ahmedbarbooka2', {'room_id': '500'}) is False
768
+ and len(core2.api.calls) == n1)
769
+ os.environ['LIVE_NOTIFY'] = 'on'
770
+
771
+ # 7) اختيار الوجهة
772
+ os.environ['LIVE_NOTIFY_TARGETS'] = 'channel'
773
+ check('targets=channel ⇒ القناة فقط', core2.notify_targets() == ['-1001234567890'],
774
+ str(core2.notify_targets()))
775
+ os.environ['LIVE_NOTIFY_TARGETS'] = 'backup'
776
+ check('targets=backup ⇒ الحساب الشخصي فقط', core2.notify_targets() == ['8482164917'],
777
+ str(core2.notify_targets()))
778
+ os.environ['LIVE_NOTIFY_TARGETS'] = 'both'
779
+ check('targets=both ⇒ الوجهتان', len(core2.notify_targets()) == 2)
780
+
781
+ # 8) فشل الإرسال: لا استثناء، لا تعطيل للتسجيل، ويُبلَّغ
782
+ # make_archive يعطي النواة مسجِّلًا صامتًا؛ هنا نلتقط سجلّها كما تفعل app.py.
783
+ CORE_LOG = []
784
+ core.log = lambda user, msg: CORE_LOG.append((user, msg))
785
+ bot.fail_send = APIError('networkOrInvalidResponse', 'انقطاع الاتصال')
786
+ check('فشل الإرسال يعيد False بلا استثناء',
787
+ core.notify_live_start('ahmadbarbooka.cinderella', {'nickname': 'سندريلا',
788
+ 'room_id': '1'}) is False)
789
+ check('الفشل سُجّل في سجلّ التطبيق',
790
+ any('تنبيه بدء بثّ' in m for _, m in CORE_LOG), str(CORE_LOG[:2]))
791
+ check('الفشل لم يُسجَّل في مانع التكرار (يُحاول في بثّ لاحق)',
792
+ (core.journal.read().get('notified') or {}).get('ahmadbarbooka.cinderella') is None)
793
+ bot.fail_send = None
794
+
795
+ # 9) زرّ التجربة من اللوحة: يُرسل دائمًا ولا يلوّث مانع التكرار
796
+ check('زرّ التجربة يُرسل بلا بثّ فعلي',
797
+ core.notify_live_start('example', {'nickname': 'مثال'}, test=True) is True)
798
+ tt = texts(bot)[-1]
799
+ check('رسالة التجربة مُعلَّمة كتجربة', 'تجريبية' in tt, tt)
800
+ check('التجربة لا تُسجَّل في مانع التكرار',
801
+ 'example' not in (core.journal.read().get('notified') or {}))
802
+
803
+ # 10) قالب مخصّص + مفتاح غير معروف يبقى كما هو
804
+ os.environ['LIVE_NOTIFY_TEXT'] = '{name} مباشر {date} {time} {unknown_key}'
805
+ t4 = core.live_start_text('u', {'nickname': 'ن'}, when=datetime(2026, 1, 2, 3, 4))
806
+ check('القالب المخصّص مستعمل', t4.startswith('ن مباشر 02-01-2026 03:04'), t4)
807
+ check('مفتاح غير معروف لا يُسقط الرسالة', '{unknown_key}' in t4, t4)
808
+ os.environ['LIVE_NOTIFY_TEXT'] = ''
809
+
810
+ # 11) مانع التكرار لا يكبر بلا حدّ
811
+ core.journal.change(lambda d: d.update(notified={('k%d' % i): str(i) for i in range(250)}))
812
+ core.notify_live_start('capuser', {'room_id': 'R'})
813
+ check('مانع التكرار يبقى تحت 200 غرفة',
814
+ len(core.journal.read().get('notified') or {}) <= 200,
815
+ str(len(core.journal.read().get('notified') or {})))
816
+ check('أحدث الغرف محفوظة بعد التقليم',
817
+ (core.journal.read().get('notified') or {}).get('capuser') == 'R')
818
+
819
+ # 12) لقطة اللوحة تُظهر حالة التنبيه
820
+ snap = core.snapshot()
821
+ check('اللقطة تُظهر التنبيه مفعّلًا', snap.get('live_notify') is True)
822
+ check('اللقطة تُظهر وجهتي التنبيه', len(snap.get('live_notify_targets') or []) == 2,
823
+ str(snap.get('live_notify_targets')))
824
+
825
  # --------------------------------------------------------------------------- #
826
  failed = [r for r in RESULTS if not r[1]]
827
  print('\n' + '=' * 62)
variables.env CHANGED
@@ -46,6 +46,13 @@ TELEGRAM_REQUIRE_BACKUP=on
46
  TELEGRAM_SILENT=off
47
  # حارس المعدل: أقل فاصلة بين إرسالين للمحادثة نفسها (ثوانٍ)
48
  TELEGRAM_MIN_CHAT_INTERVAL=4
 
 
 
 
 
 
 
49
  TELEGRAM_LOCAL_PORT=8081
50
 
51
  # ---------- المراقبة والتسجيل والتخزين ----------
 
46
  TELEGRAM_SILENT=off
47
  # حارس المعدل: أقل فاصلة بين إرسالين للمحادثة نفسها (ثوانٍ)
48
  TELEGRAM_MIN_CHAT_INTERVAL=4
49
+ # تنبيه «بدأ بثًّا مباشرًا» باسم المذيع والتاريخ والوقت — مرة لكل بثّ
50
+ # on/off. الإيقاف لا يمسّ التسجيل ولا الأرشفة.
51
+ LIVE_NOTIFY=on
52
+ # إلى أين: both = القناة + الحساب الشخصي | channel | backup
53
+ LIVE_NOTIFY_TARGETS=both
54
+ # قالب اختياري: {name} {username} {nickname} {live_title} {date} {time} {room_id}
55
+ LIVE_NOTIFY_TEXT=
56
  TELEGRAM_LOCAL_PORT=8081
57
 
58
  # ---------- المراقبة والتسجيل والتخزين ----------