# -*- coding: utf-8 -*- """ Arabic Telegram Group Management Bot Telethon (MTProto) + MongoDB (Motor) - single file. Environment variables: API_ID, API_HASH, BOT_TOKEN, MONGO_URI """ import asyncio import html import io import logging import os import re import sys import time from collections import deque from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Tuple from motor.motor_asyncio import AsyncIOMotorClient from pymongo import ASCENDING, ReturnDocument from telethon import Button, TelegramClient, errors, events, types from telethon.sessions import StringSession logging.basicConfig( format="%(asctime)s %(levelname)s %(name)s: %(message)s", level=logging.INFO, ) logging.getLogger("telethon").setLevel(logging.WARNING) logging.getLogger("pymongo").setLevel(logging.WARNING) log = logging.getLogger("groupbot") RPCError = errors.RPCError FloodWaitError = errors.FloodWaitError # ============================================================================ # Helpers # ============================================================================ _DIAC = re.compile("[\u064B-\u065F\u0670\u0640]") _DIGITS = str.maketrans( "\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669" "\u06F0\u06F1\u06F2\u06F3\u06F4\u06F5\u06F6\u06F7\u06F8\u06F9", "01234567890123456789", ) USERNAME_RE = re.compile(r"^[a-z][a-z0-9_]{3,31}$") def norm(s: Optional[str]) -> str: """Normalize Arabic text so small spelling differences do not matter.""" s = _DIAC.sub("", s or "").translate(_DIGITS) s = ( s.replace("\u0623", "\u0627") .replace("\u0625", "\u0627") .replace("\u0622", "\u0627") .replace("\u0649", "\u064A") .replace("\u0629", "\u0647") ) return re.sub(r"\s+", " ", s).strip().lower() def esc(s: Optional[str]) -> str: return html.escape(s or "", quote=False) def join_limited(lines: List[str], limit: int = 3500) -> str: out, size = [], 0 for ln in lines: if size + len(ln) + 1 > limit: out.append("...") break out.append(ln) size += len(ln) + 1 return "\n".join(out) def display_name(u: Any) -> str: if isinstance(u, types.User): if u.deleted: return "حساب محذوف" n = " ".join(x for x in (u.first_name, u.last_name) if x) return n or "بدون اسم" return "مستخدم" def mention(user_id: int, name: str) -> str: return '%s' % (user_id, esc(name)) def err_text(e: BaseException) -> str: if isinstance(e, FloodWaitError): return "الرجاء المحاولة بعد %d ثانية." % int(getattr(e, "seconds", 5)) m = (getattr(e, "message", "") or str(e)).upper() if "USER_ADMIN_INVALID" in m: return "لا يمكن تنفيذ الأمر على هذا المشرف." if "USER_CREATOR" in m: return "لا يمكن تنفيذ الأمر على مالك المجموعة." if any(x in m for x in ("CHAT_ADMIN_REQUIRED", "RIGHT_FORBIDDEN", "CHAT_WRITE_FORBIDDEN", "CHAT_ADMIN_INVITE_REQUIRED", "MESSAGE_DELETE_FORBIDDEN")): return "ليس لدي صلاحية كافية لتنفيذ هذا الأمر." if "USER_NOT_PARTICIPANT" in m: return "العضو ليس في المجموعة." if any(x in m for x in ("PARTICIPANT_ID_INVALID", "USER_ID_INVALID", "PEER_ID_INVALID")): return "لم أتمكن من العثور على هذا الحساب." if "USERNAME_NOT_OCCUPIED" in m or "USERNAME_INVALID" in m: return "اليوزر غير صحيح أو غير موجود." if "MESSAGE_ID_INVALID" in m: return "الرسالة غير موجودة." if "BOT_METHOD_INVALID" in m: return "هذه العملية غير متاحة للبوتات." return "حدث خطأ أثناء تنفيذ الأمر." # Rough (id -> date) checkpoints, used only for an approximate estimate. _ID_POINTS = [ (2768409, "2013-11-01"), (7679610, "2013-12-31"), (11538514, "2014-02-01"), (15835244, "2014-02-21"), (23646077, "2014-02-27"), (38015510, "2014-03-01"), (44634663, "2014-05-06"), (46145305, "2014-05-27"), (54845238, "2014-09-21"), (63263518, "2014-10-28"), (101260938, "2015-03-06"), (122600695, "2015-07-25"), (143445125, "2015-12-01"), (171295414, "2016-03-09"), (222021233, "2016-06-07"), (278941742, "2016-09-10"), (328594461, "2016-12-20"), (352940995, "2017-02-20"), (400169472, "2017-07-31"), (616816630, "2018-06-22"), (727572658, "2018-12-02"), ] _ID_POINTS = [(i, datetime.strptime(d, "%Y-%m-%d")) for i, d in _ID_POINTS] def estimate_creation(uid: int) -> Optional[str]: """Approximate account creation month from the numeric ID (estimate only).""" if uid < _ID_POINTS[0][0] or uid > _ID_POINTS[-1][0]: return None for (i1, d1), (i2, d2) in zip(_ID_POINTS, _ID_POINTS[1:]): if i1 <= uid <= i2: frac = (uid - i1) / float(i2 - i1) dt = d1 + (d2 - d1) * frac return dt.strftime("%Y/%m") return None # ============================================================================ # Commands / constants # ============================================================================ CMD_RAW = { "طرد": "kick", "حظر": "ban", "فك الحظر": "unban", "الغاء الحظر": "unban", "كتم": "mute", "فك الكتم": "unmute", "الغاء الكتم": "unmute", "تقييد": "restrict", "فك التقييد": "unrestrict", "الغاء التقييد": "unrestrict", "تحذير": "warn", "رفع تحذير": "unwarn", "تحذيرات": "warns", "مسح التحذيرات": "clearwarns", "كشف": "kash", "ايدي": "id", "قفل": "lock", "فتح": "unlock", "تفعيل": "enable", "تعطيل": "disable", "الاوامر": "menu", "الاعدادات": "settings", "اضف كلمة": "addword", "مسح كلمة": "delword", "الكلمات الممنوعة": "words", "مسح": "delete", "تثبيت": "pin", "الغاء التثبيت": "unpin", "اضف رد": "addreply", "مسح رد": "delreply", "تعديل رد": "editreply", "الردود": "replies", "اضف امر": "addcmd", "مسح امر": "delcmd", "تعديل امر": "editcmd", "الاوامر المخصصة": "cmds", "عقوبة": "rule", "العقوبات": "rules", "ضبط التكرار": "setspam", "ضبط الكتم": "setmute", } CMD = {norm(k): v for k, v in CMD_RAW.items()} CMD_NAMES = sorted(CMD.keys(), key=len, reverse=True) MOD_CMDS = {"kick", "ban", "unban", "mute", "unmute", "restrict", "unrestrict"} DONE_TEXT = { "kick": "تم طرد العضو.", "ban": "تم حظر العضو.", "unban": "تم فك حظر العضو.", "mute": "تم كتم العضو.", "unmute": "تم فك كتم العضو.", "restrict": "تم تقييد العضو.", "unrestrict": "تم فك تقييد العضو.", } ACTION_AR = {"none": "بدون", "mute": "كتم", "restrict": "تقييد", "kick": "طرد", "ban": "حظر"} ACTION_CYCLE = ["none", "mute", "restrict", "kick", "ban"] ACTION_FROM_AR = {norm(v): k for k, v in ACTION_AR.items()} ACTION_FROM_AR[norm("تحذير")] = "none" RIGHT_AR = { "ban_users": "حظر وتقييد الأعضاء", "delete_messages": "حذف الرسائل", "pin_messages": "تثبيت الرسائل", } _LOCK_ALIASES_RAW = { "id": ["ايدي", "الايدي"], "kash": ["كشف", "الكشف"], "links": ["الروابط", "روابط", "الرابط"], "spam": ["التكرار", "تكرار", "السبام", "الفلود"], "words": ["الكلمات", "كلمات", "الكلمات الممنوعة"], "warn": ["التحذير", "تحذير", "التحذيرات", "التحذير التلقائي"], } LOCK_ALIAS = {norm(a): k for k, vs in _LOCK_ALIASES_RAW.items() for a in vs} LOCK_NAME_AR = { "id": "ايدي", "kash": "كشف", "links": "الروابط", "spam": "حماية التكرار", "words": "الكلمات الممنوعة", } def parse_command(text: str) -> Tuple[Optional[str], str]: t = norm(text) if not t: return None, "" for name in CMD_NAMES: if t == name: return CMD[name], "" if t.startswith(name + " "): return CMD[name], t[len(name) + 1:].strip() return None, "" def default_group() -> Dict[str, Any]: return { "autowarn": True, "locks": ["kash", "spam", "words"], "banned_words": [], "warn_rules": {"3": "mute", "4": "ban"}, "spam_count": 6, "spam_seconds": 5, "mute_minutes": 60, } # ============================================================================ # Database # ============================================================================ class Database: def __init__(self, uri: str): self.client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=15000) self.db = self.client["telegram_group_bot"] self.groups = self.db["groups"] self.warns = self.db["warns"] self.customs = self.db["customs"] self.users = self.db["users"] self._groups: Dict[int, Dict[str, Any]] = {} self._customs: Dict[int, Dict[Tuple[str, str], Dict[str, Any]]] = {} self._seen: Dict[int, float] = {} async def init(self) -> None: await self.client.admin.command("ping") await self.warns.create_index( [("chat_id", ASCENDING), ("user_id", ASCENDING)], unique=True) await self.customs.create_index( [("chat_id", ASCENDING), ("kind", ASCENDING), ("name", ASCENDING)], unique=True) await self.users.create_index("username") # ---- group settings async def group(self, chat_id: int) -> Dict[str, Any]: g = self._groups.get(chat_id) if g is None: doc = await self.groups.find_one({"_id": chat_id}) or {} g = default_group() for k in list(g.keys()): if k in doc: g[k] = doc[k] self._groups[chat_id] = g return g async def set_group(self, chat_id: int, **fields: Any) -> None: g = await self.group(chat_id) await self.groups.update_one({"_id": chat_id}, {"$set": fields}, upsert=True) g.update(fields) # ---- warnings async def warn_count(self, chat_id: int, user_id: int) -> int: doc = await self.warns.find_one({"chat_id": chat_id, "user_id": user_id}) return int(doc["count"]) if doc else 0 async def add_warn(self, chat_id: int, user_id: int) -> int: doc = await self.warns.find_one_and_update( {"chat_id": chat_id, "user_id": user_id}, {"$inc": {"count": 1}}, upsert=True, return_document=ReturnDocument.AFTER, ) return int(doc["count"]) async def remove_warn(self, chat_id: int, user_id: int) -> Optional[int]: doc = await self.warns.find_one_and_update( {"chat_id": chat_id, "user_id": user_id, "count": {"$gt": 0}}, {"$inc": {"count": -1}}, return_document=ReturnDocument.AFTER, ) return int(doc["count"]) if doc else None async def clear_warns(self, chat_id: int, user_id: int) -> None: await self.warns.delete_one({"chat_id": chat_id, "user_id": user_id}) # ---- custom replies / commands async def customs_of(self, chat_id: int) -> Dict[Tuple[str, str], Dict[str, Any]]: d = self._customs.get(chat_id) if d is None: d = {} async for doc in self.customs.find({"chat_id": chat_id}): d[(doc["kind"], doc["name"])] = doc self._customs[chat_id] = d return d async def save_custom(self, chat_id: int, kind: str, name: str, text: str, src_msg: Optional[int]) -> None: doc = {"chat_id": chat_id, "kind": kind, "name": name, "text": text, "src_msg": src_msg} await self.customs.update_one( {"chat_id": chat_id, "kind": kind, "name": name}, {"$set": doc}, upsert=True) (await self.customs_of(chat_id))[(kind, name)] = doc async def delete_custom(self, chat_id: int, kind: str, name: str) -> bool: res = await self.customs.delete_one({"chat_id": chat_id, "kind": kind, "name": name}) (await self.customs_of(chat_id)).pop((kind, name), None) return res.deleted_count > 0 # ---- known users (access hashes, so IDs / usernames can be resolved) async def remember_user(self, user: Any) -> None: try: if not isinstance(user, types.User) or not user.access_hash: return now = time.monotonic() if now - self._seen.get(user.id, 0) < 600: return if len(self._seen) > 50000: self._seen.clear() self._seen[user.id] = now await self.users.update_one( {"_id": user.id}, {"$set": {"hash": user.access_hash, "username": (user.username or "").lower(), "name": display_name(user)}}, upsert=True) except Exception: log.debug("remember_user failed", exc_info=True) async def user_by_id(self, uid: int) -> Optional[Dict[str, Any]]: return await self.users.find_one({"_id": uid}) async def user_by_username(self, uname: str) -> Optional[Dict[str, Any]]: return await self.users.find_one({"username": uname.lower()}) # ============================================================================ # Permissions # ============================================================================ _RIGHT_KEYS = ("ban_users", "delete_messages", "pin_messages", "add_admins", "change_info", "invite_users") _RANK_KEYS = ("change_info", "delete_messages", "ban_users", "invite_users", "pin_messages", "add_admins", "manage_call", "other", "manage_topics") class Info: __slots__ = ("user_id", "known", "is_admin", "is_creator", "rank", "rights") def __init__(self, user_id: int): self.user_id = user_id self.known = True self.is_admin = False self.is_creator = False self.rank = 0 self.rights: Dict[str, bool] = {} def has(self, right: str) -> bool: return bool(self.is_creator or (self.is_admin and self.rights.get(right))) @property def role(self) -> str: if self.is_creator: return "مالك" if self.is_admin: return "مشرف" return "عضو" class Permissions: TTL = 20 def __init__(self, client: TelegramClient): self.client = client self.me_id = 0 self._cache: Dict[Tuple[int, int], Tuple[float, Info]] = {} async def info(self, chat_id: int, peer: Any, user_id: int) -> Info: key = (chat_id, user_id) now = time.monotonic() hit = self._cache.get(key) if hit and now - hit[0] < self.TTL: return hit[1] inf = Info(user_id) try: p = await self.client.get_permissions(chat_id, peer) inf.is_creator = bool(p.is_creator) inf.is_admin = bool(p.is_admin) if inf.is_admin: for k in _RIGHT_KEYS: inf.rights[k] = bool(getattr(p, k, False)) if inf.is_creator: inf.rank = 1000 else: r = getattr(p.participant, "admin_rights", None) inf.rank = 1 + sum(1 for k in _RANK_KEYS if getattr(r, k, False)) except RPCError as e: if "USER_NOT_PARTICIPANT" not in (getattr(e, "message", "") or "").upper(): inf.known = False log.debug("permissions lookup failed: %s", getattr(e, "message", e)) except (ValueError, TypeError): inf.known = False if len(self._cache) > 5000: self._cache.clear() self._cache[key] = (now, inf) return inf async def bot_info(self, chat_id: int) -> Info: return await self.info(chat_id, None, self.me_id) # ============================================================================ # Target resolving (Reply / @username / ID) # ============================================================================ class Target: __slots__ = ("id", "peer", "user") def __init__(self, user_id: int, peer: Any, user: Optional[types.User] = None): self.id = user_id self.peer = peer self.user = user @property def name(self) -> str: return display_name(self.user) if self.user else str(self.id) class Resolver: def __init__(self, client: TelegramClient, db: Database): self.client = client self.db = db async def _from_record(self, rec: Optional[Dict[str, Any]]) -> Optional[types.User]: if not rec: return None try: ent = await self.client.get_entity(types.InputPeerUser(rec["_id"], rec["hash"])) return ent if isinstance(ent, types.User) else None except Exception: return None async def resolve(self, event: Any, arg: str) -> Tuple[Optional[Target], Optional[str]]: msg = event.message if msg.is_reply: rm = await msg.get_reply_message() if rm is not None: user = await rm.get_sender() if isinstance(user, types.User): return Target(user.id, user, user), None return None, "لا يمكن تحديد صاحب الرسالة المردود عليها." token = arg.split()[0] if arg else "" if not token: return None, "رد على رسالة العضو، أو اكتب @username أو ID." if token.startswith("@"): uname = token[1:] if not USERNAME_RE.match(uname): return None, "اليوزر غير صحيح." ent: Any = None try: ent = await self.client.get_entity(uname) except Exception: ent = None if not isinstance(ent, types.User): ent = await self._from_record(await self.db.user_by_username(uname)) if not isinstance(ent, types.User): return None, "لم أجد حساباً بهذا اليوزر." return Target(ent.id, ent, ent), None if token.isdigit() and 0 < int(token) < 2 ** 52: uid = int(token) ent = await self._from_record(await self.db.user_by_id(uid)) if ent is None: try: got = await self.client.get_entity(uid) ent = got if isinstance(got, types.User) else None except Exception: ent = None if ent is None: return None, "لم أتمكن من العثور على الحساب. يجب أن يكون قد تفاعل في المجموعة." return Target(ent.id, ent, ent), None return None, "الصيغة غير صحيحة. استخدم @username أو ID أو الرد على رسالة." # ============================================================================ # Moderation & Warnings # ============================================================================ class Moderation: def __init__(self, client: TelegramClient): self.client = client async def do(self, chat_id: int, peer: Any, kind: str, minutes: int = 0) -> None: c = self.client until = None if minutes and minutes > 0 and kind in ("mute", "restrict"): until = datetime.now(timezone.utc) + timedelta(minutes=minutes) if kind == "kick": await c.kick_participant(chat_id, peer) elif kind == "ban": await c.edit_permissions(chat_id, peer, view_messages=False) elif kind in ("unban", "unmute", "unrestrict"): await c.edit_permissions(chat_id, peer) elif kind == "mute": await c.edit_permissions(chat_id, peer, until_date=until, send_messages=False) elif kind == "restrict": await c.edit_permissions( chat_id, peer, until_date=until, send_media=False, send_stickers=False, send_gifs=False, send_games=False, send_inline=False, embed_link_previews=False, send_polls=False) else: raise ValueError("unknown action") class Warnings: def __init__(self, db: Database, moderation: Moderation): self.db = db self.moderation = moderation @staticmethod def rule_for(rules: Dict[str, str], count: int) -> str: act = rules.get(str(count)) if act is None: keys = [int(k) for k in rules if str(k).isdigit()] if keys and count > max(keys): act = rules.get(str(max(keys))) return act if act in ACTION_AR else "none" async def enforce(self, chat_id: int, target: Target, count: int, reason: Optional[str] = None) -> str: g = await self.db.group(chat_id) action = self.rule_for(g["warn_rules"], count) text = "⚠️ %s — تحذير (%d)" % (mention(target.id, target.name), count) if reason: text += "\nالسبب: " + esc(reason) if action != "none": try: await self.moderation.do(chat_id, target.peer, action, int(g["mute_minutes"])) text += "\n" + DONE_TEXT[action] if action in ("ban", "kick"): await self.db.clear_warns(chat_id, target.id) except RPCError as e: text += "\n" + err_text(e) except ValueError: text += "\nتعذر تنفيذ العقوبة." return text @staticmethod def rules_text(g: Dict[str, Any]) -> str: rules = g["warn_rules"] lines = ["عقوبات التحذيرات:"] for n in range(1, 6): lines.append("تحذير %d ← %s" % (n, ACTION_AR.get(rules.get(str(n), "none"), "بدون"))) extra = sorted(int(k) for k in rules if str(k).isdigit() and int(k) > 5) for n in extra: lines.append("تحذير %d ← %s" % (n, ACTION_AR.get(rules[str(n)], "بدون"))) return "\n".join(lines) # ============================================================================ # Protection (links / banned words / flood) # ============================================================================ LINK_RE = re.compile( r"(https?://|www\.|t\.me/|telegram\.me/|telegram\.dog/|tg://|telegra\.ph/|" r"(? None: self.h: Dict[Tuple[int, int], deque] = {} def hit(self, chat: int, user: int, msg_id: int, count: int, seconds: int) -> Optional[List[int]]: now = time.monotonic() dq = self.h.get((chat, user)) if dq is None: dq = self.h[(chat, user)] = deque(maxlen=100) dq.append((now, msg_id)) while dq and now - dq[0][0] > seconds: dq.popleft() if len(dq) >= count: ids = [m for _, m in dq] dq.clear() return ids return None def purge(self) -> None: now = time.monotonic() for k in [k for k, dq in self.h.items() if not dq or now - dq[-1][0] > 120]: self.h.pop(k, None) class Protection: def __init__(self, client: TelegramClient, db: Database, perms: Permissions, warnings: Warnings, say: Any): self.client = client self.db = db self.perms = perms self.warnings = warnings self.say = say self.flood = Flood() @staticmethod def has_link(msg: Any) -> bool: for ent in (msg.entities or []): if isinstance(ent, (types.MessageEntityUrl, types.MessageEntityTextUrl)): return True return bool(LINK_RE.search(msg.raw_text or "")) @staticmethod def find_word(text: str, words: List[str]) -> Optional[str]: t = norm(text) if not t: return None for w in words: if w and w in t: return w return None async def check(self, event: Any, group: Dict[str, Any], sender: Any) -> bool: """Returns True if the message was handled as a violation.""" if not isinstance(sender, types.User) or sender.bot: return False chat, uid, msg = event.chat_id, event.sender_id, event.message locks = group["locks"] reason: Optional[str] = None ids: Optional[List[int]] = None if "spam" in locks: ids = self.flood.hit(chat, uid, msg.id, int(group["spam_count"]), int(group["spam_seconds"])) if ids: reason = "إرسال رسائل بسرعة" if not reason and "links" in locks and self.has_link(msg): reason, ids = "إرسال رابط", [msg.id] if not reason and "words" in locks and group["banned_words"]: if self.find_word(msg.raw_text or "", group["banned_words"]): reason, ids = "كلمة ممنوعة", [msg.id] if not reason or not ids: return False try: peer = await event.get_input_sender() except Exception: peer = uid info = await self.perms.info(chat, peer, uid) if not info.known or info.is_admin: return False try: await self.client.delete_messages(chat, ids) except RPCError as e: log.debug("delete failed in %s: %s", chat, getattr(e, "message", e)) if group["autowarn"]: count = await self.db.add_warn(chat, uid) text = await self.warnings.enforce(chat, Target(uid, sender, sender), count, reason) await self.say(event, text) return True # ============================================================================ # Custom replies / commands # ============================================================================ KIND_AR = {"reply": "الرد", "command": "الأمر"} class Customs: def __init__(self, client: TelegramClient, db: Database, say: Any): self.client = client self.db = db self.say = say self.pending: Dict[Tuple[int, int], Dict[str, Any]] = {} @staticmethod def content_of(msg: Any) -> Tuple[str, Optional[int]]: text = (msg.text or "").strip() src = None if msg.media and not isinstance(msg.media, types.MessageMediaWebPage): src = msg.id return text[:3500], src async def begin(self, event: Any, kind: str, name: str, edit: bool) -> None: chat, uid = event.chat_id, event.sender_id if not name: await self.say(event, "اكتب الاسم بعد الأمر.") return if len(name) > 60: await self.say(event, "الاسم طويل جداً.") return if kind == "command" and parse_command(name)[0]: await self.say(event, "هذا الاسم محجوز لأمر آخر.") return items = await self.db.customs_of(chat) exists = (kind, name) in items if edit and not exists: await self.say(event, "%s غير موجود." % KIND_AR[kind]) return if not edit and exists: await self.say(event, "%s موجود مسبقاً، استخدم أمر التعديل." % KIND_AR[kind]) return if not edit and sum(1 for k in items if k[0] == kind) >= 200: await self.say(event, "وصلت للحد الأقصى.") return msg = event.message text, src = "", None if msg.is_reply: rm = await msg.get_reply_message() if rm is not None: text, src = self.content_of(rm) if not text and not src: parts = (msg.raw_text or "").split("\n", 1) if len(parts) > 1 and parts[1].strip(): text = esc(parts[1].strip())[:3500] if text or src: await self.db.save_custom(chat, kind, name, text, src) await self.say(event, "تم حفظ %s." % KIND_AR[kind]) return self.pending[(chat, uid)] = {"kind": kind, "name": name, "exp": time.time() + 180} await self.say(event, "أرسل الآن محتوى %s، أو اكتب الغاء." % KIND_AR[kind]) async def capture(self, event: Any) -> bool: if not self.pending: return False key = (event.chat_id, event.sender_id) p = self.pending.get(key) if not p: return False if time.time() > p["exp"]: self.pending.pop(key, None) return False first = (event.message.raw_text or "").split("\n", 1)[0] if norm(first) == "الغاء": self.pending.pop(key, None) await self.say(event, "تم الإلغاء.") return True text, src = self.content_of(event.message) if not text and not src: return False self.pending.pop(key, None) await self.db.save_custom(event.chat_id, p["kind"], p["name"], text, src) await self.say(event, "تم حفظ %s." % KIND_AR[p["kind"]]) return True async def delete(self, event: Any, kind: str, name: str) -> None: if not name: await self.say(event, "اكتب الاسم بعد الأمر.") return ok = await self.db.delete_custom(event.chat_id, kind, name) await self.say(event, "تم حذف %s." % KIND_AR[kind] if ok else "%s غير موجود." % KIND_AR[kind]) async def list_text(self, chat_id: int, kind: str) -> str: items = await self.db.customs_of(chat_id) names = sorted(n for (k, n) in items if k == kind) title = "الردود" if kind == "reply" else "الأوامر المخصصة" if not names: return "لا توجد %s." % title lines = ["%s:" % title] + ["• " + esc(n) for n in names] return join_limited(lines) async def send(self, event: Any, doc: Dict[str, Any]) -> None: chat = event.chat_id src = doc.get("src_msg") if src: try: m = await self.client.get_messages(chat, ids=src) if m is not None and getattr(m, "media", None): await self.client.send_message(chat, m, reply_to=event.message.id) return except RPCError: pass text = doc.get("text") or "" if text: await self.client.send_message(chat, text, reply_to=event.message.id, link_preview=False) # ============================================================================ # Command menu (inline) # ============================================================================ SECTIONS = [ ("members", "👮 إدارة الأعضاء", True), ("warns", "⚠️ التحذيرات", True), ("protect", "🛡 الحماية", True), ("msgs", "💬 الرسائل", True), ("replies", "↩️ الردود", True), ("customs", "🧩 الأوامر المخصصة", True), ("settings", "⚙️ إعدادات المجموعة", True), ("public", "👥 أوامر الأعضاء", False), ] SECTION_TEXT = { "members": ( "👮 إدارة الأعضاء\n\n" "طرد • حظر • فك الحظر\nكتم • فك الكتم\nتقييد • فك التقييد\nكشف • ايدي\n\n" "يعمل الأمر بثلاث طرق:\n" "• بالرد على رسالة العضو\n" "• مع اليوزر: طرد @username\n" "• مع الايدي: طرد 123456789" ), "warns": ( "⚠️ التحذيرات\n\n" "تحذير • رفع تحذير\nتحذيرات • مسح التحذيرات\n" "قفل التحذير • تفعيل التحذير (التحذير التلقائي)\n\n" "عقوبة 3 كتم — تعديل عقوبة عدد معين\n" "العقوبات — عرض العقوبات الحالية\n" "الإجراءات: كتم، تقييد، طرد، حظر، بدون" ), "protect": ( "🛡 الحماية\n\n" "قفل الروابط • فتح الروابط\nقفل التكرار • فتح التكرار\nقفل الكلمات • فتح الكلمات\n" "اضف كلمة • مسح كلمة • الكلمات الممنوعة\n" "قفل ايدي • فتح ايدي\nقفل كشف • فتح كشف\n\n" "ضبط التكرار 6 5 — عدد الرسائل ثم الثواني\n" "ضبط الكتم 60 — مدة الكتم بالدقائق" ), "msgs": ( "💬 الرسائل\n\n" "مسح — بالرد على رسالة لحذفها\n" "مسح 10 — حذف آخر 10 رسائل\n" "تثبيت — بالرد على الرسالة\n" "الغاء التثبيت — بالرد، أو بدون رد لإلغاء الكل" ), "replies": ( "↩️ الردود\n\n" "اضف رد مرحبا ثم أرسل المحتوى أو رد على رسالة\n" "تعديل رد • مسح رد • الردود\n" "عند كتابة الكلمة يرسل البوت الرد." ), "customs": ( "🧩 الأوامر المخصصة\n\n" "اضف امر قوانين ثم أرسل المحتوى أو رد على رسالة\n" "تعديل امر • مسح امر • الاوامر المخصصة\n" "قفل قوانين • فتح قوانين — لجعله للمشرفين فقط" ), "public": ( "👥 أوامر الأعضاء\n\n" "ايدي — معلومات حسابك\n" "ايدي @username • ايدي ID • أو بالرد\n" "تحذيرات — عدد تحذيراتك\n" "وأي رد أو أمر مخصص أضافه المشرفون." ), } BOUNDS = {"spam_count": (3, 30), "spam_seconds": (2, 60), "mute_minutes": (5, 10080)} class CommandMenu: def __init__(self, db: Database): self.db = db @staticmethod def main() -> Tuple[str, List[List[Any]]]: rows, row = [], [] for i, (_, title, _a) in enumerate(SECTIONS): row.append(Button.inline(title, ("m:s:%d" % i).encode())) if len(row) == 2: rows.append(row) row = [] if row: rows.append(row) return "📋 قائمة الأوامر\nاختر القسم:", rows async def section(self, idx: int, chat_id: int) -> Tuple[str, List[List[Any]]]: key = SECTIONS[idx][0] if key == "settings": text, rows = await self.settings_view(chat_id) else: text, rows = SECTION_TEXT[key], [] n = len(SECTIONS) rows.append([ Button.inline("◀️ السابق", ("m:s:%d" % ((idx - 1) % n)).encode()), Button.inline("التالي ▶️", ("m:s:%d" % ((idx + 1) % n)).encode()), ]) rows.append([Button.inline("🔙 رجوع", b"m:main")]) return text, rows async def settings_view(self, chat_id: int) -> Tuple[str, List[List[Any]]]: g = await self.db.group(chat_id) locks = set(g["locks"]) def onoff(b: bool) -> str: return "✅" if b else "❌" def lockic(k: str) -> str: return "🔒" if k in locks else "🔓" rules = g["warn_rules"] text = ( "⚙️ إعدادات المجموعة\n\n" "التحذير التلقائي: %s\n" "قفل الروابط: %s\n" "حماية التكرار: %s (%d رسائل / %d ثواني)\n" "الكلمات الممنوعة: %s (%d كلمة)\n" "مدة الكتم التلقائي: %d دقيقة\n" "ايدي للأعضاء: %s | كشف للأعضاء: %s\n\n" "اضغط على رقم التحذير لتغيير عقوبته." ) % ( onoff(g["autowarn"]), onoff("links" in locks), onoff("spam" in locks), g["spam_count"], g["spam_seconds"], onoff("words" in locks), len(g["banned_words"]), g["mute_minutes"], "مقفل" if "id" in locks else "مفتوح", "مقفل" if "kash" in locks else "مفتوح", ) b = Button.inline rows: List[List[Any]] = [ [b("التحذير التلقائي " + onoff(g["autowarn"]), b"st:t:warn"), b("الروابط " + onoff("links" in locks), b"st:t:links")], [b("التكرار " + onoff("spam" in locks), b"st:t:spam"), b("الكلمات " + onoff("words" in locks), b"st:t:words")], [b("ايدي " + lockic("id"), b"st:t:id"), b("كشف " + lockic("kash"), b"st:t:kash")], [b("➖", b"st:n:spam_count:-1"), b("رسائل التكرار: %d" % g["spam_count"], b"noop"), b("➕", b"st:n:spam_count:1")], [b("➖", b"st:n:spam_seconds:-1"), b("الثواني: %d" % g["spam_seconds"], b"noop"), b("➕", b"st:n:spam_seconds:1")], [b("➖", b"st:n:mute_minutes:-15"), b("الكتم: %d د" % g["mute_minutes"], b"noop"), b("➕", b"st:n:mute_minutes:15")], [b("%d: %s" % (n, ACTION_AR.get(rules.get(str(n), "none"), "بدون")), ("st:r:%d" % n).encode()) for n in range(1, 6)], ] return text, rows async def apply_setting(self, parts: List[str], chat_id: int) -> None: g = await self.db.group(chat_id) kind = parts[1] if kind == "t" and len(parts) >= 3: key = parts[2] if key == "warn": await self.db.set_group(chat_id, autowarn=not g["autowarn"]) elif key in ("links", "spam", "words", "id", "kash"): locks = list(g["locks"]) if key in locks: locks.remove(key) else: locks.append(key) await self.db.set_group(chat_id, locks=locks) elif kind == "n" and len(parts) >= 4: key = parts[2] if key in BOUNDS: lo, hi = BOUNDS[key] v = min(hi, max(lo, int(g[key]) + int(parts[3]))) await self.db.set_group(chat_id, **{key: v}) elif kind == "r" and len(parts) >= 3: n = int(parts[2]) if 1 <= n <= 10: rules = dict(g["warn_rules"]) cur = rules.get(str(n), "none") cur = cur if cur in ACTION_CYCLE else "none" nxt = ACTION_CYCLE[(ACTION_CYCLE.index(cur) + 1) % len(ACTION_CYCLE)] if nxt == "none": rules.pop(str(n), None) else: rules[str(n)] = nxt await self.db.set_group(chat_id, warn_rules=rules) # ============================================================================ # Bot (router + command handlers) # ============================================================================ class Bot: def __init__(self, client: TelegramClient, db: Database): self.client = client self.db = db self.me: Any = None self.perms = Permissions(client) self.resolver = Resolver(client, db) self.moderation = Moderation(client) self.warnings = Warnings(db, self.moderation) self.protection = Protection(client, db, self.perms, self.warnings, self.say) self.customs = Customs(client, db, self.say) self.menu = CommandMenu(db) # ---- basic helpers async def say(self, event: Any, text: str, **kw: Any) -> Any: for attempt in (1, 2): try: return await event.respond(text, link_preview=False, **kw) except FloodWaitError as e: if attempt == 2 or e.seconds > 30: return None await asyncio.sleep(e.seconds + 1) except RPCError as e: log.warning("send failed in %s: %s", event.chat_id, getattr(e, "message", e)) return None return None @staticmethod def is_anon(event: Any) -> bool: m = event.message return event.sender_id is None or isinstance(m.from_id, types.PeerChannel) @staticmethod def plausible(event: Any, arg: str) -> bool: return (not arg) or event.message.is_reply or arg[0] == "@" or arg[0].isdigit() async def actor_info(self, event: Any) -> Info: try: peer = await event.get_input_sender() except Exception: peer = event.sender_id return await self.perms.info(event.chat_id, peer, event.sender_id) async def gate(self, event: Any, right: Optional[str], arg: str = "") -> Optional[Info]: """Checks the actor (and the bot) permissions. Returns actor Info or None.""" plausible = self.plausible(event, arg) if self.is_anon(event): if plausible: await self.say(event, "أوقف وضع التخفي لاستخدام أوامر الإدارة.") return None actor = await self.actor_info(event) if not actor.is_admin: if plausible: await self.say(event, "هذا الأمر للمشرفين فقط.") return None if right and not actor.has(right): await self.say(event, "ليست لديك صلاحية %s." % RIGHT_AR[right]) return None if right: bot = await self.perms.bot_info(event.chat_id) if not bot.has(right): await self.say(event, "ليس لدي صلاحية %s. ارفعني مشرفاً مع هذه الصلاحية." % RIGHT_AR[right]) return None return actor async def check_target(self, event: Any, actor: Info, target: Target, strict: bool) -> bool: if target.id == self.me.id: await self.say(event, "لا يمكنني تنفيذ ذلك على نفسي.") return False if target.id == actor.user_id: await self.say(event, "لا يمكنك تنفيذ ذلك على نفسك.") return False if not strict: return True tinfo = await self.perms.info(event.chat_id, target.peer, target.id) if tinfo.is_creator: await self.say(event, "لا يمكن تنفيذ الأمر على مالك المجموعة.") return False if tinfo.is_admin and tinfo.rank >= actor.rank: await self.say(event, "لا يمكن تنفيذ الأمر على مشرف بصلاحيات مساوية أو أعلى من صلاحياتك.") return False return True # ---- registration def register(self) -> None: self.client.add_event_handler(self.on_message, events.NewMessage(incoming=True)) self.client.add_event_handler(self.on_callback, events.CallbackQuery()) # ---- message router async def on_message(self, event: Any) -> None: try: if event.is_private: await self.on_private(event) return if not event.is_group or event.chat_id is None: return msg = event.message text = msg.raw_text or "" anon = self.is_anon(event) group = await self.db.group(event.chat_id) sender = None if not anon: try: sender = await event.get_sender() except Exception: sender = None if sender is not None: await self.db.remember_user(sender) if await self.customs.capture(event): return if sender is not None and await self.protection.check(event, group, sender): return first = text.split("\n", 1)[0][:200] if first and not first.startswith("/"): cmd, arg = parse_command(first) if cmd: await self.dispatch(event, cmd, arg) return if not anon and len(first) <= 100 and await self.try_custom(event, norm(first), group): return except Exception: log.exception("on_message failed") async def on_private(self, event: Any) -> None: text = (event.message.raw_text or "").strip() if not text.startswith("/start"): return url = ("https://t.me/%s?startgroup=true&admin=delete_messages+restrict_members+pin_messages+invite_users" % (self.me.username or "")) await self.say( event, "مرحباً بك 👋\nأنا بوت مخصص لإدارة المجموعات.\n" "أضفني إلى مجموعتك وارفعني مشرفاً، ثم اكتب: الاوامر", buttons=[[Button.url("➕ إضافة البوت إلى مجموعة", url)]], ) async def try_custom(self, event: Any, name: str, group: Dict[str, Any]) -> bool: items = await self.db.customs_of(event.chat_id) if not items: return False doc = items.get(("command", name)) or items.get(("reply", name)) if not doc: return False if ("c:" + name) in group["locks"]: actor = await self.actor_info(event) if not actor.is_admin: return True await self.customs.send(event, doc) return True # ---- dispatcher async def dispatch(self, event: Any, cmd: str, arg: str) -> None: try: if cmd in MOD_CMDS: await self.c_moderate(event, cmd, arg) elif cmd == "warn": await self.c_warn(event, arg) elif cmd == "unwarn": await self.c_unwarn(event, arg) elif cmd == "warns": await self.c_warns(event, arg) elif cmd == "clearwarns": await self.c_clearwarns(event, arg) elif cmd == "id": await self.c_id(event, arg, False) elif cmd == "kash": await self.c_id(event, arg, True) elif cmd in ("lock", "unlock"): await self.c_lock(event, arg, cmd == "lock") elif cmd in ("enable", "disable"): await self.c_enable(event, arg, cmd == "enable") elif cmd == "menu": text, rows = self.menu.main() await self.say(event, text, buttons=rows) elif cmd == "settings": actor = await self.gate(event, None, arg) if actor: idx = [s[0] for s in SECTIONS].index("settings") text, rows = await self.menu.section(idx, event.chat_id) await self.say(event, text, buttons=rows) elif cmd == "addword": await self.c_addword(event, arg) elif cmd == "delword": await self.c_delword(event, arg) elif cmd == "words": await self.c_words(event, arg) elif cmd == "delete": await self.c_delete(event, arg) elif cmd == "pin": await self.c_pin(event, arg, True) elif cmd == "unpin": await self.c_pin(event, arg, False) elif cmd in ("addreply", "editreply", "addcmd", "editcmd", "delreply", "delcmd"): actor = await self.gate(event, None, arg) if not actor: return kind = "reply" if cmd.endswith("reply") else "command" if cmd.startswith("del"): await self.customs.delete(event, kind, arg) else: await self.customs.begin(event, kind, arg, cmd.startswith("edit")) elif cmd in ("replies", "cmds"): actor = await self.gate(event, None, arg) if actor: await self.say(event, await self.customs.list_text( event.chat_id, "reply" if cmd == "replies" else "command")) elif cmd == "rule": await self.c_rule(event, arg) elif cmd == "rules": actor = await self.gate(event, None, arg) if actor: await self.say(event, self.warnings.rules_text(await self.db.group(event.chat_id))) elif cmd == "setspam": await self.c_setspam(event, arg) elif cmd == "setmute": await self.c_setmute(event, arg) except FloodWaitError as e: await self.say(event, err_text(e)) except RPCError as e: await self.say(event, err_text(e)) except ValueError: log.warning("command %s failed", cmd, exc_info=True) await self.say(event, "تعذر تنفيذ الأمر. تأكد أن المجموعة سوبر جروب.") except Exception: log.exception("command %s crashed", cmd) # ---- moderation commands async def c_moderate(self, event: Any, cmd: str, arg: str) -> None: actor = await self.gate(event, "ban_users", arg) if not actor: return target, err = await self.resolver.resolve(event, arg) if err or target is None: await self.say(event, err or "تعذر تحديد العضو.") return if not await self.check_target(event, actor, target, cmd in ("kick", "ban", "mute", "restrict")): return g = await self.db.group(event.chat_id) await self.moderation.do(event.chat_id, target.peer, cmd, 0) await self.say(event, DONE_TEXT[cmd]) async def c_warn(self, event: Any, arg: str) -> None: actor = await self.gate(event, "ban_users", arg) if not actor: return target, err = await self.resolver.resolve(event, arg) if err or target is None: await self.say(event, err or "تعذر تحديد العضو.") return if not await self.check_target(event, actor, target, True): return count = await self.db.add_warn(event.chat_id, target.id) await self.say(event, await self.warnings.enforce(event.chat_id, target, count)) async def c_unwarn(self, event: Any, arg: str) -> None: actor = await self.gate(event, "ban_users", arg) if not actor: return target, err = await self.resolver.resolve(event, arg) if err or target is None: await self.say(event, err or "تعذر تحديد العضو.") return left = await self.db.remove_warn(event.chat_id, target.id) if left is None: await self.say(event, "لا توجد تحذيرات على هذا العضو.") else: await self.say(event, "تم رفع تحذير عن العضو. المتبقي: %d" % left) async def c_clearwarns(self, event: Any, arg: str) -> None: actor = await self.gate(event, "ban_users", arg) if not actor: return target, err = await self.resolver.resolve(event, arg) if err or target is None: await self.say(event, err or "تعذر تحديد العضو.") return await self.db.clear_warns(event.chat_id, target.id) await self.say(event, "تم مسح تحذيرات العضو.") async def c_warns(self, event: Any, arg: str) -> None: if not self.plausible(event, arg) or self.is_anon(event): return if event.message.is_reply or arg: actor = await self.gate(event, None, arg) if not actor: return target, err = await self.resolver.resolve(event, arg) if err or target is None: await self.say(event, err or "تعذر تحديد العضو.") return uid, who = target.id, "العضو" else: uid, who = event.sender_id, "لديك" n = await self.db.warn_count(event.chat_id, uid) if who == "لديك": await self.say(event, "عدد تحذيراتك: %d" % n) else: await self.say(event, "عدد تحذيرات العضو: %d" % n) # ---- id / kash async def c_id(self, event: Any, arg: str, detailed: bool) -> None: if arg and not event.message.is_reply and not (arg[0] == "@" or arg[0].isdigit()): return chat = event.chat_id group = await self.db.group(chat) anon = self.is_anon(event) is_admin = False if not anon: is_admin = (await self.actor_info(event)).is_admin key = "kash" if detailed else "id" if key in group["locks"] and not is_admin: if not detailed: await self.say(event, "أمر ايدي مقفل للأعضاء.") return user: Any = None if event.message.is_reply or arg: target, err = await self.resolver.resolve(event, arg) if err or target is None: await self.say(event, err or "تعذر تحديد الحساب.") return user = target.user elif not anon: user = await event.get_sender() if not isinstance(user, types.User): await self.say(event, "لا يمكن تحديد الحساب.") return text = await self.build_card(chat, user, detailed) await self.send_card(event, user, text) async def build_card(self, chat_id: int, user: types.User, detailed: bool) -> str: lines = ["👤 الاسم: %s" % esc("حساب محذوف" if user.deleted else (user.first_name or "بدون اسم"))] if user.last_name and not user.deleted: lines.append("👥 العائلة: %s" % esc(user.last_name)) lines.append("🔖 اليوزر: %s" % ("@" + user.username if user.username else "لا يوجد")) lines.append("🆔 الايدي: %d" % user.id) lines.append("🤖 النوع: %s" % ("بوت" if user.bot else "مستخدم")) if detailed and user.username: lines.append("🔗 الرابط: https://t.me/%s" % user.username) est = estimate_creation(user.id) if est: lines.append("📅 تاريخ إنشاء تقريبي: %s (تقدير فقط)" % est) else: lines.append("📅 لا يمكن تقدير تاريخ الإنشاء بشكل موثوق.") if detailed: try: tinfo = await self.perms.info(chat_id, user, user.id) if tinfo.known: lines.append("🎖 الرتبة: %s" % tinfo.role) except Exception: pass n = await self.db.warn_count(chat_id, user.id) lines.append("⚠️ التحذيرات: %d" % n) return "\n".join(lines) async def send_card(self, event: Any, user: types.User, text: str) -> None: photo = None if isinstance(user.photo, types.UserProfilePhoto): try: data = await self.client.download_profile_photo(user, file=bytes) if data: photo = io.BytesIO(data) photo.name = "photo.jpg" except Exception: photo = None if photo is not None: try: await self.client.send_file(event.chat_id, photo, caption=text, reply_to=event.message.id) return except RPCError as e: log.debug("send photo failed: %s", getattr(e, "message", e)) try: await event.reply(text, link_preview=False) except RPCError as e: log.warning("reply failed: %s", getattr(e, "message", e)) # ---- locks async def c_lock(self, event: Any, arg: str, lock: bool) -> None: if not arg: if self.plausible(event, arg): actor = await self.gate(event, None, arg) if actor: await self.say(event, "اكتب اسم القفل. مثال: قفل الروابط") return actor = None chat = event.chat_id key = LOCK_ALIAS.get(arg) if key is None: customs = await self.db.customs_of(chat) if any((k, arg) in customs for k in ("reply", "command")): key = "c:" + arg else: return actor = await self.gate(event, None, "") if not actor: return if key == "warn": await self.db.set_group(chat, autowarn=not lock) await self.say(event, "تم تعطيل نظام التحذير التلقائي." if lock else "تم تفعيل نظام التحذير التلقائي.") return g = await self.db.group(chat) locks = list(g["locks"]) if lock and key not in locks: locks.append(key) elif not lock and key in locks: locks.remove(key) await self.db.set_group(chat, locks=locks) name = LOCK_NAME_AR.get(key, arg) await self.say(event, ("تم قفل %s." if lock else "تم فتح %s.") % esc(name)) async def c_enable(self, event: Any, arg: str, enable: bool) -> None: if LOCK_ALIAS.get(arg) != "warn": return actor = await self.gate(event, None, "") if not actor: return await self.db.set_group(event.chat_id, autowarn=enable) await self.say(event, "تم تفعيل نظام التحذير التلقائي." if enable else "تم تعطيل نظام التحذير التلقائي.") # ---- banned words async def c_addword(self, event: Any, arg: str) -> None: actor = await self.gate(event, None, arg) if not actor: return if not arg: await self.say(event, "اكتب: اضف كلمة + الكلمة.") return if len(arg) > 60: await self.say(event, "الكلمة طويلة جداً.") return g = await self.db.group(event.chat_id) words = list(g["banned_words"]) if arg in words: await self.say(event, "الكلمة موجودة مسبقاً.") return if len(words) >= 500: await self.say(event, "وصلت للحد الأقصى من الكلمات.") return words.append(arg) await self.db.set_group(event.chat_id, banned_words=words) await self.say(event, "تمت إضافة الكلمة.") async def c_delword(self, event: Any, arg: str) -> None: actor = await self.gate(event, None, arg) if not actor: return g = await self.db.group(event.chat_id) words = list(g["banned_words"]) if arg not in words: await self.say(event, "الكلمة غير موجودة.") return words.remove(arg) await self.db.set_group(event.chat_id, banned_words=words) await self.say(event, "تم حذف الكلمة.") async def c_words(self, event: Any, arg: str) -> None: actor = await self.gate(event, None, arg) if not actor: return g = await self.db.group(event.chat_id) if not g["banned_words"]: await self.say(event, "لا توجد كلمات ممنوعة.") return lines = ["الكلمات الممنوعة:"] + ["• " + esc(w) for w in g["banned_words"]] await self.say(event, join_limited(lines)) # ---- messages async def c_delete(self, event: Any, arg: str) -> None: actor = await self.gate(event, "delete_messages", arg) if not actor: return msg = event.message if msg.is_reply: ids = [msg.reply_to_msg_id, msg.id] elif arg.isdigit() and int(arg) > 0: n = min(int(arg), 100) ids = list(range(max(1, msg.id - n), msg.id + 1)) else: await self.say(event, "رد على رسالة أو اكتب: مسح 10") return await self.client.delete_messages(event.chat_id, ids) async def c_pin(self, event: Any, arg: str, pin: bool) -> None: actor = await self.gate(event, "pin_messages", arg) if not actor: return msg = event.message if pin: if not msg.is_reply: await self.say(event, "رد على الرسالة المراد تثبيتها.") return await self.client.pin_message(event.chat_id, msg.reply_to_msg_id) await self.say(event, "تم تثبيت الرسالة.") else: if msg.is_reply: await self.client.unpin_message(event.chat_id, msg.reply_to_msg_id) await self.say(event, "تم إلغاء تثبيت الرسالة.") else: await self.client.unpin_message(event.chat_id) await self.say(event, "تم إلغاء تثبيت جميع الرسائل.") # ---- settings commands async def c_rule(self, event: Any, arg: str) -> None: actor = await self.gate(event, None, arg) if not actor: return m = re.match(r"^(\d{1,2})\s+(\S+)$", arg) act = ACTION_FROM_AR.get(m.group(2)) if m else None if not m or act is None or not (1 <= int(m.group(1)) <= 10): await self.say(event, "مثال: عقوبة 3 كتم\nالإجراءات: كتم، تقييد، طرد، حظر، بدون") return g = await self.db.group(event.chat_id) rules = dict(g["warn_rules"]) if act == "none": rules.pop(str(int(m.group(1))), None) else: rules[str(int(m.group(1)))] = act await self.db.set_group(event.chat_id, warn_rules=rules) await self.say(event, "تم تحديث العقوبة.") async def c_setspam(self, event: Any, arg: str) -> None: actor = await self.gate(event, None, arg) if not actor: return m = re.match(r"^(\d+)\s+(\d+)$", arg) if not m: await self.say(event, "مثال: ضبط التكرار 6 5\n(عدد الرسائل ثم الثواني)") return c, s = int(m.group(1)), int(m.group(2)) c = min(max(c, BOUNDS["spam_count"][0]), BOUNDS["spam_count"][1]) s = min(max(s, BOUNDS["spam_seconds"][0]), BOUNDS["spam_seconds"][1]) await self.db.set_group(event.chat_id, spam_count=c, spam_seconds=s) await self.say(event, "تم الضبط: %d رسائل خلال %d ثواني." % (c, s)) async def c_setmute(self, event: Any, arg: str) -> None: actor = await self.gate(event, None, arg) if not actor: return if not arg.isdigit(): await self.say(event, "مثال: ضبط الكتم 60 (بالدقائق)") return lo, hi = BOUNDS["mute_minutes"] v = min(max(int(arg), lo), hi) await self.db.set_group(event.chat_id, mute_minutes=v) await self.say(event, "تم الضبط: مدة الكتم %d دقيقة." % v) # ---- inline callbacks async def cb_is_admin(self, event: Any) -> bool: try: peer = await event.get_input_sender() except Exception: peer = event.sender_id inf = await self.perms.info(event.chat_id, peer, event.sender_id) return inf.is_admin @staticmethod async def _edit(event: Any, text: str, rows: List[List[Any]]) -> None: try: await event.edit(text, buttons=rows, link_preview=False) except RPCError as e: if "MESSAGE_NOT_MODIFIED" not in (getattr(e, "message", "") or "").upper(): log.debug("edit failed: %s", getattr(e, "message", e)) async def on_callback(self, event: Any) -> None: try: data = (event.data or b"").decode("utf-8", "ignore") if event.chat_id is None or event.is_private or data == "noop": await event.answer() return parts = data.split(":") if data == "m:main": text, rows = self.menu.main() await self._edit(event, text, rows) elif len(parts) == 3 and parts[0] == "m" and parts[1] == "s": idx = int(parts[2]) if not 0 <= idx < len(SECTIONS): await event.answer() return if SECTIONS[idx][2] and not await self.cb_is_admin(event): await event.answer("هذا القسم للمشرفين فقط.", alert=True) return text, rows = await self.menu.section(idx, event.chat_id) await self._edit(event, text, rows) elif parts[0] == "st" and len(parts) >= 3: if not await self.cb_is_admin(event): await event.answer("للمشرفين فقط.", alert=True) return await self.menu.apply_setting(parts, event.chat_id) idx = [s[0] for s in SECTIONS].index("settings") text, rows = await self.menu.section(idx, event.chat_id) await self._edit(event, text, rows) await event.answer() except FloodWaitError: pass except Exception: log.exception("on_callback failed") try: await event.answer() except Exception: pass # ============================================================================ # Health server (Hugging Face Spaces needs an open port) & main # ============================================================================ async def health_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: try: await asyncio.wait_for(reader.read(1024), timeout=5) body = b"Bot is running" writer.write( b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n" b"Content-Length: %d\r\nConnection: close\r\n\r\n" % len(body) + body ) await writer.drain() except Exception: pass finally: try: writer.close() except Exception: pass async def janitor(bot: Bot) -> None: while True: await asyncio.sleep(300) try: bot.protection.flood.purge() now = time.time() for k in [k for k, p in bot.customs.pending.items() if p["exp"] < now]: bot.customs.pending.pop(k, None) except Exception: log.debug("janitor error", exc_info=True) def require_env() -> Tuple[int, str, str, str]: names = ("API_ID", "API_HASH", "BOT_TOKEN", "MONGO_URI") missing = [n for n in names if not os.environ.get(n, "").strip()] if missing: print("Missing environment variables: " + ", ".join(missing), file=sys.stderr) sys.exit(1) try: api_id = int(os.environ["API_ID"].strip()) except ValueError: print("API_ID must be an integer.", file=sys.stderr) sys.exit(1) return (api_id, os.environ["API_HASH"].strip(), os.environ["BOT_TOKEN"].strip(), os.environ["MONGO_URI"].strip()) async def main() -> None: api_id, api_hash, bot_token, mongo_uri = require_env() port = int(os.environ.get("PORT", "7860")) server = await asyncio.start_server(health_handler, "0.0.0.0", port) log.info("Health server listening on port %d", port) db = Database(mongo_uri) while True: try: await db.init() break except Exception as e: log.error("MongoDB connection failed (%s). Retrying in 10s...", type(e).__name__) await asyncio.sleep(10) log.info("MongoDB connected") client = TelegramClient( StringSession(), api_id, api_hash, flood_sleep_threshold=60, connection_retries=-1, retry_delay=2, ) client.parse_mode = "html" bot = Bot(client, db) bot.register() while True: try: await client.start(bot_token=bot_token) break except FloodWaitError as e: log.warning("FloodWait on login: sleeping %ds", e.seconds) await asyncio.sleep(e.seconds + 1) except Exception as e: log.error("Login failed (%s). Retrying in 10s...", type(e).__name__) await asyncio.sleep(10) bot.me = await client.get_me() bot.perms.me_id = bot.me.id log.info("Bot started as @%s", bot.me.username) task = asyncio.create_task(janitor(bot)) try: while True: try: await client.run_until_disconnected() break except Exception: log.exception("Connection loop error; reconnecting in 5s") await asyncio.sleep(5) try: if not client.is_connected(): await client.connect() except Exception: pass finally: task.cancel() server.close() if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: pass