oth / main.py
Oth1's picture
Upload main.py
36e1160 verified
Raw History Blame Contribute Delete
70.6 kB
# -*- 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 '<a href="tg://user?id=%d">%s</a>' % (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 = ["<b>ุนู‚ูˆุจุงุช ุงู„ุชุญุฐูŠุฑุงุช:</b>"]
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"(?<![a-z0-9-])[a-z0-9][a-z0-9-]{1,}\.(?:com|net|org|io|me|co|info|xyz|ly|gl|gg|tk|ml|ga|cf|"
r"top|club|site|online|shop|store|app|dev|link|live|tv|cc|ru|ir|sa|ae|eg|iq|sy|ps|jo|kw|qa|ma|dz|tn)"
r"(?![a-z0-9]))",
re.IGNORECASE,
)
class Flood:
def __init__(self) -> 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 = ["<b>%s:</b>" % 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": (
"<b>๐Ÿ‘ฎ ุฅุฏุงุฑุฉ ุงู„ุฃุนุถุงุก</b>\n\n"
"ุทุฑุฏ โ€ข ุญุธุฑ โ€ข ููƒ ุงู„ุญุธุฑ\nูƒุชู… โ€ข ููƒ ุงู„ูƒุชู…\nุชู‚ูŠูŠุฏ โ€ข ููƒ ุงู„ุชู‚ูŠูŠุฏ\nูƒุดู โ€ข ุงูŠุฏูŠ\n\n"
"ูŠุนู…ู„ ุงู„ุฃู…ุฑ ุจุซู„ุงุซ ุทุฑู‚:\n"
"โ€ข ุจุงู„ุฑุฏ ุนู„ู‰ ุฑุณุงู„ุฉ ุงู„ุนุถูˆ\n"
"โ€ข ู…ุน ุงู„ูŠูˆุฒุฑ: <code>ุทุฑุฏ @username</code>\n"
"โ€ข ู…ุน ุงู„ุงูŠุฏูŠ: <code>ุทุฑุฏ 123456789</code>"
),
"warns": (
"<b>โš ๏ธ ุงู„ุชุญุฐูŠุฑุงุช</b>\n\n"
"ุชุญุฐูŠุฑ โ€ข ุฑูุน ุชุญุฐูŠุฑ\nุชุญุฐูŠุฑุงุช โ€ข ู…ุณุญ ุงู„ุชุญุฐูŠุฑุงุช\n"
"ู‚ูู„ ุงู„ุชุญุฐูŠุฑ โ€ข ุชูุนูŠู„ ุงู„ุชุญุฐูŠุฑ (ุงู„ุชุญุฐูŠุฑ ุงู„ุชู„ู‚ุงุฆูŠ)\n\n"
"<code>ุนู‚ูˆุจุฉ 3 ูƒุชู…</code> โ€” ุชุนุฏูŠู„ ุนู‚ูˆุจุฉ ุนุฏุฏ ู…ุนูŠู†\n"
"ุงู„ุนู‚ูˆุจุงุช โ€” ุนุฑุถ ุงู„ุนู‚ูˆุจุงุช ุงู„ุญุงู„ูŠุฉ\n"
"ุงู„ุฅุฌุฑุงุกุงุช: ูƒุชู…ุŒ ุชู‚ูŠูŠุฏุŒ ุทุฑุฏุŒ ุญุธุฑุŒ ุจุฏูˆู†"
),
"protect": (
"<b>๐Ÿ›ก ุงู„ุญู…ุงูŠุฉ</b>\n\n"
"ู‚ูู„ ุงู„ุฑูˆุงุจุท โ€ข ูุชุญ ุงู„ุฑูˆุงุจุท\nู‚ูู„ ุงู„ุชูƒุฑุงุฑ โ€ข ูุชุญ ุงู„ุชูƒุฑุงุฑ\nู‚ูู„ ุงู„ูƒู„ู…ุงุช โ€ข ูุชุญ ุงู„ูƒู„ู…ุงุช\n"
"ุงุถู ูƒู„ู…ุฉ โ€ข ู…ุณุญ ูƒู„ู…ุฉ โ€ข ุงู„ูƒู„ู…ุงุช ุงู„ู…ู…ู†ูˆุนุฉ\n"
"ู‚ูู„ ุงูŠุฏูŠ โ€ข ูุชุญ ุงูŠุฏูŠ\nู‚ูู„ ูƒุดู โ€ข ูุชุญ ูƒุดู\n\n"
"<code>ุถุจุท ุงู„ุชูƒุฑุงุฑ 6 5</code> โ€” ุนุฏุฏ ุงู„ุฑุณุงุฆู„ ุซู… ุงู„ุซูˆุงู†ูŠ\n"
"<code>ุถุจุท ุงู„ูƒุชู… 60</code> โ€” ู…ุฏุฉ ุงู„ูƒุชู… ุจุงู„ุฏู‚ุงุฆู‚"
),
"msgs": (
"<b>๐Ÿ’ฌ ุงู„ุฑุณุงุฆู„</b>\n\n"
"ู…ุณุญ โ€” ุจุงู„ุฑุฏ ุนู„ู‰ ุฑุณุงู„ุฉ ู„ุญุฐูู‡ุง\n"
"<code>ู…ุณุญ 10</code> โ€” ุญุฐู ุขุฎุฑ 10 ุฑุณุงุฆู„\n"
"ุชุซุจูŠุช โ€” ุจุงู„ุฑุฏ ุนู„ู‰ ุงู„ุฑุณุงู„ุฉ\n"
"ุงู„ุบุงุก ุงู„ุชุซุจูŠุช โ€” ุจุงู„ุฑุฏุŒ ุฃูˆ ุจุฏูˆู† ุฑุฏ ู„ุฅู„ุบุงุก ุงู„ูƒู„"
),
"replies": (
"<b>โ†ฉ๏ธ ุงู„ุฑุฏูˆุฏ</b>\n\n"
"<code>ุงุถู ุฑุฏ ู…ุฑุญุจุง</code> ุซู… ุฃุฑุณู„ ุงู„ู…ุญุชูˆู‰ ุฃูˆ ุฑุฏ ุนู„ู‰ ุฑุณุงู„ุฉ\n"
"ุชุนุฏูŠู„ ุฑุฏ โ€ข ู…ุณุญ ุฑุฏ โ€ข ุงู„ุฑุฏูˆุฏ\n"
"ุนู†ุฏ ูƒุชุงุจุฉ ุงู„ูƒู„ู…ุฉ ูŠุฑุณู„ ุงู„ุจูˆุช ุงู„ุฑุฏ."
),
"customs": (
"<b>๐Ÿงฉ ุงู„ุฃูˆุงู…ุฑ ุงู„ู…ุฎุตุตุฉ</b>\n\n"
"<code>ุงุถู ุงู…ุฑ ู‚ูˆุงู†ูŠู†</code> ุซู… ุฃุฑุณู„ ุงู„ู…ุญุชูˆู‰ ุฃูˆ ุฑุฏ ุนู„ู‰ ุฑุณุงู„ุฉ\n"
"ุชุนุฏูŠู„ ุงู…ุฑ โ€ข ู…ุณุญ ุงู…ุฑ โ€ข ุงู„ุงูˆุงู…ุฑ ุงู„ู…ุฎุตุตุฉ\n"
"ู‚ูู„ ู‚ูˆุงู†ูŠู† โ€ข ูุชุญ ู‚ูˆุงู†ูŠู† โ€” ู„ุฌุนู„ู‡ ู„ู„ู…ุดุฑููŠู† ูู‚ุท"
),
"public": (
"<b>๐Ÿ‘ฅ ุฃูˆุงู…ุฑ ุงู„ุฃุนุถุงุก</b>\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 "<b>๐Ÿ“‹ ู‚ุงุฆู…ุฉ ุงู„ุฃูˆุงู…ุฑ</b>\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 = (
"<b>โš™๏ธ ุฅุนุฏุงุฏุงุช ุงู„ู…ุฌู…ูˆุนุฉ</b>\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 = ["๐Ÿ‘ค <b>ุงู„ุงุณู…:</b> %s" % esc("ุญุณุงุจ ู…ุญุฐูˆู" if user.deleted else (user.first_name or "ุจุฏูˆู† ุงุณู…"))]
if user.last_name and not user.deleted:
lines.append("๐Ÿ‘ฅ <b>ุงู„ุนุงุฆู„ุฉ:</b> %s" % esc(user.last_name))
lines.append("๐Ÿ”– <b>ุงู„ูŠูˆุฒุฑ:</b> %s" % ("@" + user.username if user.username else "ู„ุง ูŠูˆุฌุฏ"))
lines.append("๐Ÿ†” <b>ุงู„ุงูŠุฏูŠ:</b> <code>%d</code>" % user.id)
lines.append("๐Ÿค– <b>ุงู„ู†ูˆุน:</b> %s" % ("ุจูˆุช" if user.bot else "ู…ุณุชุฎุฏู…"))
if detailed and user.username:
lines.append("๐Ÿ”— <b>ุงู„ุฑุงุจุท:</b> https://t.me/%s" % user.username)
est = estimate_creation(user.id)
if est:
lines.append("๐Ÿ“… <b>ุชุงุฑูŠุฎ ุฅู†ุดุงุก ุชู‚ุฑูŠุจูŠ:</b> %s (ุชู‚ุฏูŠุฑ ูู‚ุท)" % est)
else:
lines.append("๐Ÿ“… ู„ุง ูŠู…ูƒู† ุชู‚ุฏูŠุฑ ุชุงุฑูŠุฎ ุงู„ุฅู†ุดุงุก ุจุดูƒู„ ู…ูˆุซูˆู‚.")
if detailed:
try:
tinfo = await self.perms.info(chat_id, user, user.id)
if tinfo.known:
lines.append("๐ŸŽ– <b>ุงู„ุฑุชุจุฉ:</b> %s" % tinfo.role)
except Exception:
pass
n = await self.db.warn_count(chat_id, user.id)
lines.append("โš ๏ธ <b>ุงู„ุชุญุฐูŠุฑุงุช:</b> %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 = ["<b>ุงู„ูƒู„ู…ุงุช ุงู„ู…ู…ู†ูˆุนุฉ:</b>"] + ["โ€ข " + 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