class Context(EmbedContext):
    bot: Erasmus

    async def send_error(self, text: str, /) -> discord.Message:
        return await self.send_embed(text, color=discord.Color.red())

    async def send_passage(self, passage: Passage, /) -> discord.Message:
        text = passage.text

        if len(text) > 2048:
            text = f'{_truncation_warning}{text[:_max_length]}\u2026'

        embed = discord.Embed.from_dict(
            {'description': text, 'footer': {'text': passage.citation}}
        )

        return await self.send(embed=embed) class Passage(object):
    text: str
    range: VerseRange
    version: str | None = None

    @property
    def citation(self, /) -> str:
        if self.version is not None:
            return f'{self.range} ({self.version})'
        else:
            return str(self.range)

    def get_truncated(self, limit: int, /) -> str:
        citation = self.citation
        end = limit - (len(citation) + _truncation_warning_len)
        text = self.text[:end]
        return f'{_truncation_warning}{text}\u2026\n\n{citation}'

    def __str__(self, /) -> str:
        return f'{self.text}\n\n{self.citation}' class SearchResults(object):
    verses: list[Passage]
    total: int class VerseRange(object):
    book: str
    start: Verse
    end: Verse | None = None
    version: str | None = None
    book_mask: int = attrib(init=False)

    def __attrs_post_init__(self, /) -> None:
        self.book = get_book(self.book)
        self.book_mask = get_book_mask(self.book)

    @property
    def verses(self, /) -> str:
        verse = str(self.start)

        if self.end is not None:
            if self.end.chapter == self.start.chapter:
                verse += f'-{self.end.verse}'
            else:
                verse += f'-{self.end}'

        return verse

    def __str__(self, /) -> str:
        return f'{self.book} {self.verses}'

    @classmethod
    def from_string(cls, verse: str, /) -> VerseRange:
        if (match := _search_reference_re.match(verse)) is None:
            raise ReferenceNotUnderstoodError(verse)

        return cls.from_match(match)

    @classmethod
    def from_match(cls, match: Match[str], /) -> VerseRange:
        groups = match.groupdict()

        chapter_start_int = int(groups['chapter_start'])
        start = Verse(chapter_start_int, int(groups['verse_start']))

        end: Verse | None = None
        end_str = groups['verse_end']

        if end_str is not None:
            end_int = int(end_str)
            chapter_end_int = chapter_start_int

            chapter_end_str = groups['chapter_end']
            if chapter_end_str is not None:
                chapter_end_int = int(chapter_end_str)

            end = Verse(chapter_end_int, end_int)

        version: str | None = None
        if 'version' in groups:
            version = groups['version']

        return cls(groups['book'], start, end, version)

    @classmethod
    def get_all_from_string(
        cls, string: str, /, *, only_bracketed: bool = False
    ) -> list[VerseRange | Exception]:
        ranges: list[VerseRange | Exception] = []
        lookup_pattern: Pattern[str]

        if only_bracketed:
            lookup_pattern = _bracketed_reference_with_version_re
        else:
            lookup_pattern = _reference_or_bracketed_with_version_re

        if (match := lookup_pattern.search(string)) is not None:
            while match:
                try:
                    ranges.append(cls.from_match(match))
                except Exception as exc:
                    ranges.append(exc)

                match = lookup_pattern.search(string, match.end())

        return ranges

    @classmethod
    async def convert(cls, ctx: Context, argument: str, /) -> VerseRange:
        return cls.from_string(argument) def get_book(book_name_or_abbr: str, /) -> str:
    book = _book_input_map.get(book_name_or_abbr.lower(), '')

    if book == '':
        raise BookNotUnderstoodError(book)

    return book def get_book_mask(book_name: str, /) -> int:
    return _book_mask_map.get(book_name, 0) class BibleVersion(Base):
    __tablename__ = 'bible_versions'

    id = db.Column(db.Integer, primary_key=True)
    command = db.Column(db.String, unique=True, nullable=False)
    name = db.Column(db.String, nullable=False)
    abbr = db.Column(db.String, nullable=False)
    service = db.Column(db.String, nullable=False)
    service_version = db.Column(db.String, nullable=False)
    rtl = db.Column(db.Boolean)
    books = db.Column(db.BigInteger, nullable=False)

    def as_bible(self, /) -> Bible:
        return cast(Bible, self)

    async def set_for_user(self, user_id: int, /) -> None:
        await UserPref.create_or_update(
            set_=('bible_id',), user_id=user_id, bible_id=self.id
        )

    async def set_for_guild(self, guild_id: int, /) -> None:
        await GuildPref.create_or_update(
            set_=('bible_id',), guild_id=guild_id, bible_id=self.id
        )

    @staticmethod
    async def get_all(*, ordered: bool = False) -> AsyncIterator[BibleVersion]:
        query = BibleVersion.query

        if ordered:
            query = query.order_by(db.asc(BibleVersion.command))

        async with db.transaction():
            async for version in query.gino.iterate():
                yield version

    @staticmethod
    async def get_by_command(command: str, /) -> BibleVersion:
        bible = await BibleVersion.query.where(
            BibleVersion.command == command
        ).gino.first()

        if not bible:
            raise InvalidVersionError(command)

        return bible

    @staticmethod
    async def get_by_abbr(abbr: str, /) -> BibleVersion | None:
        return await BibleVersion.query.where(
            BibleVersion.command.ilike(abbr)
        ).gino.first()

    @staticmethod
    async def get_for_user(user_id: int, guild_id: int | None, /) -> BibleVersion:
        user_pref = (
            await UserPref.load(bible_version=BibleVersion)
            .query.where(UserPref.user_id == user_id)
            .gino.first()
        )

        if user_pref is not None and user_pref.bible_version is not None:
            return user_pref.bible_version

        if guild_id is not None:
            guild_pref = (
                await GuildPref.load(bible_version=BibleVersion)
                .query.where(GuildPref.guild_id == guild_id)
                .gino.first()
            )

            if guild_pref is not None and guild_pref.bible_version is not None:
                return guild_pref.bible_version

        return await BibleVersion.get_by_command('esv') class GuildPref(Base):
    __tablename__ = 'guild_prefs'

    bible_version: BibleVersion | None

    guild_id = db.Column(Snowflake, primary_key=True)
    bible_id = db.Column(db.Integer, db.ForeignKey('bible_versions.id')) class UserPref(Base):
    __tablename__ = 'user_prefs'

    bible_version: BibleVersion | None

    user_id = db.Column(Snowflake, primary_key=True)
    bible_id = db.Column(db.Integer, db.ForeignKey('bible_versions.id')) class Erasmus(
    GinoBot[Context],
    DblBot[Context],
    abc.OnMessage,
    abc.OnCommandError[Context],
):
    config: Config

    context_cls = Context
    db = db

    def __init__(self, config: Config, /, *args: Any, **kwargs: Any) -> None:
        kwargs['help_command'] = HelpCommand(
            paginator=formatting.Paginator(),
            command_attrs={
                'brief': 'List commands for this bot or get help for commands',
                'cooldown': commands.Cooldown(5, 30.0, commands.BucketType.channel),
            },
        )
        kwargs['description'] = _description
        kwargs['intents'] = discord.Intents(guilds=True, reactions=True, messages=True)

        super().__init__(config, *args, **kwargs)

        for extension in _extensions:
            try:
                self.load_extension(f'erasmus.cogs.{extension}')
            except Exception:
                _log.exception('Failed to load extension %s.', extension)

    async def on_message(self, message: discord.Message, /) -> None:
        if message.author.bot:
            return

        await self.process_commands(message)

    async def process_commands(self, message: discord.Message, /) -> None:
        ctx = await self.get_context(message)

        if ctx.command is None:
            await self.cogs['Bible'].lookup_from_message(ctx, message)
            return

        await self.invoke(ctx)

    async def on_ready(self, /) -> None:
        await super().on_ready()
        await self.change_presence(
            activity=discord.Game(name=f'| {self.default_prefix}help')
        )

        user = self.user
        _log.info('Erasmus ready. Logged in as %s %s', user.name, user.id)

    async def on_command_error(self, ctx: Context, exc: Exception, /) -> None:
        if (
            isinstance(
                exc,
                (
                    commands.CommandInvokeError,
                    commands.BadArgument,
                    commands.ConversionError,
                ),
            )
            and exc.__cause__ is not None
        ):
            exc = cast(Exception, exc.__cause__)

        if isinstance(exc, ErasmusError):
            # All of these are handled in their respective cogs
            return

        message = 'An error occurred'

        if isinstance(exc, commands.NoPrivateMessage):
            message = 'This command is not available in private messages'
        elif isinstance(exc, commands.CommandOnCooldown):
            message = ''
            if exc.cooldown.type == commands.BucketType.user:
                message = 'You have used this command too many times.'
            elif exc.cooldown.type == commands.BucketType.channel:
                message = (
                    f'`{ctx.prefix}{ctx.invoked_with}` has been used too many '
                    'times in this channel.'
                )
            retry_period: pendulum.Period = (
                pendulum.now().add(seconds=int(exc.retry_after)).diff()  # type: ignore
            )
            message = (
                f'{message} You can retry again in '  # type: ignore
                f'{retry_period.in_words()}.'
            )
        elif isinstance(exc, commands.MissingPermissions):
            message = 'You do not have the correct permissions to run this command'
        elif isinstance(exc, exceptions.OnlyDirectMessage):
            message = 'This command is only available in private messages'
        elif isinstance(exc, commands.MissingRequiredArgument):
            message = f'The required argument `{exc.param.name}` is missing'
        elif isinstance(exc, CannotPaginate):
            if exc.reason == CannotPaginateReason.embed_links:
                message = 'I need the "Embed Links" permission'
            elif exc.reason == CannotPaginateReason.send_messages:
                message = 'I need the "Send Messages" permission'
            elif exc.reason == CannotPaginateReason.add_reactions:
                message = 'I need the "Add Reactions" permission'
            elif exc.reason == CannotPaginateReason.read_message_history:
                message = 'I need the "Read Message History" permission'
        elif isinstance(exc, menus.CannotSendMessages):
            message = 'I need the "Send Messages" permission'
        elif isinstance(exc, menus.CannotEmbedLinks):
            message = 'I need the "Embed Links" permission'
        elif isinstance(exc, menus.CannotAddReactions):
            message = 'I need the "Add Reactions" permission'
        elif isinstance(exc, menus.CannotReadMessageHistory):
            message = 'I need the "Read Message History" permission'
        else:
            if ctx.command is None:
                qualified_name = 'NO COMMAND'
            else:
                qualified_name = ctx.command.qualified_name

            if ctx.message is None:
                content = 'NO MESSAGE'
            else:
                content = ctx.message.content

            _log.exception(
                'Exception occurred in command "%s"\nInvoked by: %s',
                qualified_name,
                content,
                exc_info=exc,
                stack_info=True,
            )

        await ctx.send_error(formatting.escape(message, mass_mentions=True)) class BibleNotSupportedError(ErasmusError):
    version: str

    def __init__(self, version: str, /) -> None:
        self.version = version class BookNotInVersionError(ErasmusError):
    book: str
    version: str

    def __init__(self, book: str, version: str, /) -> None:
        self.book = book
        self.version = version class BookNotUnderstoodError(ErasmusError):
    book: str

    def __init__(self, book: str, /) -> None:
        self.book = book class DoNotUnderstandError(ErasmusError):
    pass class InvalidVersionError(ErasmusError):
    version: str

    def __init__(self, version: str, /) -> None:
        self.version = version class NoUserVersionError(ErasmusError):
    pass class ReferenceNotUnderstoodError(ErasmusError):
    reference: str

    def __init__(self, reference: str, /) -> None:
        self.reference = reference class ServiceLookupTimeout(ServiceTimeout):
    verses: VerseRange

    def __init__(self, bible: Bible, verses: VerseRange, /) -> None:
        super().__init__(bible)
        self.verses = verses class ServiceNotSupportedError(ErasmusError):
    service_name: str

    def __init__(self, service_name: str, /) -> None:
        self.service_name = service_name class ServiceSearchTimeout(ServiceTimeout):
    terms: list[str]

    def __init__(self, bible: Bible, terms: list[str], /) -> None:
        super().__init__(bible)
        self.terms = terms class ServiceTimeout(ErasmusError):
    bible: Bible

    def __init__(self, bible: Bible, /) -> None:
        self.bible = bible class EmbedPageSource(TotalPageSource[T]):
    embed: discord.Embed

    async def prepare(self, /) -> None:
        self.embed = discord.Embed()

    async def format_page(
        self: EPS,
        menu: menus.MenuPages[EPS],
        page: T,
        /,
    ) -> discord.Embed:
        self.embed.clear_fields()
        self.embed.description = discord.Embed.Empty

        await self.set_page_text(page)

        maximum = self.get_max_pages()

        if maximum is not None and maximum > 1:
            text = (
                f'Page {menu.current_page + 1}/{maximum} ({self.get_total()} entries)'
            )
            self.embed.set_footer(text=text)

        return self.embed

    async def set_page_text(self, page: T, /) -> None:
        ... class MenuPages(_MenuPages[TPS]):
    def __init__(self, source: TPS, zero_results_text: str, /) -> None:
        self.zero_results_text = zero_results_text
        self.help_task: asyncio.Task[None] | None = None

        super().__init__(source, timeout=120, check_embeds=True)

    async def start(
        self,
        ctx: commands.Context,
        /,
        *,
        channel: discord.abc.Messageable | None = None,
        wait: bool = False,
    ) -> None:
        await self.source._prepare_once()

        if self.source.get_total() > 0:
            await super().start(ctx, channel=channel, wait=wait)
        else:
            await ctx.send(embed=discord.Embed(description='I found 0 results'))

    @menus.button('\N{INFORMATION SOURCE}', position=menus.Last(3))
    async def show_help(self, payload: discord.RawReactionActionEvent, /) -> None:
        '''shows this message'''
        messages = [
            'Welcome to the interactive pager!\n',
            'This interactively allows you to see pages of text by navigating with '
            'reactions. They are as follows:\n',
        ]

        for emoji, button in self.buttons.items():
            messages.append(f'{emoji} - {button.action.__doc__}')

        if not self._can_remove_reactions:
            messages.append(
                '\n'
                + formatting.warning(
                    'Giving me "Manage Messages" permissions will provide a better '
                    'user experience for the interactive pager'
                )
            )

        embed = discord.Embed(description='\n'.join(messages))
        embed.description = '\n'.join(messages)
        embed.set_footer(
            text=f'You were on page {self.current_page + 1} before this message.'
        )

        await self.message.edit(embed=embed)

        async def go_back_to_current_page() -> None:
            try:
                await asyncio.sleep(30.0)
            except asyncio.CancelledError:
                pass
            finally:
                if self._running:
                    self.help_task = None
                    await self.show_current_page()

        self.help_task = self.bot.loop.create_task(go_back_to_current_page())

    @menus.button('\N{INPUT SYMBOL FOR NUMBERS}', position=menus.Last(1.5))
    async def numbered_page(self, payload: discord.RawReactionActionEvent, /) -> None:
        '''lets you type a page number to go to'''
        channel = self.message.channel
        author_id = payload.user_id

        to_delete: list[discord.Message] = []
        to_delete.append(await channel.send('What page do you want to go to?'))

        def message_check(msg: discord.Message, /) -> bool:
            return (
                msg.author.id == author_id
                and channel == msg.channel
                and msg.content.isdigit()
            )

        try:
            message = await self.bot.wait_for(
                'message', check=message_check, timeout=30.0
            )
        except asyncio.TimeoutError:
            to_delete.append(await channel.send('Took too long.'))
            await asyncio.sleep(5)
        else:
            page = int(message.content)
            to_delete.append(message)
            max_pages = self.source.get_max_pages()
            if max_pages is None or page > 0 and page <= max_pages:
                await self.show_page(page - 1)
            else:
                to_delete.append(
                    await channel.send(f'Invalid page given. ({page}/{max_pages})')
                )
                await asyncio.sleep(5)

        try:
            await cast(discord.TextChannel, channel).delete_messages(to_delete)
        except Exception:
            pass

    async def finalize(self, timed_out: bool, /) -> None:
        if self.help_task is not None:
            self.help_task.cancel()
            self.help_task = None

        try:
            if timed_out:
                await self.message.clear_reactions()
            else:
                await self.message.delete()
        except discord.HTTPException:
            pass

    def reaction_check(self, payload: discord.RawReactionActionEvent, /) -> bool:
        process = super().reaction_check(payload)

        if process and self.help_task:
            self.help_task.cancel()
            self.help_task = None
            return False

        return process class ServiceManager(object):
    service_map: dict[str, Service] = attrib(factory=dict)
    timeout: float = 10

    def __contains__(self, key: str, /) -> bool:
        return key in self.service_map

    def __len__(self, /) -> int:
        return len(self.service_map)

    async def get_passage(self, bible: Bible, verses: VerseRange, /) -> Passage:
        service = self.service_map.get(bible.service)
        assert service is not None
        try:
            _log.debug(f'Getting passage {verses} ({bible.abbr})')
            with async_timeout.timeout(self.timeout):
                passage = await service.get_passage(bible, verses)
                passage.version = bible.abbr
                _log.debug(f'Got passage {passage.citation}')
                return passage
        except asyncio.TimeoutError:
            raise ServiceLookupTimeout(bible, verses)

    async def search(
        self, bible: Bible, terms: list[str], /, *, limit: int = 20, offset: int = 0
    ) -> SearchResults:
        service = self.service_map.get(bible.service)
        assert service is not None
        try:
            with async_timeout.timeout(self.timeout):
                return await service.search(bible, terms, limit=limit, offset=offset)
        except asyncio.TimeoutError:
            raise ServiceSearchTimeout(bible, terms)

    @classmethod
    def from_config(
        cls,
        config: Config,
        session: aiohttp.ClientSession,
        /,
    ) -> ServiceManager:
        service_map: dict[str, Service] = {}
        service_configs = config.get('services', {})

        for name, service_cls in services.__dict__.items():
            if callable(service_cls):
                section = service_configs.get(name)
                service_map[name] = service_cls(config=section, session=session)

        return cls(service_map)
from typing import Any, Final, Protocol, TypeVar, cast
from botus_receptus import Cog, checks, formatting
from botus_receptus.db import UniqueViolationError
from discord.ext import commands
from ..context import Context
from ..data import Passage, SearchResults, VerseRange, get_book, get_book_mask
from ..db.bible import BibleVersion, GuildPref, UserPref
from ..erasmus import Erasmus
from ..exceptions import (
    BibleNotSupportedError,
    BookNotInVersionError,
    BookNotUnderstoodError,
    DoNotUnderstandError,
    InvalidVersionError,
    NoUserVersionError,
    ReferenceNotUnderstoodError,
    ServiceLookupTimeout,
    ServiceNotSupportedError,
    ServiceSearchTimeout,
    ServiceTimeout,
)
from ..menu_pages import EmbedPageSource, MenuPages
from ..service_manager import ServiceManager
import discord
                        bible = user_bible

                    await self.__lookup(ctx, bible, verse_range)
                except Exception as exc:
                    await self.bot.on_command_error(ctx, exc)

    async def cog_command_error(self, ctx: Context, error: Exception, /) -> None:
        if (
            isinstance(
                error,
                (
                    commands.CommandInvokeError,
                    commands.BadArgument,
                    commands.ConversionError,
                ),
            )
            and error.__cause__ is not None
        ):
            error = cast(Exception, error.__cause__)

        if isinstance(error, BookNotUnderstoodError):
            message = f'I do not understand the book "{error.book}"'
        elif isinstance(error, BookNotInVersionError):
            message = f'{error.version} does not contain {error.book}'
        elif isinstance(error, DoNotUnderstandError):
            message = 'I do not understand that request'
        elif isinstance(error, ReferenceNotUnderstoodError):
            message = f'I do not understand the reference "{error.reference}"'
        elif isinstance(error, BibleNotSupportedError):
            message = f'`{ctx.prefix}{error.version}` is not supported'
        elif isinstance(error, NoUserVersionError):
            message = (
                f'You must first set your default version with `{ctx.prefix}setversion`'
            )
        elif isinstance(error, InvalidVersionError):
            message = (
                f'`{error.version}` is not a valid version. Check '
                f'`{ctx.prefix}versions` for valid versions'
            )
        elif isinstance(error, ServiceNotSupportedError):
            message = (
                f'The service configured for '
                f'`{self.bot.default_prefix}{ctx.invoked_with}` is not supported'
            )
        elif isinstance(error, ServiceTimeout):
            if isinstance(error, ServiceLookupTimeout):
                message = (
                    f'The request timed out looking up {error.verses} in '
                    + error.bible.name
                )
            elif isinstance(error, ServiceSearchTimeout):
                message = (
                    f'The request timed out searching for '
                    f'"{" ".join(error.terms)}" in {error.bible.name}'
                )
        else:
            return

        await ctx.send_error(formatting.escape(message, mass_mentions=True))

    @commands.command(
        aliases=[''],
        brief='Look up a verse in your preferred version',
        help=_lookup_help,
    )
    async def lookup(self, ctx: Context, /, *, reference: VerseRange) -> None:
        bible = await BibleVersion.get_for_user(
            ctx.author.id, ctx.guild.id if ctx.guild is not None else None
        )

        async with ctx.typing():
            await self.__lookup(ctx, bible, reference)

    @commands.command(
        aliases=['s'],
        brief='Search for terms in your preferred version',
        help=_search_help,
    )
    async def search(self, ctx: Context, /, *terms: str) -> None:
        bible = await BibleVersion.get_for_user(
            ctx.author.id, ctx.guild.id if ctx.guild is not None else None
        )

        await self.__search(ctx, bible, *terms)

    @commands.command(
        brief='List which Bible versions are available for lookup and search'
    )
    @commands.cooldown(rate=2, per=30.0, type=commands.BucketType.channel)
    async def versions(self, ctx: Context, /) -> None:
        lines = ['I support the following Bible versions:', '']

        lines += [
            f'  `{ctx.prefix}{version.command}`: {version.name}'
            async for version in BibleVersion.get_all(ordered=True)
        ]

        lines.append(
            "\nYou can search any version by prefixing the version command with 's' "
            f'(ex. `{ctx.prefix}sesv terms...`)'
        )

        output = '\n'.join(lines)
        await ctx.send_embed(f'\n{output}\n')

    @commands.command(brief='Set your preferred version', help=_setversion_help)
    @commands.cooldown(rate=2, per=60.0, type=commands.BucketType.user)
    async def setversion(self, ctx: Context, version: str, /) -> None:
        version = version.lower()
        if version[0] == ctx.prefix:
            version = version[1:]

        existing = await BibleVersion.get_by_command(version)
        await existing.set_for_user(ctx.author.id)

        await ctx.send_embed(f'Version set to `{version}`')

    @commands.command(brief='Delete your preferred version', help=_unsetversion_help)
    @commands.cooldown(rate=2, per=60.0, type=commands.BucketType.user)
    async def unsetversion(self, ctx: Context, /) -> None:

        user_prefs = await UserPref.get(ctx.author.id)