Teleautoadminbot / database.py
Alialdlfy1's picture
Upload 168 files
14c1acb verified
Raw History Blame Contribute Delete
94.8 kB
import json
import os
import time
import shutil
from threading import Lock
DB_FILE = "/data/data.json"
BACKUP_DIR = "/data/backups"
SCHEMA_VERSION = 2
lock = Lock()
DEFAULT_BLOCKED_WORDS = [
"إعلان", "اعلان", "تبادل", "ممول", "تمويل", "للتواصل",
"اشتراك", "تابعونا", "اشترك", "قناتنا", "بوت", "عبر البوت",
"عرض خاص", "لفترة محدودة", "اضغط هنا", "لمشاهدة المزيد",
"حصرياً", "تم النشر بواسطة"
]
DEFAULT_DATA = {
"schema_version": SCHEMA_VERSION,
"channels": {},
"public_sources": [],
"source_meta": {},
"blocked_words": DEFAULT_BLOCKED_WORDS,
"allowed_users": [],
"last_source_messages": {},
"settings": {},
"global_remove_terms": [],
"recent_fingerprints": [],
"source_stats": {},
"source_logs": {},
"system": {},
"last_errors": [],
"named_backups": [],
"channel_failures": {},
"published_messages": {},
"sessions": {},
"ai_keys": {},
"publishing_bots": {},
"websites": {},
"trash": [],
"naming_counters": {"session": 0, "ai_key": 0, "bot": 0, "website": 0},
"channel_configs": {},
"settings_clipboard": None,
"_migrated_channel_word_lists": False,
"notification_settings": {
"session_stopped": True,
"ai_key_error": True,
"source_stopped": True,
"channel_idle_hour": True,
"backup_failed": True,
"db_issue": True,
"last_alert_ts": {},
},
}
def _now():
return int(time.time())
def _dedupe_int_list(values):
out = []
seen = set()
for value in values or []:
try:
item = int(value)
except Exception:
item = value
key = str(item)
if key not in seen:
seen.add(key)
out.append(item)
return out
def _normalize_channel(cid, value):
if not isinstance(value, dict):
value = {"name": str(value)}
value.setdefault("name", value.get("title") or str(cid))
value.setdefault("title", value.get("name") or str(cid))
value.setdefault("username", "")
value.setdefault("link", "")
value.setdefault("chat_type", "")
value.setdefault("tail", "")
value.setdefault("special_sources", [])
value.setdefault("paused", False)
value.setdefault("posts_count", 0)
value.setdefault("publish_delay", None)
value.setdefault("bold_publish", True)
value.setdefault("quote_publish", False)
# إعدادات الاقتباس حسب نوع المنشور.
# توافق رجعي: إذا كان quote_publish القديم مفعلاً، نفعّل كل الأنواع افتراضياً.
old_quote = bool(value.get("quote_publish", False))
quote_types = value.get("quote_types")
if not isinstance(quote_types, dict):
quote_types = {}
value["quote_types"] = {
"text": bool(quote_types.get("text", old_quote)),
"photo": bool(quote_types.get("photo", old_quote)),
"video": bool(quote_types.get("video", old_quote)),
"album": bool(quote_types.get("album", old_quote)),
}
# يبقى المفتاح القديم موجود للتوافق مع النسخ الاحتياطية القديمة.
value["quote_publish"] = any(value["quote_types"].values())
value.setdefault("hashtags", [])
if not isinstance(value.get("hashtags"), list):
value["hashtags"] = []
value.setdefault("fail_count", 0)
value.setdefault("disable_web_page_preview", False)
value.setdefault("added_at", _now())
value.setdefault("tail_enabled", True)
# None means the channel has not yet inherited the legacy global setting.
value.setdefault("ignore_short_posts", None)
value.setdefault("tail_min_words", 20)
value.setdefault("tail_position", "bottom")
value["special_sources"] = _dedupe_int_list(value.get("special_sources", []))
# الكلمات المحظورة وقائمة الحذف الآن مستقلة لكل قناة (وليست عامة).
# None = لم تُهاجَر/تُحدَّد بعد؛ تتم معالجتها لاحقاً في _normalize_data.
if not isinstance(value.get("blocked_words"), list):
value["blocked_words"] = None
if not isinstance(value.get("delete_terms"), list):
value["delete_terms"] = None
return value
def _normalize_source_meta(source_id, value=None):
if not isinstance(value, dict):
value = {}
sid = str(source_id)
value.setdefault("id", int(source_id) if str(source_id).lstrip('-').isdigit() else source_id)
value.setdefault("name", value.get("title") or sid)
value.setdefault("title", value.get("name") or sid)
value.setdefault("username", "")
value.setdefault("link", "")
value.setdefault("chat_type", "")
value.setdefault("added_at", _now())
value.setdefault("paused", False)
value.setdefault("remove_terms", [])
if not isinstance(value.get("remove_terms"), list):
value["remove_terms"] = []
value.setdefault("remove_emoji", False)
default_types = {"text": True, "photo": True, "video": True, "album": True, "voice": False, "audio": False, "document": False}
types = value.get("content_types")
if not isinstance(types, dict):
types = {}
value["content_types"] = {k: bool(types.get(k, v)) for k, v in default_types.items()}
return value
def _normalize_data(data):
if not isinstance(data, dict):
data = {}
for key, value in DEFAULT_DATA.items():
if key not in data:
data[key] = value.copy() if isinstance(value, (list, dict)) else value
if not isinstance(data.get("channels"), dict):
data["channels"] = {}
if not isinstance(data.get("public_sources"), list):
data["public_sources"] = []
if not isinstance(data.get("source_meta"), dict):
data["source_meta"] = {}
if not isinstance(data.get("blocked_words"), list):
data["blocked_words"] = DEFAULT_BLOCKED_WORDS.copy()
if not isinstance(data.get("allowed_users"), list):
data["allowed_users"] = []
if not isinstance(data.get("last_source_messages"), dict):
data["last_source_messages"] = {}
if not isinstance(data.get("settings"), dict):
data["settings"] = {}
if not isinstance(data.get("global_remove_terms"), list):
data["global_remove_terms"] = []
if not isinstance(data.get("recent_fingerprints"), list):
data["recent_fingerprints"] = []
if not isinstance(data.get("source_stats"), dict):
data["source_stats"] = {}
if not isinstance(data.get("source_logs"), dict):
data["source_logs"] = {}
if not isinstance(data.get("system"), dict):
data["system"] = {}
if not isinstance(data.get("last_errors"), list):
data["last_errors"] = []
if not isinstance(data.get("named_backups"), list):
data["named_backups"] = []
if not isinstance(data.get("channel_failures"), dict):
data["channel_failures"] = {}
if not isinstance(data.get("published_messages"), dict):
data["published_messages"] = {}
if not isinstance(data.get("sessions"), dict):
data["sessions"] = {}
if not isinstance(data.get("ai_keys"), dict):
data["ai_keys"] = {}
if not isinstance(data.get("publishing_bots"), dict):
data["publishing_bots"] = {}
if not isinstance(data.get("websites"), dict):
data["websites"] = {}
if not isinstance(data.get("trash"), list):
data["trash"] = []
if not isinstance(data.get("naming_counters"), dict):
data["naming_counters"] = {"session": 0, "ai_key": 0, "bot": 0, "website": 0}
if not isinstance(data.get("channel_configs"), dict):
data["channel_configs"] = {}
if not isinstance(data.get("notification_settings"), dict):
data["notification_settings"] = DEFAULT_DATA["notification_settings"].copy()
ns = data["notification_settings"]
for k, v in DEFAULT_DATA["notification_settings"].items():
ns.setdefault(k, v.copy() if isinstance(v, dict) else v)
data.setdefault("settings_clipboard", None)
data["schema_version"] = SCHEMA_VERSION
data["public_sources"] = _dedupe_int_list(data.get("public_sources", []))
channels = {}
for cid, ch in data.get("channels", {}).items():
channels[str(cid)] = _normalize_channel(cid, ch)
data["channels"] = channels
# توافق رجعي: القنوات القديمة ترث قيمة الفلتر العام مرة واحدة في الذاكرة،
# ثم تُحفظ القيمة محلياً عند أول كتابة لاحقة دون تغيير الإعداد العام.
legacy_ignore_short = bool(data.get("settings", {}).get("ignore_short_posts", False))
for ch in data["channels"].values():
if ch.get("ignore_short_posts") is None:
ch["ignore_short_posts"] = legacy_ignore_short
# هجرة لمرة واحدة: نسخ القوائم العامة القديمة (الكلمات المحظورة / قائمة
# الحذف العامة) داخل كل قناة موجودة حتى تصبح كل قناة مستقلة بقوائمها،
# بدون فقدان البيانات القديمة. تعمل مرة وحدة فقط بفضل العلم أدناه، حتى
# القنوات التي تُفرّغ قوائمها لاحقاً ما تنرجع تنعبي من جديد.
if not data.get("_migrated_channel_word_lists"):
legacy_blocked = data.get("blocked_words") or DEFAULT_BLOCKED_WORDS.copy()
legacy_terms = data.get("global_remove_terms") or []
for ch in data["channels"].values():
if ch.get("blocked_words") is None:
ch["blocked_words"] = list(legacy_blocked)
if ch.get("delete_terms") is None:
ch["delete_terms"] = list(legacy_terms)
data["_migrated_channel_word_lists"] = True
# أي قناة بلا قيمة بعد (مثلاً أُضيفت حديثاً بعد الهجرة) تاخذ افتراضي فارغ/عام.
for ch in data["channels"].values():
if ch.get("blocked_words") is None:
ch["blocked_words"] = DEFAULT_BLOCKED_WORDS.copy()
if ch.get("delete_terms") is None:
ch["delete_terms"] = []
meta = {}
for source in data["public_sources"]:
sid = str(source)
meta[sid] = _normalize_source_meta(source, data.get("source_meta", {}).get(sid, {}))
for sid, value in data.get("source_meta", {}).items():
if sid not in meta:
meta[sid] = _normalize_source_meta(sid, value)
data["source_meta"] = meta
# normalize last source ids as strings
data["last_source_messages"] = {str(k): v for k, v in data.get("last_source_messages", {}).items()}
return data
def _is_valid_data(data, allow_empty=True):
"""يتأكد أن ملف البيانات صالح وليس فارغاً/تالفاً."""
if not isinstance(data, dict):
return False
if "channels" not in data or "public_sources" not in data:
return False
if not isinstance(data.get("channels"), dict):
return False
if not isinstance(data.get("public_sources"), list):
return False
if not allow_empty:
if not data.get("channels") and not data.get("public_sources") and not data.get("source_meta"):
return False
return True
def _load_json_file(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if not _is_valid_data(data, allow_empty=True):
raise ValueError("ملف بيانات غير صالح")
return _normalize_data(data)
def _backup_candidates():
files = []
if os.path.exists(DB_FILE + ".bak"):
files.append(DB_FILE + ".bak")
if os.path.isdir(BACKUP_DIR):
files.extend(
os.path.join(BACKUP_DIR, x)
for x in os.listdir(BACKUP_DIR)
if x.endswith(".json")
)
return sorted(files, key=lambda p: os.path.getmtime(p), reverse=True)
def _restore_latest_backup():
"""يحاول استعادة أحدث نسخة احتياطية صالحة."""
os.makedirs("/data", exist_ok=True)
for path in _backup_candidates():
try:
data = _load_json_file(path)
tmp = DB_FILE + ".restore.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp, DB_FILE)
return data
except Exception:
continue
return None
def verify_or_recover_storage():
"""فحص تخزين آمن عند تشغيل التطبيق/البوت."""
os.makedirs("/data", exist_ok=True)
if os.path.exists(DB_FILE):
try:
data = _load_json_file(DB_FILE)
return {"ok": True, "recovered": False, "message": "data.json صالح", "data": data}
except Exception as e:
recovered = _restore_latest_backup()
if recovered is not None:
return {"ok": True, "recovered": True, "message": f"تمت الاستعادة من Backup بعد تلف data.json: {e}", "data": recovered}
return {"ok": False, "recovered": False, "message": f"data.json تالف ولا توجد نسخة احتياطية صالحة: {e}", "data": None}
recovered = _restore_latest_backup()
if recovered is not None:
return {"ok": True, "recovered": True, "message": "تمت الاستعادة من Backup لأن data.json غير موجود", "data": recovered}
# أول تشغيل فقط: إنشاء ملف جديد فارغ
data = _normalize_data(DEFAULT_DATA.copy())
with open(DB_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return {"ok": True, "recovered": False, "message": "تم إنشاء قاعدة بيانات جديدة", "data": data}
def _read():
os.makedirs("/data", exist_ok=True)
status = verify_or_recover_storage()
if not status.get("ok"):
raise RuntimeError(status.get("message", "فشل قراءة قاعدة البيانات"))
return _normalize_data(status.get("data") or DEFAULT_DATA.copy())
def _make_prewrite_backup():
"""نسخة احتياطية فورية قبل أي كتابة حتى لا تضيع البيانات عند توقف الاستضافة."""
try:
if os.path.exists(DB_FILE) and os.path.getsize(DB_FILE) > 5:
shutil.copy2(DB_FILE, DB_FILE + ".bak")
except Exception:
pass
def _write(data):
os.makedirs("/data", exist_ok=True)
data = _normalize_data(data)
# حماية من الكتابة بملف فارغ فوق بيانات موجودة
if not _is_valid_data(data, allow_empty=True):
raise ValueError("رفض كتابة بيانات غير صالحة")
_make_prewrite_backup()
tmp_file = DB_FILE + ".tmp"
with open(tmp_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.flush()
try:
os.fsync(f.fileno())
except Exception:
pass
os.replace(tmp_file, DB_FILE)
def _write_backup(data):
try:
os.makedirs(BACKUP_DIR, exist_ok=True)
data = _normalize_data(data)
if not _is_valid_data(data, allow_empty=True):
return
backup_file = os.path.join(BACKUP_DIR, f"backup_{int(time.time())}.json")
with open(backup_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# keep last 20 backups
files = sorted([os.path.join(BACKUP_DIR, x) for x in os.listdir(BACKUP_DIR) if x.endswith('.json')])
for old in files[:-20]:
try:
os.remove(old)
except Exception:
pass
except Exception:
pass
def get_all_channels():
with lock:
data = _read()
return [{"id": k, **v} for k, v in data["channels"].items()]
def add_channel(channel_id, name="", title="", username="", link="", chat_type=""):
cid = str(channel_id)
with lock:
data = _read()
created = cid not in data["channels"]
if created:
data["channels"][cid] = _normalize_channel(cid, {
"name": title or name or cid,
"title": title or name or cid,
"username": username or "",
"link": link or "",
"chat_type": chat_type or "",
"tail": "",
"special_sources": [],
"paused": False,
"posts_count": 0,
"added_at": _now(),
})
else:
ch = data["channels"][cid]
if title or name:
ch["name"] = title or name
ch["title"] = title or name
if username is not None:
ch["username"] = username or ch.get("username", "")
if link is not None:
ch["link"] = link or ch.get("link", "")
if chat_type is not None:
ch["chat_type"] = chat_type or ch.get("chat_type", "")
_write(data)
if created:
_write_backup(data)
return created
def delete_channel(channel_id):
cid = str(channel_id)
with lock:
data = _read()
if cid in data["channels"]:
del data["channels"][cid]
_write(data)
_write_backup(data)
return True
return False
def update_channel(channel_id, key, value):
cid = str(channel_id)
with lock:
data = _read()
if cid in data["channels"]:
data["channels"][cid][key] = value
_write(data)
_write_backup(data)
return True
return False
def increment_post_count(channel_id):
cid = str(channel_id)
with lock:
data = _read()
if cid in data["channels"]:
data["channels"][cid].setdefault("posts_count", 0)
data["channels"][cid]["posts_count"] += 1
_write(data)
return True
return False
def get_channel(channel_id):
cid = str(channel_id)
with lock:
data = _read()
return data["channels"].get(cid)
def get_public_sources():
with lock:
data = _read()
return data["public_sources"]
def get_source_meta(source_id):
sid = str(source_id)
with lock:
data = _read()
return data.get("source_meta", {}).get(sid, _normalize_source_meta(source_id))
def get_all_public_sources_with_meta():
with lock:
data = _read()
return [{"id": s, **data.get("source_meta", {}).get(str(s), _normalize_source_meta(s))} for s in data["public_sources"]]
def add_public_source(source_id, name="", title="", username="", link="", chat_type=""):
try:
source_id = int(source_id)
except Exception:
pass
sid = str(source_id)
with lock:
data = _read()
created = source_id not in data["public_sources"]
if created:
data["public_sources"].append(source_id)
old = data.setdefault("source_meta", {}).get(sid, {})
meta = _normalize_source_meta(source_id, old)
if title or name:
meta["name"] = title or name
meta["title"] = title or name
if username is not None:
meta["username"] = username or meta.get("username", "")
if link is not None:
meta["link"] = link or meta.get("link", "")
if chat_type is not None:
meta["chat_type"] = chat_type or meta.get("chat_type", "")
data["source_meta"][sid] = meta
_write(data)
if created:
_write_backup(data)
return created
def update_source_meta(source_id, **kwargs):
sid = str(source_id)
with lock:
data = _read()
meta = _normalize_source_meta(source_id, data.setdefault("source_meta", {}).get(sid, {}))
for key, value in kwargs.items():
if value is not None:
meta[key] = value
data["source_meta"][sid] = meta
_write(data)
return True
def remove_public_source(source_id):
try:
source_id = int(source_id)
except Exception:
pass
sid = str(source_id)
with lock:
data = _read()
if source_id in data["public_sources"]:
data["public_sources"].remove(source_id)
data["last_source_messages"].pop(sid, None)
data.get("source_meta", {}).pop(sid, None)
_write(data)
_write_backup(data)
return True
return False
def get_blocked_words():
with lock:
data = _read()
return data["blocked_words"]
def add_blocked_word(word):
word = str(word).strip()
if not word:
return False
with lock:
data = _read()
if word not in data["blocked_words"]:
data["blocked_words"].append(word)
_write(data)
_write_backup(data)
return True
return False
def remove_blocked_word(word):
word = str(word).strip()
with lock:
data = _read()
if word in data["blocked_words"]:
data["blocked_words"].remove(word)
_write(data)
_write_backup(data)
return True
return False
# ============================================================
# الكلمات المحظورة / قائمة الحذف — مستقلة لكل قناة
# ============================================================
def get_channel_blocked_words(channel_id):
cid = str(channel_id)
with lock:
data = _read()
ch = data["channels"].get(cid)
return list(ch.get("blocked_words", [])) if ch else []
def add_channel_blocked_word(channel_id, word):
word = str(word).strip()
if not word:
return False
cid = str(channel_id)
with lock:
data = _read()
ch = data["channels"].get(cid)
if not ch:
return False
ch.setdefault("blocked_words", [])
if word not in ch["blocked_words"]:
ch["blocked_words"].append(word)
_write(data)
_write_backup(data)
return True
return False
def remove_channel_blocked_word(channel_id, word):
word = str(word).strip()
cid = str(channel_id)
with lock:
data = _read()
ch = data["channels"].get(cid)
if not ch:
return False
if word in ch.get("blocked_words", []):
ch["blocked_words"].remove(word)
_write(data)
_write_backup(data)
return True
return False
def get_channel_delete_terms(channel_id):
cid = str(channel_id)
with lock:
data = _read()
ch = data["channels"].get(cid)
return list(ch.get("delete_terms", [])) if ch else []
def add_channel_delete_terms(channel_id, terms):
cid = str(channel_id)
with lock:
data = _read()
ch = data["channels"].get(cid)
if not ch:
return {"added": 0, "exists": 0}
existing = [str(x).strip() for x in ch.get("delete_terms", []) if str(x).strip()]
added = 0
exists = 0
for term in terms:
term = str(term).strip()
if not term:
continue
if term in existing:
exists += 1
else:
existing.append(term)
added += 1
ch["delete_terms"] = existing
_write(data)
_write_backup(data)
return {"added": added, "exists": exists}
def remove_channel_delete_terms(channel_id, terms):
cid = str(channel_id)
with lock:
data = _read()
ch = data["channels"].get(cid)
if not ch:
return {"removed": 0, "missing": 0}
existing = [str(x).strip() for x in ch.get("delete_terms", []) if str(x).strip()]
removed = 0
missing = 0
for term in terms:
term = str(term).strip()
if term in existing:
existing.remove(term)
removed += 1
else:
missing += 1
ch["delete_terms"] = existing
_write(data)
_write_backup(data)
return {"removed": removed, "missing": missing}
def get_last_source_message(source_id):
with lock:
data = _read()
value = data.get("last_source_messages", {}).get(str(source_id))
try:
if isinstance(value, dict):
return int(value.get("id")) if value.get("id") is not None else None
return int(value) if value is not None else None
except Exception:
return None
def set_last_source_message(source_id, message_id):
with lock:
data = _read()
data.setdefault("last_source_messages", {})[str(source_id)] = {"id": int(message_id), "ts": _now()}
_write(data)
return True
def reset_last_source_message(source_id):
with lock:
data = _read()
if str(source_id) in data.get("last_source_messages", {}):
del data["last_source_messages"][str(source_id)]
_write(data)
_write_backup(data)
return True
return False
def claim_source_event(source_id, message_id):
"""يحجز معالجة رسالة بشكل ذري دائم عبر مساري updates وpolling.
مفتاح الحجز: source_id:message_id داخل source_event_claims.
يرجع True إذا تم الحجز الآن (المعاملة جديدة)، False إذا كانت محجوزة مسبقاً.
التنفيذ بالكامل داخل قفل الملف الحالي فلا يحدث سباق TOCTOU.
"""
if source_id is None or message_id is None:
return False
sid = str(source_id)
mid = int(message_id)
with lock:
data = _read()
claims = data.setdefault("source_event_claims", {})
per_source = claims.setdefault(sid, [])
if any(isinstance(x, dict) and int(x.get("message_id", -1)) == mid for x in per_source):
return False
per_source.append({"message_id": mid, "ts": _now()})
claims[sid] = per_source[-2000:]
_write(data)
return True
def is_source_event_claimed(source_id, message_id):
"""قراءة فقط: هل سبق حجز هذه الرسالة من أي مسار؟"""
if source_id is None or message_id is None:
return False
sid = str(source_id)
mid = int(message_id)
with lock:
data = _read()
per_source = data.get("source_event_claims", {}).get(sid, [])
return any(isinstance(x, dict) and int(x.get("message_id", -1)) == mid for x in per_source)
def release_source_event_claim(source_id, message_id):
"""يحرر claim عند فشل المعالجة حتى تسمح دورة لاحقة بإعادة المحاولة.
العملية ذرية داخل القفل، ولا تحذف إلا claim المطابق للمصدر والرسالة."""
if source_id is None or message_id is None:
return False
sid = str(source_id)
mid = int(message_id)
with lock:
data = _read()
claims = data.setdefault("source_event_claims", {})
per_source = claims.get(sid, [])
kept = [x for x in per_source if not (isinstance(x, dict) and int(x.get("message_id", -1)) == mid)]
if len(kept) == len(per_source):
return False
if kept:
claims[sid] = kept
else:
claims.pop(sid, None)
data["source_event_claims"] = claims
_write(data)
return True
def export_data():
with lock:
data = _read()
data = cleanup_old_runtime_data(data, days=3)
return json.dumps(_normalize_data(data), ensure_ascii=False, indent=2)
def import_data(json_str):
with lock:
new_data = json.loads(json_str)
if not _is_valid_data(new_data, allow_empty=False):
raise ValueError("ملف الاستيراد فارغ أو غير صالح. تم رفضه لحماية بياناتك.")
new_data = _normalize_data(new_data)
# لا نقبل ملف بلا قنوات وبلا مصادر إلا إذا كان مقصوداً، وهذا غير مسموح هنا
if not new_data.get("channels") and not new_data.get("public_sources") and not new_data.get("source_meta"):
raise ValueError("ملف الاستيراد لا يحتوي قنوات أو مصادر.")
_make_prewrite_backup()
_write_backup(_read())
new_data = cleanup_old_runtime_data(new_data, days=3)
_write(new_data)
_write_backup(new_data)
return {
"channels": len(new_data.get("channels", {})),
"public_sources": len(new_data.get("public_sources", [])),
"source_meta": len(new_data.get("source_meta", {})),
"blocked_words": len(new_data.get("blocked_words", [])),
"global_remove_terms": len(new_data.get("global_remove_terms", [])),
"last_source_messages": len(new_data.get("last_source_messages", {})),
"recent_fingerprints": len(new_data.get("recent_fingerprints", [])),
}
def cleanup_old_runtime_data(data=None, days=3):
"""حذف بيانات التكرار والسجلات و IDs المؤقتة الأقدم من 3 أيام حتى لا يكبر الملف."""
cutoff = _now() - int(days) * 86400
own_write = data is None
if data is None:
data = _read()
items = data.get("recent_fingerprints", [])
if isinstance(items, list):
data["recent_fingerprints"] = [
x for x in items
if isinstance(x, dict) and int(x.get("ts", _now())) >= cutoff
][-500:]
logs = data.get("source_logs", {})
if isinstance(logs, dict):
new_logs = {}
for sid, entries in logs.items():
if isinstance(entries, list):
new_logs[sid] = [
x for x in entries
if isinstance(x, dict) and int(x.get("ts", _now())) >= cutoff
][-20:]
data["source_logs"] = new_logs
# تنظيف IDs آخر الأخبار للمصادر إذا خزنّت بصيغة حديثة مع تاريخ.
# القيم القديمة الرقمية تُترك كما هي للتوافق حتى يتم تحديثها تلقائياً عند أول فحص.
last_ids = data.get("last_source_messages", {})
if isinstance(last_ids, dict):
cleaned_last = {}
for sid, value in last_ids.items():
if isinstance(value, dict):
try:
if int(value.get("ts", _now())) >= cutoff:
cleaned_last[str(sid)] = value
except Exception:
pass
else:
cleaned_last[str(sid)] = value
data["last_source_messages"] = cleaned_last
last_errors = data.get("last_errors", [])
if isinstance(last_errors, list):
data["last_errors"] = [
x for x in last_errors
if isinstance(x, dict) and int(x.get("ts", _now())) >= cutoff
][-50:]
# تنظيف حجوزات منع التكرار القديمة حتى لا يكبر الملف.
claims = data.get("source_event_claims", {})
if isinstance(claims, dict):
new_claims = {}
for sid, entries in claims.items():
if isinstance(entries, list):
fresh = [
x for x in entries
if isinstance(x, dict) and int(x.get("ts", _now())) >= cutoff
]
if fresh:
new_claims[str(sid)] = fresh[-1000:]
data["source_event_claims"] = new_claims
# تنظيف سجل الرسائل المنشورة القديمة حتى لا يكبر الملف.
published = data.get("published_messages", {})
if isinstance(published, dict):
new_published = {}
for cid, by_type in published.items():
if not isinstance(by_type, dict):
continue
new_by_type = {}
for kind, entries in by_type.items():
if isinstance(entries, list):
new_by_type[kind] = [
x for x in entries
if isinstance(x, dict) and int(x.get("ts", _now())) >= cutoff
][-1000:]
new_published[str(cid)] = new_by_type
data["published_messages"] = new_published
if own_write:
_write(data)
return _normalize_data(data)
def get_source_remove_terms(source_id):
sid = str(source_id)
with lock:
data = _read()
meta = data.get("source_meta", {}).get(sid, _normalize_source_meta(source_id))
terms = meta.get("remove_terms", [])
if not isinstance(terms, list):
return []
return [str(x).strip() for x in terms if str(x).strip()]
def add_source_remove_terms(source_id, terms):
sid = str(source_id)
clean_terms = []
for term in terms or []:
term = str(term).strip()
if term:
clean_terms.append(term)
if not clean_terms:
return {"added": 0, "exists": 0}
with lock:
data = _read()
meta = _normalize_source_meta(source_id, data.setdefault("source_meta", {}).get(sid, {}))
existing = [str(x).strip() for x in meta.get("remove_terms", []) if str(x).strip()]
existing_keys = {x.lower() for x in existing}
added = 0
exists = 0
for term in clean_terms:
key = term.lower()
if key in existing_keys:
exists += 1
continue
existing.append(term)
existing_keys.add(key)
added += 1
meta["remove_terms"] = existing
data["source_meta"][sid] = meta
_write(data)
if added:
_write_backup(data)
return {"added": added, "exists": exists}
def remove_source_remove_terms(source_id, terms):
sid = str(source_id)
remove_keys = {str(x).strip().lower() for x in terms or [] if str(x).strip()}
if not remove_keys:
return {"removed": 0, "missing": 0}
with lock:
data = _read()
meta = _normalize_source_meta(source_id, data.setdefault("source_meta", {}).get(sid, {}))
existing = [str(x).strip() for x in meta.get("remove_terms", []) if str(x).strip()]
before = len(existing)
existing = [x for x in existing if x.lower() not in remove_keys]
removed = before - len(existing)
meta["remove_terms"] = existing
data["source_meta"][sid] = meta
_write(data)
if removed:
_write_backup(data)
return {"removed": removed, "missing": max(0, len(remove_keys) - removed)}
def remove_source_remove_term(source_id, term_or_index):
terms = get_source_remove_terms(source_id)
try:
idx = int(term_or_index)
if 0 <= idx < len(terms):
return bool(remove_source_remove_terms(source_id, [terms[idx]]).get("removed"))
except Exception:
pass
return bool(remove_source_remove_terms(source_id, [term_or_index]).get("removed"))
# ===== Phase 3 Part 1: source filters, global cleanup, smart dedupe =====
def get_global_remove_terms():
with lock:
data = _read()
terms = data.get("global_remove_terms", [])
if not isinstance(terms, list):
return []
return [str(x).strip() for x in terms if str(x).strip()]
def add_global_remove_terms(terms):
clean_terms = []
for term in terms or []:
term = str(term).strip()
if term:
clean_terms.append(term)
if not clean_terms:
return {"added": 0, "exists": 0}
with lock:
data = _read()
existing = [str(x).strip() for x in data.get("global_remove_terms", []) if str(x).strip()]
existing_keys = {x.lower() for x in existing}
added = 0
exists = 0
for term in clean_terms:
key = term.lower()
if key in existing_keys:
exists += 1
continue
existing.append(term)
existing_keys.add(key)
added += 1
data["global_remove_terms"] = existing
_write(data)
if added:
_write_backup(data)
return {"added": added, "exists": exists}
def remove_global_remove_terms(terms):
remove_keys = {str(x).strip().lower() for x in terms or [] if str(x).strip()}
if not remove_keys:
return {"removed": 0, "missing": 0}
with lock:
data = _read()
existing = [str(x).strip() for x in data.get("global_remove_terms", []) if str(x).strip()]
before = len(existing)
existing = [x for x in existing if x.lower() not in remove_keys]
removed = before - len(existing)
data["global_remove_terms"] = existing
_write(data)
if removed:
_write_backup(data)
return {"removed": removed, "missing": max(0, len(remove_keys) - removed)}
def set_source_paused(source_id, paused=True):
return update_source_meta(source_id, paused=bool(paused))
def is_source_paused(source_id):
sid = str(source_id)
with lock:
data = _read()
meta = data.get("source_meta", {}).get(sid, _normalize_source_meta(source_id))
return bool(meta.get("paused", False))
def set_source_remove_emoji(source_id, enabled=True):
return update_source_meta(source_id, remove_emoji=bool(enabled))
def get_source_remove_emoji(source_id):
sid = str(source_id)
with lock:
data = _read()
meta = data.get("source_meta", {}).get(sid, _normalize_source_meta(source_id))
return bool(meta.get("remove_emoji", False))
def get_source_content_types(source_id):
sid = str(source_id)
with lock:
data = _read()
meta = data.get("source_meta", {}).get(sid, _normalize_source_meta(source_id))
default_types = {"text": True, "photo": True, "video": True, "album": True, "voice": False, "audio": False, "document": False}
types = meta.get("content_types", {})
if not isinstance(types, dict):
types = {}
return {k: bool(types.get(k, v)) for k, v in default_types.items()}
def set_source_content_type(source_id, content_type, enabled=True):
if content_type not in {"text", "photo", "video", "album", "voice", "audio", "document"}:
return False
sid = str(source_id)
default_types = {"text": True, "photo": True, "video": True, "album": True, "voice": False, "audio": False, "document": False}
with lock:
data = _read()
meta = _normalize_source_meta(source_id, data.setdefault("source_meta", {}).get(sid, {}))
current = meta.get("content_types", {})
if not isinstance(current, dict):
current = {}
types = {k: bool(current.get(k, v)) for k, v in default_types.items()}
types[content_type] = bool(enabled)
# منع إطفاء كل الأنواع حتى لا يصبح المصدر شغال بلا أي محتوى
if not any(types.values()):
return False
meta["content_types"] = types
data["source_meta"][sid] = meta
_write(data)
_write_backup(data)
return True
def set_source_content_types(source_id, content_types):
allowed = {"text", "photo", "video", "album", "voice", "audio", "document"}
values = {k: bool(content_types.get(k, False)) for k in allowed}
if not any(values.values()):
return False
return update_source_meta(source_id, content_types=values)
DEDUP_WINDOW_SECONDS = 2 * 86400 # نافذة مقارنة 48 ساعة
def get_recent_fingerprints(scope_id=None):
"""بصمات الأخبار المقبولة والمنشورة فعلياً خلال آخر 48 ساعة.
إذا تم تمرير scope_id تُرجع بصمات هذا القسم فقط (Dedup مشترك حسب القسم)."""
cutoff = _now() - DEDUP_WINDOW_SECONDS
with lock:
data = _read()
items = data.get("recent_fingerprints", [])
if not isinstance(items, list):
return []
fresh = [x for x in items if isinstance(x, dict) and x.get("fp") and int(x.get("ts", _now())) >= cutoff]
if scope_id is not None:
fresh = [x for x in fresh if str(x.get("scope_id", "") or "") == str(scope_id)]
if len(fresh) != len(items):
data["recent_fingerprints"] = fresh[-500:]
_write(data)
return fresh[-500:]
def add_recent_fingerprint(fp, sample="", limit=500, source_id=None, message_id=None, event_fp=None, url_fp=None, scope_id=None, section_label=None):
fp = str(fp or "").strip()
if not fp:
return False
cutoff = _now() - DEDUP_WINDOW_SECONDS
with lock:
data = _read()
items = data.get("recent_fingerprints", [])
if not isinstance(items, list):
items = []
items = [x for x in items if isinstance(x, dict) and x.get("fp") != fp and int(x.get("ts", _now())) >= cutoff]
items.append({
"fp": fp,
"sample": str(sample or "")[:500],
"ts": _now(),
"source_id": str(source_id) if source_id is not None else None,
"message_id": message_id,
"event_fp": str(event_fp or ""),
"url_fp": str(url_fp or ""),
"scope_id": str(scope_id or ""),
"section_label": str(section_label or ""),
})
if len(items) > int(limit):
items = items[-int(limit):]
data["recent_fingerprints"] = items
_write(data)
return True
def _get_scope_claims(data):
"""مطالبات الأحداث على مستوى القسم (scope_id:event_fp) — Dedup مشترك بين المصادر."""
return data.setdefault("section_event_claims", {})
def claim_section_event(scope_id, event_fp):
"""حجز ذري دائم للحدث على مستوى القسم: scope_id + fingerprint الحدث.
يستخدم من مساري updates وpolling ومن مصادر مختلفة داخل نفس القسم.
داخل قفل قاعدة البيانات فلا يوجد سباق TOCTOU.
يرجع True إذا فاز هذا الاستدعاء بالحجز (الخبر جديد لهذا القسم)، False إذا سبق حجزه."""
sid = str(scope_id or "")
fp = str(event_fp or "").strip()
if not sid or not fp:
return False
with lock:
data = _read()
claims = _get_scope_claims(data)
per_scope = claims.setdefault(sid, [])
if any(isinstance(x, dict) and str(x.get("event_fp", "")) == fp for x in per_scope):
return False
per_scope.append({"event_fp": fp, "ts": _now()})
cutoff = _now() - DEDUP_WINDOW_SECONDS
claims[sid] = [x for x in per_scope if int(x.get("ts", _now())) >= cutoff][-2000:]
_write(data)
return True
def is_section_event_claimed(scope_id, event_fp):
"""قراءة فقط: هل سبق حجز هذا الحدث لهذا القسم؟"""
sid = str(scope_id or "")
fp = str(event_fp or "").strip()
if not sid or not fp:
return False
with lock:
data = _read()
per_scope = data.get("section_event_claims", {}).get(sid, [])
return any(isinstance(x, dict) and str(x.get("event_fp", "")) == fp for x in per_scope)
def set_channel_publish_delay(channel_id, delay_seconds):
try:
if delay_seconds is None or str(delay_seconds).strip() == "":
value = None
else:
value = max(0.0, float(delay_seconds))
except Exception:
return False
return update_channel(channel_id, "publish_delay", value)
def get_channel_publish_delay(channel_id, default=0.5):
ch = get_channel(channel_id)
if not ch:
return float(default)
value = ch.get("publish_delay", None)
if value is None or value == "":
return float(default)
try:
return max(0.0, float(value))
except Exception:
return float(default)
def set_channel_bold_publish(channel_id, enabled=True):
return update_channel(channel_id, "bold_publish", bool(enabled))
def get_channel_bold_publish(channel_id, default=True):
ch = get_channel(channel_id)
if not ch:
return bool(default)
return bool(ch.get("bold_publish", default))
def record_source_event(source_id, event_type, reason="", message_id=None, target_count=0):
sid = str(source_id)
with lock:
data = _read()
stats = data.setdefault("source_stats", {}).setdefault(sid, {
"received": 0, "published": 0, "rejected": 0, "ignored": 0,
"duplicates": 0, "errors": 0, "last_message_id": None,
"last_event": "", "last_reason": "", "last_ts": None
})
event_type = str(event_type or "").lower()
if event_type == "received":
stats["received"] = int(stats.get("received", 0)) + 1
elif event_type == "published":
stats["published"] = int(stats.get("published", 0)) + 1
elif event_type == "rejected":
stats["rejected"] = int(stats.get("rejected", 0)) + 1
elif event_type == "duplicate":
stats["duplicates"] = int(stats.get("duplicates", 0)) + 1
stats["ignored"] = int(stats.get("ignored", 0)) + 1
elif event_type == "error":
stats["errors"] = int(stats.get("errors", 0)) + 1
else:
stats["ignored"] = int(stats.get("ignored", 0)) + 1
stats["last_message_id"] = message_id
stats["last_event"] = event_type
stats["last_reason"] = str(reason or "")[:300]
stats["last_ts"] = _now()
data["source_stats"][sid] = stats
logs = data.setdefault("source_logs", {}).setdefault(sid, [])
logs.append({
"ts": _now(), "event": event_type, "reason": str(reason or "")[:300],
"message_id": message_id, "target_count": int(target_count or 0)
})
data["source_logs"][sid] = logs[-20:]
_write(data)
return True
def get_source_stats(source_id):
sid = str(source_id)
with lock:
data = _read()
return data.get("source_stats", {}).get(sid, {
"received": 0, "published": 0, "rejected": 0, "ignored": 0,
"duplicates": 0, "errors": 0, "last_message_id": None,
"last_event": "", "last_reason": "", "last_ts": None
})
def get_source_logs(source_id, limit=20):
sid = str(source_id)
with lock:
data = _read()
logs = data.get("source_logs", {}).get(sid, [])
if not isinstance(logs, list):
return []
return logs[-int(limit):]
def has_recent_source_event(source_id, message_id):
if source_id is None or message_id is None:
return False
target_id = str(message_id)
return any(str(item.get("message_id")) == target_id for item in get_source_logs(source_id, limit=20) if isinstance(item, dict))
def copy_source_settings(from_source_id, to_source_ids):
from_sid = str(from_source_id)
copied = 0
with lock:
data = _read()
source_meta = _normalize_source_meta(from_source_id, data.setdefault("source_meta", {}).get(from_sid, {}))
keys = ["remove_terms", "remove_emoji", "content_types", "paused"]
for dst in to_source_ids or []:
sid = str(dst)
meta = _normalize_source_meta(dst, data.setdefault("source_meta", {}).get(sid, {}))
for key in keys:
if key in source_meta:
# copy JSON-compatible value
meta[key] = json.loads(json.dumps(source_meta[key], ensure_ascii=False))
data["source_meta"][sid] = meta
copied += 1
_write(data)
if copied:
_write_backup(data)
return copied
def set_system_value(key, value):
with lock:
data = _read()
data.setdefault("system", {})[str(key)] = value
_write(data)
return True
def get_system_value(key, default=None):
with lock:
data = _read()
return data.get("system", {}).get(str(key), default)
# ===== Professional safety/ops additions =====
def is_maintenance_mode():
with lock:
data = _read()
return bool(data.get("system", {}).get("maintenance_mode", False))
def set_maintenance_mode(enabled):
with lock:
data = _read()
data.setdefault("system", {})["maintenance_mode"] = bool(enabled)
data["system"]["maintenance_updated_at"] = _now()
_write(data)
return True
def add_last_error(context, error):
with lock:
data = _read()
errors = data.setdefault("last_errors", [])
if not isinstance(errors, list):
errors = []
errors.append({
"ts": _now(),
"context": str(context or "")[:120],
"error": str(error or "")[:500],
})
data["last_errors"] = errors[-20:]
data.setdefault("system", {})["last_error"] = f"{context}: {error}"[:600]
data["system"]["last_error_ts"] = _now()
_write(data)
return True
def get_last_errors(limit=20):
with lock:
data = _read()
errors = data.get("last_errors", [])
if not isinstance(errors, list):
return []
return errors[-int(limit):]
def clear_last_errors():
with lock:
data = _read()
data["last_errors"] = []
data.setdefault("system", {})["last_error"] = ""
_write(data)
return True
def _normalize_quote_types(value=None, fallback=False):
if not isinstance(value, dict):
value = {}
return {
"text": bool(value.get("text", fallback)),
"photo": bool(value.get("photo", fallback)),
"video": bool(value.get("video", fallback)),
"album": bool(value.get("album", fallback)),
}
def set_channel_quote_publish(channel_id, enabled=True):
# المفتاح القديم: يبدّل كل الأنواع مرة واحدة للتوافق فقط.
cid = str(channel_id)
enabled = bool(enabled)
with lock:
data = _read()
if cid not in data.get("channels", {}):
return False
data["channels"][cid]["quote_types"] = _normalize_quote_types({}, enabled)
data["channels"][cid]["quote_publish"] = enabled
_write(data)
_write_backup(data)
return True
def get_channel_quote_publish(channel_id, default=False):
ch = get_channel(channel_id)
if not ch:
return bool(default)
qt = _normalize_quote_types(ch.get("quote_types"), ch.get("quote_publish", default))
return any(qt.values())
def get_channel_quote_types(channel_id):
ch = get_channel(channel_id)
if not ch:
return _normalize_quote_types({}, False)
return _normalize_quote_types(ch.get("quote_types"), ch.get("quote_publish", False))
def get_channel_quote_type(channel_id, content_type="text", default=False):
ctype = str(content_type or "text")
if ctype not in {"text", "photo", "video", "album"}:
ctype = "text"
return bool(get_channel_quote_types(channel_id).get(ctype, default))
def set_channel_quote_type(channel_id, content_type, enabled=True):
cid = str(channel_id)
ctype = str(content_type or "text")
if ctype not in {"text", "photo", "video", "album"}:
return False
with lock:
data = _read()
if cid not in data.get("channels", {}):
return False
ch = data["channels"][cid]
qt = _normalize_quote_types(ch.get("quote_types"), ch.get("quote_publish", False))
qt[ctype] = bool(enabled)
ch["quote_types"] = qt
ch["quote_publish"] = any(qt.values())
_write(data)
_write_backup(data)
return True
def get_channel_hashtags(channel_id):
ch = get_channel(channel_id)
if not ch:
return []
tags = ch.get("hashtags", [])
if not isinstance(tags, list):
return []
out = []
seen = set()
for tag in tags:
tag = str(tag).strip()
if not tag:
continue
if not tag.startswith("#"):
tag = "#" + tag.lstrip("#")
key = tag.lower()
if key not in seen:
seen.add(key)
out.append(tag)
return out
def add_channel_hashtags(channel_id, tags):
clean = []
for tag in tags or []:
tag = str(tag).strip()
if not tag:
continue
if not tag.startswith("#"):
tag = "#" + tag.lstrip("#")
clean.append(tag)
if not clean:
return {"added": 0, "exists": 0}
cid = str(channel_id)
with lock:
data = _read()
if cid not in data["channels"]:
return {"added": 0, "exists": 0}
existing = []
for tag in data["channels"][cid].get("hashtags", []):
tag = str(tag).strip()
if tag:
existing.append(tag if tag.startswith("#") else "#" + tag.lstrip("#"))
keys = {x.lower() for x in existing}
added = 0
exists = 0
for tag in clean:
key = tag.lower()
if key in keys:
exists += 1
continue
existing.append(tag)
keys.add(key)
added += 1
data["channels"][cid]["hashtags"] = existing
_write(data)
if added:
_write_backup(data)
return {"added": added, "exists": exists}
def remove_channel_hashtags(channel_id, tags):
remove_keys = set()
for tag in tags or []:
tag = str(tag).strip()
if not tag:
continue
if not tag.startswith("#"):
tag = "#" + tag.lstrip("#")
remove_keys.add(tag.lower())
if not remove_keys:
return {"removed": 0, "missing": 0}
cid = str(channel_id)
with lock:
data = _read()
if cid not in data["channels"]:
return {"removed": 0, "missing": len(remove_keys)}
existing = []
for tag in data["channels"][cid].get("hashtags", []):
tag = str(tag).strip()
if tag:
existing.append(tag if tag.startswith("#") else "#" + tag.lstrip("#"))
before = len(existing)
existing = [x for x in existing if x.lower() not in remove_keys]
removed = before - len(existing)
data["channels"][cid]["hashtags"] = existing
_write(data)
if removed:
_write_backup(data)
return {"removed": removed, "missing": max(0, len(remove_keys)-removed)}
def record_channel_publish_success(channel_id):
cid = str(channel_id)
with lock:
data = _read()
if cid in data.get("channels", {}):
data["channels"][cid]["fail_count"] = 0
data.setdefault("channel_failures", {})[cid] = 0
_write(data)
return True
def record_channel_publish_failure(channel_id, error="", limit=5):
cid = str(channel_id)
with lock:
data = _read()
failures = data.setdefault("channel_failures", {})
count = int(failures.get(cid, 0) or 0) + 1
failures[cid] = count
if cid in data.get("channels", {}):
data["channels"][cid]["fail_count"] = count
if count >= int(limit):
data["channels"][cid]["paused"] = True
data["channels"][cid]["auto_paused_reason"] = str(error or "")[:300]
data["channels"][cid]["auto_paused_at"] = _now()
errors = data.setdefault("last_errors", [])
errors.append({"ts": _now(), "context": f"publish channel {cid}", "error": str(error or "")[:500]})
data["last_errors"] = errors[-20:]
_write(data)
return count
def create_named_backup(name):
name = str(name or "").strip()
if not name:
name = f"backup_{_now()}"
safe = "".join(ch for ch in name if ch.isalnum() or ch in ("_", "-", ".", " ")).strip().replace(" ", "_")
if not safe:
safe = f"backup_{_now()}"
with lock:
data = _read()
os.makedirs(BACKUP_DIR, exist_ok=True)
path = os.path.join(BACKUP_DIR, f"manual_{safe}_{_now()}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(_normalize_data(data), f, ensure_ascii=False, indent=2)
named = data.setdefault("named_backups", [])
named.append({"name": name, "file": path, "ts": _now()})
data["named_backups"] = named[-30:]
_write(data)
return path
def get_named_backups():
with lock:
data = _read()
items = data.get("named_backups", [])
return items if isinstance(items, list) else []
# ===== Published message tracking for channel cleanup =====
def record_published_message(channel_id, message_id, content_type):
"""يحفظ ID الرسالة التي نشرها البوت حتى يمكن حذفها لاحقاً حسب النوع."""
if not message_id:
return False
cid = str(channel_id)
ctype = str(content_type or "text")
if ctype not in {"text", "photo", "video", "album"}:
ctype = "photo"
with lock:
data = _read()
box = data.setdefault("published_messages", {}).setdefault(cid, {}).setdefault(ctype, [])
mid = int(message_id)
if not any(isinstance(x, dict) and int(x.get("id", -1)) == mid for x in box):
box.append({"id": mid, "ts": _now()})
data["published_messages"][cid][ctype] = box[-1000:]
_write(data)
return True
def record_published_messages(channel_id, message_ids, content_type):
count = 0
for mid in message_ids or []:
if record_published_message(channel_id, mid, content_type):
count += 1
return count
def get_published_message_ids(channel_id, content_type):
cid = str(channel_id)
ctype = str(content_type or "")
with lock:
data = _read()
entries = data.get("published_messages", {}).get(cid, {}).get(ctype, [])
ids = []
for item in entries:
try:
ids.append(int(item.get("id") if isinstance(item, dict) else item))
except Exception:
pass
return ids
def clear_published_message_ids(channel_id, content_type, message_ids=None):
cid = str(channel_id)
ctype = str(content_type or "")
remove_set = None
if message_ids is not None:
remove_set = {int(x) for x in message_ids if str(x).lstrip("-").isdigit()}
with lock:
data = _read()
entries = data.setdefault("published_messages", {}).setdefault(cid, {}).setdefault(ctype, [])
if remove_set is None:
removed = len(entries)
data["published_messages"][cid][ctype] = []
else:
before = len(entries)
data["published_messages"][cid][ctype] = [
x for x in entries
if int(x.get("id") if isinstance(x, dict) else x) not in remove_set
]
removed = before - len(data["published_messages"][cid][ctype])
_write(data)
return removed
def get_ignore_short_posts():
with lock:
data = _read()
return bool(data.get("settings", {}).get("ignore_short_posts", False))
def set_ignore_short_posts(enabled):
with lock:
data = _read()
data.setdefault("settings", {})["ignore_short_posts"] = bool(enabled)
_write(data)
_write_backup(data)
return True
def get_channel_ignore_short_posts(channel_id, default=None):
"""Return the channel-local short-post flag with legacy fallback."""
ch = get_channel(channel_id)
if not ch:
return bool(get_ignore_short_posts() if default is None else default)
value = ch.get("ignore_short_posts")
if value is None:
return bool(get_ignore_short_posts() if default is None else default)
return bool(value)
def set_channel_ignore_short_posts(channel_id, enabled):
"""Persist the short-post flag for one channel only."""
return update_channel(channel_id, "ignore_short_posts", bool(enabled))
# ============================================================
# Link Preview: per-channel disable
# ============================================================
def get_channel_disable_preview(channel_id, default=False):
ch = get_channel(channel_id)
if not ch:
return bool(default)
return bool(ch.get("disable_web_page_preview", default))
def set_channel_disable_preview(channel_id, enabled):
return update_channel(channel_id, "disable_web_page_preview", bool(enabled))
def get_channel_tail_enabled(channel_id):
ch = get_channel(str(channel_id))
return bool(ch.get("tail_enabled", True)) if ch else True
def set_channel_tail_enabled(channel_id, enabled):
return update_channel(str(channel_id), "tail_enabled", bool(enabled))
def get_channel_tail_min_words(channel_id):
ch = get_channel(str(channel_id))
return int(ch.get("tail_min_words", 20)) if ch else 20
def set_channel_tail_min_words(channel_id, count):
return update_channel(str(channel_id), "tail_min_words", max(1, int(count)))
def get_channel_tail_position(channel_id):
ch = get_channel(str(channel_id))
val = ch.get("tail_position", "bottom") if ch else "bottom"
return val if val in ("top", "bottom") else "bottom"
def set_channel_tail_position(channel_id, position):
position = position if position in ("top", "bottom") else "bottom"
return update_channel(str(channel_id), "tail_position", position)
def get_settings_clipboard():
with lock:
data = _read()
return data.get("settings_clipboard")
def set_settings_clipboard(clipboard_data):
with lock:
data = _read()
data["settings_clipboard"] = clipboard_data
_write(data)
# ============================================================
# Notification Center Settings
# ============================================================
NOTIFICATION_TYPES = {
"session_stopped": "توقف Session",
"ai_key_error": "خطأ في مفتاح AI",
"source_stopped": "توقف مصدر",
"channel_idle_hour": "قناة لم تنشر منذ ساعة",
"backup_failed": "فشل Backup",
"db_issue": "مشكلة بقاعدة البيانات",
}
def get_notification_settings():
with lock:
data = _read()
ns = data.get("notification_settings", {})
return {k: bool(ns.get(k, True)) for k in NOTIFICATION_TYPES}
def set_notification_setting(key, enabled):
if key not in NOTIFICATION_TYPES:
return False
with lock:
data = _read()
ns = data.setdefault("notification_settings", {})
ns[str(key)] = bool(enabled)
_write(data)
return True
def get_notification_last_alert(key):
with lock:
data = _read()
ns = data.get("notification_settings", {})
return ns.get("last_alert_ts", {}).get(str(key), 0)
def set_notification_last_alert(key, ts=None):
if ts is None:
ts = _now()
with lock:
data = _read()
ns = data.setdefault("notification_settings", {})
alerts = ns.setdefault("last_alert_ts", {})
alerts[str(key)] = int(ts)
_write(data)
return True
# ============================================================
# Multi-Bot Expansion: Sessions
# ============================================================
def _normalize_session(sid, value):
if not isinstance(value, dict):
value = {}
sid = str(sid)
value.setdefault("id", sid)
value.setdefault("name", value.get("name") or f"Session {sid.split('_')[-1]}")
value.setdefault("api_id", 0)
value.setdefault("api_hash", "")
value.setdefault("session_string", "")
value.setdefault("enabled", True)
value.setdefault("created_at", _now())
value.setdefault("last_used", None)
value.setdefault("usage_count", 0)
value.setdefault("error_count", 0)
value.setdefault("status", "idle")
value.setdefault("channels_using", [])
return value
def get_all_sessions():
with lock:
data = _read()
return [{"id": k, **v} for k, v in data.get("sessions", {}).items()]
def get_session(session_id):
sid = str(session_id)
with lock:
data = _read()
raw = data.get("sessions", {}).get(sid)
if raw is None:
return None
return {"id": sid, **raw}
def add_session(api_id, api_hash, session_string, name=None):
items = get_all_sessions()
n = _find_available_number([s.get("name", "") for s in items], "Session")
final_name = name or f"Session {n}"
sid = f"session_{n}"
with lock:
data = _read()
if sid in data.get("sessions", {}):
return False
entry = _normalize_session(sid, {
"id": sid, "name": final_name,
"api_id": int(api_id), "api_hash": str(api_hash),
"session_string": str(session_string),
"enabled": True, "created_at": _now(),
})
data.setdefault("sessions", {})[sid] = entry
_write(data)
_write_backup(data)
return True
def update_session(session_id, **kwargs):
sid = str(session_id)
allowed = {"name", "api_id", "api_hash", "session_string", "enabled", "status"}
with lock:
data = _read()
entry = data.get("sessions", {}).get(sid)
if not entry:
return False
for k, v in kwargs.items():
if k in allowed and v is not None:
entry[k] = v
_write(data)
return True
def delete_session(session_id):
sid = str(session_id)
with lock:
data = _read()
entry = data.get("sessions", {}).pop(sid, None)
if entry is None:
return False
item = {"id": f"trash_session_{_now()}", "type": "session", "original_id": sid, "data": entry, "deleted_at": _now()}
data.setdefault("trash", []).append(item)
_write(data)
_write_backup(data)
return True
def set_session_enabled(session_id, enabled):
return update_session(session_id, enabled=bool(enabled))
def record_session_usage(session_id):
sid = str(session_id)
with lock:
data = _read()
entry = data.get("sessions", {}).get(sid)
if not entry:
return False
entry["last_used"] = _now()
entry["usage_count"] = int(entry.get("usage_count", 0)) + 1
_write(data)
return True
def get_session_stats(session_id):
s = get_session(session_id)
if not s:
return {}
return {
"usage_count": s.get("usage_count", 0),
"error_count": s.get("error_count", 0),
"last_used": s.get("last_used"),
"status": s.get("status", "idle"),
"enabled": s.get("enabled", True),
"name": s.get("name", ""),
}
# ============================================================
# Multi-Bot Expansion: AI Keys
# ============================================================
def _normalize_ai_key(kid, value):
if not isinstance(value, dict):
value = {}
kid = str(kid)
value.setdefault("id", kid)
value.setdefault("name", value.get("name") or f"AI {kid.split('_')[-1]}")
value.setdefault("provider", "gemini")
value.setdefault("api_key", "")
value.setdefault("enabled", True)
value.setdefault("created_at", _now())
value.setdefault("last_used", None)
value.setdefault("usage_count", 0)
value.setdefault("error_count", 0)
value.setdefault("channels_using", [])
value.setdefault("models", ["gemini-pro"])
return value
def get_all_ai_keys():
with lock:
data = _read()
return [{"id": k, **v} for k, v in data.get("ai_keys", {}).items()]
def get_ai_key(key_id):
kid = str(key_id)
with lock:
data = _read()
raw = data.get("ai_keys", {}).get(kid)
return {"id": kid, **raw} if raw else None
def add_ai_key(provider, api_key, name=None):
items = get_all_ai_keys()
n = _find_available_number([k.get("name", "") for k in items], "AI")
final_name = name or f"AI {n}"
kid = f"ai_{n}"
with lock:
data = _read()
if kid in data.get("ai_keys", {}):
return False
entry = _normalize_ai_key(kid, {
"id": kid, "name": final_name,
"provider": str(provider), "api_key": str(api_key),
"enabled": True, "created_at": _now(),
})
data.setdefault("ai_keys", {})[kid] = entry
_write(data)
_write_backup(data)
return True
def update_ai_key(key_id, **kwargs):
kid = str(key_id)
allowed = {"name", "provider", "api_key", "enabled", "models"}
with lock:
data = _read()
entry = data.get("ai_keys", {}).get(kid)
if not entry:
return False
for k, v in kwargs.items():
if k in allowed and v is not None:
entry[k] = v
_write(data)
return True
def delete_ai_key(key_id):
kid = str(key_id)
with lock:
data = _read()
entry = data.get("ai_keys", {}).pop(kid, None)
if entry is None:
return False
item = {"id": f"trash_ai_{_now()}", "type": "ai_key", "original_id": kid, "data": entry, "deleted_at": _now()}
data.setdefault("trash", []).append(item)
_write(data)
_write_backup(data)
return True
def set_ai_key_enabled(key_id, enabled):
return update_ai_key(key_id, enabled=bool(enabled))
def record_ai_key_usage(key_id):
kid = str(key_id)
with lock:
data = _read()
entry = data.get("ai_keys", {}).get(kid)
if not entry:
return False
entry["last_used"] = _now()
entry["usage_count"] = int(entry.get("usage_count", 0)) + 1
_write(data)
return True
def get_ai_key_stats(key_id):
k = get_ai_key(key_id)
if not k:
return {}
return {
"provider": k.get("provider", ""),
"usage_count": k.get("usage_count", 0),
"error_count": k.get("error_count", 0),
"last_used": k.get("last_used"),
"enabled": k.get("enabled", True),
"name": k.get("name", ""),
}
# ============================================================
# Multi-Bot Expansion: Publishing Bots
# ============================================================
def _normalize_publishing_bot(bid, value):
if not isinstance(value, dict):
value = {}
bid = str(bid)
value.setdefault("id", bid)
value.setdefault("name", value.get("name") or f"Bot {bid.split('_')[-1]}")
value.setdefault("token", "")
value.setdefault("username", "")
value.setdefault("enabled", True)
value.setdefault("created_at", _now())
value.setdefault("last_publish", None)
value.setdefault("publish_count", 0)
value.setdefault("error_count", 0)
value.setdefault("channels_using", [])
return value
def get_all_publishing_bots():
with lock:
data = _read()
return [{"id": k, **v} for k, v in data.get("publishing_bots", {}).items()]
def get_publishing_bot(bot_id):
bid = str(bot_id)
with lock:
data = _read()
raw = data.get("publishing_bots", {}).get(bid)
return {"id": bid, **raw} if raw else None
def add_publishing_bot(token, username="", name=None):
items = get_all_publishing_bots()
n = _find_available_number([b.get("name", "") for b in items], "Bot")
final_name = name or f"Bot {n}"
bid = f"bot_{n}"
with lock:
data = _read()
if bid in data.get("publishing_bots", {}):
return False
entry = _normalize_publishing_bot(bid, {
"id": bid, "name": final_name,
"token": str(token), "username": str(username),
"enabled": True, "created_at": _now(),
})
data.setdefault("publishing_bots", {})[bid] = entry
_write(data)
_write_backup(data)
return True
def update_publishing_bot(bot_id, **kwargs):
bid = str(bot_id)
allowed = {"name", "token", "username", "enabled"}
with lock:
data = _read()
entry = data.get("publishing_bots", {}).get(bid)
if not entry:
return False
for k, v in kwargs.items():
if k in allowed and v is not None:
entry[k] = v
_write(data)
return True
def delete_publishing_bot(bot_id):
bid = str(bot_id)
with lock:
data = _read()
entry = data.get("publishing_bots", {}).pop(bid, None)
if entry is None:
return False
item = {"id": f"trash_bot_{_now()}", "type": "publishing_bot", "original_id": bid, "data": entry, "deleted_at": _now()}
data.setdefault("trash", []).append(item)
_write(data)
_write_backup(data)
return True
def set_publishing_bot_enabled(bot_id, enabled):
return update_publishing_bot(bot_id, enabled=bool(enabled))
def record_bot_publish(bot_id, content_type="text"):
bid = str(bot_id)
ctype = str(content_type or "text")
if ctype not in ("text", "photo", "video", "album"):
ctype = "text"
with lock:
data = _read()
entry = data.get("publishing_bots", {}).get(bid)
if not entry:
return False
entry["last_publish"] = _now()
entry["publish_count"] = int(entry.get("publish_count", 0)) + 1
stats = entry.setdefault("stats", {})
stats[ctype] = int(stats.get(ctype, 0)) + 1
_write(data)
return True
# ============================================================
# Bot-Channel Verification
# ============================================================
def _normalize_verification(key, value):
if not isinstance(value, dict):
value = {}
value.setdefault("verified", False)
value.setdefault("can_post", False)
value.setdefault("status", "")
value.setdefault("permissions", {})
value.setdefault("last_check", 0)
value.setdefault("bot_name", "")
value.setdefault("channel_name", "")
return value
def set_bot_channel_verification(bot_id, channel_id, verified, can_post=False, status="", permissions=None, bot_name="", channel_name=""):
"""تخزين نتيجة التحقق من وجود بوت في قناة."""
key = f"{bot_id}|{channel_id}"
with lock:
data = _read()
mapping = data.setdefault("bot_channel_verified", {})
entry = _normalize_verification(key, mapping.get(key, {}))
entry["verified"] = bool(verified)
entry["can_post"] = bool(can_post)
entry["status"] = str(status)
entry["permissions"] = dict(permissions or {})
entry["last_check"] = _now()
entry["bot_name"] = str(bot_name)
entry["channel_name"] = str(channel_name)
mapping[key] = entry
_write(data)
return True
def get_bot_channel_verification(bot_id, channel_id):
"""إرجاع حالة التحقق المخزنة لبوت في قناة."""
key = f"{bot_id}|{channel_id}"
with lock:
data = _read()
entry = data.get("bot_channel_verified", {}).get(key)
return _normalize_verification(key, entry)
def get_all_bot_channel_verifications():
"""إرجاع كل حالات التحقق."""
with lock:
data = _read()
return {k: _normalize_verification(k, v) for k, v in data.get("bot_channel_verified", {}).items()}
def get_verifications_for_bot(bot_id):
"""إرجاع كل التحققات لبوت معين."""
result = {}
prefix = f"{bot_id}|"
for k, v in get_all_bot_channel_verifications().items():
if k.startswith(prefix):
ch_id = k[len(prefix):]
result[ch_id] = v
return result
def get_verifications_for_channel(channel_id):
"""إرجاع كل التحققات لقناة معينة."""
result = {}
suffix = f"|{channel_id}"
for k, v in get_all_bot_channel_verifications().items():
if k.endswith(suffix):
bot_id = k[:-len(suffix)]
result[bot_id] = v
return result
# ============================================================
# Multi-Bot Expansion: Website Sources
# ============================================================
def _normalize_website(wid, value):
if not isinstance(value, dict):
value = {}
wid = str(wid)
value.setdefault("id", wid)
value.setdefault("name", value.get("name") or f"Website {wid.split('_')[-1]}")
value.setdefault("url", "")
value.setdefault("enabled", True)
value.setdefault("selector", "body")
value.setdefault("interval", 300)
value.setdefault("created_at", _now())
value.setdefault("last_fetch", None)
value.setdefault("error_count", 0)
value.setdefault("channels_using", [])
return value
def get_all_websites():
with lock:
data = _read()
return [{"id": k, **v} for k, v in data.get("websites", {}).items()]
def get_website(website_id):
wid = str(website_id)
with lock:
data = _read()
raw = data.get("websites", {}).get(wid)
return {"id": wid, **raw} if raw else None
def add_website(url, name=None, selector="body"):
items = get_all_websites()
n = _find_available_number([w.get("name", "") for w in items], "Website")
final_name = name or f"Website {n}"
wid = f"web_{n}"
with lock:
data = _read()
if wid in data.get("websites", {}):
return False
entry = _normalize_website(wid, {
"id": wid, "name": final_name,
"url": str(url), "selector": str(selector),
"enabled": True, "created_at": _now(),
})
data.setdefault("websites", {})[wid] = entry
_write(data)
_write_backup(data)
return True
def update_website(website_id, **kwargs):
wid = str(website_id)
allowed = {"name", "url", "enabled", "selector", "interval"}
with lock:
data = _read()
entry = data.get("websites", {}).get(wid)
if not entry:
return False
for k, v in kwargs.items():
if k in allowed and v is not None:
entry[k] = v
_write(data)
return True
def delete_website(website_id):
wid = str(website_id)
with lock:
data = _read()
entry = data.get("websites", {}).pop(wid, None)
if entry is None:
return False
item = {"id": f"trash_web_{_now()}", "type": "website", "original_id": wid, "data": entry, "deleted_at": _now()}
data.setdefault("trash", []).append(item)
_write(data)
_write_backup(data)
return True
def set_website_enabled(website_id, enabled):
return update_website(website_id, enabled=bool(enabled))
# ============================================================
# Multi-Bot Expansion: Trash System
# ============================================================
def get_trash_items():
with lock:
data = _read()
items = data.get("trash", [])
return items if isinstance(items, list) else []
def restore_from_trash(trash_id):
with lock:
data = _read()
items = data.get("trash", [])
for idx, item in enumerate(items):
if item.get("id") == trash_id:
orig_type = item.get("type")
orig_id = item.get("original_id")
orig_data = item.get("data", {})
if orig_type == "session":
data.setdefault("sessions", {})[orig_id] = orig_data
elif orig_type == "ai_key":
data.setdefault("ai_keys", {})[orig_id] = orig_data
elif orig_type == "publishing_bot":
data.setdefault("publishing_bots", {})[orig_id] = orig_data
elif orig_type == "website":
data.setdefault("websites", {})[orig_id] = orig_data
else:
return False
items.pop(idx)
data["trash"] = items
_write(data)
_write_backup(data)
return True
return False
def permanent_delete_from_trash(trash_id):
with lock:
data = _read()
items = data.get("trash", [])
for idx, item in enumerate(items):
if item.get("id") == trash_id:
items.pop(idx)
data["trash"] = items
_write(data)
return True
return False
def empty_trash():
with lock:
data = _read()
data["trash"] = []
_write(data)
return True
# ============================================================
# Multi-Bot Expansion: Auto Naming
# ============================================================
def _find_available_number(names, prefix):
used = set()
for name in names:
if name.startswith(prefix):
try:
num = int(name[len(prefix):].strip())
used.add(num)
except ValueError:
pass
n = 1
while n in used:
n += 1
return n
# ============================================================
# Multi-Bot Expansion: Channel Config Extensions
# ============================================================
def _normalize_channel_config(cid, value):
if not isinstance(value, dict):
value = {}
cid = str(cid)
value.setdefault("title_quote", False)
value.setdefault("signature_quote", False)
value.setdefault("assigned_sessions", [])
value.setdefault("assigned_bots", [])
value.setdefault("assigned_ai", [])
value.setdefault("websites", [])
value.setdefault("rss_sources", [])
value.setdefault("prompt", "")
value.setdefault("first_comment", "")
value.setdefault("schedule", None)
return value
def get_channel_config(channel_id):
cid = str(channel_id)
with lock:
data = _read()
raw = data.get("channel_configs", {}).get(cid)
return _normalize_channel_config(cid, raw)
def update_channel_config(channel_id, **kwargs):
cid = str(channel_id)
allowed = {"title_quote", "signature_quote", "assigned_sessions", "assigned_bots", "assigned_ai", "websites", "rss_sources", "prompt", "first_comment", "schedule"}
with lock:
data = _read()
cfg = _normalize_channel_config(cid, data.setdefault("channel_configs", {}).get(cid, {}))
for k, v in kwargs.items():
if k in allowed and v is not None:
cfg[k] = v
data["channel_configs"][cid] = cfg
_write(data)
return True
def set_channel_title_quote(channel_id, enabled):
return update_channel_config(channel_id, title_quote=bool(enabled))
def get_channel_title_quote(channel_id):
cfg = get_channel_config(channel_id)
return bool(cfg.get("title_quote", False))
def set_channel_signature_quote(channel_id, enabled):
return update_channel_config(channel_id, signature_quote=bool(enabled))
def get_channel_signature_quote(channel_id):
cfg = get_channel_config(channel_id)
return bool(cfg.get("signature_quote", False))
# ============================================================
# Multi-Bot Expansion: Dependency Check
# ============================================================
def get_dependencies_for(item_type, item_id):
cid = str(item_id)
result = []
for ch in get_all_channels():
ch_id = str(ch.get("id", ""))
cfg = get_channel_config(ch_id)
if item_type == "session":
if cid in [str(x) for x in cfg.get("assigned_sessions", [])]:
result.append(ch)
elif item_type == "ai_key":
if cid in [str(x) for x in cfg.get("assigned_ai", [])]:
result.append(ch)
elif item_type == "publishing_bot":
if cid in [str(x) for x in cfg.get("assigned_bots", [])]:
result.append(ch)
elif item_type == "website":
if cid in [str(x) for x in cfg.get("websites", [])]:
result.append(ch)
return result
# ============================================================
# Smart Cache System
# ============================================================
CACHE_TTL = {
"bot_in_channel": 300, # 5 د للعمليات الحساسة
"channel_accessible": 600, # 10 د للتحقق من وجود القناة
"bot_token_valid": 3600, # 1 س لصحة التوكن
"session_valid": 600, # 10 د لصلاحية الجلسة
"ui_display": 900, # 15 د للعرض في الواجهات
}
_verification_cache = {}
_cache_lock = Lock()
def cache_get(key):
"""إرجاع قيمة مخبأة إذا كانت ضمن TTL."""
with _cache_lock:
entry = _verification_cache.get(key)
if entry is None:
return None
if time.time() - entry["ts"] < entry["ttl"]:
return entry["data"]
del _verification_cache[key]
return None
def cache_set(key, data, ttl=None):
"""تخزين قيمة مع TTL."""
if ttl is None:
ttl = CACHE_TTL.get("ui_display", 900)
with _cache_lock:
_verification_cache[key] = {"data": data, "ts": time.time(), "ttl": ttl}
def cache_invalidate(key):
"""حذف قيمة من الكاش."""
with _cache_lock:
_verification_cache.pop(key, None)
def cache_invalidate_bot(bot_id):
"""مسح كل الكاش لبوت معين."""
with _cache_lock:
to_del = [k for k in _verification_cache if f"|{bot_id}|" in k or f"token_valid|{bot_id}" in k]
for k in to_del:
del _verification_cache[k]
def cache_invalidate_channel(channel_id):
"""مسح كل الكاش لقناة معينة."""
with _cache_lock:
to_del = [k for k in _verification_cache if k.endswith(f"|{channel_id}") or k == f"channel|{channel_id}"]
for k in to_del:
del _verification_cache[k]
def cache_clear_expired():
"""مسح القيم المنتهية."""
now = time.time()
with _cache_lock:
to_del = [k for k, v in _verification_cache.items() if now - v["ts"] >= v["ttl"]]
for k in to_del:
del _verification_cache[k]
# ============================================================
# Real-Time Verification Functions (Database Layer)
# ============================================================
VERIFY_TTL_SHORT = 60 # 1 دقيقة
VERIFY_TTL_NORMAL = 300 # 5 دقائق
def get_fresh_bot_verification(bot_id, channel_id):
"""إرجاع التحقق من الكاش فقط إذا كان حديثاً (أقل من TTL قصير)."""
key = f"verify|{bot_id}|{channel_id}"
return cache_get(key)
def set_fresh_bot_verification(bot_id, channel_id, result):
"""تخزين نتيجة تحقق حديثة في الكاش."""
key = f"verify|{bot_id}|{channel_id}"
cache_set(key, result, ttl=VERIFY_TTL_SHORT)
def invalidate_bot_verification(bot_id, channel_id):
"""مسح التحقق المخبأ لبوت في قناة."""
key = f"verify|{bot_id}|{channel_id}"
cache_invalidate(key)
# ============================================================
# Bot Manager - المصدر الوحيد لجميع Bot Tokens
# ============================================================
class BotManager:
"""يدير جميع بوتات النشر.
- أي كود يحتاج توكن بوت يستخدم هذا المدير فقط.
- لا يسمح بالوصول المباشر إلى التوكن من أي مكان آخر.
- يوفر التوثيق والتخزين والربط مع القنوات.
"""
def __init__(self):
self._cache = {}
# ---- المصدر الوحيد للتوكن ----
def get_token(self, bot_id):
"""الطريقة الوحيدة في المشروع بأكمله للحصول على توكن بوت."""
b = get_publishing_bot(str(bot_id))
if not b:
return ""
return b.get("token", "")
def get_bot(self, bot_id):
"""إرجاع بيانات البوت كاملة (بدون ضمان إخفاء التوكن للاستخدام الداخلي)."""
return get_publishing_bot(str(bot_id))
def get_all_bots(self):
"""إرجاع جميع البوتات."""
return get_all_publishing_bots()
def add_bot(self, token, username="", name=""):
"""إضافة بوت جديد."""
return add_publishing_bot(token, username=username, name=name)
def delete_bot(self, bot_id):
"""حذف بوت ونقل إلى سلة المهملات."""
return delete_publishing_bot(str(bot_id))
def update_bot(self, bot_id, **kwargs):
"""تحديث بيانات بوت."""
return update_publishing_bot(str(bot_id), **kwargs)
def set_enabled(self, bot_id, enabled):
"""تشغيل/إيقاف بوت."""
return set_publishing_bot_enabled(str(bot_id), enabled)
def record_publish(self, bot_id, content_type="text"):
"""تسجيل عملية نشر لبوت."""
return record_bot_publish(str(bot_id), content_type)
def validate_token_format(self, token):
"""التحقق من صيغة التوكن (بدون اتصال بـ Telegram)."""
return bool(token and ":" in token and len(token) > 20)
# ============================================================
# Channel Manager - إدارة موحدة للقنوات
# ============================================================
class ChannelManager:
"""يدير جميع القنوات بشكل موحد.
- يدمج معلومات القناة (channels) مع إعداداتها (channel_configs).
- كل قناة لها Chat ID كمعرف أساسي.
- لا يعتمد على الأسماء.
"""
def get(self, channel_id):
"""إرجاع معلومات القناة موحدة مع إعداداتها."""
cid = str(channel_id)
ch = get_channel(cid)
if not ch:
return None
cfg = get_channel_config(cid)
result = dict(ch)
result["config"] = cfg
return result
def get_all(self):
"""إرجاع جميع القنوات مع إعداداتها."""
channels = get_all_channels()
result = []
for ch in channels:
cid = str(ch["id"])
cfg = get_channel_config(cid)
item = dict(ch)
item["config"] = cfg
result.append(item)
return result
def get_active(self):
"""إرجاع القنوات غير الموقوفة فقط."""
return [ch for ch in self.get_all() if not ch.get("paused")]
def add(self, channel_id, **meta):
"""إضافة قناة جديدة."""
return add_channel(str(channel_id), **meta)
def delete(self, channel_id):
"""حذف قناة."""
return delete_channel(str(channel_id))
def update(self, channel_id, key, value):
"""تحديث حقل في القناة."""
return update_channel(str(channel_id), key, value)
def get_config(self, channel_id):
"""إرجاع إعدادات القناة."""
return get_channel_config(str(channel_id))
def update_config(self, channel_id, **kwargs):
"""تحديث إعدادات القناة."""
return update_channel_config(str(channel_id), **kwargs)
def exists(self, channel_id):
"""التحقق من وجود القناة في قاعدة البيانات."""
return get_channel(str(channel_id)) is not None
# ============================================================
# Bot-Channel Mapper - جدول ربط مستقل
# ============================================================
class BotChannelMapper:
"""يدير العلاقة بين البوتات والقنوات.
- المصدر الوحيد لمعرفة أي بوت مرتبط بأي قناة.
- لا يعتمد على التخزين في الذاكرة.
- كل العلاقات مخزنة في قاعدة البيانات.
"""
def assign(self, bot_id, channel_id):
"""ربط بوت بقناة."""
bid = str(bot_id)
cid = str(channel_id)
cfg = get_channel_config(cid)
assigned = list(cfg.get("assigned_bots", []))
if bid not in [str(x) for x in assigned]:
assigned.append(bid)
update_channel_config(cid, assigned_bots=assigned)
return True
return False
def unassign(self, bot_id, channel_id):
"""فك ربط بوت من قناة."""
bid = str(bot_id)
cid = str(channel_id)
cfg = get_channel_config(cid)
assigned = [str(x) for x in cfg.get("assigned_bots", [])]
if bid in assigned:
assigned = [x for x in assigned if x != bid]
update_channel_config(cid, assigned_bots=assigned)
return True
return False
def get_bots_for_channel(self, channel_id):
"""إرجاع IDs البوتات المرتبطة بقناة."""
cfg = get_channel_config(str(channel_id))
return [str(x) for x in cfg.get("assigned_bots", [])]
def get_channels_for_bot(self, bot_id):
"""إرجاع IDs القنوات التي يستخدمها بوت."""
bid = str(bot_id)
result = []
for ch in get_all_channels():
cid = str(ch["id"])
cfg = get_channel_config(cid)
if bid in [str(x) for x in cfg.get("assigned_bots", [])]:
result.append(cid)
return result
def get_assigned_bot_objects(self, channel_id):
"""إرجاع بيانات البوتات الكاملة المرتبطة بقناة."""
bots = []
for bid in self.get_bots_for_channel(channel_id):
b = get_publishing_bot(bid)
if b:
bots.append(b)
return bots
def is_assigned(self, bot_id, channel_id):
"""التحقق من ارتباط بوت بقناة."""
return str(bot_id) in self.get_bots_for_channel(channel_id)
def unassign_all_for_bot(self, bot_id):
"""فك ربط بوت من جميع القنوات."""
bid = str(bot_id)
count = 0
for cid in self.get_channels_for_bot(bid):
if self.unassign(bid, cid):
count += 1
return count
# إنشاء instance عامة للاستخدام في جميع أنحاء المشروع
bot_manager = BotManager()
channel_manager = ChannelManager()
mapper = BotChannelMapper()