From 905cb39080cf226327b64d1aaccdc39382ddae04 Mon Sep 17 00:00:00 2001 From: Herbstblatt <48344463+Herbstblatt@users.noreply.github.com> Date: Fri, 29 Jul 2022 18:54:53 +0300 Subject: [PATCH 1/5] Initial verification setup --- cogs/verification/__init__.py | 7 ++ cogs/verification/cog.py | 136 ++++++++++++++++++++++++++++++++++ cogs/verification/errors.py | 21 ++++++ cogs/verification/models.py | 53 +++++++++++++ config-default.yml | 1 + utils/converters.py | 4 + 6 files changed, 222 insertions(+) create mode 100644 cogs/verification/__init__.py create mode 100644 cogs/verification/cog.py create mode 100644 cogs/verification/errors.py create mode 100644 cogs/verification/models.py diff --git a/cogs/verification/__init__.py b/cogs/verification/__init__.py new file mode 100644 index 0000000..9ab9526 --- /dev/null +++ b/cogs/verification/__init__.py @@ -0,0 +1,7 @@ +from .cog import Verification +from bot import Bot + +__all__ = ("setup",) + +async def setup(bot: Bot) -> None: + await bot.add_cog(Verification(bot)) \ No newline at end of file diff --git a/cogs/verification/cog.py b/cogs/verification/cog.py new file mode 100644 index 0000000..4040fc8 --- /dev/null +++ b/cogs/verification/cog.py @@ -0,0 +1,136 @@ +from typing import TYPE_CHECKING, Optional +import discord +from discord.ext import commands + +from .errors import AlreadyVerified, OwnershipNotProved, RequirementsNotSatsified +from .models import Account, RequirementsCheckResult, RequirementsCheckResultStatus, User +from config import config +from utils.converters import AccountConverter + +if TYPE_CHECKING: + from bot import Bot + from utils.context import WhiteContext + +class Verification(commands.Cog): + def __init__(self, bot: "Bot"): + self.bot = bot + + async def fetch_user_from_db( + self, + fandom_name: Optional[str] = None, + discord_id: Optional[int] = None, + guild_id: Optional[int] = None, + trusted_only: bool = True + ) -> Optional["User"]: + """Fetches fandom-discord bindings from the database and returns Account object with that data. + + Arguments: + fandom_name (Optional[str]): Account name on Fandom + discord_id (Optional[int]): Account id on Discord + guild_id (Optional[int]): A guild id to fetch the binding for + trusted_only (bool): Whether to fetch only trusted bindings + + Returns: + An Account object or None when no bindings satsify the given parameters. + """ + pass + + async def is_verified(self, ctx: "WhiteContext", member: discord.Member) -> bool: + """Checks whether the given account is verified in the given context. + + Arguments: + ctx (WhiteContext): The context to check in + member (discord.Member): The member to check + + Returns: + True if the account is verified, False otherwise. + """ + pass + + async def check_requirements(self, ctx: "WhiteContext", user: User) -> RequirementsCheckResult: + """Checks whether the user satsifies verification requirements in the given context. + + Arguments: + ctx (WhiteContext): The context to check requirements in + user (User): The user to check requirements for + + Returns: + ReqiurementsCheckResult object. + """ + + async def verify(self, ctx: "WhiteContext", member: discord.Member, account: Account): + """Verifies the user in the given context under the given account. + + This method performs real actions, like giving roles and changing nickname. + It does not perform any checks or create bindings. It's up to the caller to do so. + + Arguments: + ctx (WhiteContext): The context + member (discord.Member): The member to verify + account (Account): The account that will be used for verification + + Returns: + None + + Raises: + MissingPermissions: The bot doesn't have permissions to verify that user. + """ + + @commands.hybrid_command(name="verify") + @discord.app_commands.guild_only() + @commands.guild_only() + async def verify_command(self, ctx: "WhiteContext", *, account: Account = commands.param(converter=AccountConverter)): + """Верифицирует Вас на сервере. + + Аргументы: + account: Имя Вашего аккаунта на Фэндоме + """ + await ctx.defer() + + # a few asserts to make type checker happy + assert isinstance(ctx.author, discord.Member) + assert ctx.guild is not None + + if await self.is_verified(ctx, ctx.author): + raise AlreadyVerified() + + user = await self.fetch_user_from_db(discord_id=ctx.author.id, trusted_only=True) + if user is None or account not in user.fandom_accounts: + if account.discord_tag != str(ctx.author): + raise OwnershipNotProved() + + if user is None: + user = User(_bot=self.bot) + await user.add_account( + fandom_account=account.name, + discord_id=ctx.author.id, + guild_id=ctx.guild.id, + trusted=True + ) + + req_check_result = await self.check_requirements(ctx, user) + if req_check_result.status is RequirementsCheckResultStatus.failed: + raise RequirementsNotSatsified(req_check_result) + + await self.verify(ctx, ctx.author, account) + + em = discord.Embed( + description=f"Вы были успешно верифицированы на сервере {ctx.guild.name}.", + color=discord.Color.green(), + timestamp=discord.utils.utcnow() + ) + em.set_author( + name=account.name, + url=account.page_url, + icon_url=account.avatar_url + ) + em.add_field( + name="Аккаунт на Фэндоме", + value=f"{config.emojis.success} Владение подтверждено", + ) + em.add_field( + name="Цензы", + value=f"{config.emojis.success} Все цензы пройдены" + ) + await ctx.send(embed=em) + diff --git a/cogs/verification/errors.py b/cogs/verification/errors.py new file mode 100644 index 0000000..c2516db --- /dev/null +++ b/cogs/verification/errors.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from utils.errors import WhiteException + +class VerificationError(WhiteException): + pass + +class AlreadyVerified(VerificationError): + pass + +class OwnershipNotProved(VerificationError): + pass + +class RequirementsNotSatsified(VerificationError): + pass + +@dataclass +class MissingPermissions(VerificationError): + add_role: bool + remove_role: bool + change_nickname: bool + create_role: bool \ No newline at end of file diff --git a/cogs/verification/models.py b/cogs/verification/models.py new file mode 100644 index 0000000..a84452f --- /dev/null +++ b/cogs/verification/models.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass, field +import enum +from typing import TYPE_CHECKING, List, Optional + +import discord + +if TYPE_CHECKING: + from bot import Bot + from utils.wiki import Wiki + +@dataclass +class Account: + name: str + id: Optional[int] + discord_tag: str + wiki: "Wiki" + + @property + def page_url(self): + return self.wiki.url_to("User:" + self.name) + + @property + def avatar_url(self): + return f"https://services.fandom.com/user-avatar/user/{self.id}/avatar" + + +@dataclass +class User: + """Represents an user. + + The user is a person that can have multiple accounts both on Fandom an Discord. + """ + _bot: "Bot" + fandom_accounts: List[Account] = field(default_factory=list) + discord_accounts: List[discord.abc.Snowflake] = field(default_factory=list) + + async def add_account( + self, + *, + guild_id: int, + fandom_account: Optional[str] = None, + discord_id: Optional[int] = None, + trusted: bool = True + ): + pass + +class RequirementsCheckResultStatus(enum.Enum): + ok = True + failed = False + +@dataclass +class RequirementsCheckResult: + status: RequirementsCheckResultStatus diff --git a/config-default.yml b/config-default.yml index 34066d6..3f20dec 100644 --- a/config-default.yml +++ b/config-default.yml @@ -17,6 +17,7 @@ cogs: # - cogs.help - cogs.info - cogs.fandom + - cogs.verification - cogs.wikilinks emojis: diff --git a/utils/converters.py b/utils/converters.py index e0c7a0a..faa452b 100644 --- a/utils/converters.py +++ b/utils/converters.py @@ -88,3 +88,7 @@ async def convert(cls, ctx, argument): @classmethod async def transform(cls, interaction: discord.Interaction, value: str) -> str: return value + + +class AccountConverter(app_commands.Transformer): + pass \ No newline at end of file From 5330834c3267dac9e4d4fbf0bd2305a1c4ee1448 Mon Sep 17 00:00:00 2001 From: Herbstblatt <48344463+Herbstblatt@users.noreply.github.com> Date: Sat, 30 Jul 2022 10:46:54 +0300 Subject: [PATCH 2/5] Create a table schema for storing bindings --- .../20220730054441_create_users_table.sql | 14 +++++++++++ db/schema.sql | 23 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 db/migrations/20220730054441_create_users_table.sql diff --git a/db/migrations/20220730054441_create_users_table.sql b/db/migrations/20220730054441_create_users_table.sql new file mode 100644 index 0000000..11e482f --- /dev/null +++ b/db/migrations/20220730054441_create_users_table.sql @@ -0,0 +1,14 @@ +-- migrate:up +CREATE TABLE users ( + fandom_name text, + discord_id bigint, + guild_id bigint, + trusted boolean, + active boolean +); + +CREATE INDEX ON users (fandom_name, discord_id, guild_id); + +-- migrate:down +DROP TABLE users; + diff --git a/db/schema.sql b/db/schema.sql index 10c877b..0f7fade 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -22,6 +22,19 @@ CREATE TABLE public.schema_migrations ( ); +-- +-- Name: users; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.users ( + fandom_name text, + discord_id bigint, + guild_id bigint, + trusted boolean, + active boolean +); + + -- -- Name: wh_guilds; Type: TABLE; Schema: public; Owner: - -- @@ -68,6 +81,13 @@ ALTER TABLE ONLY public.wh_wikilink_webhooks ADD CONSTRAINT wh_wikilink_webhooks_pkey PRIMARY KEY (channel_id); +-- +-- Name: users_fandom_name_discord_id_guild_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_fandom_name_discord_id_guild_id_idx ON public.users USING btree (fandom_name, discord_id, guild_id); + + -- -- PostgreSQL database dump complete -- @@ -81,4 +101,5 @@ INSERT INTO public.schema_migrations (version) VALUES ('20210519201710'), ('20211006194734'), ('20211222113737'), - ('20211226164418'); + ('20211226164418'), + ('20220730054441'); From 9b9acf05a2d1a47a047411add696389f830808b0 Mon Sep 17 00:00:00 2001 From: Herbstblatt <48344463+Herbstblatt@users.noreply.github.com> Date: Sat, 30 Jul 2022 12:55:07 +0300 Subject: [PATCH 3/5] Create verification strings --- resources/strings.yml | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 resources/strings.yml diff --git a/resources/strings.yml b/resources/strings.yml new file mode 100644 index 0000000..61c7ac6 --- /dev/null +++ b/resources/strings.yml @@ -0,0 +1,39 @@ +verification: + verification_successful: Вы были успешно верифицированы на сервере {server_name} + account_header: Аккаунт на Фэндоме + ownership_confirmed: Владение подтверждено + requirements_header: Цензы + requirements_satsified: Все цензы пройдены + + how_to_fix: Как исправить проблему? + account_does_not_exist_message: К сожалению, верификация не удалась, поскольку аккаунта **{account}** не существует. + account_does_not_exist_instructions: Проверьте написание никнейма. Возможно, вы опечатались. + + ownership_not_confirmed_message: > + К сожалению, верификация не удалась, поскольку нет подтверждения. что аккаунт **{account}** действительно принадлежит Вам. + ownership_not_confirmed_instructions: > + Нажмите на кнопку ниже, чтобы указать Ваш ник в Discord в Вашем профиле на Фэндоме. После этого повторите попытку. + Обратите внимание, что на обновление кэша может уйти некоторое время, поэтому если вы установили никнейм, а бот не видит его, подождите некоторое время. + Вы можете также установить никнейм вручную. + add_tag_button: Проставить тег в профиль + + requirements_not_satsified_message: > + К сожалению, верификация не удалась, поскольку Вы не прошли по цензам, установленным администрацией сервера. Ниже указаны пункты, которым не соответствует Ваш аккаунт. + requirement_descriptions: + edits: Иметь более **{edits}** правок на вики + registration: Зарегистрироваться на Фэндоме более **{days}** дней назад + joining: Присоединиться к вики более **{days}** дней назад + local_block: Не иметь локальной блокировки + local_block_days: Не иметь локальной блокировки длительностью более {days} {plural(days, 'дня', 'дней')} + global_block: Не иметь глобальной блокировки + requirement_messages: + edits: Сейчас у вас *{edits}* правок + registration: Вы зарегистрированы *{days}* дней назад + joining: Вы присоединились *{days}* дней назад + local_block: Ваш аккаунт локально заблокирован до {time} + local_block_on_other_account: > + Один из Ваших аккаунтов локально заблокирован. В целях сохранения приватности имя аккаунта скрыто. Вы можете посмотреть статус своих аккаунтов командой `/accounts list`. + global_block: Ваш аккаунт глобально заблокирован + global_block_on_other_account: > + Один из Ваших аккаунтов локально заблокирован. В целях сохранения приватности имя аккаунта скрыто. Вы можете посмотреть статус своих аккаунтов командой `/accounts list`. + hidden_requirements: {len} {plural(len, 'пройденный', 'пройденных')} {plural(len, 'ценз скрыт', 'ценза скрыто', 'цензов скрыто')} From f1c6a464a13f6ad2b5db9a4a794482fee6fc89b4 Mon Sep 17 00:00:00 2001 From: Herbstblatt <48344463+Herbstblatt@users.noreply.github.com> Date: Sat, 30 Jul 2022 13:03:51 +0300 Subject: [PATCH 4/5] Use strings from yaml file --- cogs/verification/cog.py | 15 +++++++++------ config.py | 3 +++ resources/strings.yml | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/cogs/verification/cog.py b/cogs/verification/cog.py index 4040fc8..ff7b866 100644 --- a/cogs/verification/cog.py +++ b/cogs/verification/cog.py @@ -1,16 +1,19 @@ +import string from typing import TYPE_CHECKING, Optional import discord from discord.ext import commands from .errors import AlreadyVerified, OwnershipNotProved, RequirementsNotSatsified from .models import Account, RequirementsCheckResult, RequirementsCheckResultStatus, User -from config import config +from config import config, strings as _strings from utils.converters import AccountConverter if TYPE_CHECKING: from bot import Bot from utils.context import WhiteContext +strings = _strings.verification + class Verification(commands.Cog): def __init__(self, bot: "Bot"): self.bot = bot @@ -115,7 +118,7 @@ async def verify_command(self, ctx: "WhiteContext", *, account: Account = comman await self.verify(ctx, ctx.author, account) em = discord.Embed( - description=f"Вы были успешно верифицированы на сервере {ctx.guild.name}.", + description=strings.verification_successful.format(guild=ctx.guild.name), color=discord.Color.green(), timestamp=discord.utils.utcnow() ) @@ -125,12 +128,12 @@ async def verify_command(self, ctx: "WhiteContext", *, account: Account = comman icon_url=account.avatar_url ) em.add_field( - name="Аккаунт на Фэндоме", - value=f"{config.emojis.success} Владение подтверждено", + name=strings.account_header, + value=f"{config.emojis.success} {strings.ownership_confirmed}", ) em.add_field( - name="Цензы", - value=f"{config.emojis.success} Все цензы пройдены" + name=strings.requirements_header, + value=f"{config.emojis.success} {strings.requirements_satsified}" ) await ctx.send(embed=em) diff --git a/config.py b/config.py index ba58a81..1934899 100644 --- a/config.py +++ b/config.py @@ -57,3 +57,6 @@ class BotEmojis: if Path("config.yml").exists(): with open("config.yml") as f: config.update(yaml.safe_load(f)) + +with open("resourses/strings.yml") as f: + strings = _BotConfigImpl(yaml.safe_load(f)) diff --git a/resources/strings.yml b/resources/strings.yml index 61c7ac6..9398dd0 100644 --- a/resources/strings.yml +++ b/resources/strings.yml @@ -1,5 +1,5 @@ verification: - verification_successful: Вы были успешно верифицированы на сервере {server_name} + verification_successful: Вы были успешно верифицированы на сервере {guild} account_header: Аккаунт на Фэндоме ownership_confirmed: Владение подтверждено requirements_header: Цензы From 4c8f30b5be41facec82df9f9ddbeadc9b1a3688d Mon Sep 17 00:00:00 2001 From: Herbstblatt <48344463+Herbstblatt@users.noreply.github.com> Date: Tue, 30 Aug 2022 19:21:04 +0300 Subject: [PATCH 5/5] Implement querying users from db --- cogs/verification/cog.py | 47 ++++++++++++++++++++++++++++++++----- cogs/verification/models.py | 29 +++++++++++++++++++---- config.py | 2 +- resources/strings.yml | 33 +++++++++++++++++--------- 4 files changed, 88 insertions(+), 23 deletions(-) diff --git a/cogs/verification/cog.py b/cogs/verification/cog.py index ff7b866..a242c4c 100644 --- a/cogs/verification/cog.py +++ b/cogs/verification/cog.py @@ -1,10 +1,11 @@ -import string from typing import TYPE_CHECKING, Optional import discord from discord.ext import commands +from utils.wiki import Wiki + from .errors import AlreadyVerified, OwnershipNotProved, RequirementsNotSatsified -from .models import Account, RequirementsCheckResult, RequirementsCheckResultStatus, User +from .models import Account, Binding, RequirementsCheckResult, RequirementsCheckResultStatus, User from config import config, strings as _strings from utils.converters import AccountConverter @@ -22,7 +23,6 @@ async def fetch_user_from_db( self, fandom_name: Optional[str] = None, discord_id: Optional[int] = None, - guild_id: Optional[int] = None, trusted_only: bool = True ) -> Optional["User"]: """Fetches fandom-discord bindings from the database and returns Account object with that data. @@ -30,13 +30,48 @@ async def fetch_user_from_db( Arguments: fandom_name (Optional[str]): Account name on Fandom discord_id (Optional[int]): Account id on Discord - guild_id (Optional[int]): A guild id to fetch the binding for trusted_only (bool): Whether to fetch only trusted bindings Returns: - An Account object or None when no bindings satsify the given parameters. + An User object or None when no bindings satsify the given parameters. """ - pass + conditions = [] + args = [] + if fandom_name is not None: + conditions.append(f"fandom_name = ${len(conditions) + 1}") + args.append(fandom_name) + if discord_id is not None: + conditions.append(f"discord_id = ${len(conditions) + 1}") + args.append(discord_id) + if trusted_only: + conditions.append(f"trusted = true") + + async with self.bot.pool.acquire() as conn: + results = await conn.fetch(f""" + WITH RECURSIVE tmp AS ( + SELECT * + FROM users + WHERE {" AND ".join(conditions)} + UNION + SELECT users.fandom_name, users.discord_id, users.guild_id, users.trusted, users.active + FROM users + JOIN tmp ON users.fandom_name = tmp.fandom_name or users.discord_id = tmp.discord_id + {"WHERE users.trusted = true" if trusted_only else ""} + ) SELECT * FROM tmp; + """, *args) + + if len(results) == 0: + return None + + bindings = [ + Binding( + fandom_account=Account(name=result["fandom_name"], wiki=Wiki.from_dot_notation("ru.c")), + discord_account=discord.Object(id=result["discord_id"]), + trusted=result["trusted"], + active=result["active"] + ) for result in results + ] + return User(_bot=self.bot, bindings=bindings) async def is_verified(self, ctx: "WhiteContext", member: discord.Member) -> bool: """Checks whether the given account is verified in the given context. diff --git a/cogs/verification/models.py b/cogs/verification/models.py index a84452f..461c3db 100644 --- a/cogs/verification/models.py +++ b/cogs/verification/models.py @@ -11,9 +11,9 @@ @dataclass class Account: name: str - id: Optional[int] - discord_tag: str wiki: "Wiki" + id: Optional[int] = None + discord_tag: Optional[str] = None @property def page_url(self): @@ -22,7 +22,19 @@ def page_url(self): @property def avatar_url(self): return f"https://services.fandom.com/user-avatar/user/{self.id}/avatar" + + def __hash__(self) -> int: + return hash(self.name) + + def __eq__(self, other: "Account") -> bool: + return self.name == other.name +@dataclass +class Binding: + fandom_account: Account + discord_account: discord.abc.Snowflake + trusted: bool + active: bool @dataclass class User: @@ -31,9 +43,8 @@ class User: The user is a person that can have multiple accounts both on Fandom an Discord. """ _bot: "Bot" - fandom_accounts: List[Account] = field(default_factory=list) - discord_accounts: List[discord.abc.Snowflake] = field(default_factory=list) - + bindings: List[Binding] = field(default_factory=list) + async def add_account( self, *, @@ -44,6 +55,14 @@ async def add_account( ): pass + @property + def fandom_accounts(self) -> List[Account]: + return list(set(b.fandom_account for b in self.bindings)) + + @property + def discord_accounts(self) -> List[discord.abc.Snowflake]: + return list(set(b.discord_account for b in self.bindings)) + class RequirementsCheckResultStatus(enum.Enum): ok = True failed = False diff --git a/config.py b/config.py index 1934899..0cbe1c5 100644 --- a/config.py +++ b/config.py @@ -58,5 +58,5 @@ class BotEmojis: with open("config.yml") as f: config.update(yaml.safe_load(f)) -with open("resourses/strings.yml") as f: +with open("resources/strings.yml") as f: strings = _BotConfigImpl(yaml.safe_load(f)) diff --git a/resources/strings.yml b/resources/strings.yml index 9398dd0..3edd0c2 100644 --- a/resources/strings.yml +++ b/resources/strings.yml @@ -1,12 +1,14 @@ verification: - verification_successful: Вы были успешно верифицированы на сервере {guild} + verification_successful: | + Вы были успешно верифицированы на сервере {guild} account_header: Аккаунт на Фэндоме ownership_confirmed: Владение подтверждено requirements_header: Цензы requirements_satsified: Все цензы пройдены how_to_fix: Как исправить проблему? - account_does_not_exist_message: К сожалению, верификация не удалась, поскольку аккаунта **{account}** не существует. + account_does_not_exist_message: | + К сожалению, верификация не удалась, поскольку аккаунта **{account}** не существует. account_does_not_exist_instructions: Проверьте написание никнейма. Возможно, вы опечатались. ownership_not_confirmed_message: > @@ -20,20 +22,29 @@ verification: requirements_not_satsified_message: > К сожалению, верификация не удалась, поскольку Вы не прошли по цензам, установленным администрацией сервера. Ниже указаны пункты, которым не соответствует Ваш аккаунт. requirement_descriptions: - edits: Иметь более **{edits}** правок на вики - registration: Зарегистрироваться на Фэндоме более **{days}** дней назад - joining: Присоединиться к вики более **{days}** дней назад + edits: | + Иметь более **{edits}** правок на вики + registration: | + Зарегистрироваться на Фэндоме более **{days}** дней назад + joining: | + Присоединиться к вики более **{days}** дней назад local_block: Не иметь локальной блокировки - local_block_days: Не иметь локальной блокировки длительностью более {days} {plural(days, 'дня', 'дней')} + local_block_days: | + Не иметь локальной блокировки длительностью более {days} {plural(days, 'дня', 'дней')} global_block: Не иметь глобальной блокировки requirement_messages: - edits: Сейчас у вас *{edits}* правок - registration: Вы зарегистрированы *{days}* дней назад - joining: Вы присоединились *{days}* дней назад - local_block: Ваш аккаунт локально заблокирован до {time} + edits: | + Сейчас у вас *{edits}* правок + registration: | + Вы зарегистрированы *{days}* дней назад + joining: | + Вы присоединились *{days}* дней назад + local_block: | + Ваш аккаунт локально заблокирован до {time} local_block_on_other_account: > Один из Ваших аккаунтов локально заблокирован. В целях сохранения приватности имя аккаунта скрыто. Вы можете посмотреть статус своих аккаунтов командой `/accounts list`. global_block: Ваш аккаунт глобально заблокирован global_block_on_other_account: > Один из Ваших аккаунтов локально заблокирован. В целях сохранения приватности имя аккаунта скрыто. Вы можете посмотреть статус своих аккаунтов командой `/accounts list`. - hidden_requirements: {len} {plural(len, 'пройденный', 'пройденных')} {plural(len, 'ценз скрыт', 'ценза скрыто', 'цензов скрыто')} + hidden_requirements: | + {len} {plural(len, 'пройденный', 'пройденных')} {plural(len, 'ценз скрыт', 'ценза скрыто', 'цензов скрыто')}