Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9fd7b08
Added all the tag files in resources and modified cogs/tags.py file t…
RohanJnr Feb 29, 2020
f256346
added white spaces on statements before bullet points for proper rend…
RohanJnr Feb 29, 2020
fe31808
Re-corrected the lines which I had changed by mistake
RohanJnr Feb 29, 2020
d583e9b
Merge branch 'master' into tags_overhaul
RohanJnr Feb 29, 2020
1b56868
Caching all the tags when the bot has loaded(caching only once) inste…
RohanJnr Feb 29, 2020
a7ad0b7
Merge branch 'tags_overhaul' of https://github.com/RohanJnr/bot into …
RohanJnr Feb 29, 2020
1c7b2d8
Use "pathlib" instead of "os" module and context manager
RohanJnr Mar 4, 2020
073fdf1
Convert "get_tags()" and "_get_tag()" to sync functions
RohanJnr Mar 4, 2020
c624c25
Merge branch 'master' into tags_overhaul
sco1 Mar 4, 2020
564690f
Update tag files for new linting hooks
sco1 Mar 4, 2020
4662cfd
Update ytdl tag to the new YouTube ToS
Akarys42 Mar 12, 2020
f2d10e4
remove repetitive file search
RohanJnr Mar 12, 2020
9bc6744
Merge branch 'master' into tags_overhaul
RohanJnr Mar 12, 2020
55effb3
convert get_tags() method to staticmethod
RohanJnr Mar 12, 2020
d56639c
Merge branch 'tags_overhaul' of https://github.com/RohanJnr/bot into …
RohanJnr Mar 12, 2020
e9ae8a8
Remove line that calls get_tags() method
RohanJnr Mar 14, 2020
52ed9aa
Tags: use constant for command prefix in embed footer
MarkKoz Mar 14, 2020
eeaccea
Tags: add restrictions 1 & 9 from YouTube ToS to ytdl tag
MarkKoz Mar 14, 2020
5865126
convert _get_tags_via_content() method to non-async
RohanJnr Mar 15, 2020
156492c
Merge branch 'tags_overhaul' of https://github.com/RohanJnr/bot into …
RohanJnr Mar 15, 2020
520c730
not awaiting _get_tags_via_content() method as it is non-async
RohanJnr Mar 15, 2020
8eea9c3
Fixed tag search via contents, any keywords.
ikuyarihS Mar 15, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 34 additions & 106 deletions bot/cogs/tags.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
import logging
import re
import time
from pathlib import Path
from typing import Callable, Dict, Iterable, List, Optional

from discord import Colour, Embed
from discord.ext.commands import Cog, Context, group

from bot import constants
from bot.bot import Bot
from bot.constants import Channels, Cooldowns, MODERATION_ROLES, Roles
from bot.converters import TagContentConverter, TagNameConverter
from bot.decorators import with_role
from bot.converters import TagNameConverter
from bot.pagination import LinePaginator

log = logging.getLogger(__name__)

TEST_CHANNELS = (
Channels.bot_commands,
Channels.helpers
constants.Channels.bot_commands,
constants.Channels.helpers
)

REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE)
FOOTER_TEXT = f"To show a tag, type {constants.Bot.prefix}tags <tagname>."


class Tags(Cog):
Expand All @@ -28,21 +29,27 @@ class Tags(Cog):
def __init__(self, bot: Bot):
self.bot = bot
self.tag_cooldowns = {}
self._cache = self.get_tags()

self._cache = {}
self._last_fetch: float = 0.0

async def _get_tags(self, is_forced: bool = False) -> None:
@staticmethod
def get_tags() -> dict:
"""Get all tags."""
# refresh only when there's a more than 5m gap from last call.
time_now: float = time.time()
if is_forced or not self._last_fetch or time_now - self._last_fetch > 5 * 60:
tags = await self.bot.api_client.get('bot/tags')
self._cache = {tag['title'].lower(): tag for tag in tags}
self._last_fetch = time_now
# Save all tags in memory.
cache = {}
tag_files = Path("bot", "resources", "tags").iterdir()
for file in tag_files:
tag_title = file.stem
tag = {
"title": tag_title,
"embed": {
"description": file.read_text()
}
}
cache[tag_title] = tag
return cache

@staticmethod
def _fuzzy_search(search: str, target: str) -> int:
def _fuzzy_search(search: str, target: str) -> float:
Comment thread
lemonsaurus marked this conversation as resolved.
"""A simple scoring algorithm based on how many letters are found / total, with order in mind."""
current, index = 0, 0
_search = REGEX_NON_ALPHABET.sub('', search.lower())
Expand Down Expand Up @@ -78,22 +85,19 @@ def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None

return []

async def _get_tag(self, tag_name: str) -> list:
def _get_tag(self, tag_name: str) -> list:
"""Get a specific tag."""
await self._get_tags()
found = [self._cache.get(tag_name.lower(), None)]
if not found[0]:
return self._get_suggestions(tag_name)
return found

async def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str) -> list:
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()
Comment thread
ikuyarihS marked this conversation as resolved.

keywords_processed: List[str] = []
for keyword in keywords.split(','):
keyword_sanitized = keyword.strip().casefold()
Expand Down Expand Up @@ -130,7 +134,7 @@ async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags:
sorted(f"**»** {tag['title']}" for tag in matching_tags),
ctx,
embed,
footer_text="To show a tag, type !tags <tagname>.",
footer_text=FOOTER_TEXT,
empty=False,
max_lines=15
)
Expand All @@ -147,17 +151,17 @@ async def search_tag_content(self, ctx: Context, *, keywords: str) -> None:

Only search for tags that has ALL the keywords.
"""
matching_tags = await self._get_tags_via_content(all, keywords)
matching_tags = 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:
async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = 'any') -> 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')
matching_tags = 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'))
Expand All @@ -174,7 +178,7 @@ def _command_on_cooldown(tag_name: str) -> bool:
cooldown_conditions = (
tag_name
and tag_name in self.tag_cooldowns
and (now - self.tag_cooldowns[tag_name]["time"]) < Cooldowns.tags
and (now - self.tag_cooldowns[tag_name]["time"]) < constants.Cooldowns.tags
and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id
)

Expand All @@ -183,17 +187,16 @@ def _command_on_cooldown(tag_name: str) -> bool:
return False

if _command_on_cooldown(tag_name):
time_left = Cooldowns.tags - (time.time() - self.tag_cooldowns[tag_name]["time"])
time_elapsed = time.time() - self.tag_cooldowns[tag_name]["time"]
time_left = constants.Cooldowns.tags - time_elapsed
log.info(
f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. "
f"Cooldown ends in {time_left:.1f} seconds."
)
return

await self._get_tags()

if tag_name is not None:
founds = await self._get_tag(tag_name)
founds = self._get_tag(tag_name)

if len(founds) == 1:
tag = founds[0]
Expand Down Expand Up @@ -222,86 +225,11 @@ def _command_on_cooldown(tag_name: str) -> bool:
sorted(f"**»** {tag['title']}" for tag in tags),
ctx,
embed,
footer_text="To show a tag, type !tags <tagname>.",
footer_text=FOOTER_TEXT,
empty=False,
max_lines=15
)

@tags_group.command(name='set', aliases=('add', 's'))
@with_role(*MODERATION_ROLES)
async def set_command(
self,
ctx: Context,
tag_name: TagNameConverter,
*,
tag_content: TagContentConverter,
) -> None:
"""Create a new tag."""
body = {
'title': tag_name.lower().strip(),
'embed': {
'title': tag_name,
'description': tag_content
}
}

await self.bot.api_client.post('bot/tags', json=body)
self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}')

log.debug(f"{ctx.author} successfully added the following tag to our database: \n"
f"tag_name: {tag_name}\n"
f"tag_content: '{tag_content}'\n")

await ctx.send(embed=Embed(
title="Tag successfully added",
description=f"**{tag_name}** added to tag database.",
colour=Colour.blurple()
))

@tags_group.command(name='edit', aliases=('e', ))
@with_role(*MODERATION_ROLES)
async def edit_command(
self,
ctx: Context,
tag_name: TagNameConverter,
*,
tag_content: TagContentConverter,
) -> None:
"""Edit an existing tag."""
body = {
'embed': {
'title': tag_name,
'description': tag_content
}
}

await self.bot.api_client.patch(f'bot/tags/{tag_name}', json=body)
self._cache[tag_name.lower()] = await self.bot.api_client.get(f'bot/tags/{tag_name}')

log.debug(f"{ctx.author} successfully edited the following tag in our database: \n"
f"tag_name: {tag_name}\n"
f"tag_content: '{tag_content}'\n")

await ctx.send(embed=Embed(
title="Tag successfully edited",
description=f"**{tag_name}** edited in the database.",
colour=Colour.blurple()
))

@tags_group.command(name='delete', aliases=('remove', 'rm', 'd'))
@with_role(Roles.admins, Roles.owners)
async def delete_command(self, ctx: Context, *, tag_name: TagNameConverter) -> None:
"""Remove a tag from the database."""
await self.bot.api_client.delete(f'bot/tags/{tag_name}')
self._cache.pop(tag_name.lower(), None)

log.debug(f"{ctx.author} successfully deleted the tag called '{tag_name}'")
await ctx.send(embed=Embed(
title=tag_name,
description=f"Tag successfully removed: {tag_name}.",
colour=Colour.blurple()
))


def setup(bot: Bot) -> None:
"""Load the Tags cog."""
Expand Down
17 changes: 17 additions & 0 deletions bot/resources/tags/args-kwargs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
`*args` and `**kwargs`

These special parameters allow functions to take arbitrary amounts of positional and keyword arguments. The names `args` and `kwargs` are purely convention, and could be named any other valid variable name. The special functionality comes from the single and double asterisks (`*`). If both are used in a function signature, `*args` **must** appear before `**kwargs`.

**Single asterisk**
`*args` will ingest an arbitrary amount of **positional arguments**, and store it in a tuple. If there are parameters after `*args` in the parameter list with no default value, they will become **required** keyword arguments by default.

**Double asterisk**
`**kwargs` will ingest an arbitrary amount of **keyword arguments**, and store it in a dictionary. There can be **no** additional parameters **after** `**kwargs` in the parameter list.

**Use cases**
• **Decorators** (see `!tags decorators`)
• **Inheritance** (overriding methods)
• **Future proofing** (in the case of the first two bullet points, if the parameters change, your code won't break)
• **Flexibility** (writing functions that behave like `dict()` or `print()`)

*See* `!tags positional-keyword` *for information about positional and keyword arguments*
9 changes: 9 additions & 0 deletions bot/resources/tags/ask.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Asking good questions will yield a much higher chance of a quick response:

• Don't ask to ask your question, just go ahead and tell us your problem.
• Don't ask if anyone is knowledgeable in some area, filtering serves no purpose.
• Try to solve the problem on your own first, we're not going to write code for you.
• Show us the code you've tried and any errors or unexpected results it's giving.
• Be patient while we're helping you.

You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/).
25 changes: 25 additions & 0 deletions bot/resources/tags/class.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
**Classes**

Classes are used to create objects that have specific behavior.

Every object in python has a class, including `list`s, `dict`ionaries and even numbers. Using a class to group code and data like this is the foundation of Object Oriented Programming. Classes allow you to expose a simple, consistent interface while hiding the more complicated details. This simplifies the rest of your program and makes it easier to separately maintain and debug each component.

Here is an example class:

```python
class Foo:
def __init__(self, somedata):
self.my_attrib = somedata

def show(self):
print(self.my_attrib)
```

To use a class, you need to instantiate it. The following creates a new object named `bar`, with `Foo` as its class.

```python
bar = Foo('data')
bar.show()
```

We can access any of `Foo`'s methods via `bar.my_method()`, and access any of `bar`s data via `bar.my_attribute`.
20 changes: 20 additions & 0 deletions bot/resources/tags/classmethod.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Although most methods are tied to an _object instance_, it can sometimes be useful to create a method that does something with _the class itself_. To achieve this in Python, you can use the `@classmethod` decorator. This is often used to provide alternative constructors for a class.

For example, you may be writing a class that takes some magic token (like an API key) as a constructor argument, but you sometimes read this token from a configuration file. You could make use of a `@classmethod` to create an alternate constructor for when you want to read from the configuration file.
```py
class Bot:
def __init__(self, token: str):
self._token = token

@classmethod
def from_config(cls, config: dict) -> Bot:
token = config['token']
return cls(token)

# now we can create the bot instance like this
alternative_bot = Bot.from_config(default_config)

# but this still works, too
regular_bot = Bot("tokenstring")
```
This is just one of the many use cases of `@classmethod`. A more in-depth explanation can be found [here](https://stackoverflow.com/questions/12179271/meaning-of-classmethod-and-staticmethod-for-beginner#12179752).
17 changes: 17 additions & 0 deletions bot/resources/tags/codeblock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Discord has support for Markdown, which allows you to post code with full syntax highlighting. Please use these whenever you paste code, as this helps improve the legibility and makes it easier for us to help you.

To do this, use the following method:

\```python
print('Hello world!')
\```

Note:
• **These are backticks, not quotes.** Backticks can usually be found on the tilde key.
• You can also use py as the language instead of python
• The language must be on the first line next to the backticks with **no** space between them

This will result in the following:
```py
print('Hello world!')
```
31 changes: 31 additions & 0 deletions bot/resources/tags/decorators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
**Decorators**

A decorator is a function that modifies another function.

Consider the following example of a timer decorator:
```py
>>> import time
>>> def timer(f):
... def inner(*args, **kwargs):
... start = time.time()
... result = f(*args, **kwargs)
... print('Time elapsed:', time.time() - start)
... return result
... return inner
...
>>> @timer
... def slow(delay=1):
... time.sleep(delay)
... return 'Finished!'
...
>>> print(slow())
Time elapsed: 1.0011568069458008
Finished!
>>> print(slow(3))
Time elapsed: 3.000307321548462
Finished!
```

More information:
• [Corey Schafer's video on decorators](https://youtu.be/FsAPt_9Bf3U)
• [Real python article](https://realpython.com/primer-on-python-decorators/)
20 changes: 20 additions & 0 deletions bot/resources/tags/dictcomps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
**Dictionary Comprehensions**

Like lists, there is a convenient way of creating dictionaries:
```py
>>> ftoc = {f: round((5/9)*(f-32)) for f in range(-40,101,20)}
>>> print(ftoc)
{-40: -40, -20: -29, 0: -18, 20: -7, 40: 4, 60: 16, 80: 27, 100: 38}
```
In the example above, I created a dictionary of temperatures in Fahrenheit, that are mapped to (*roughly*) their Celsius counterpart within a small range. These comprehensions are useful for succinctly creating dictionaries from some other sequence.

They are also very useful for inverting the key value pairs of a dictionary that already exists, such that the value in the old dictionary is now the key, and the corresponding key is now its value:
```py
>>> ctof = {v:k for k, v in ftoc.items()}
>>> print(ctof)
{-40: -40, -29: -20, -18: 0, -7: 20, 4: 40, 16: 60, 27: 80, 38: 100}
```

Also like list comprehensions, you can add a conditional to it in order to filter out items you don't want.

For more information and examples, check [PEP 274](https://www.python.org/dev/peps/pep-0274/)
Loading