We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
tag-search command to search tags via their contents instead of names.
Currently, it can be difficult to find the specific command for a tag even if you know it exists. A command to allow you to search through tag bodies would help with finding the right tags. For example doing `!tag-search backtick` would return `!code-block` (and any other tags that include the word backtick).

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `bot/cogs/tags.py`
Content:
```
1 import logging
2 import re
3 import time
4 from typing import Dict, List, Optional
5 
6 from discord import Colour, Embed
7 from discord.ext.commands import Cog, Context, group
8 
9 from bot.bot import Bot
10 from bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles
11 from bot.converters import TagContentConverter, TagNameConverter
12 from bot.decorators import with_role
13 from bot.pagination import LinePaginator
14 
15 log = logging.getLogger(__name__)
16 
17 TEST_CHANNELS = (
18     Channels.bot_commands,
19     Channels.helpers
20 )
21 
22 REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE)
23 
24 
25 class Tags(Cog):
26     """Save new tags and fetch existing tags."""
27 
28     def __init__(self, bot: Bot):
29         self.bot = bot
30         self.tag_cooldowns = {}
31 
32         self._cache = {}
33         self._last_fetch: float = 0.0
34 
35     async def _get_tags(self, is_forced: bool = False) -> None:
36         """Get all tags."""
37         # refresh only when there's a more than 5m gap from last call.
38         time_now: float = time.time()
39         if is_forced or not self._last_fetch or time_now - self._last_fetch > 5 * 60:
40             tags = await self.bot.api_client.get('bot/tags')
41             self._cache = {tag['title'].lower(): tag for tag in tags}
42             self._last_fetch = time_now
43 
44     @staticmethod
45     def _fuzzy_search(search: str, target: str) -> int:
46         """A simple scoring algorithm based on how many letters are found / total, with order in mind."""
47         current, index = 0, 0
48         _search = REGEX_NON_ALPHABET.sub('', search.lower())
49         _targets = iter(REGEX_NON_ALPHABET.split(target.lower()))
50         _target = next(_targets)
51         try:
52             while True:
53                 while index < len(_target) and _search[current] == _target[index]:
54                     current += 1
55                     index += 1
56                 index, _target = 0, next(_targets)
57         except (StopIteration, IndexError):
58             pass
59         return current / len(_search) * 100
60 
61     def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]:
62         """Return a list of suggested tags."""
63         scores: Dict[str, int] = {
64             tag_title: Tags._fuzzy_search(tag_name, tag['title'])
65             for tag_title, tag in self._cache.items()
66         }
67 
68         thresholds = thresholds or [100, 90, 80, 70, 60]
69 
70         for threshold in thresholds:
71             suggestions = [
72                 self._cache[tag_title]
73                 for tag_title, matching_score in scores.items()
74                 if matching_score >= threshold
75             ]
76             if suggestions:
77                 return suggestions
78 
79         return []
80 
81     async def _get_tag(self, tag_name: str) -> list:
82         """Get a specific tag."""
83         await self._get_tags()
84         found = [self._cache.get(tag_name.lower(), None)]
85         if not found[0]:
86             return self._get_suggestions(tag_name)
87         return found
88 
89     @group(name='tags', aliases=('tag', 't'), invoke_without_command=True)
90     async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
91         """Show all known tags, a single tag, or run a subcommand."""
92         await ctx.invoke(self.get_command, tag_name=tag_name)
93 
94     @tags_group.command(name='get', aliases=('show', 'g'))
95     async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
96         """Get a specified tag, or a list of all tags if no tag is specified."""
97         def _command_on_cooldown(tag_name: str) -> bool:
98             """
99             Check if the command is currently on cooldown, on a per-tag, per-channel basis.
100 
101             The cooldown duration is set in constants.py.
102             """
103             now = time.time()
104 
105             cooldown_conditions = (
106                 tag_name
107                 and tag_name in self.tag_cooldowns
108                 and (now - self.tag_cooldowns[tag_name]["time"]) < Cooldowns.tags
109                 and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id
110             )
111 
112             if cooldown_conditions:
113                 return True
114             return False
115 
116         if _command_on_cooldown(tag_name):
117             time_left = Cooldowns.tags - (time.time() - self.tag_cooldowns[tag_name]["time"])
118             log.info(
119                 f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. "
120                 f"Cooldown ends in {time_left:.1f} seconds."
121             )
122             return
123 
124         await self._get_tags()
125 
126         if tag_name is not None:
127             founds = await self._get_tag(tag_name)
128 
129             if len(founds) == 1:
130                 tag = founds[0]
131                 if ctx.channel.id not in TEST_CHANNELS:
132                     self.tag_cooldowns[tag_name] = {
133                         "time": time.time(),
134                         "channel": ctx.channel.id
135                     }
136                 await ctx.send(embed=Embed.from_dict(tag['embed']))
137             elif founds and len(tag_name) >= 3:
138                 await ctx.send(embed=Embed(
139                     title='Did you mean ...',
140                     description='\n'.join(tag['title'] for tag in founds[:10])
141                 ))
142 
143         else:
144             tags = self._cache.values()
145             if not tags:
146                 await ctx.send(embed=Embed(
147                     description="**There are no tags in the database!**",
148                     colour=Colour.red()
149                 ))
150             else:
151                 embed: Embed = Embed(title="**Current tags**")
152                 await LinePaginator.paginate(
153                     sorted(f"**»**   {tag['title']}" for tag in tags),
154                     ctx,
155                     embed,
156                     footer_text="To show a tag, type !tags <tagname>.",
157                     empty=False,
158                     max_lines=15
159                 )
160 
161     @tags_group.command(name='set', aliases=('add', 's'))
162     @with_role(*MODERATION_ROLES)
163     async def set_command(
164         self,
165         ctx: Context,
166         tag_name: TagNameConverter,
167         *,
168         tag_content: TagContentConverter,
169     ) -> None:
170         """Create a new tag."""
171         body = {
172             'title': tag_name.lower().strip(),
173             'embed': {
174                 'title': tag_name,
175                 'description': tag_content
176             }
177         }
178 
179         await self.bot.api_client.post('bot/tags', json=body)
180         self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}')
181 
182         log.debug(f"{ctx.author} successfully added the following tag to our database: \n"
183                   f"tag_name: {tag_name}\n"
184                   f"tag_content: '{tag_content}'\n")
185 
186         await ctx.send(embed=Embed(
187             title="Tag successfully added",
188             description=f"**{tag_name}** added to tag database.",
189             colour=Colour.blurple()
190         ))
191 
192     @tags_group.command(name='edit', aliases=('e', ))
193     @with_role(*MODERATION_ROLES)
194     async def edit_command(
195         self,
196         ctx: Context,
197         tag_name: TagNameConverter,
198         *,
199         tag_content: TagContentConverter,
200     ) -> None:
201         """Edit an existing tag."""
202         body = {
203             'embed': {
204                 'title': tag_name,
205                 'description': tag_content
206             }
207         }
208 
209         await self.bot.api_client.patch(f'bot/tags/{tag_name}', json=body)
210         self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}')
211 
212         log.debug(f"{ctx.author} successfully edited the following tag in our database: \n"
213                   f"tag_name: {tag_name}\n"
214                   f"tag_content: '{tag_content}'\n")
215 
216         await ctx.send(embed=Embed(
217             title="Tag successfully edited",
218             description=f"**{tag_name}** edited in the database.",
219             colour=Colour.blurple()
220         ))
221 
222     @tags_group.command(name='delete', aliases=('remove', 'rm', 'd'))
223     @with_role(Roles.admins, Roles.owners)
224     async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None:
225         """Remove a tag from the database."""
226         await self.bot.api_client.delete(f'bot/tags/{tag_name}')
227         self._cache.pop(tag_name.lower(), None)
228 
229         log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'")
230         await ctx.send(embed=Embed(
231             title=tag_name,
232             description=f"Tag successfully removed: {tag_name}.",
233             colour=Colour.blurple()
234         ))
235 
236 
237 def setup(bot: Bot) -> None:
238     """Load the Tags cog."""
239     bot.add_cog(Tags(bot))
240
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -1,7 +1,7 @@
 import logging
 import re
 import time
-from typing import Dict, List, Optional
+from typing import Callable, Dict, Iterable, List, Optional
 
 from discord import Colour, Embed
 from discord.ext.commands import Cog, Context, group
@@ -86,11 +86,80 @@
             return self._get_suggestions(tag_name)
         return found
 
+    async def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str) -> list:
+        """
+        Search for tags via contents.
+
+        `predicate` will be the built-in any, all, or a custom callable. Must return a bool.
+        """
+        await self._get_tags()
+
+        keywords_processed: List[str] = []
+        for keyword in keywords.split(','):
+            keyword_sanitized = keyword.strip().casefold()
+            if not keyword_sanitized:
+                # this happens when there are leading / trailing / consecutive comma.
+                continue
+            keywords_processed.append(keyword_sanitized)
+
+        if not keywords_processed:
+            # after sanitizing, we can end up with an empty list, for example when keywords is ','
+            # in that case, we simply want to search for such keywords directly instead.
+            keywords_processed = [keywords]
+
+        matching_tags = []
+        for tag in self._cache.values():
+            if check(query in tag['embed']['description'].casefold() for query in keywords_processed):
+                matching_tags.append(tag)
+
+        return matching_tags
+
+    async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags: list) -> None:
+        """Send the result of matching tags to user."""
+        if not matching_tags:
+            pass
+        elif len(matching_tags) == 1:
+            await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed']))
+        else:
+            is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0
+            embed = Embed(
+                title=f"Here are the tags containing the given keyword{'s' * is_plural}:",
+                description='\n'.join(tag['title'] for tag in matching_tags[:10])
+            )
+            await LinePaginator.paginate(
+                sorted(f"**»**   {tag['title']}" for tag in matching_tags),
+                ctx,
+                embed,
+                footer_text="To show a tag, type !tags <tagname>.",
+                empty=False,
+                max_lines=15
+            )
+
     @group(name='tags', aliases=('tag', 't'), invoke_without_command=True)
     async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
         """Show all known tags, a single tag, or run a subcommand."""
         await ctx.invoke(self.get_command, tag_name=tag_name)
 
+    @tags_group.group(name='search', invoke_without_command=True)
+    async def search_tag_content(self, ctx: Context, *, keywords: str) -> None:
+        """
+        Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma.
+
+        Only search for tags that has ALL the keywords.
+        """
+        matching_tags = await self._get_tags_via_content(all, keywords)
+        await self._send_matching_tags(ctx, keywords, matching_tags)
+
+    @search_tag_content.command(name='any')
+    async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = None) -> None:
+        """
+        Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma.
+
+        Search for tags that has ANY of the keywords.
+        """
+        matching_tags = await self._get_tags_via_content(any, keywords or 'any')
+        await self._send_matching_tags(ctx, keywords, matching_tags)
+
     @tags_group.command(name='get', aliases=('show', 'g'))
     async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
         """Get a specified tag, or a list of all tags if no tag is specified."""

{"golden_diff": "diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py\n--- a/bot/cogs/tags.py\n+++ b/bot/cogs/tags.py\n@@ -1,7 +1,7 @@\n import logging\n import re\n import time\n-from typing import Dict, List, Optional\n+from typing import Callable, Dict, Iterable, List, Optional\n \n from discord import Colour, Embed\n from discord.ext.commands import Cog, Context, group\n@@ -86,11 +86,80 @@\n             return self._get_suggestions(tag_name)\n         return found\n \n+    async def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str) -> list:\n+        \"\"\"\n+        Search for tags via contents.\n+\n+        `predicate` will be the built-in any, all, or a custom callable. Must return a bool.\n+        \"\"\"\n+        await self._get_tags()\n+\n+        keywords_processed: List[str] = []\n+        for keyword in keywords.split(','):\n+            keyword_sanitized = keyword.strip().casefold()\n+            if not keyword_sanitized:\n+                # this happens when there are leading / trailing / consecutive comma.\n+                continue\n+            keywords_processed.append(keyword_sanitized)\n+\n+        if not keywords_processed:\n+            # after sanitizing, we can end up with an empty list, for example when keywords is ','\n+            # in that case, we simply want to search for such keywords directly instead.\n+            keywords_processed = [keywords]\n+\n+        matching_tags = []\n+        for tag in self._cache.values():\n+            if check(query in tag['embed']['description'].casefold() for query in keywords_processed):\n+                matching_tags.append(tag)\n+\n+        return matching_tags\n+\n+    async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags: list) -> None:\n+        \"\"\"Send the result of matching tags to user.\"\"\"\n+        if not matching_tags:\n+            pass\n+        elif len(matching_tags) == 1:\n+            await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed']))\n+        else:\n+            is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0\n+            embed = Embed(\n+                title=f\"Here are the tags containing the given keyword{'s' * is_plural}:\",\n+                description='\\n'.join(tag['title'] for tag in matching_tags[:10])\n+            )\n+            await LinePaginator.paginate(\n+                sorted(f\"**\u00bb**   {tag['title']}\" for tag in matching_tags),\n+                ctx,\n+                embed,\n+                footer_text=\"To show a tag, type !tags <tagname>.\",\n+                empty=False,\n+                max_lines=15\n+            )\n+\n     @group(name='tags', aliases=('tag', 't'), invoke_without_command=True)\n     async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:\n         \"\"\"Show all known tags, a single tag, or run a subcommand.\"\"\"\n         await ctx.invoke(self.get_command, tag_name=tag_name)\n \n+    @tags_group.group(name='search', invoke_without_command=True)\n+    async def search_tag_content(self, ctx: Context, *, keywords: str) -> None:\n+        \"\"\"\n+        Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma.\n+\n+        Only search for tags that has ALL the keywords.\n+        \"\"\"\n+        matching_tags = await self._get_tags_via_content(all, keywords)\n+        await self._send_matching_tags(ctx, keywords, matching_tags)\n+\n+    @search_tag_content.command(name='any')\n+    async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = None) -> None:\n+        \"\"\"\n+        Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma.\n+\n+        Search for tags that has ANY of the keywords.\n+        \"\"\"\n+        matching_tags = await self._get_tags_via_content(any, keywords or 'any')\n+        await self._send_matching_tags(ctx, keywords, matching_tags)\n+\n     @tags_group.command(name='get', aliases=('show', 'g'))\n     async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:\n         \"\"\"Get a specified tag, or a list of all tags if no tag is specified.\"\"\"\n", "issue": "tag-search command to search tags via their contents instead of names.\nCurrently, it can be difficult to find the specific command for a tag even if you know it exists. A command to allow you to search through tag bodies would help with finding the right tags. For example doing `!tag-search backtick` would return `!code-block` (and any other tags that include the word backtick).\n", "before_files": [{"content": "import logging\nimport re\nimport time\nfrom typing import Dict, List, Optional\n\nfrom discord import Colour, Embed\nfrom discord.ext.commands import Cog, Context, group\n\nfrom bot.bot import Bot\nfrom bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles\nfrom bot.converters import TagContentConverter, TagNameConverter\nfrom bot.decorators import with_role\nfrom bot.pagination import LinePaginator\n\nlog = logging.getLogger(__name__)\n\nTEST_CHANNELS = (\n    Channels.bot_commands,\n    Channels.helpers\n)\n\nREGEX_NON_ALPHABET = re.compile(r\"[^a-z]\", re.MULTILINE & re.IGNORECASE)\n\n\nclass Tags(Cog):\n    \"\"\"Save new tags and fetch existing tags.\"\"\"\n\n    def __init__(self, bot: Bot):\n        self.bot = bot\n        self.tag_cooldowns = {}\n\n        self._cache = {}\n        self._last_fetch: float = 0.0\n\n    async def _get_tags(self, is_forced: bool = False) -> None:\n        \"\"\"Get all tags.\"\"\"\n        # refresh only when there's a more than 5m gap from last call.\n        time_now: float = time.time()\n        if is_forced or not self._last_fetch or time_now - self._last_fetch > 5 * 60:\n            tags = await self.bot.api_client.get('bot/tags')\n            self._cache = {tag['title'].lower(): tag for tag in tags}\n            self._last_fetch = time_now\n\n    @staticmethod\n    def _fuzzy_search(search: str, target: str) -> int:\n        \"\"\"A simple scoring algorithm based on how many letters are found / total, with order in mind.\"\"\"\n        current, index = 0, 0\n        _search = REGEX_NON_ALPHABET.sub('', search.lower())\n        _targets = iter(REGEX_NON_ALPHABET.split(target.lower()))\n        _target = next(_targets)\n        try:\n            while True:\n                while index < len(_target) and _search[current] == _target[index]:\n                    current += 1\n                    index += 1\n                index, _target = 0, next(_targets)\n        except (StopIteration, IndexError):\n            pass\n        return current / len(_search) * 100\n\n    def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]:\n        \"\"\"Return a list of suggested tags.\"\"\"\n        scores: Dict[str, int] = {\n            tag_title: Tags._fuzzy_search(tag_name, tag['title'])\n            for tag_title, tag in self._cache.items()\n        }\n\n        thresholds = thresholds or [100, 90, 80, 70, 60]\n\n        for threshold in thresholds:\n            suggestions = [\n                self._cache[tag_title]\n                for tag_title, matching_score in scores.items()\n                if matching_score >= threshold\n            ]\n            if suggestions:\n                return suggestions\n\n        return []\n\n    async def _get_tag(self, tag_name: str) -> list:\n        \"\"\"Get a specific tag.\"\"\"\n        await self._get_tags()\n        found = [self._cache.get(tag_name.lower(), None)]\n        if not found[0]:\n            return self._get_suggestions(tag_name)\n        return found\n\n    @group(name='tags', aliases=('tag', 't'), invoke_without_command=True)\n    async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:\n        \"\"\"Show all known tags, a single tag, or run a subcommand.\"\"\"\n        await ctx.invoke(self.get_command, tag_name=tag_name)\n\n    @tags_group.command(name='get', aliases=('show', 'g'))\n    async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:\n        \"\"\"Get a specified tag, or a list of all tags if no tag is specified.\"\"\"\n        def _command_on_cooldown(tag_name: str) -> bool:\n            \"\"\"\n            Check if the command is currently on cooldown, on a per-tag, per-channel basis.\n\n            The cooldown duration is set in constants.py.\n            \"\"\"\n            now = time.time()\n\n            cooldown_conditions = (\n                tag_name\n                and tag_name in self.tag_cooldowns\n                and (now - self.tag_cooldowns[tag_name][\"time\"]) < Cooldowns.tags\n                and self.tag_cooldowns[tag_name][\"channel\"] == ctx.channel.id\n            )\n\n            if cooldown_conditions:\n                return True\n            return False\n\n        if _command_on_cooldown(tag_name):\n            time_left = Cooldowns.tags - (time.time() - self.tag_cooldowns[tag_name][\"time\"])\n            log.info(\n                f\"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. \"\n                f\"Cooldown ends in {time_left:.1f} seconds.\"\n            )\n            return\n\n        await self._get_tags()\n\n        if tag_name is not None:\n            founds = await self._get_tag(tag_name)\n\n            if len(founds) == 1:\n                tag = founds[0]\n                if ctx.channel.id not in TEST_CHANNELS:\n                    self.tag_cooldowns[tag_name] = {\n                        \"time\": time.time(),\n                        \"channel\": ctx.channel.id\n                    }\n                await ctx.send(embed=Embed.from_dict(tag['embed']))\n            elif founds and len(tag_name) >= 3:\n                await ctx.send(embed=Embed(\n                    title='Did you mean ...',\n                    description='\\n'.join(tag['title'] for tag in founds[:10])\n                ))\n\n        else:\n            tags = self._cache.values()\n            if not tags:\n                await ctx.send(embed=Embed(\n                    description=\"**There are no tags in the database!**\",\n                    colour=Colour.red()\n                ))\n            else:\n                embed: Embed = Embed(title=\"**Current tags**\")\n                await LinePaginator.paginate(\n                    sorted(f\"**\u00bb**   {tag['title']}\" for tag in tags),\n                    ctx,\n                    embed,\n                    footer_text=\"To show a tag, type !tags <tagname>.\",\n                    empty=False,\n                    max_lines=15\n                )\n\n    @tags_group.command(name='set', aliases=('add', 's'))\n    @with_role(*MODERATION_ROLES)\n    async def set_command(\n        self,\n        ctx: Context,\n        tag_name: TagNameConverter,\n        *,\n        tag_content: TagContentConverter,\n    ) -> None:\n        \"\"\"Create a new tag.\"\"\"\n        body = {\n            'title': tag_name.lower().strip(),\n            'embed': {\n                'title': tag_name,\n                'description': tag_content\n            }\n        }\n\n        await self.bot.api_client.post('bot/tags', json=body)\n        self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}')\n\n        log.debug(f\"{ctx.author} successfully added the following tag to our database: \\n\"\n                  f\"tag_name: {tag_name}\\n\"\n                  f\"tag_content: '{tag_content}'\\n\")\n\n        await ctx.send(embed=Embed(\n            title=\"Tag successfully added\",\n            description=f\"**{tag_name}** added to tag database.\",\n            colour=Colour.blurple()\n        ))\n\n    @tags_group.command(name='edit', aliases=('e', ))\n    @with_role(*MODERATION_ROLES)\n    async def edit_command(\n        self,\n        ctx: Context,\n        tag_name: TagNameConverter,\n        *,\n        tag_content: TagContentConverter,\n    ) -> None:\n        \"\"\"Edit an existing tag.\"\"\"\n        body = {\n            'embed': {\n                'title': tag_name,\n                'description': tag_content\n            }\n        }\n\n        await self.bot.api_client.patch(f'bot/tags/{tag_name}', json=body)\n        self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}')\n\n        log.debug(f\"{ctx.author} successfully edited the following tag in our database: \\n\"\n                  f\"tag_name: {tag_name}\\n\"\n                  f\"tag_content: '{tag_content}'\\n\")\n\n        await ctx.send(embed=Embed(\n            title=\"Tag successfully edited\",\n            description=f\"**{tag_name}** edited in the database.\",\n            colour=Colour.blurple()\n        ))\n\n    @tags_group.command(name='delete', aliases=('remove', 'rm', 'd'))\n    @with_role(Roles.admins, Roles.owners)\n    async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None:\n        \"\"\"Remove a tag from the database.\"\"\"\n        await self.bot.api_client.delete(f'bot/tags/{tag_name}')\n        self._cache.pop(tag_name.lower(), None)\n\n        log.debug(f\"{ctx.author} successfully deleted the tag called '{tag_name}'\")\n        await ctx.send(embed=Embed(\n            title=tag_name,\n            description=f\"Tag successfully removed: {tag_name}.\",\n            colour=Colour.blurple()\n        ))\n\n\ndef setup(bot: Bot) -> None:\n    \"\"\"Load the Tags cog.\"\"\"\n    bot.add_cog(Tags(bot))\n", "path": "bot/cogs/tags.py"}], "after_files": [{"content": "import logging\nimport re\nimport time\nfrom typing import Callable, Dict, Iterable, List, Optional\n\nfrom discord import Colour, Embed\nfrom discord.ext.commands import Cog, Context, group\n\nfrom bot.bot import Bot\nfrom bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles\nfrom bot.converters import TagContentConverter, TagNameConverter\nfrom bot.decorators import with_role\nfrom bot.pagination import LinePaginator\n\nlog = logging.getLogger(__name__)\n\nTEST_CHANNELS = (\n    Channels.bot_commands,\n    Channels.helpers\n)\n\nREGEX_NON_ALPHABET = re.compile(r\"[^a-z]\", re.MULTILINE & re.IGNORECASE)\n\n\nclass Tags(Cog):\n    \"\"\"Save new tags and fetch existing tags.\"\"\"\n\n    def __init__(self, bot: Bot):\n        self.bot = bot\n        self.tag_cooldowns = {}\n\n        self._cache = {}\n        self._last_fetch: float = 0.0\n\n    async def _get_tags(self, is_forced: bool = False) -> None:\n        \"\"\"Get all tags.\"\"\"\n        # refresh only when there's a more than 5m gap from last call.\n        time_now: float = time.time()\n        if is_forced or not self._last_fetch or time_now - self._last_fetch > 5 * 60:\n            tags = await self.bot.api_client.get('bot/tags')\n            self._cache = {tag['title'].lower(): tag for tag in tags}\n            self._last_fetch = time_now\n\n    @staticmethod\n    def _fuzzy_search(search: str, target: str) -> int:\n        \"\"\"A simple scoring algorithm based on how many letters are found / total, with order in mind.\"\"\"\n        current, index = 0, 0\n        _search = REGEX_NON_ALPHABET.sub('', search.lower())\n        _targets = iter(REGEX_NON_ALPHABET.split(target.lower()))\n        _target = next(_targets)\n        try:\n            while True:\n                while index < len(_target) and _search[current] == _target[index]:\n                    current += 1\n                    index += 1\n                index, _target = 0, next(_targets)\n        except (StopIteration, IndexError):\n            pass\n        return current / len(_search) * 100\n\n    def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]:\n        \"\"\"Return a list of suggested tags.\"\"\"\n        scores: Dict[str, int] = {\n            tag_title: Tags._fuzzy_search(tag_name, tag['title'])\n            for tag_title, tag in self._cache.items()\n        }\n\n        thresholds = thresholds or [100, 90, 80, 70, 60]\n\n        for threshold in thresholds:\n            suggestions = [\n                self._cache[tag_title]\n                for tag_title, matching_score in scores.items()\n                if matching_score >= threshold\n            ]\n            if suggestions:\n                return suggestions\n\n        return []\n\n    async def _get_tag(self, tag_name: str) -> list:\n        \"\"\"Get a specific tag.\"\"\"\n        await self._get_tags()\n        found = [self._cache.get(tag_name.lower(), None)]\n        if not found[0]:\n            return self._get_suggestions(tag_name)\n        return found\n\n    async def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str) -> list:\n        \"\"\"\n        Search for tags via contents.\n\n        `predicate` will be the built-in any, all, or a custom callable. Must return a bool.\n        \"\"\"\n        await self._get_tags()\n\n        keywords_processed: List[str] = []\n        for keyword in keywords.split(','):\n            keyword_sanitized = keyword.strip().casefold()\n            if not keyword_sanitized:\n                # this happens when there are leading / trailing / consecutive comma.\n                continue\n            keywords_processed.append(keyword_sanitized)\n\n        if not keywords_processed:\n            # after sanitizing, we can end up with an empty list, for example when keywords is ','\n            # in that case, we simply want to search for such keywords directly instead.\n            keywords_processed = [keywords]\n\n        matching_tags = []\n        for tag in self._cache.values():\n            if check(query in tag['embed']['description'].casefold() for query in keywords_processed):\n                matching_tags.append(tag)\n\n        return matching_tags\n\n    async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags: list) -> None:\n        \"\"\"Send the result of matching tags to user.\"\"\"\n        if not matching_tags:\n            pass\n        elif len(matching_tags) == 1:\n            await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed']))\n        else:\n            is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0\n            embed = Embed(\n                title=f\"Here are the tags containing the given keyword{'s' * is_plural}:\",\n                description='\\n'.join(tag['title'] for tag in matching_tags[:10])\n            )\n            await LinePaginator.paginate(\n                sorted(f\"**\u00bb**   {tag['title']}\" for tag in matching_tags),\n                ctx,\n                embed,\n                footer_text=\"To show a tag, type !tags <tagname>.\",\n                empty=False,\n                max_lines=15\n            )\n\n    @group(name='tags', aliases=('tag', 't'), invoke_without_command=True)\n    async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:\n        \"\"\"Show all known tags, a single tag, or run a subcommand.\"\"\"\n        await ctx.invoke(self.get_command, tag_name=tag_name)\n\n    @tags_group.group(name='search', invoke_without_command=True)\n    async def search_tag_content(self, ctx: Context, *, keywords: str) -> None:\n        \"\"\"\n        Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma.\n\n        Only search for tags that has ALL the keywords.\n        \"\"\"\n        matching_tags = await self._get_tags_via_content(all, keywords)\n        await self._send_matching_tags(ctx, keywords, matching_tags)\n\n    @search_tag_content.command(name='any')\n    async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = None) -> None:\n        \"\"\"\n        Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma.\n\n        Search for tags that has ANY of the keywords.\n        \"\"\"\n        matching_tags = await self._get_tags_via_content(any, keywords or 'any')\n        await self._send_matching_tags(ctx, keywords, matching_tags)\n\n    @tags_group.command(name='get', aliases=('show', 'g'))\n    async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:\n        \"\"\"Get a specified tag, or a list of all tags if no tag is specified.\"\"\"\n        def _command_on_cooldown(tag_name: str) -> bool:\n            \"\"\"\n            Check if the command is currently on cooldown, on a per-tag, per-channel basis.\n\n            The cooldown duration is set in constants.py.\n            \"\"\"\n            now = time.time()\n\n            cooldown_conditions = (\n                tag_name\n                and tag_name in self.tag_cooldowns\n                and (now - self.tag_cooldowns[tag_name][\"time\"]) < Cooldowns.tags\n                and self.tag_cooldowns[tag_name][\"channel\"] == ctx.channel.id\n            )\n\n            if cooldown_conditions:\n                return True\n            return False\n\n        if _command_on_cooldown(tag_name):\n            time_left = Cooldowns.tags - (time.time() - self.tag_cooldowns[tag_name][\"time\"])\n            log.info(\n                f\"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. \"\n                f\"Cooldown ends in {time_left:.1f} seconds.\"\n            )\n            return\n\n        await self._get_tags()\n\n        if tag_name is not None:\n            founds = await self._get_tag(tag_name)\n\n            if len(founds) == 1:\n                tag = founds[0]\n                if ctx.channel.id not in TEST_CHANNELS:\n                    self.tag_cooldowns[tag_name] = {\n                        \"time\": time.time(),\n                        \"channel\": ctx.channel.id\n                    }\n                await ctx.send(embed=Embed.from_dict(tag['embed']))\n            elif founds and len(tag_name) >= 3:\n                await ctx.send(embed=Embed(\n                    title='Did you mean ...',\n                    description='\\n'.join(tag['title'] for tag in founds[:10])\n                ))\n\n        else:\n            tags = self._cache.values()\n            if not tags:\n                await ctx.send(embed=Embed(\n                    description=\"**There are no tags in the database!**\",\n                    colour=Colour.red()\n                ))\n            else:\n                embed: Embed = Embed(title=\"**Current tags**\")\n                await LinePaginator.paginate(\n                    sorted(f\"**\u00bb**   {tag['title']}\" for tag in tags),\n                    ctx,\n                    embed,\n                    footer_text=\"To show a tag, type !tags <tagname>.\",\n                    empty=False,\n                    max_lines=15\n                )\n\n    @tags_group.command(name='set', aliases=('add', 's'))\n    @with_role(*MODERATION_ROLES)\n    async def set_command(\n        self,\n        ctx: Context,\n        tag_name: TagNameConverter,\n        *,\n        tag_content: TagContentConverter,\n    ) -> None:\n        \"\"\"Create a new tag.\"\"\"\n        body = {\n            'title': tag_name.lower().strip(),\n            'embed': {\n                'title': tag_name,\n                'description': tag_content\n            }\n        }\n\n        await self.bot.api_client.post('bot/tags', json=body)\n        self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}')\n\n        log.debug(f\"{ctx.author} successfully added the following tag to our database: \\n\"\n                  f\"tag_name: {tag_name}\\n\"\n                  f\"tag_content: '{tag_content}'\\n\")\n\n        await ctx.send(embed=Embed(\n            title=\"Tag successfully added\",\n            description=f\"**{tag_name}** added to tag database.\",\n            colour=Colour.blurple()\n        ))\n\n    @tags_group.command(name='edit', aliases=('e', ))\n    @with_role(*MODERATION_ROLES)\n    async def edit_command(\n        self,\n        ctx: Context,\n        tag_name: TagNameConverter,\n        *,\n        tag_content: TagContentConverter,\n    ) -> None:\n        \"\"\"Edit an existing tag.\"\"\"\n        body = {\n            'embed': {\n                'title': tag_name,\n                'description': tag_content\n            }\n        }\n\n        await self.bot.api_client.patch(f'bot/tags/{tag_name}', json=body)\n        self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}')\n\n        log.debug(f\"{ctx.author} successfully edited the following tag in our database: \\n\"\n                  f\"tag_name: {tag_name}\\n\"\n                  f\"tag_content: '{tag_content}'\\n\")\n\n        await ctx.send(embed=Embed(\n            title=\"Tag successfully edited\",\n            description=f\"**{tag_name}** edited in the database.\",\n            colour=Colour.blurple()\n        ))\n\n    @tags_group.command(name='delete', aliases=('remove', 'rm', 'd'))\n    @with_role(Roles.admins, Roles.owners)\n    async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None:\n        \"\"\"Remove a tag from the database.\"\"\"\n        await self.bot.api_client.delete(f'bot/tags/{tag_name}')\n        self._cache.pop(tag_name.lower(), None)\n\n        log.debug(f\"{ctx.author} successfully deleted the tag called '{tag_name}'\")\n        await ctx.send(embed=Embed(\n            title=tag_name,\n            description=f\"Tag successfully removed: {tag_name}.\",\n            colour=Colour.blurple()\n        ))\n\n\ndef setup(bot: Bot) -> None:\n    \"\"\"Load the Tags cog.\"\"\"\n    bot.add_cog(Tags(bot))\n", "path": "bot/cogs/tags.py"}]}