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..a242c4c --- /dev/null +++ b/cogs/verification/cog.py @@ -0,0 +1,174 @@ +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, Binding, RequirementsCheckResult, RequirementsCheckResultStatus, User +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 + + async def fetch_user_from_db( + self, + fandom_name: Optional[str] = None, + discord_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 + trusted_only (bool): Whether to fetch only trusted bindings + + Returns: + An User object or None when no bindings satsify the given parameters. + """ + 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. + + 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=strings.verification_successful.format(guild=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=strings.account_header, + value=f"{config.emojis.success} {strings.ownership_confirmed}", + ) + em.add_field( + name=strings.requirements_header, + value=f"{config.emojis.success} {strings.requirements_satsified}" + ) + 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..461c3db --- /dev/null +++ b/cogs/verification/models.py @@ -0,0 +1,72 @@ +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 + wiki: "Wiki" + id: Optional[int] = None + discord_tag: Optional[str] = None + + @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" + + 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: + """Represents an user. + + The user is a person that can have multiple accounts both on Fandom an Discord. + """ + _bot: "Bot" + bindings: List[Binding] = 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 + + @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 + +@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/config.py b/config.py index ba58a81..0cbe1c5 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("resources/strings.yml") as f: + strings = _BotConfigImpl(yaml.safe_load(f)) 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'); diff --git a/resources/strings.yml b/resources/strings.yml new file mode 100644 index 0000000..3edd0c2 --- /dev/null +++ b/resources/strings.yml @@ -0,0 +1,50 @@ +verification: + verification_successful: | + Вы были успешно верифицированы на сервере {guild} + 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, 'ценз скрыт', 'ценза скрыто', 'цензов скрыто')} 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