11from __future__ import annotations
22
33import asyncio
4+ import contextlib
5+ import re
46import sys
57import textwrap
68from collections import defaultdict
4244
4345COMMAND_LOCK_SINGLETON = "inventory refresh"
4446
47+ REDO_EMOJI = "\U0001f501 " # :repeat:
48+ REDO_TIMEOUT = 30
49+ COMPACT_DOC_REGEX = r"\[\[(?P<symbol>\S*)\]\]"
50+
4551
4652class 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