Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Support for snippets (#21) #85

Draft
wants to merge 18 commits into
base: main
Choose a base branch
from
Draft
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
12 changes: 12 additions & 0 deletions bot/V5__add_table_for_snippets.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- Revision Version: V5
-- Revises: V4
-- Creation Date: 2024-03-10 05:51:39.252162 UTC
-- Reason: add table for snippets

CREATE TABLE IF NOT EXISTS snippets
(
guild_id bigint NOT NULL,
name VARCHAR(100),
content TEXT,
PRIMARY KEY (guild_id, name)
);
196 changes: 196 additions & 0 deletions bot/cogs/snippets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Optional, Union

import asyncpg.exceptions
import discord
from discord.ext import commands
from libs.snippets.model import create_snippet, get_snippet
from libs.snippets.views import SnippetPreCreationConfirmationView

if TYPE_CHECKING:
from libs.utils.context import GuildContext
from rodhaj import Rodhaj


class Snippets(commands.Cog):
"""Send or display pre-written text to users"""

def __init__(self, bot: Rodhaj):
self.bot = bot
self.pool = self.bot.pool

# Editing Utilities

async def edit_prompt_user(self, ctx: GuildContext, name: str):
raise NotImplementedError("TODO: Add prompt for editing snippet.")

@commands.guild_only()
@commands.hybrid_group(name="snippets", alias=["snippet"], fallback="get")
async def snippet(self, ctx: GuildContext, *, name: str):
"""Allows for use snippets of text for later retrieval or for quicker responses

If an subcommand is not called, then this will search
the database for the requested snippet
"""
await ctx.send("Implement getting snippets here")

@commands.guild_only()
@snippet.command()
async def remove(self, ctx: GuildContext, name: str):
query = """
DELETE FROM snippets
WHERE name = $2
RETURNING id
"""
result = await self.pool.fetchrow(query, ctx.guild.id, name)
if result is None:
await ctx.reply(
Copy link
Member

@No767 No767 May 30, 2024

Choose a reason for hiding this comment

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

Suggested change
await ctx.reply(
await ctx.send(...)

I would strongly recommend not using commands.Context.reply in this case. It sends an unnecessary ping to the user, and is generally considered extremely annoying and bad practice. Instead, replace it with commands.Context.send()

embed=discord.Embed(
Copy link
Member

Choose a reason for hiding this comment

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

I would strongly recommend replacing these embeds with just an message. It isn't necessary to do this as most users will find an jarring differences compared to the rest of the codebase

title="Deletion failed",
colour=discord.Colour.red(),
description=f"Snippet `{name}` was not found and "
+ "hence was not deleted.",
),
ephemeral=True,
Copy link
Member

Choose a reason for hiding this comment

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

The ephemeral keyword argument only works on interactions. It doesn't really make sense to have it enabled when in regular prefixed messages, that this message can be seen by everyone

)
else:
Copy link
Member

Choose a reason for hiding this comment

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

For future reference, there isn't an need for an else statement here. It would be ideal to terminate it using an return keyword instead. An good example would be here

await ctx.reply(
embed=discord.Embed(
title="Deletion successful",
colour=discord.Colour.green(),
description=f"Snippet `{name}` was deleted successfully",
),
ephemeral=True,
)

# TODO: Run all str inputs through custom converters
@commands.guild_only()
@snippet.command()
async def new(
self,
ctx: GuildContext,
name: str,
*,
content: Optional[str] = None,
):
if (
await get_snippet(self.pool, ctx.guild.id, ctx.message.author.id, name)
is not None
):
await ctx.send(
content=f"Snippet `{name}` already exists!",
)
return

if not content:
timeout = 15
confirmation_view = SnippetPreCreationConfirmationView(
self.bot, ctx, name, timeout
)
await ctx.reply(
content=f"Create snippet with id `{name}`?",
view=confirmation_view,
delete_after=timeout,
)
else:
self.bot.dispatch(
"snippet_create",
ctx.guild,
ctx.message.author,
name,
content,
ctx,
)

@commands.guild_only()
@snippet.command(name="list")
async def snippets_list(
self, ctx: GuildContext, json: Optional[bool] = False
) -> None:
await ctx.send("list snippets")

@commands.guild_only()
@snippet.command()
async def show(self, ctx: GuildContext, name: str):
query = """
Copy link
Member

Choose a reason for hiding this comment

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

The rest of the code should follow the suggestions that are noted above

SELECT content FROM snippets
WHERE name = $1
"""
data = await self.pool.fetchrow(query, name)
if data is None:
ret_embed = discord.Embed(
title="Oops...",
colour=discord.Colour.red(),
description=f"The snippet `{name}` was not found. "
+ "To create a new snippet with this name, "
+ f"please run `snippet create {name} <content>`",
)
await ctx.reply(embed=ret_embed, ephemeral=True)
else:
ret_data = discord.Embed(
title=f"Snippet information for `{name}`",
colour=discord.Colour.green(),
description=data[0],
)
await ctx.reply(embed=ret_data, ephemeral=True)

@commands.guild_only()
@snippet.command()
async def edit(self, ctx: GuildContext, name: str, content: Optional[str]):
if content is None:
await self.edit_prompt_user(ctx, name)
Copy link
Member

Choose a reason for hiding this comment

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

If you have an function and it's only used once, then it would be better to contain the logic within the command function body instead.

return
query = """
UPDATE snippets
SET content = $2
WHERE name = $1
RETURNING name
"""
result = await self.pool.fetchrow(query, name, content)
if result is None:
await ctx.reply(
embed=discord.Embed(
title="Oops...",
colour=discord.Colour.red(),
description=f"Cannot edit snippet `{name}` as there is no such "
+ "snippet. To create a new snippet with the corresponding "
+ f"name, please run `snippet new {name} <snippet text>`.",
),
ephemeral=True,
)
else:
await ctx.reply(
embed=discord.Embed(
title="Snippet changed",
colour=discord.Colour.green(),
description=f"The contents of snippet {result[0]} has been "
+ f"changed to \n\n{content}",
),
ephemeral=True,
)

@commands.Cog.listener()
async def on_snippet_create(
self,
guild: discord.Guild,
creator: Union[discord.User, discord.Member],
snippet_name: str,
snippet_text: str,
response_context: GuildContext,
):
try:
await create_snippet(
self.pool, guild.id, creator.id, snippet_name, snippet_text
)
if response_context:
await response_context.send(
"Snippet created successfully", delete_after=5
)
except asyncpg.exceptions.UniqueViolationError:
if response_context:
await response_context.send("Snippet already exists", delete_after=5)


async def setup(bot: Rodhaj):
await bot.add_cog(Snippets(bot))
36 changes: 36 additions & 0 deletions bot/libs/snippets/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from collections import namedtuple

import asyncpg.pool

SnippetHeader = namedtuple(
"SnippetHeader",
["id", "name", "content", "uses", "owner_id", "location_id", "created_at"],
)


async def get_snippet(
pool: asyncpg.pool.Pool, guild_id: int, owner_id: int, snippet_name: str
):
fields_str = ",".join(SnippetHeader._fields)
query = f"""
SELECT {fields_str} from snippets
WHERE location_id = $1 AND owner_id = $2 AND name = $3
"""
row = await pool.fetchrow(query, guild_id, owner_id, snippet_name)
if not row:
return None
return SnippetHeader(*row)


async def create_snippet(
pool: asyncpg.pool.Pool,
guild_id: int,
owner_id: int,
snippet_name: str,
snippet_text: str,
):
query = """
INSERT INTO snippets (owner_id, location_id, name, content)
VALUES ($1, $2, $3, $4)
"""
await pool.execute(query, guild_id, owner_id, snippet_name, snippet_text)
63 changes: 63 additions & 0 deletions bot/libs/snippets/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import discord.ui
from libs.utils import GuildContext, RoboView
from rodhaj import Rodhaj


class SnippetCreationModal(discord.ui.Modal, title="Editing Snippet"):
content = discord.ui.TextInput(
label="Snippet message",
placeholder="Call me Ishmael. Some years ago—never mind "
+ "how long precisely...",
style=discord.TextStyle.paragraph,
)

def __init__(self, bot: Rodhaj, context: GuildContext, name: str):
super().__init__(timeout=12 * 3600)
self._bot = bot
self._ctx = context
self._snippet_name = name
self.title = f"Creating Snippet {name}"

async def on_submit(self, interaction: discord.Interaction):
await interaction.response.defer()
self._bot.dispatch(
"snippet_create",
self._ctx.guild,
self._ctx.author,
self._snippet_name,
self.content.value,
self._ctx,
)
self.stop()


class SnippetPreCreationConfirmationView(discord.ui.View):
def __init__(self, bot: Rodhaj, ctx: GuildContext, snippet_name: str, timeout=15):
super().__init__(timeout=timeout)
self._bot = bot
self._ctx = ctx
self._snippet_name = snippet_name

@discord.ui.button(label="Create Snippet", style=discord.ButtonStyle.green)
async def create_snippet(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user.id != self._ctx.author.id:
return
button.disabled = True
modal = SnippetCreationModal(self._bot, self._ctx, self._snippet_name)
await interaction.response.send_modal(modal)
await interaction.edit_original_response(
content="Creating Snippet...", view=None
)
await modal.wait()
await interaction.delete_original_response()
self.stop()

async def on_timeout(self):
self.clear_items()
self.stop()


class SnippetInfoView(RoboView):
pass
36 changes: 36 additions & 0 deletions bot/migrations/V7__snippets.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- Revision Version: V7
-- Revises: V6
-- Creation Date: 2024-06-25 22:08:13.554817 UTC
-- Reason: snippets

CREATE TABLE IF NOT EXISTS snippets (
id SERIAL PRIMARY KEY,
name TEXT,
content TEXT,
uses INTEGER DEFAULT (0),
owner_id BIGINT,
location_id BIGINT,
created_at TIMESTAMPTZ DEFAULT (now() at time zone 'utc')
);

-- Create indices to speed up regular and trigram searches
CREATE INDEX IF NOT EXISTS snippets_name_idx ON snippets (name);
CREATE INDEX IF NOT EXISTS snippets_location_id_idx ON snippets (location_id);
CREATE INDEX IF NOT EXISTS snippets_name_trgm_idx ON snippets USING GIN (name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS snippets_name_lower_idx ON snippets (LOWER(name));
CREATE UNIQUE INDEX IF NOT EXISTS snippets_uniq_idx ON snippets (LOWER(name), location_id);

CREATE TABLE IF NOT EXISTS snippets_lookup (
id SERIAL PRIMARY KEY,
name TEXT,
location_id BIGINT,
owner_id BIGINT,
created_at TIMESTAMPTZ DEFAULT (now() at time zone 'utc'),
snippets_id INTEGER REFERENCES snippets (id) ON DELETE CASCADE ON UPDATE NO ACTION
);

CREATE INDEX IF NOT EXISTS snippets_lookup_name_idx ON snippets_lookup (name);
CREATE INDEX IF NOT EXISTS snippets_lookup_location_id_idx ON snippets_lookup (location_id);
CREATE INDEX IF NOT EXISTS snippets_lookup_name_trgm_idx ON snippets_lookup USING GIN (name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS snippets_lookup_name_lower_idx ON snippets_lookup (LOWER(name));
CREATE UNIQUE INDEX IF NOT EXISTS snippets_lookup_uniq_idx ON snippets_lookup (LOWER(name), location_id);
1 change: 1 addition & 0 deletions docs/dev-guide/intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ The following SQL queries can be used to create the user and database:

CREATE ROLE rodhaj WITH LOGIN PASSWORD 'somepass';
CREATE DATABASE rodhaj OWNER rodhaj;
CREATE EXTENSION IF NOT EXISTS pg_trgm;

.. note::

Expand Down
Loading