mirror of
https://github.com/MuRuLOSE/limoka.git
synced 2026-06-16 14:34:17 +02:00
Added and updated repositories 2025-07-11 08:27:20
This commit is contained in:
@@ -1,495 +1,495 @@
|
||||
# Finds an intersection between users in different groups
|
||||
# Copyright © 2024 https://t.me/nalinor
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
# meta developer: @nalinormods
|
||||
|
||||
# Even taking into account the fact I double-checked every math expression,
|
||||
# you, as a reader, have to remember that I'm a programmer, not a math expert.
|
||||
# Some complicated operations (like symmetric difference on the complement of a set) may go wrong.
|
||||
# I'd be glad to see your pull requests here if you find an error.
|
||||
|
||||
# Reminder: In the code below I use "negated" and "negatable" sets as a definition for "the complement of a set".
|
||||
# It's easier for me to say that a set is "negated" rather than a set is "complement", "completion", "completed", ...
|
||||
|
||||
import ast
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from typing import cast
|
||||
|
||||
from telethon import TelegramClient, errors
|
||||
from telethon.errors import ChatAdminRequiredError
|
||||
from telethon.hints import Entity
|
||||
from telethon.tl import types
|
||||
from telethon.tl.custom import Message
|
||||
from telethon.tl.functions.channels import JoinChannelRequest
|
||||
|
||||
from .. import loader, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InvalidChatID(Exception):
|
||||
def __init__(self, chat_id: "int | str", reason: str):
|
||||
self.chat_id = chat_id
|
||||
self.reason = reason
|
||||
|
||||
def __str__(self):
|
||||
return f"Invalid chat ID {self.chat_id}: {self.reason}"
|
||||
|
||||
|
||||
class NegatableSet(set):
|
||||
"""
|
||||
Set that can be negated.
|
||||
Negated set A is a set of all elements that are not in A (a complement of set A).
|
||||
|
||||
https://en.wikipedia.org/wiki/Complement_(set_theory)
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.negated = False
|
||||
|
||||
def __invert__(self):
|
||||
"""Creates a copy of set and marks it as negated"""
|
||||
return NegatableSet(self).negate()
|
||||
|
||||
def negate(self) -> "NegatableSet":
|
||||
"""Marks that the set is negated in-place"""
|
||||
self.negated = not self.negated
|
||||
return self
|
||||
|
||||
def __and__(self, other: "NegatableSet") -> "NegatableSet":
|
||||
if self.negated and other.negated:
|
||||
return NegatableSet(self.union(other)).negate()
|
||||
|
||||
if other.negated:
|
||||
return NegatableSet(self.difference(other))
|
||||
|
||||
if self.negated:
|
||||
return NegatableSet(other.difference(self))
|
||||
|
||||
return NegatableSet(self.intersection(other))
|
||||
|
||||
def __or__(self, other: "NegatableSet") -> "NegatableSet":
|
||||
if self.negated and other.negated:
|
||||
return NegatableSet(self.intersection(other)).negate()
|
||||
|
||||
if other.negated:
|
||||
return NegatableSet(other.difference(self)).negate()
|
||||
|
||||
if self.negated:
|
||||
return NegatableSet(self.difference(other)).negate()
|
||||
|
||||
return NegatableSet(self.union(other))
|
||||
|
||||
def __sub__(self, other: "NegatableSet") -> "NegatableSet":
|
||||
if self.negated and other.negated:
|
||||
return NegatableSet(self.difference(other))
|
||||
|
||||
if other.negated:
|
||||
return NegatableSet(self.intersection(other))
|
||||
|
||||
if self.negated:
|
||||
return NegatableSet(self.union(other)).negate()
|
||||
|
||||
return NegatableSet(self.difference(other))
|
||||
|
||||
def __xor__(self, other: "NegatableSet") -> "NegatableSet":
|
||||
if self.negated and other.negated:
|
||||
# (A - B) | (B - A)
|
||||
return NegatableSet(self.difference(other).union(other.difference(self)))
|
||||
|
||||
if self.negated or other.negated:
|
||||
# (A & B) | ~(A | B)
|
||||
# => ~((A - B) | (B - A))
|
||||
return NegatableSet(
|
||||
self.difference(other).union(other.difference(self))
|
||||
).negate()
|
||||
|
||||
return NegatableSet(self.symmetric_difference(other))
|
||||
|
||||
def __ior__(self, other):
|
||||
raise NotImplementedError("use __or__ instead")
|
||||
|
||||
def __iand__(self, other):
|
||||
raise NotImplementedError("use __and__ instead")
|
||||
|
||||
|
||||
class QueryExecutor:
|
||||
async def fetch_set(self, key: "int | str") -> NegatableSet:
|
||||
"""Fetches a set to be used in a query"""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
async def execute(self, query: str) -> NegatableSet:
|
||||
"""Executes a query"""
|
||||
|
||||
query = query.replace("&&", "&").replace("||", "|").replace("@", "")
|
||||
|
||||
body = ast.parse(query).body
|
||||
|
||||
if not body:
|
||||
raise SyntaxError("empty body")
|
||||
if len(body) > 1:
|
||||
raise SyntaxError("more than one statement in the body")
|
||||
if not isinstance(body[0], ast.Expr):
|
||||
raise SyntaxError(
|
||||
f"expected expression, {body[0].__class__.__name__} found"
|
||||
)
|
||||
|
||||
expr = cast(ast.Expr, body[0])
|
||||
result = await self.query(expr.value)
|
||||
|
||||
return result
|
||||
|
||||
async def execute_simplified(self, params: "list[str]") -> NegatableSet:
|
||||
first = await self.fetch_set(params[0])
|
||||
for param in params[1:]:
|
||||
first = first & await self.fetch_set(param)
|
||||
return first
|
||||
|
||||
async def query(self, expr: ast.expr) -> NegatableSet:
|
||||
"""Recursively iterates over the expression tree and evaluates it"""
|
||||
if isinstance(expr, ast.Name):
|
||||
return await self.fetch_set(expr.id)
|
||||
|
||||
if isinstance(expr, ast.Constant):
|
||||
if isinstance(expr.value, (str, int)):
|
||||
return await self.fetch_set(expr.value)
|
||||
|
||||
raise SyntaxError(f"invalid constant value: {expr.value}")
|
||||
|
||||
if (
|
||||
isinstance(expr, ast.UnaryOp)
|
||||
and isinstance(expr.op, ast.USub)
|
||||
and isinstance(expr.operand, ast.Constant)
|
||||
):
|
||||
if isinstance(expr.operand.value, int):
|
||||
return await self.fetch_set(-expr.operand.value)
|
||||
|
||||
raise SyntaxError(f"invalid constant value: {expr.operand.value}")
|
||||
|
||||
if isinstance(expr, ast.BoolOp):
|
||||
first = await self.query(expr.values[0])
|
||||
|
||||
for value in expr.values[1:]:
|
||||
if isinstance(expr.op, ast.And):
|
||||
first = first & await self.query(value)
|
||||
else:
|
||||
first = first | await self.query(value)
|
||||
|
||||
return first
|
||||
|
||||
if isinstance(expr, ast.BinOp):
|
||||
if isinstance(expr.op, ast.BitAnd):
|
||||
return await self.query(expr.left) & await self.query(expr.right)
|
||||
|
||||
if isinstance(expr.op, (ast.BitOr, ast.Add)):
|
||||
return await self.query(expr.left) | await self.query(expr.right)
|
||||
|
||||
if isinstance(expr.op, ast.Sub):
|
||||
return await self.query(expr.left) - await self.query(expr.right)
|
||||
|
||||
if isinstance(expr.op, ast.BitXor):
|
||||
return await self.query(expr.left) ^ await self.query(expr.right)
|
||||
|
||||
if isinstance(expr, ast.UnaryOp) and isinstance(
|
||||
expr.op,
|
||||
(
|
||||
ast.Not,
|
||||
ast.Invert,
|
||||
ast.USub,
|
||||
),
|
||||
):
|
||||
return (await self.query(expr.operand)).negate()
|
||||
|
||||
logger.debug("remaining expression: %s", ast.dump(expr))
|
||||
raise SyntaxError(f"operator {expr.__class__.__name__} is not supported")
|
||||
|
||||
|
||||
members_cache: "dict[int | str, tuple[dict[int], float]]" = {}
|
||||
|
||||
|
||||
class UsersQueryExecutor(QueryExecutor):
|
||||
def __init__(self, client: TelegramClient):
|
||||
super().__init__()
|
||||
|
||||
self.users: "dict[int]" = {}
|
||||
self.client = client
|
||||
|
||||
async def fetch_set(self, key: "int | str") -> NegatableSet:
|
||||
if key in ["me", "self"]:
|
||||
me = await self.client.get_me()
|
||||
self.users[me.id] = me
|
||||
|
||||
return NegatableSet([me.id])
|
||||
|
||||
try:
|
||||
key = int(key)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
chat = await self.client.get_entity(await self.client.get_input_entity(key))
|
||||
except (ValueError, errors.BadRequestError) as e:
|
||||
raise InvalidChatID(key, str(e))
|
||||
|
||||
if isinstance(chat, types.User):
|
||||
raise InvalidChatID(key, "chat ID belongs to a user")
|
||||
|
||||
if key in members_cache and members_cache[key][1] > time.perf_counter():
|
||||
logger.debug("Using cached participants for %s", key)
|
||||
members = members_cache[key][0]
|
||||
else:
|
||||
logger.debug("Fetching participants for %s", key)
|
||||
try:
|
||||
members = {
|
||||
member.id: member
|
||||
async for member in self.client.iter_participants(chat.id)
|
||||
}
|
||||
except ChatAdminRequiredError:
|
||||
raise InvalidChatID(
|
||||
key, "insufficient privileges to view users in chat"
|
||||
)
|
||||
|
||||
members_cache[key] = (members, time.perf_counter() + 600)
|
||||
|
||||
self.users.update(members)
|
||||
|
||||
return NegatableSet(members.keys())
|
||||
|
||||
|
||||
def format_user(user: Entity, tags: bool = True) -> str:
|
||||
"""Formats a user to be displayed in the results"""
|
||||
if user.username:
|
||||
link, username = f"https://t.me/{user.username}", f"@{user.username}"
|
||||
elif user.usernames:
|
||||
username = user.usernames[0].username
|
||||
link, username = f"https://t.me/{username}", f"@{username}"
|
||||
else:
|
||||
link, username = f"tg://user?id={user.id}", ""
|
||||
|
||||
name = (
|
||||
f"{user.first_name} {user.last_name}"
|
||||
if user.last_name
|
||||
else user.first_name
|
||||
if user.first_name
|
||||
else "Deleted Account"
|
||||
)
|
||||
|
||||
if tags:
|
||||
name = utils.escape_html(name)
|
||||
return f"<a href='{link}'>{name}</a> (<code>{user.id}</code>)"
|
||||
|
||||
return f"{user.id} {name} {username}"
|
||||
|
||||
|
||||
# noinspection PyCallingNonCallable,PyAttributeOutsideInit
|
||||
# pylint: disable=not-callable,attribute-defined-outside-init,invalid-name
|
||||
@loader.tds
|
||||
class MembersQueryMod(loader.Module):
|
||||
"""Finds an intersection between members of different groups"""
|
||||
|
||||
strings = {
|
||||
"name": "MembersQuery",
|
||||
"author": "@nalinormods",
|
||||
"usage": """
|
||||
📝 <b>MembersQuery module syntax</b>
|
||||
|
||||
A brief of Python syntax is used for queries.
|
||||
|
||||
Specify groups as a username (with or without @) or chat ID.
|
||||
Channels are also accepted in case you are an admin. You can't fetch more than 200 members from a channel, so results may be incomplete.
|
||||
Specify yourself as <code>me</code> or <code>self</code>.
|
||||
|
||||
Each group is represented by a set of its members (see set theory). You can use these operations:
|
||||
<code>&</code>, <code>and</code> — intersection (members that are in both groups <b>at same time</b>)
|
||||
<code>|</code>, <code>or</code>, <code>+</code> — union (members of A, B or both groups)
|
||||
<code>-</code> — difference (members of A group that are not in B group)
|
||||
<code>^</code> — symmetric difference (members of A or B group, but not both)
|
||||
<code>~</code>, <code>not</code>, unary <code>-</code> — negation (specifies a group that anybody joined except members of the group)
|
||||
|
||||
<b>Examples</b>:
|
||||
<code>@mymusicgroup and @mychessgroup</code> — members of both groups at same time
|
||||
<code>@nalinormods & ~@nalinormodschat</code> — subscribers of a channel that didn't join a group yet
|
||||
<code>hikka_ub | hikka_talks | hikka_offtop</code> — members of any of these groups
|
||||
<code>-1001234567890 - me</code> — members of a private group except yourself
|
||||
|
||||
ℹ️ In order to increase performance, the module caches the list of members for 10 minutes. Reload the module or restart the userbot to clear the cache.
|
||||
""",
|
||||
"no_args": "❌ <b>Specify at least one group</b>",
|
||||
"syntax_error": (
|
||||
"❌ <b>You have an syntax error in query"
|
||||
" <code>{query}</code>:</b>\n<code>{error}</code>"
|
||||
),
|
||||
"invalid_chat_id": "❌ <b>Invalid chat ID {chat_id}:</b>\n<code>{error}</code>",
|
||||
"running": "🕑 <b>Executing query <code>{query}</code>...</b>",
|
||||
"no_results": "🚫 <b>No results found</b> for query <code>{query}</code>",
|
||||
"results": "🔍 <b>{n} users found</b> for query <code>{query}</code>",
|
||||
"results_file": "📤 <b>The list is too long, so it's sent in file.</b>",
|
||||
"result_is_negated": (
|
||||
"⚠️ <b>The final set is negated, so result may be incomplete. "
|
||||
"Rewrite your query to get accurate results</b>"
|
||||
),
|
||||
}
|
||||
|
||||
strings_ru = {
|
||||
"_cls_doc": (
|
||||
"Поиск пересечения групп на предмет наличия одних и тех же пользователей"
|
||||
),
|
||||
"_cmd_doc_mjoin": (
|
||||
"<юзернейм/ID группы> ... — Найти пользователей, которые находятся во всех"
|
||||
" заданных группах одновременно"
|
||||
),
|
||||
"_cmd_doc_mquery": (
|
||||
"<запрос?> — Найти пользователей из групп по заданному запросу. Вызови без"
|
||||
" аргументов для получения справки для справки."
|
||||
),
|
||||
"usage": """
|
||||
📝 <b>Синтаксис модуля MembersQuery</b>
|
||||
|
||||
Для запросов используется часть синтаксиса Python.
|
||||
|
||||
Чтобы указать группы, используй их юзернейм (с или без @) или ID чата.
|
||||
Также можно указать канал, в котором ты являешься админом. В канале есть лимит на получение макс. 200 пользователей, поэтому результаты могут быть неполными.
|
||||
Чтобы указать себя, используй <code>me</code> или <code>self</code>.
|
||||
|
||||
Каждая группа представлена в виде множества её участников (см. теоримю множеств). Доступны следующие операции:
|
||||
<code>&</code>, <code>and</code> — пересечение (участники и первой, и второй группы <b>одновременно</b>)
|
||||
<code>|</code>, <code>or</code>, <code>+</code> — объединение (участники первой, второй или обеих групп)
|
||||
<code>-</code> — разность (участники первой группы, которые не находятся во второй)
|
||||
<code>^</code> — симметрическая разность (участники первой или второй группы, но не обеих)
|
||||
<code>~</code>, <code>not</code>, <code>-</code> — отрицание (обозначает условную группу, в котором находятся все, кроме участников группы)
|
||||
|
||||
<b>Примеры использования</b>:
|
||||
<code>@mymusicgroup and @mychessgroup</code> — участники обеих групп одновременно
|
||||
<code>@nalinormods & ~@nalinormodschat</code> — подписчики канала, которые ещё не вступили в группу
|
||||
<code>hikka_ub | hikka_talks | hikka_offtop</code> — участники любой из этих групп
|
||||
<code>-1001234567890 - me</code> — участники приватной группы, кроме тебя
|
||||
|
||||
ℹ️ В целях производительности, модуль кэширует список участников на 10 минут. Перезагрузите модуль или весь юзербот, чтобы очистить кэш.
|
||||
""",
|
||||
"no_args": "❌ <b>Укажите хотя бы одну группу</b>",
|
||||
"syntax_error": (
|
||||
"❌ <b>В запросе <code>{query}</code> есть синтаксическая"
|
||||
" ошибка:</b>\n<code>{error}</code>"
|
||||
),
|
||||
"invalid_chat_id": (
|
||||
"❌ <b>Неверный ID/юзернейм чата {chat_id}:</b>\n<code>{error}</code>"
|
||||
),
|
||||
"running": "🕑 <b>Запрос <code>{query}</code> выполняется...</b>",
|
||||
"no_results": "🚫 <b>Результаты не найдены</b> по запросу <code>{query}</code>",
|
||||
"results": (
|
||||
"🔍 <b>Пользователей найдено: {n}</b> по запросу <code>{query}</code>"
|
||||
),
|
||||
"results_file": (
|
||||
"📤 <b>Полученный список слишком большой, поэтому он отправлен в файле.</b>"
|
||||
),
|
||||
"result_is_negated": (
|
||||
"⚠️ <b>Результат получен из отрицательного множества, поэтому он может быть"
|
||||
" неполным. Исправь запрос, чтобы получить точный результат</b>"
|
||||
),
|
||||
}
|
||||
|
||||
async def client_ready(self, client: TelegramClient, _):
|
||||
"""client_ready hook"""
|
||||
self.client = client
|
||||
|
||||
await client(JoinChannelRequest(channel=self.strings("author")))
|
||||
|
||||
def format_results(
|
||||
self, query: str, results: NegatableSet, users: dict
|
||||
) -> (str, "io.BytesIO | None"):
|
||||
"""Formats results to be displayed in the message"""
|
||||
negated = results.negated
|
||||
if negated:
|
||||
results = set(users.keys()).difference(results)
|
||||
|
||||
if not results:
|
||||
return self.strings("no_results").format(query=query), None
|
||||
|
||||
text = self.strings("results").format(query=query, n=len(results)) + "\n\n"
|
||||
|
||||
use_file = len(results) > 30
|
||||
formatted_results = (
|
||||
format_user(users[user_id], tags=not use_file) for user_id in results
|
||||
)
|
||||
|
||||
if use_file:
|
||||
text += self.strings("results_file") + "\n\n"
|
||||
|
||||
stream = io.BytesIO()
|
||||
stream.write("\n".join(formatted_results).encode("utf-8"))
|
||||
stream.seek(0)
|
||||
stream.name = "result.txt"
|
||||
else:
|
||||
text += "\n".join(formatted_results) + "\n\n"
|
||||
stream = None
|
||||
|
||||
if negated:
|
||||
text += self.strings("result_is_negated")
|
||||
|
||||
return text, stream
|
||||
|
||||
async def mjoincmd(self, message: Message):
|
||||
"""<username/chat ID> ... — Find users that are in all given chats at same time"""
|
||||
text = utils.get_args_raw(message)
|
||||
if not text:
|
||||
return await utils.answer(message, self.strings("no_args"))
|
||||
|
||||
await self.mquerycmd(message, simplified=True)
|
||||
|
||||
async def mquerycmd(self, message: Message, simplified: bool = False):
|
||||
"""<query?> — Find users from given chats that match the query. Call without args for help."""
|
||||
text = utils.get_args_raw(message)
|
||||
if not text:
|
||||
return await utils.answer(message, self.strings("usage"))
|
||||
|
||||
m = await utils.answer(message, self.strings("running").format(query=text))
|
||||
if isinstance(m, list):
|
||||
m = m[0]
|
||||
|
||||
executor = UsersQueryExecutor(self.client)
|
||||
|
||||
try:
|
||||
if simplified:
|
||||
result = await executor.execute_simplified(text.split())
|
||||
else:
|
||||
result = await executor.execute(text)
|
||||
except SyntaxError as e:
|
||||
await utils.answer(
|
||||
m, self.strings("syntax_error").format(error=e, query=text)
|
||||
)
|
||||
return
|
||||
except InvalidChatID as e:
|
||||
await utils.answer(
|
||||
m,
|
||||
self.strings("invalid_chat_id").format(
|
||||
chat_id=e.chat_id, error=e.reason
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
text, stream = self.format_results(text, result, executor.users)
|
||||
|
||||
if stream:
|
||||
await self.client.send_file(
|
||||
message.chat_id,
|
||||
stream,
|
||||
caption=text,
|
||||
reply_to=message.reply_to_msg_id,
|
||||
)
|
||||
await m.delete()
|
||||
else:
|
||||
await utils.answer(m, text)
|
||||
# Finds an intersection between users in different groups
|
||||
# Copyright © 2024 https://t.me/nalinor
|
||||
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
# meta developer: @nalinormods
|
||||
|
||||
# Even taking into account the fact I double-checked every math expression,
|
||||
# you, as a reader, have to remember that I'm a programmer, not a math expert.
|
||||
# Some complicated operations (like symmetric difference on the complement of a set) may go wrong.
|
||||
# I'd be glad to see your pull requests here if you find an error.
|
||||
|
||||
# Reminder: In the code below I use "negated" and "negatable" sets as a definition for "the complement of a set".
|
||||
# It's easier for me to say that a set is "negated" rather than a set is "complement", "completion", "completed", ...
|
||||
|
||||
import ast
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from typing import cast
|
||||
|
||||
from telethon import TelegramClient, errors
|
||||
from telethon.errors import ChatAdminRequiredError
|
||||
from telethon.hints import Entity
|
||||
from telethon.tl import types
|
||||
from telethon.tl.custom import Message
|
||||
from telethon.tl.functions.channels import JoinChannelRequest
|
||||
|
||||
from .. import loader, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InvalidChatID(Exception):
|
||||
def __init__(self, chat_id: "int | str", reason: str):
|
||||
self.chat_id = chat_id
|
||||
self.reason = reason
|
||||
|
||||
def __str__(self):
|
||||
return f"Invalid chat ID {self.chat_id}: {self.reason}"
|
||||
|
||||
|
||||
class NegatableSet(set):
|
||||
"""
|
||||
Set that can be negated.
|
||||
Negated set A is a set of all elements that are not in A (a complement of set A).
|
||||
|
||||
https://en.wikipedia.org/wiki/Complement_(set_theory)
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.negated = False
|
||||
|
||||
def __invert__(self):
|
||||
"""Creates a copy of set and marks it as negated"""
|
||||
return NegatableSet(self).negate()
|
||||
|
||||
def negate(self) -> "NegatableSet":
|
||||
"""Marks that the set is negated in-place"""
|
||||
self.negated = not self.negated
|
||||
return self
|
||||
|
||||
def __and__(self, other: "NegatableSet") -> "NegatableSet":
|
||||
if self.negated and other.negated:
|
||||
return NegatableSet(self.union(other)).negate()
|
||||
|
||||
if other.negated:
|
||||
return NegatableSet(self.difference(other))
|
||||
|
||||
if self.negated:
|
||||
return NegatableSet(other.difference(self))
|
||||
|
||||
return NegatableSet(self.intersection(other))
|
||||
|
||||
def __or__(self, other: "NegatableSet") -> "NegatableSet":
|
||||
if self.negated and other.negated:
|
||||
return NegatableSet(self.intersection(other)).negate()
|
||||
|
||||
if other.negated:
|
||||
return NegatableSet(other.difference(self)).negate()
|
||||
|
||||
if self.negated:
|
||||
return NegatableSet(self.difference(other)).negate()
|
||||
|
||||
return NegatableSet(self.union(other))
|
||||
|
||||
def __sub__(self, other: "NegatableSet") -> "NegatableSet":
|
||||
if self.negated and other.negated:
|
||||
return NegatableSet(self.difference(other))
|
||||
|
||||
if other.negated:
|
||||
return NegatableSet(self.intersection(other))
|
||||
|
||||
if self.negated:
|
||||
return NegatableSet(self.union(other)).negate()
|
||||
|
||||
return NegatableSet(self.difference(other))
|
||||
|
||||
def __xor__(self, other: "NegatableSet") -> "NegatableSet":
|
||||
if self.negated and other.negated:
|
||||
# (A - B) | (B - A)
|
||||
return NegatableSet(self.difference(other).union(other.difference(self)))
|
||||
|
||||
if self.negated or other.negated:
|
||||
# (A & B) | ~(A | B)
|
||||
# => ~((A - B) | (B - A))
|
||||
return NegatableSet(
|
||||
self.difference(other).union(other.difference(self))
|
||||
).negate()
|
||||
|
||||
return NegatableSet(self.symmetric_difference(other))
|
||||
|
||||
def __ior__(self, other):
|
||||
raise NotImplementedError("use __or__ instead")
|
||||
|
||||
def __iand__(self, other):
|
||||
raise NotImplementedError("use __and__ instead")
|
||||
|
||||
|
||||
class QueryExecutor:
|
||||
async def fetch_set(self, key: "int | str") -> NegatableSet:
|
||||
"""Fetches a set to be used in a query"""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
async def execute(self, query: str) -> NegatableSet:
|
||||
"""Executes a query"""
|
||||
|
||||
query = query.replace("&&", "&").replace("||", "|").replace("@", "")
|
||||
|
||||
body = ast.parse(query).body
|
||||
|
||||
if not body:
|
||||
raise SyntaxError("empty body")
|
||||
if len(body) > 1:
|
||||
raise SyntaxError("more than one statement in the body")
|
||||
if not isinstance(body[0], ast.Expr):
|
||||
raise SyntaxError(
|
||||
f"expected expression, {body[0].__class__.__name__} found"
|
||||
)
|
||||
|
||||
expr = cast(ast.Expr, body[0])
|
||||
result = await self.query(expr.value)
|
||||
|
||||
return result
|
||||
|
||||
async def execute_simplified(self, params: "list[str]") -> NegatableSet:
|
||||
first = await self.fetch_set(params[0])
|
||||
for param in params[1:]:
|
||||
first = first & await self.fetch_set(param)
|
||||
return first
|
||||
|
||||
async def query(self, expr: ast.expr) -> NegatableSet:
|
||||
"""Recursively iterates over the expression tree and evaluates it"""
|
||||
if isinstance(expr, ast.Name):
|
||||
return await self.fetch_set(expr.id)
|
||||
|
||||
if isinstance(expr, ast.Constant):
|
||||
if isinstance(expr.value, (str, int)):
|
||||
return await self.fetch_set(expr.value)
|
||||
|
||||
raise SyntaxError(f"invalid constant value: {expr.value}")
|
||||
|
||||
if (
|
||||
isinstance(expr, ast.UnaryOp)
|
||||
and isinstance(expr.op, ast.USub)
|
||||
and isinstance(expr.operand, ast.Constant)
|
||||
):
|
||||
if isinstance(expr.operand.value, int):
|
||||
return await self.fetch_set(-expr.operand.value)
|
||||
|
||||
raise SyntaxError(f"invalid constant value: {expr.operand.value}")
|
||||
|
||||
if isinstance(expr, ast.BoolOp):
|
||||
first = await self.query(expr.values[0])
|
||||
|
||||
for value in expr.values[1:]:
|
||||
if isinstance(expr.op, ast.And):
|
||||
first = first & await self.query(value)
|
||||
else:
|
||||
first = first | await self.query(value)
|
||||
|
||||
return first
|
||||
|
||||
if isinstance(expr, ast.BinOp):
|
||||
if isinstance(expr.op, ast.BitAnd):
|
||||
return await self.query(expr.left) & await self.query(expr.right)
|
||||
|
||||
if isinstance(expr.op, (ast.BitOr, ast.Add)):
|
||||
return await self.query(expr.left) | await self.query(expr.right)
|
||||
|
||||
if isinstance(expr.op, ast.Sub):
|
||||
return await self.query(expr.left) - await self.query(expr.right)
|
||||
|
||||
if isinstance(expr.op, ast.BitXor):
|
||||
return await self.query(expr.left) ^ await self.query(expr.right)
|
||||
|
||||
if isinstance(expr, ast.UnaryOp) and isinstance(
|
||||
expr.op,
|
||||
(
|
||||
ast.Not,
|
||||
ast.Invert,
|
||||
ast.USub,
|
||||
),
|
||||
):
|
||||
return (await self.query(expr.operand)).negate()
|
||||
|
||||
logger.debug("remaining expression: %s", ast.dump(expr))
|
||||
raise SyntaxError(f"operator {expr.__class__.__name__} is not supported")
|
||||
|
||||
|
||||
members_cache: "dict[int | str, tuple[dict[int], float]]" = {}
|
||||
|
||||
|
||||
class UsersQueryExecutor(QueryExecutor):
|
||||
def __init__(self, client: TelegramClient):
|
||||
super().__init__()
|
||||
|
||||
self.users: "dict[int]" = {}
|
||||
self.client = client
|
||||
|
||||
async def fetch_set(self, key: "int | str") -> NegatableSet:
|
||||
if key in ["me", "self"]:
|
||||
me = await self.client.get_me()
|
||||
self.users[me.id] = me
|
||||
|
||||
return NegatableSet([me.id])
|
||||
|
||||
try:
|
||||
key = int(key)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
chat = await self.client.get_entity(await self.client.get_input_entity(key))
|
||||
except (ValueError, errors.BadRequestError) as e:
|
||||
raise InvalidChatID(key, str(e))
|
||||
|
||||
if isinstance(chat, types.User):
|
||||
raise InvalidChatID(key, "chat ID belongs to a user")
|
||||
|
||||
if key in members_cache and members_cache[key][1] > time.perf_counter():
|
||||
logger.debug("Using cached participants for %s", key)
|
||||
members = members_cache[key][0]
|
||||
else:
|
||||
logger.debug("Fetching participants for %s", key)
|
||||
try:
|
||||
members = {
|
||||
member.id: member
|
||||
async for member in self.client.iter_participants(chat.id)
|
||||
}
|
||||
except ChatAdminRequiredError:
|
||||
raise InvalidChatID(
|
||||
key, "insufficient privileges to view users in chat"
|
||||
)
|
||||
|
||||
members_cache[key] = (members, time.perf_counter() + 600)
|
||||
|
||||
self.users.update(members)
|
||||
|
||||
return NegatableSet(members.keys())
|
||||
|
||||
|
||||
def format_user(user: Entity, tags: bool = True) -> str:
|
||||
"""Formats a user to be displayed in the results"""
|
||||
if user.username:
|
||||
link, username = f"https://t.me/{user.username}", f"@{user.username}"
|
||||
elif user.usernames:
|
||||
username = user.usernames[0].username
|
||||
link, username = f"https://t.me/{username}", f"@{username}"
|
||||
else:
|
||||
link, username = f"tg://user?id={user.id}", ""
|
||||
|
||||
name = (
|
||||
f"{user.first_name} {user.last_name}"
|
||||
if user.last_name
|
||||
else user.first_name
|
||||
if user.first_name
|
||||
else "Deleted Account"
|
||||
)
|
||||
|
||||
if tags:
|
||||
name = utils.escape_html(name)
|
||||
return f"<a href='{link}'>{name}</a> (<code>{user.id}</code>)"
|
||||
|
||||
return f"{user.id} {name} {username}"
|
||||
|
||||
|
||||
# noinspection PyCallingNonCallable,PyAttributeOutsideInit
|
||||
# pylint: disable=not-callable,attribute-defined-outside-init,invalid-name
|
||||
@loader.tds
|
||||
class MembersQueryMod(loader.Module):
|
||||
"""Finds an intersection between members of different groups"""
|
||||
|
||||
strings = {
|
||||
"name": "MembersQuery",
|
||||
"author": "@nalinormods",
|
||||
"usage": """
|
||||
📝 <b>MembersQuery module syntax</b>
|
||||
|
||||
A brief of Python syntax is used for queries.
|
||||
|
||||
Specify groups as a username (with or without @) or chat ID.
|
||||
Channels are also accepted in case you are an admin. You can't fetch more than 200 members from a channel, so results may be incomplete.
|
||||
Specify yourself as <code>me</code> or <code>self</code>.
|
||||
|
||||
Each group is represented by a set of its members (see set theory). You can use these operations:
|
||||
<code>&</code>, <code>and</code> — intersection (members that are in both groups <b>at same time</b>)
|
||||
<code>|</code>, <code>or</code>, <code>+</code> — union (members of A, B or both groups)
|
||||
<code>-</code> — difference (members of A group that are not in B group)
|
||||
<code>^</code> — symmetric difference (members of A or B group, but not both)
|
||||
<code>~</code>, <code>not</code>, unary <code>-</code> — negation (specifies a group that anybody joined except members of the group)
|
||||
|
||||
<b>Examples</b>:
|
||||
<code>@mymusicgroup and @mychessgroup</code> — members of both groups at same time
|
||||
<code>@nalinormods & ~@nalinormodschat</code> — subscribers of a channel that didn't join a group yet
|
||||
<code>hikka_ub | hikka_talks | hikka_offtop</code> — members of any of these groups
|
||||
<code>-1001234567890 - me</code> — members of a private group except yourself
|
||||
|
||||
ℹ️ In order to increase performance, the module caches the list of members for 10 minutes. Reload the module or restart the userbot to clear the cache.
|
||||
""",
|
||||
"no_args": "❌ <b>Specify at least one group</b>",
|
||||
"syntax_error": (
|
||||
"❌ <b>You have an syntax error in query"
|
||||
" <code>{query}</code>:</b>\n<code>{error}</code>"
|
||||
),
|
||||
"invalid_chat_id": "❌ <b>Invalid chat ID {chat_id}:</b>\n<code>{error}</code>",
|
||||
"running": "🕑 <b>Executing query <code>{query}</code>...</b>",
|
||||
"no_results": "🚫 <b>No results found</b> for query <code>{query}</code>",
|
||||
"results": "🔍 <b>{n} users found</b> for query <code>{query}</code>",
|
||||
"results_file": "📤 <b>The list is too long, so it's sent in file.</b>",
|
||||
"result_is_negated": (
|
||||
"⚠️ <b>The final set is negated, so result may be incomplete. "
|
||||
"Rewrite your query to get accurate results</b>"
|
||||
),
|
||||
}
|
||||
|
||||
strings_ru = {
|
||||
"_cls_doc": (
|
||||
"Поиск пересечения групп на предмет наличия одних и тех же пользователей"
|
||||
),
|
||||
"_cmd_doc_mjoin": (
|
||||
"<юзернейм/ID группы> ... — Найти пользователей, которые находятся во всех"
|
||||
" заданных группах одновременно"
|
||||
),
|
||||
"_cmd_doc_mquery": (
|
||||
"<запрос?> — Найти пользователей из групп по заданному запросу. Вызови без"
|
||||
" аргументов для получения справки для справки."
|
||||
),
|
||||
"usage": """
|
||||
📝 <b>Синтаксис модуля MembersQuery</b>
|
||||
|
||||
Для запросов используется часть синтаксиса Python.
|
||||
|
||||
Чтобы указать группы, используй их юзернейм (с или без @) или ID чата.
|
||||
Также можно указать канал, в котором ты являешься админом. В канале есть лимит на получение макс. 200 пользователей, поэтому результаты могут быть неполными.
|
||||
Чтобы указать себя, используй <code>me</code> или <code>self</code>.
|
||||
|
||||
Каждая группа представлена в виде множества её участников (см. теоримю множеств). Доступны следующие операции:
|
||||
<code>&</code>, <code>and</code> — пересечение (участники и первой, и второй группы <b>одновременно</b>)
|
||||
<code>|</code>, <code>or</code>, <code>+</code> — объединение (участники первой, второй или обеих групп)
|
||||
<code>-</code> — разность (участники первой группы, которые не находятся во второй)
|
||||
<code>^</code> — симметрическая разность (участники первой или второй группы, но не обеих)
|
||||
<code>~</code>, <code>not</code>, <code>-</code> — отрицание (обозначает условную группу, в котором находятся все, кроме участников группы)
|
||||
|
||||
<b>Примеры использования</b>:
|
||||
<code>@mymusicgroup and @mychessgroup</code> — участники обеих групп одновременно
|
||||
<code>@nalinormods & ~@nalinormodschat</code> — подписчики канала, которые ещё не вступили в группу
|
||||
<code>hikka_ub | hikka_talks | hikka_offtop</code> — участники любой из этих групп
|
||||
<code>-1001234567890 - me</code> — участники приватной группы, кроме тебя
|
||||
|
||||
ℹ️ В целях производительности, модуль кэширует список участников на 10 минут. Перезагрузите модуль или весь юзербот, чтобы очистить кэш.
|
||||
""",
|
||||
"no_args": "❌ <b>Укажите хотя бы одну группу</b>",
|
||||
"syntax_error": (
|
||||
"❌ <b>В запросе <code>{query}</code> есть синтаксическая"
|
||||
" ошибка:</b>\n<code>{error}</code>"
|
||||
),
|
||||
"invalid_chat_id": (
|
||||
"❌ <b>Неверный ID/юзернейм чата {chat_id}:</b>\n<code>{error}</code>"
|
||||
),
|
||||
"running": "🕑 <b>Запрос <code>{query}</code> выполняется...</b>",
|
||||
"no_results": "🚫 <b>Результаты не найдены</b> по запросу <code>{query}</code>",
|
||||
"results": (
|
||||
"🔍 <b>Пользователей найдено: {n}</b> по запросу <code>{query}</code>"
|
||||
),
|
||||
"results_file": (
|
||||
"📤 <b>Полученный список слишком большой, поэтому он отправлен в файле.</b>"
|
||||
),
|
||||
"result_is_negated": (
|
||||
"⚠️ <b>Результат получен из отрицательного множества, поэтому он может быть"
|
||||
" неполным. Исправь запрос, чтобы получить точный результат</b>"
|
||||
),
|
||||
}
|
||||
|
||||
async def client_ready(self, client: TelegramClient, _):
|
||||
"""client_ready hook"""
|
||||
self.client = client
|
||||
|
||||
await client(JoinChannelRequest(channel=self.strings("author")))
|
||||
|
||||
def format_results(
|
||||
self, query: str, results: NegatableSet, users: dict
|
||||
) -> (str, "io.BytesIO | None"):
|
||||
"""Formats results to be displayed in the message"""
|
||||
negated = results.negated
|
||||
if negated:
|
||||
results = set(users.keys()).difference(results)
|
||||
|
||||
if not results:
|
||||
return self.strings("no_results").format(query=query), None
|
||||
|
||||
text = self.strings("results").format(query=query, n=len(results)) + "\n\n"
|
||||
|
||||
use_file = len(results) > 30
|
||||
formatted_results = (
|
||||
format_user(users[user_id], tags=not use_file) for user_id in results
|
||||
)
|
||||
|
||||
if use_file:
|
||||
text += self.strings("results_file") + "\n\n"
|
||||
|
||||
stream = io.BytesIO()
|
||||
stream.write("\n".join(formatted_results).encode("utf-8"))
|
||||
stream.seek(0)
|
||||
stream.name = "result.txt"
|
||||
else:
|
||||
text += "\n".join(formatted_results) + "\n\n"
|
||||
stream = None
|
||||
|
||||
if negated:
|
||||
text += self.strings("result_is_negated")
|
||||
|
||||
return text, stream
|
||||
|
||||
async def mjoincmd(self, message: Message):
|
||||
"""<username/chat ID> ... — Find users that are in all given chats at same time"""
|
||||
text = utils.get_args_raw(message)
|
||||
if not text:
|
||||
return await utils.answer(message, self.strings("no_args"))
|
||||
|
||||
await self.mquerycmd(message, simplified=True)
|
||||
|
||||
async def mquerycmd(self, message: Message, simplified: bool = False):
|
||||
"""<query?> — Find users from given chats that match the query. Call without args for help."""
|
||||
text = utils.get_args_raw(message)
|
||||
if not text:
|
||||
return await utils.answer(message, self.strings("usage"))
|
||||
|
||||
m = await utils.answer(message, self.strings("running").format(query=text))
|
||||
if isinstance(m, list):
|
||||
m = m[0]
|
||||
|
||||
executor = UsersQueryExecutor(self.client)
|
||||
|
||||
try:
|
||||
if simplified:
|
||||
result = await executor.execute_simplified(text.split())
|
||||
else:
|
||||
result = await executor.execute(text)
|
||||
except SyntaxError as e:
|
||||
await utils.answer(
|
||||
m, self.strings("syntax_error").format(error=e, query=text)
|
||||
)
|
||||
return
|
||||
except InvalidChatID as e:
|
||||
await utils.answer(
|
||||
m,
|
||||
self.strings("invalid_chat_id").format(
|
||||
chat_id=e.chat_id, error=e.reason
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
text, stream = self.format_results(text, result, executor.users)
|
||||
|
||||
if stream:
|
||||
await self.client.send_file(
|
||||
message.chat_id,
|
||||
stream,
|
||||
caption=text,
|
||||
reply_to=message.reply_to_msg_id,
|
||||
)
|
||||
await m.delete()
|
||||
else:
|
||||
await utils.answer(m, text)
|
||||
|
||||
Reference in New Issue
Block a user