Skip to content

Commit a37be2a

Browse files
committed
feat: add the compact docs feature
1 parent 28ea3bc commit a37be2a

File tree

1 file changed

+124
-41
lines changed

1 file changed

+124
-41
lines changed

bot/exts/info/doc/_cog.py

Lines changed: 124 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import contextlib
5+
import re
46
import sys
57
import textwrap
68
from collections import defaultdict
@@ -42,6 +44,10 @@
4244

4345
COMMAND_LOCK_SINGLETON = "inventory refresh"
4446

47+
REDO_EMOJI = "\U0001f501" # :repeat:
48+
REDO_TIMEOUT = 30
49+
COMPACT_DOC_REGEX = r"\[\[(?P<symbol>\S*)\]\]"
50+
4551

4652
class DocItem(NamedTuple):
4753
"""Holds inventory symbol information."""
@@ -96,7 +102,6 @@ def update_single(self, package_name: str, base_url: str, inventory: InventoryDi
96102

97103
for group, items in inventory.items():
98104
for symbol_name, relative_doc_url in items:
99-
100105
# e.g. get 'class' from 'py:class'
101106
group_name = group.split(":")[1]
102107
symbol_name = self.ensure_unique_symbol_name(
@@ -146,7 +151,7 @@ async def update_or_reschedule_inventory(
146151
delay = FETCH_RESCHEDULE_DELAY.first
147152
log.info(f"Failed to fetch inventory; attempting again in {delay} minutes.")
148153
self.inventory_scheduler.schedule_later(
149-
delay*60,
154+
delay * 60,
150155
api_package_name,
151156
self.update_or_reschedule_inventory(api_package_name, base_url, inventory_url),
152157
)
@@ -216,9 +221,8 @@ async def refresh_inventories(self) -> None:
216221
await self.item_fetcher.clear()
217222

218223
coros = [
219-
self.update_or_reschedule_inventory(
220-
package["package"], package["base_url"], package["inventory_url"]
221-
) for package in await self.bot.api_client.get("bot/documentation-links")
224+
self.update_or_reschedule_inventory(package["package"], package["base_url"], package["inventory_url"])
225+
for package in await self.bot.api_client.get("bot/documentation-links")
222226
]
223227
await asyncio.gather(*coros)
224228
log.debug("Finished inventory refresh.")
@@ -264,6 +268,29 @@ async def get_symbol_markdown(self, doc_item: DocItem) -> str:
264268
return "Unable to parse the requested symbol."
265269
return markdown
266270

271+
async def _get_symbols_items(self, symbols: list[str]) -> list[tuple[str, DocItem | None]]:
272+
"""
273+
Get DocItems for the given list of symbols, and update the stats for the fetched doc items.
274+
275+
Returns (symbol, None) for any symbol for which a DocItem could not be found.
276+
"""
277+
if not self.refresh_event.is_set():
278+
log.debug("Waiting for inventories to be refreshed before processing item.")
279+
await self.refresh_event.wait()
280+
281+
# Ensure a refresh can't run in case of a context switch until the with block is exited
282+
with self.symbol_get_event:
283+
items: list[tuple[str, DocItem | None]] = []
284+
for symbol_name in symbols:
285+
symbol_name, doc_item = self.get_symbol_item(symbol_name)
286+
if doc_item:
287+
items.append((symbol_name, doc_item))
288+
self.bot.stats.incr(f"doc_fetches.{doc_item.package}")
289+
else:
290+
items.append((symbol_name, None))
291+
292+
return items
293+
267294
async def create_symbol_embed(self, symbol_name: str) -> discord.Embed | None:
268295
"""
269296
Attempt to scrape and fetch the data for the given `symbol_name`, and build an embed from its contents.
@@ -273,33 +300,99 @@ async def create_symbol_embed(self, symbol_name: str) -> discord.Embed | None:
273300
First check the DocRedisCache before querying the cog's `BatchParser`.
274301
"""
275302
log.trace(f"Building embed for symbol `{symbol_name}`")
276-
if not self.refresh_event.is_set():
277-
log.debug("Waiting for inventories to be refreshed before processing item.")
278-
await self.refresh_event.wait()
279-
# Ensure a refresh can't run in case of a context switch until the with block is exited
280-
with self.symbol_get_event:
281-
symbol_name, doc_item = self.get_symbol_item(symbol_name)
303+
_items = await self._get_symbols_items([symbol_name])
304+
symbol_name, doc_item = _items[0]
305+
306+
if doc_item is None:
307+
log.debug(f"{symbol_name=} does not exist.")
308+
return None
309+
310+
# Show all symbols with the same name that were renamed in the footer,
311+
# with a max of 200 chars.
312+
if symbol_name in self.renamed_symbols:
313+
renamed_symbols = ", ".join(self.renamed_symbols[symbol_name])
314+
footer_text = textwrap.shorten("Similar names: " + renamed_symbols, 200, placeholder=" ...")
315+
else:
316+
footer_text = ""
317+
318+
embed = discord.Embed(
319+
title=discord.utils.escape_markdown(symbol_name),
320+
url=f"{doc_item.url}#{doc_item.symbol_id}",
321+
description=await self.get_symbol_markdown(doc_item),
322+
)
323+
embed.set_footer(text=footer_text)
324+
return embed
325+
326+
async def create_compact_doc_message(self, symbols: list[str]) -> str:
327+
"""Create a markdown bullet list of links to docs for the given list of symbols."""
328+
items = await self._get_symbols_items(symbols)
329+
content = ""
330+
for symbol_name, doc_item in items:
282331
if doc_item is None:
283-
log.debug("Symbol does not exist.")
284-
return None
332+
log.debug(f"{symbol_name=} does not exist.")
333+
else:
334+
content += f"- [{discord.utils.escape_markdown(symbol_name)}](<{doc_item.url}#{doc_item.symbol_id}>)\n"
285335

286-
self.bot.stats.incr(f"doc_fetches.{doc_item.package}")
336+
return content
287337

288-
# Show all symbols with the same name that were renamed in the footer,
289-
# with a max of 200 chars.
290-
if symbol_name in self.renamed_symbols:
291-
renamed_symbols = ", ".join(self.renamed_symbols[symbol_name])
292-
footer_text = textwrap.shorten("Similar names: " + renamed_symbols, 200, placeholder=" ...")
293-
else:
294-
footer_text = ""
338+
async def _handle_edit(self, original: discord.Message, bot_reply: discord.Message) -> bool:
339+
"""
340+
Re-eval a message for symbols if it gets edited.
295341
296-
embed = discord.Embed(
297-
title=discord.utils.escape_markdown(symbol_name),
298-
url=f"{doc_item.url}#{doc_item.symbol_id}",
299-
description=await self.get_symbol_markdown(doc_item)
342+
The edit logic is essentially the same as that of the code eval (snekbox) command.
343+
"""
344+
def edit_check(before: discord.Message, after: discord.Message) -> bool:
345+
return (
346+
original.id == before.id
347+
and before.content != after.content
348+
and len(re.findall(COMPACT_DOC_REGEX, after.content.replace("`", ""))) > 0
300349
)
301-
embed.set_footer(text=footer_text)
302-
return embed
350+
351+
def reaction_check(rxn: discord.Reaction, user: discord.User) -> bool:
352+
return rxn.message.id == original.id and user.id == original.author.id and str(rxn) == REDO_EMOJI
353+
354+
try:
355+
_, new_message = await self.bot.wait_for("message_edit", check=edit_check, timeout=REDO_TIMEOUT)
356+
await new_message.add_reaction(REDO_EMOJI)
357+
await self.bot.wait_for("reaction_add", check=reaction_check, timeout=10)
358+
symbols = re.findall(COMPACT_DOC_REGEX, new_message.content.replace("`", ""))
359+
if not symbols:
360+
return None
361+
362+
log.trace(f"Getting doc links for {symbols=}, as {original.id=} was edited.")
363+
links = await self.create_compact_doc_message(symbols)
364+
await bot_reply.edit(content=links)
365+
with contextlib.suppress(discord.HTTPException):
366+
await original.clear_reaction(REDO_EMOJI)
367+
return True
368+
except TimeoutError:
369+
with contextlib.suppress(discord.HTTPException):
370+
await original.clear_reaction(REDO_EMOJI)
371+
return False
372+
373+
@commands.Cog.listener()
374+
async def on_message(self, message: discord.Message) -> None:
375+
"""
376+
Scan messages for symbols enclosed in double square brackets i.e [[]].
377+
378+
Reply with links to documentations for the specified symbols.
379+
"""
380+
if message.author == self.bot.user:
381+
return
382+
383+
symbols = re.findall(COMPACT_DOC_REGEX, message.content.replace("`", ""))
384+
if not symbols:
385+
return
386+
387+
log.trace(f"Getting doc links for {symbols=}, {message.id=}")
388+
async with message.channel.typing():
389+
links = await self.create_compact_doc_message(symbols)
390+
reply_msg = await message.reply(links)
391+
392+
while True:
393+
again = await self._handle_edit(message, reply_msg)
394+
if not again:
395+
break
303396

304397
@commands.group(name="docs", aliases=("doc", "d"), invoke_without_command=True)
305398
async def docs_group(self, ctx: commands.Context, *, symbol_name: str | None) -> None:
@@ -321,8 +414,7 @@ async def get_command(self, ctx: commands.Context, *, symbol_name: str | None) -
321414
"""
322415
if not symbol_name:
323416
inventory_embed = discord.Embed(
324-
title=f"All inventories (`{len(self.base_urls)}` total)",
325-
colour=discord.Colour.blue()
417+
title=f"All inventories (`{len(self.base_urls)}` total)", colour=discord.Colour.blue()
326418
)
327419

328420
lines = sorted(f"- [`{name}`]({url})" for name, url in self.base_urls.items())
@@ -381,11 +473,7 @@ async def set_command(
381473
if base_url and not base_url.endswith("/"):
382474
raise commands.BadArgument("The base url must end with a slash.")
383475
inventory_url, inventory_dict = inventory
384-
body = {
385-
"package": package_name,
386-
"base_url": base_url,
387-
"inventory_url": inventory_url
388-
}
476+
body = {"package": package_name, "base_url": base_url, "inventory_url": inventory_url}
389477
try:
390478
await self.bot.api_client.post("bot/documentation-links", json=body)
391479
except ResponseCodeError as err:
@@ -439,18 +527,13 @@ async def refresh_command(self, ctx: commands.Context) -> None:
439527
removed = "- " + removed
440528

441529
embed = discord.Embed(
442-
title="Inventories refreshed",
443-
description=f"```diff\n{added}\n{removed}```" if added or removed else ""
530+
title="Inventories refreshed", description=f"```diff\n{added}\n{removed}```" if added or removed else ""
444531
)
445532
await ctx.send(embed=embed)
446533

447534
@docs_group.command(name="cleardoccache", aliases=("deletedoccache",))
448535
@commands.has_any_role(*MODERATION_ROLES)
449-
async def clear_cache_command(
450-
self,
451-
ctx: commands.Context,
452-
package_name: PackageName | Literal["*"]
453-
) -> None:
536+
async def clear_cache_command(self, ctx: commands.Context, package_name: PackageName | Literal["*"]) -> None:
454537
"""Clear the persistent redis cache for `package`."""
455538
if await doc_cache.delete(package_name):
456539
await self.item_fetcher.stale_inventory_notifier.symbol_counter.delete(package_name)

0 commit comments

Comments
 (0)