diff --git a/bot/V5__add_table_for_snippets.sql b/bot/V5__add_table_for_snippets.sql new file mode 100644 index 0000000..8b4255f --- /dev/null +++ b/bot/V5__add_table_for_snippets.sql @@ -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) +); diff --git a/bot/cogs/snippets.py b/bot/cogs/snippets.py new file mode 100644 index 0000000..73216f0 --- /dev/null +++ b/bot/cogs/snippets.py @@ -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( + embed=discord.Embed( + title="Deletion failed", + colour=discord.Colour.red(), + description=f"Snippet `{name}` was not found and " + + "hence was not deleted.", + ), + ephemeral=True, + ) + else: + 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 = """ + 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} `", + ) + 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) + 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} `.", + ), + 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)) diff --git a/bot/libs/snippets/model.py b/bot/libs/snippets/model.py new file mode 100644 index 0000000..cb2544b --- /dev/null +++ b/bot/libs/snippets/model.py @@ -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) diff --git a/bot/libs/snippets/views.py b/bot/libs/snippets/views.py new file mode 100644 index 0000000..81603bf --- /dev/null +++ b/bot/libs/snippets/views.py @@ -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 diff --git a/bot/migrations/V7__snippets.sql b/bot/migrations/V7__snippets.sql new file mode 100644 index 0000000..e650afb --- /dev/null +++ b/bot/migrations/V7__snippets.sql @@ -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); \ No newline at end of file diff --git a/docs/dev-guide/intro.rst b/docs/dev-guide/intro.rst index 35f9a70..dcc25aa 100644 --- a/docs/dev-guide/intro.rst +++ b/docs/dev-guide/intro.rst @@ -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::