Download main.py from Oth1/oth: direct link, hf CLI and curl.
- Browser
- Download file 70.6 kB
-
https://huggingface.co/spaces/Oth1/oth/resolve/main/main.py
- Command line
-
hf download hf://spaces/Oth1/oth/main.py
-
curl -L -o main.py https://huggingface.co/spaces/Oth1/oth/resolve/main/main.py
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))) | |
| 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 | |
| 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 | |
| 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 | |
| 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() | |
| 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 "")) | |
| 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]] = {} | |
| 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 | |
| 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 | |
| def is_anon(event: Any) -> bool: | |
| m = event.message | |
| return event.sender_id is None or isinstance(m.from_id, types.PeerChannel) | |
| 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 | |
| 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 | |