diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 105f051083..633b3ae647 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +import contextlib +import re import sys import textwrap from collections import defaultdict @@ -42,6 +44,10 @@ COMMAND_LOCK_SINGLETON = "inventory refresh" +REDO_EMOJI = "\U0001f501" # :repeat: +REDO_TIMEOUT = 30 +COMPACT_DOC_REGEX = r"\[\[(?P\S*)\]\]" + class DocItem(NamedTuple): """Holds inventory symbol information.""" @@ -96,7 +102,6 @@ def update_single(self, package_name: str, base_url: str, inventory: InventoryDi for group, items in inventory.items(): for symbol_name, relative_doc_url in items: - # e.g. get 'class' from 'py:class' group_name = group.split(":")[1] symbol_name = self.ensure_unique_symbol_name( @@ -264,6 +269,25 @@ async def get_symbol_markdown(self, doc_item: DocItem) -> str: return "Unable to parse the requested symbol." return markdown + async def _get_symbols_items(self, symbols: list[str]) -> dict[str, DocItem | None]: + """Get DocItems for the given list of symbols, and update the stats for the fetched doc items.""" + if not self.refresh_event.is_set(): + log.debug("Waiting for inventories to be refreshed before processing item.") + await self.refresh_event.wait() + + # Ensure a refresh can't run in case of a context switch until the with block is exited + with self.symbol_get_event: + items: dict[str, DocItem | None] = {} + for symbol_name in symbols: + symbol_name, doc_item = self.get_symbol_item(symbol_name) + if doc_item: + items[symbol_name] = doc_item + self.bot.stats.incr(f"doc_fetches.{doc_item.package}") + else: + items[symbol_name] = None + + return items + async def create_symbol_embed(self, symbol_name: str) -> discord.Embed | None: """ Attempt to scrape and fetch the data for the given `symbol_name`, and build an embed from its contents. @@ -273,33 +297,107 @@ async def create_symbol_embed(self, symbol_name: str) -> discord.Embed | None: First check the DocRedisCache before querying the cog's `BatchParser`. """ log.trace(f"Building embed for symbol `{symbol_name}`") - if not self.refresh_event.is_set(): - log.debug("Waiting for inventories to be refreshed before processing item.") - await self.refresh_event.wait() - # Ensure a refresh can't run in case of a context switch until the with block is exited - with self.symbol_get_event: - symbol_name, doc_item = self.get_symbol_item(symbol_name) - if doc_item is None: - log.debug("Symbol does not exist.") - return None + _items = await self._get_symbols_items([symbol_name]) + doc_item = _items[symbol_name] + + if doc_item is None: + log.debug(f"{symbol_name=} does not exist.") + return None + + # Show all symbols with the same name that were renamed in the footer, + # with a max of 200 chars. + if symbol_name in self.renamed_symbols: + renamed_symbols = ", ".join(self.renamed_symbols[symbol_name]) + footer_text = textwrap.shorten("Similar names: " + renamed_symbols, 200, placeholder=" ...") + else: + footer_text = "" + + embed = discord.Embed( + title=discord.utils.escape_markdown(symbol_name), + url=f"{doc_item.url}#{doc_item.symbol_id}", + description=await self.get_symbol_markdown(doc_item), + ) + embed.set_footer(text=footer_text) + return embed - self.bot.stats.incr(f"doc_fetches.{doc_item.package}") + async def create_compact_doc_message(self, symbols: list[str]) -> str: + """ + Create a markdown bullet list of links to docs for the given list of symbols. - # Show all symbols with the same name that were renamed in the footer, - # with a max of 200 chars. - if symbol_name in self.renamed_symbols: - renamed_symbols = ", ".join(self.renamed_symbols[symbol_name]) - footer_text = textwrap.shorten("Similar names: " + renamed_symbols, 200, placeholder=" ...") + Link to at most 10 items, ignoring the rest. + """ + items = await self._get_symbols_items(symbols) + content = "" + link_count = 0 + for symbol_name, doc_item in items.items(): + if link_count >= 10: + break + if doc_item is None: + log.debug(f"{symbol_name=} does not exist.") else: - footer_text = "" + link_count += 1 + content += f"- [{discord.utils.escape_markdown(symbol_name)}](<{doc_item.url}#{doc_item.symbol_id}>)\n" + + return content - embed = discord.Embed( - title=discord.utils.escape_markdown(symbol_name), - url=f"{doc_item.url}#{doc_item.symbol_id}", - description=await self.get_symbol_markdown(doc_item) + async def _handle_edit(self, original: discord.Message, bot_reply: discord.Message) -> bool: + """ + Re-eval a message for symbols if it gets edited. + + The edit logic is essentially the same as that of the code eval (snekbox) command. + """ + def edit_check(before: discord.Message, after: discord.Message) -> bool: + return ( + original.id == before.id + and before.content != after.content + and len(re.findall(COMPACT_DOC_REGEX, after.content.replace("`", ""))) > 0 ) - embed.set_footer(text=footer_text) - return embed + + def reaction_check(rxn: discord.Reaction, user: discord.User) -> bool: + return rxn.message.id == original.id and user.id == original.author.id and str(rxn) == REDO_EMOJI + + try: + _, new_message = await self.bot.wait_for("message_edit", check=edit_check, timeout=REDO_TIMEOUT) + await new_message.add_reaction(REDO_EMOJI) + await self.bot.wait_for("reaction_add", check=reaction_check, timeout=10) + symbols = re.findall(COMPACT_DOC_REGEX, new_message.content.replace("`", "")) + if not symbols: + return None + + log.trace(f"Getting doc links for {symbols=}, as {original.id=} was edited.") + links = await self.create_compact_doc_message(symbols) + await bot_reply.edit(content=links) + with contextlib.suppress(discord.HTTPException): + await original.clear_reaction(REDO_EMOJI) + return True + except TimeoutError: + with contextlib.suppress(discord.HTTPException): + await original.clear_reaction(REDO_EMOJI) + return False + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """ + Scan messages for symbols enclosed in double square brackets i.e [[]]. + + Reply with links to documentations for the specified symbols. + """ + if message.author == self.bot.user: + return + + symbols = re.findall(COMPACT_DOC_REGEX, message.content.replace("`", "")) + if not symbols: + return + + log.trace(f"Getting doc links for {symbols=}, {message.id=}") + async with message.channel.typing(): + links = await self.create_compact_doc_message(symbols) + reply_msg = await message.reply(links) + + while True: + again = await self._handle_edit(message, reply_msg) + if not again: + break @commands.group(name="docs", aliases=("doc", "d"), invoke_without_command=True) async def docs_group(self, ctx: commands.Context, *, symbol_name: str | None) -> None: @@ -439,8 +537,7 @@ async def refresh_command(self, ctx: commands.Context) -> None: removed = "- " + removed embed = discord.Embed( - title="Inventories refreshed", - description=f"```diff\n{added}\n{removed}```" if added or removed else "" + title="Inventories refreshed", description=f"```diff\n{added}\n{removed}```" if added or removed else "" ) await ctx.send(embed=embed)