# -*- 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