Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ hikari = {extras = ["speedups"]}
hikari-crescent = "*"
python-dotenv = "*"
uvloop = "*"
black = "*"
beautifulsoup4 = "*"
guesslang = {ref = "d3b55c255d1886bce96610c04cb9241afca6f9d4", git = "https://github.com/SimeonAleksov/guesslang.git"}

[dev-packages]
types-beautifulsoup4 = "*"
black = "*"
flake8 = "*"
isort = "*"
Expand Down
767 changes: 666 additions & 101 deletions Pipfile.lock

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions bot/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

import commands
import crescent
import events
from dotenv import load_dotenv

_LOG_PREFIX = "[BOT]"
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


if os.name != "nt":
Expand All @@ -20,5 +23,10 @@

for module in inspect.getmembers(commands, predicate=inspect.ismodule):
bot.plugins.load(f"commands.{module[0]}")
logger.info(f"{_LOG_PREFIX} Loading module commands.{module[0]}.")

for module in inspect.getmembers(events, predicate=inspect.ismodule):
bot.plugins.load(f"events.{module[0]}")
logger.info(f"{_LOG_PREFIX} Loading module events.{module[0]}.")

bot.run()
Empty file added bot/codeblocks/__init__.py
Empty file.
33 changes: 33 additions & 0 deletions bot/codeblocks/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import enum

from codeblocks.utils import black_string


class Language(enum.Enum):
PYTHON = "Python"
HTML = "HTML"
JAVASCRIPT = "JavaScript"
NONE = ""

@classmethod
def choices(cls):
return [lang.value for lang in cls if lang.value]

def get_markdown_name(self):
_mapping = {"Python": "py", "HTML": "html", "JavaScript": "js"}
return _mapping.get(self.value)


_EXAMPLE_CODE = black_string("def add_one(foo: int) -> int: return foo + 1")
_ESCAPED_CODEBLOCK = f"\`\`\`python\n{_EXAMPLE_CODE}\n\`\`\`" # noqa: W605


def get_escaped_codeblock():
return _ESCAPED_CODEBLOCK


def get_example_code():
return _EXAMPLE_CODE


LANGUAGE_GUESS_TRESHOLD = 0.5
116 changes: 116 additions & 0 deletions bot/codeblocks/parsers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import abc
import ast
import logging
from typing import Optional, Union

import bs4
import guesslang
import hikari
from codeblocks.constants import LANGUAGE_GUESS_TRESHOLD, Language
from codeblocks.types import Codeblock, MarkdownCodeblock
from codeblocks.utils import black_string

logger = logging.getLogger(__name__)
_LOG_PREFIX = "[CODEBLOCK-PARSER]"


class CodeblockParser(abc.ABC):
def __init__(self, message: hikari.Message) -> None:
self.codeblock = Codeblock.from_message(message=message)

def validate_and_format(self):
return MarkdownCodeblock.set_content(
content=self.format_code(self.codeblock.content),
language=self.validate_and_get_language(),
)

@abc.abstractmethod
def validate_and_get_language(self):
pass

@abc.abstractmethod
def format_code(self, content: str) -> str:
pass


class PythonParser(CodeblockParser):
def validate_and_get_language(self):
if self.is_valid_python(self.codeblock.content):
return Language.PYTHON

def format_code(self, content: str) -> str:
return black_string(content)

@classmethod
def is_valid_python(cls, content: str) -> bool:
logger.info(f"{_LOG_PREFIX} Checking if message is valid python code.")
try:
content = content.replace("\x00", "")
tree = ast.parse(content)
except SyntaxError:
return False
return not all(isinstance(node, ast.Expr) for node in tree.body)


class HTMLParser(CodeblockParser):
def validate_and_get_language(self):
logger.info(f"{_LOG_PREFIX} Checking if message is valid html code.")
if self.is_valid_html(content=self.codeblock.content):
return Language.HTML

@classmethod
def is_valid_html(cls, content: str) -> bool:
logger.info(f"{_LOG_PREFIX} Checking if message is valid html code.")
return bool(bs4.BeautifulSoup(content, "html.parser").find())

def format_code(self, content: str) -> str:
return content


class JavascriptParser(CodeblockParser):
def validate_and_get_language(self):
if self.is_javascript_code(content=self.codeblock.content):
return Language.JAVASCRIPT

def format_code(self, content: str) -> str:
return content

@classmethod
def is_javascript_code(cls, content: str) -> Optional[bool]:
logger.info(f"{_LOG_PREFIX} Checking if message is valid javascript code.")
guess = guesslang.Guess()
if (
dict(guess.probabilities(content))[Language.JAVASCRIPT.value]
> LANGUAGE_GUESS_TRESHOLD
):
return guess.language_name(content) in Language.choices()
return None


def get_parser(
message: hikari.Message,
) -> Optional[Union[HTMLParser, PythonParser, JavascriptParser]]:
Comment on lines +90 to +92
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're on 3.10 (which reminds me, should probably upgrade to 3.11), so we can do:

Suggested change
def get_parser(
message: hikari.Message,
) -> Optional[Union[HTMLParser, PythonParser, JavascriptParser]]:
def get_parser(
message: hikari.Message,
) -> HTMLParser | PythonParser | JavascriptParser || None:

Additionally... Couldn't this be:

Suggested change
def get_parser(
message: hikari.Message,
) -> Optional[Union[HTMLParser, PythonParser, JavascriptParser]]:
def get_parser(message: hikari.Message) -> CodeblockParser | None:

if message.content is not None:
if PythonParser.is_valid_python(content=message.content):
logger.info(f"{_LOG_PREFIX} Message is unformatted python code.")
return PythonParser(message=message)
elif HTMLParser.is_valid_html(content=message.content):
logger.info(f"{_LOG_PREFIX} Message is unformatted html code.")
return HTMLParser(message=message)
elif JavascriptParser.is_javascript_code(content=message.content):
logger.info(f"{_LOG_PREFIX} Message is unformatted javascript code.")
return JavascriptParser(message=message)
logger.info(f"{_LOG_PREFIX} Message is not valid code, no action necessary.")
return None


def should_parse_message(content: str) -> bool:
return not (_is_codeblock(content=content) or _is_inline_block(content=content))


def _is_codeblock(content: str):
return content.startswith("```") and content.endswith("```")


def _is_inline_block(content: str):
return content.startswith("`") and content.endswith("`")
41 changes: 41 additions & 0 deletions bot/codeblocks/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from __future__ import annotations

from typing import NamedTuple, Optional

import hikari
from codeblocks.constants import Language
from markup import services as markdown_services


class Codeblock(
NamedTuple(
"Codeblock",
[
("content", Optional[str]),
("length", int),
],
)
):
@classmethod
def from_message(cls, message: hikari.Message) -> Codeblock:
return cls(
content=message.content,
length=len(message.content.split("\n"))
if message.content is not None
else 0,
)


class MarkdownCodeblock(
NamedTuple(
"MarkdownCodeblock",
[
("content", Optional[str]),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
("content", Optional[str]),
("content", str | None),

],
)
):
@classmethod
def set_content(cls, content: str, language: Language) -> MarkdownCodeblock:
return cls(
content=markdown_services.codeblock(text=content, language=language),
)
13 changes: 13 additions & 0 deletions bot/codeblocks/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import black


def black_string(content):
return black.format_str(
src_contents=content,
mode=black.Mode(
target_versions={black.TargetVersion.PY36},
line_length=80,
string_normalization=False,
is_pyi=False,
),
)
25 changes: 25 additions & 0 deletions bot/commands/nags.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import crescent
import hikari
from codeblocks import constants as codeblocks_constants
from markup import services as markup_services

plugin = crescent.Plugin("nags")

Expand Down Expand Up @@ -34,3 +36,26 @@ async def callback(self, ctx: crescent.Context) -> None:
await ctx.respond(
"Don't ask to ask, just ask: See https://dontasktoask.com/"
)


@plugin.include
@crescent.command(
name="markdown", description="How to properly format and markdown a codeblock."
)
class MarkdownCode:
user = crescent.option(hikari.User, "The user to mention", default=None)

async def callback(self, ctx: crescent.Context) -> None:
message = (
"When asking for help, you should properly markdown your code, for instance: \n\n"
f"{codeblocks_constants.get_escaped_codeblock()}\n\n"
f"This becomes: "
f"{markup_services.codeblock(codeblocks_constants.get_example_code(), codeblocks_constants.Language.PYTHON)}"
)
if self.user:
await ctx.respond(
(message),
user_mentions=[self.user],
)
else:
await ctx.respond(message)
3 changes: 3 additions & 0 deletions bot/events/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__all__ = ["codeblock"]

from . import codeblock
27 changes: 27 additions & 0 deletions bot/events/codeblock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import crescent
import hikari
from codeblocks.parsers import get_parser, should_parse_message

plugin = crescent.Plugin("codeblocks")


@plugin.include
@crescent.event
async def on_message_format(event: hikari.MessageCreateEvent):
if event.message.author.is_bot or event.message.content is None:
return
if not should_parse_message(event.message.content):
return

codeblock = get_parser(message=event.message)
if codeblock is not None:
await event.message.respond(
"Please use the slash command `/markdown` for more information on how to properly format your code.",
reply=True,
mentions_reply=True,
)
await event.message.respond(
codeblock.validate_and_format().content,
reply=True,
mentions_reply=True,
)
Empty file added bot/markup/__init__.py
Empty file.
23 changes: 23 additions & 0 deletions bot/markup/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Optional
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't much like the name of this file, can it be utils? Or whatever is more appropriate.


from codeblocks.constants import Language


def bold(text: str) -> str:
return f"**{text}**"


def italics(text: str) -> str:
return f"*{text}*"


def inline_code(text: str) -> str:
return f"`{text}`"


def codeblock(text: str, language: Language) -> Optional[str]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def codeblock(text: str, language: Language) -> Optional[str]:
def codeblock(text: str, language: Language) -> str | None:

try:
return f"```{language.get_markdown_name()}\n{text}\n```"
except AttributeError:
pass
return None