From 08c4187d70340df883fd307c3f5bfdd67302ffb0 Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Mon, 30 Dec 2024 14:21:13 -0500 Subject: [PATCH 01/22] add pycache to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 564bdc2..7a5efd3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ result # Python .ruff_cache .ropeproject +**/__pycache__/ # Runtime *.sqlite3 From 5390181cfa9869ebd1ca2b4d6d31517d85184817 Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 14:08:33 -0500 Subject: [PATCH 02/22] set synchronous and foreign_keys on startup --- nomen/db.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/nomen/db.py b/nomen/db.py index b9f69f3..4b882bf 100644 --- a/nomen/db.py +++ b/nomen/db.py @@ -12,9 +12,6 @@ log.setLevel(logging.INFO) schema = """ PRAGMA user_version = 1; -PRAGMA main.synchronous = NORMAL; -PRAGMA foreign_keys = ON; - CREATE TABLE keywords ( guild_id INTEGER NOT NULL, keyword TEXT NOT NULL, @@ -80,6 +77,8 @@ async def setup_db(db_file): log.debug("Running start script") await db.executescript(""" PRAGMA optimize(0x10002); + PRAGMA main.synchronous = NORMAL; + PRAGMA foreign_keys = ON; """) log.debug("Adding contains function") From 3a4e5378285cde5c21e434a6346db8fe9e036d14 Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 14:08:57 -0500 Subject: [PATCH 03/22] properly check ignore_active value in schema --- nomen/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomen/db.py b/nomen/db.py index 4b882bf..07f594c 100644 --- a/nomen/db.py +++ b/nomen/db.py @@ -32,7 +32,7 @@ CREATE TABLE users ( use_embed INTEGER NOT NULL DEFAULT 1 CHECK(use_embed IN (0, 1)), notify_self INTEGER NOT NULL DEFAULT 0 CHECK(notify_self IN (0, 1)), bots_notify INTEGER NOT NULL DEFAULT 0 CHECK(bots_notify IN (0, 1)), - ignore_active INTEGER NOT NULL DEFAULT 0 CHECK(bots_notify IN (0, 1)) + ignore_active INTEGER NOT NULL DEFAULT 0 CHECK(ignore_active IN (0, 1)) ) WITHOUT ROWID; From e31d19bcca9292da805dde6d226d30a39b8e1449 Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 14:14:46 -0500 Subject: [PATCH 04/22] convert db helpers to methods --- nomen/db.py | 26 ++++++++++++++++++++++++++ nomen/notifications.py | 12 ++++++------ nomen/utils.py | 12 ------------ 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/nomen/db.py b/nomen/db.py index 07f594c..7a38522 100644 --- a/nomen/db.py +++ b/nomen/db.py @@ -70,6 +70,32 @@ def run_db_migrations(db_file): log.debug("Finished running automatic migration") +async def fetch_singleton(db, sql, params=None): + """ + Fetch an object from the database, with the assumption that the result is 1 row by 1 column + """ + + result = await db.execute_fetchall(f"{sql} LIMIT 1", params) + return result[0][0] + + +async def fetch_exists(db, sql, params=None): + return await fetch_singleton(db, f"SELECT EXISTS({sql})", params) + + +async def fetch_unpacked(db, sql, params=None): + cur = await db.cursor() + cur.row_factory = lambda cursor, row: row[0] + cur = await cur.execute(sql, params) + return await cur.fetchall() + + +log.debug("Monkeypatching in helpers") +aiosqlite.Connection.fetch_singleton = fetch_singleton +aiosqlite.Connection.fetch_exists = fetch_exists +aiosqlite.Connection.fetch_unpacked = fetch_unpacked + + async def setup_db(db_file): log.debug(f"Connecting to {db_file}") db = await aiosqlite.connect(db_file) diff --git a/nomen/notifications.py b/nomen/notifications.py index 48e5de1..704964d 100644 --- a/nomen/notifications.py +++ b/nomen/notifications.py @@ -6,7 +6,7 @@ from typing import Union from disnake import Embed, Member, TextChannel from disnake.ext.commands import Cog, group, guild_only -from .utils import can_view, confirm, fetch_exists, fetch_unpacked, test_keyword +from .utils import can_view, confirm, test_keyword log = logging.getLogger("nomen.notifications") log.setLevel(logging.DEBUG) @@ -73,8 +73,8 @@ async def handle_triggers(ctx, message): "is_bot": ctx.author.bot, } - disabled = await fetch_exists( - ctx.bot.db, "SELECT * FROM users WHERE user_id=:author AND unlikely(disabled IS 1)", params + disabled = await ctx.bot.db.fetch_exists( + "SELECT * FROM users WHERE user_id=:author AND unlikely(disabled IS 1)", params ) if disabled: @@ -181,7 +181,7 @@ class Notifications(Cog): await ctx.send(f"{'Regex' if regex else 'Keyword'} matches a word that is too common") return - conflicts = await fetch_unpacked(ctx.bot.db, existing, params) + conflicts = await ctx.bot.db.fetch_unpacked(existing, params) if conflicts: log.debug("Keyword conflicts with existing keyword") @@ -191,7 +191,7 @@ class Notifications(Cog): ) return - conflicts = await fetch_unpacked(ctx.bot.db, redundant, params) + conflicts = await ctx.bot.db.fetch_unpacked(redundant, params) if conflicts: log.debug("Keyword renders existing redundant") @@ -304,7 +304,7 @@ class Notifications(Cog): @guild_only() async def pause(self, ctx): params = (ctx.author.id, ctx.guild_id) - if await fetch_exists("SELECT * FROM user_pauses WHERE user_id=? AND guild_id=?"): + if await ctx.bot.db.fetch_exists("SELECT * FROM user_pauses WHERE user_id=? AND guild_id=?", params): await ctx.bot.db.execute("DELETE FROM user_pauses WHERE user_id=? AND guild_id=?", params) await ctx.bot.send(f"Resumed notifications in {ctx.guild}") else: diff --git a/nomen/utils.py b/nomen/utils.py index 3b9861e..417a9c8 100644 --- a/nomen/utils.py +++ b/nomen/utils.py @@ -111,18 +111,6 @@ def unpack(lst_of_tpl): return list(map(first, lst_of_tpl)) -async def fetch_unpacked(db, sql, params=None): - cur = await db.cursor() - cur.row_factory = lambda cursor, row: first(row) - cur = await cur.execute(sql, params) - return await cur.fetchall() - - -async def fetch_exists(db, sql, params=None): - result = await db.execute_fetchall(f"SELECT EXISTS({sql})", params) - return result[0][0] - - async def in_thread(member, thread): # FIXME: Currently overlooks the situation where a moderator isn't in a thread but has manage threads return any(member.id == thread_member.id for thread_member in await thread.fetch_members()) From e0001625aee146f7d2f3da4973fcdd1803209769 Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 14:15:20 -0500 Subject: [PATCH 05/22] use sqlite3 row class for rows --- nomen/db.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nomen/db.py b/nomen/db.py index 7a38522..c9baabc 100644 --- a/nomen/db.py +++ b/nomen/db.py @@ -107,6 +107,9 @@ async def setup_db(db_file): PRAGMA foreign_keys = ON; """) + log.debug("Setting row factory") + db.row_factory = sqlite3.Row + log.debug("Adding contains function") await db.create_function("contains", 3, contains, deterministic=True) From 3de920f54b0fb7a2251bd9cbdd4a2477dd871d2e Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 18:09:11 -0500 Subject: [PATCH 06/22] add repr to db rows --- nomen/db.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nomen/db.py b/nomen/db.py index c9baabc..6eb708d 100644 --- a/nomen/db.py +++ b/nomen/db.py @@ -96,6 +96,11 @@ aiosqlite.Connection.fetch_exists = fetch_exists aiosqlite.Connection.fetch_unpacked = fetch_unpacked +class Row(sqlite3.Row): + def __repr__(self): + return f"Row<{repr(dict(self))}>" + + async def setup_db(db_file): log.debug(f"Connecting to {db_file}") db = await aiosqlite.connect(db_file) @@ -108,7 +113,7 @@ async def setup_db(db_file): """) log.debug("Setting row factory") - db.row_factory = sqlite3.Row + db.row_factory = Row log.debug("Adding contains function") await db.create_function("contains", 3, contains, deterministic=True) From 1573d0b04655afc84fd061885094374ddb624e01 Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 18:09:46 -0500 Subject: [PATCH 07/22] improve error logging --- nomen/main.py | 46 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/nomen/main.py b/nomen/main.py index 214f69d..cd480bc 100644 --- a/nomen/main.py +++ b/nomen/main.py @@ -1,6 +1,7 @@ import io import logging import os +import pprint import sys import textwrap import traceback @@ -120,18 +121,57 @@ async def on_ready(): log.info(bot.user.id) log.info("-------------") + bot.app_info = await bot.application_info() + bot.owner = bot.app_info.owner + @bot.event async def on_command_error(ctx, error): if isinstance(error, commands.CommandNotFound): return - if isinstance(error, commands.NoPrivateMessage): + if isinstance( + error, + ( + commands.UserInputError, + commands.CheckFailure, + ), + ): await ctx.send(error) return - log.exception(error) - await ctx.send(error) + log.error(error, exc_info=error) + timestamp = int(ctx.message.created_at.timestamp()) + await ctx.bot.owner.send( + f"# Command Error Occurred: {error}\n\n" + f"```\n{"".join(traceback.format_exception(error))}```\n\n" + f"## Context\n" + f"**From:** {ctx.author.mention} ({ctx.author.name})\n" + f", | [Jump to Command]({ctx.message.jump_url}) | <#{ctx.channel.id}>\n" + ) + await ctx.send( + f"Error: {error}\n\n**Note:** Infinidoge (Developer) has already been sent a notification that an error occurred and will come investigate when available." + ) + + +@bot.event +async def on_error(event, *args, **kwargs): + ptraceback = "".join(traceback.format_exc()) + pargs = pprint.pformat(args) + pkwargs = pprint.pformat(kwargs) + try: + await bot.owner.send( + f"# Error Occurred: Event `{event}`" + f"```\n{ptraceback}```" + f"## args" + f"```\n{pargs}\n```" + f"## kwargs" + f"```\n{pkwargs}\n```" + ) + except: # Don't want recursive errors + pass + + log.error(f"Error in event {event}.\nargs={pargs}\nkwargs={pkwargs}", exc_info=sys.exc_info()) @bot.command(hidden=True) From d868427fc6d07c12c6fffb0d1e694298841fba3a Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 18:09:58 -0500 Subject: [PATCH 08/22] add short helpers for getting/changing user settings --- nomen/main.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nomen/main.py b/nomen/main.py index cd480bc..548daa1 100644 --- a/nomen/main.py +++ b/nomen/main.py @@ -90,11 +90,11 @@ class Nomen(Bot): await super().close() await self.db.close() - async def user_toggle(self, user_id, item): - await self.db.execute( - "REPLACE INTO users (user_id, {item}) VALUES(:user_id, iff((SELECT {item} FROM users WHERE user_id=:user_id)), 0, 1)", - {"user_id": user_id}, - ) + async def get_setting(self, user_id, setting): + return await self.db.fetch_singleton(f"SELECT {setting} FROM users WHERE user_id=?", (user_id,)) + + async def set_setting(self, user_id, setting, value: bool): + await self.db.execute(f"REPLACE INTO users (user_id, {setting}) VALUES(?, ?)", (user_id, value)) await self.db.commit() From 42281010c5b8e8e93fbfb5d9edd5aa158c44c98a Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 18:10:28 -0500 Subject: [PATCH 09/22] fix admin eval command --- nomen/main.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nomen/main.py b/nomen/main.py index 548daa1..38fc884 100644 --- a/nomen/main.py +++ b/nomen/main.py @@ -198,13 +198,13 @@ async def sql(ctx, *, query): @bot.command(hidden=True, name="eval") @commands.is_owner() -async def admin_eval(self, ctx, *, body: str): +async def admin_eval(ctx, *, body: str): env = { - "bot": self.bot, + "bot": ctx.bot, "ctx": ctx, - "channel": ctx.message.channel, - "author": ctx.message.author, - "guild": ctx.message.guild, + "channel": ctx.channel, + "author": ctx.author, + "guild": ctx.guild, "message": ctx.message, } From c0d535c8df3c04da7564b89a16a88b6ac04ab10f Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 18:13:54 -0500 Subject: [PATCH 10/22] remove redundant db migration log --- nomen/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nomen/main.py b/nomen/main.py index 38fc884..84710cd 100644 --- a/nomen/main.py +++ b/nomen/main.py @@ -266,8 +266,7 @@ async def prefix(ctx, prefix=None): def run(): try: - if run_db_migrations(DB_FILE): - log.info(f"Migrated DB {DB_FILE}") + run_db_migrations(DB_FILE) except RuntimeError: pass else: From 8b538ba78d44c6ff62e52c8e276e6f56a8064601 Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 18:15:00 -0500 Subject: [PATCH 11/22] implement ignoring, blocking, and pausing --- nomen/notifications.py | 131 +++++++++++++++++++++++++++++++++-------- 1 file changed, 108 insertions(+), 23 deletions(-) diff --git a/nomen/notifications.py b/nomen/notifications.py index 704964d..93bc234 100644 --- a/nomen/notifications.py +++ b/nomen/notifications.py @@ -3,7 +3,8 @@ from asyncio import TaskGroup from textwrap import indent from typing import Union -from disnake import Embed, Member, TextChannel +from disnake import Embed, Member, Thread, User +from disnake.abc import GuildChannel from disnake.ext.commands import Cog, group, guild_only from .utils import can_view, confirm, test_keyword @@ -68,11 +69,16 @@ async def handle_triggers(ctx, message): params = { "author": ctx.author.id, "channel": ctx.channel.id, + "category": ctx.channel.category_id, "guild": ctx.guild.id, "content": message.content, "is_bot": ctx.author.bot, + "parent": 0, } + if isinstance(ctx.channel, Thread) and ctx.channel.parent: + params["parent"] = ctx.channel.parent.id + disabled = await ctx.bot.db.fetch_exists( "SELECT * FROM users WHERE user_id=:author AND unlikely(disabled IS 1)", params ) @@ -94,11 +100,12 @@ async def handle_triggers(ctx, message): AND (unlikely(bots_notify IS 1) OR :is_bot IS NOT 1) -- Don't notify from bots unless wanted AND likely(NOT EXISTS(SELECT * FROM ( -- Don't notify if... SELECT target FROM user_ignores -- Author or channel is ignored in this guild - WHERE user_id=user_id AND guild_id=guild_id + WHERE user_id=user_id AND guild_id=:guild UNION SELECT target FROM user_blocks -- Author is blocked WHERE user_id=user_id - ) WHERE target IN (:author, :channel))) + ) WHERE target IN (:author, :channel, :category, :parent))) + AND likely(NOT EXISTS(SELECT * FROM user_pauses WHERE user_id=user_id AND guild_id=:guild)) AND guild_id=:guild AND unlikely(contains(:content, keyword, regex)) ) WHERE n=1 @@ -303,32 +310,110 @@ class Notifications(Cog): @keyword.command() @guild_only() async def pause(self, ctx): - params = (ctx.author.id, ctx.guild_id) + """ + Pause notifications in this guild + """ + + params = (ctx.author.id, ctx.guild.id) if await ctx.bot.db.fetch_exists("SELECT * FROM user_pauses WHERE user_id=? AND guild_id=?", params): - await ctx.bot.db.execute("DELETE FROM user_pauses WHERE user_id=? AND guild_id=?", params) - await ctx.bot.send(f"Resumed notifications in {ctx.guild}") + await ctx.send(f"Notifications in {ctx.guild} are already paused") else: await ctx.bot.db.execute("INSERT INTO user_pauses VALUES(?, ?)", params) - await ctx.bot.send(f"Paused notifications in {ctx.guild}") - # TODO: Pause guild notifications in handler - - @keyword.group(invoke_without_command=True) - @guild_only() - async def ignore(self, ctx, target: Union[TextChannel, Member]): - await ctx.bot.db.execute("INSERT INTO user_ignores VALUES(?, ?, ?)", (ctx.author.id, ctx.guild.id, target.id)) - await ctx.bot.db.commit() - await ctx.send(f"Now ignoring {target}") + await ctx.bot.db.commit() + await ctx.send(f"Paused notifications in {ctx.guild}") @keyword.command() @guild_only() - async def uningnore(self, ctx, target: Union[TextChannel, Member]): - pass - # TODO: Unignore + async def resume(self, ctx): + """ + Resume notifications in this guild + """ - @ignore.command() + params = (ctx.author.id, ctx.guild_id) + if await ctx.bot.db.fetch_exists("SELECT * FROM user_pauses WHERE user_id=? AND guild_id=?", params): + await ctx.bot.db.execute("DELETE FROM user_pauses WHERE user_id=? AND guild_id=?", params) + await ctx.bot.db.commit() + await ctx.send(f"Resumed notifications in {ctx.guild}") + else: + await ctx.send(f"Notifications in {ctx.guild} aren't paused") + + @keyword.command() @guild_only() - async def active(self, ctx): - await ctx.bot.user_toggle(ctx.author.id, "ignore_active") - await ctx.bot.send("Toggled ignore active channel") # TODO: Send current state + async def ignore(self, ctx, target: Union[GuildChannel, Member]): + """ + Ignore a channel or member in this guild - # TODO: Ignore active channel in handler + This prevents you from receiving any notifications from that channel or user in this guild + """ + + params = (ctx.author.id, ctx.guild.id, target.id) + if await ctx.bot.db.fetch_exists( + "SELECT * FROM user_ignores WHERE user_id=? AND guild_id=? AND target=?", params + ): + await ctx.send(f"Target `{repr(target)}` already ignored") + return + + await ctx.bot.db.execute("INSERT INTO user_ignores VALUES(?, ?, ?)", params) + await ctx.bot.db.commit() + await ctx.send(f"Now ignoring `{repr(target)}`") + + @keyword.command() + @guild_only() + async def unignore(self, ctx, target: Union[GuildChannel, Member]): + """ + Unignore a channel or member in this guild + + This allows you to receive notifications from that channel or user again + """ + + params = (ctx.author.id, ctx.guild.id, target.id) + if not await ctx.bot.db.fetch_exists( + "SELECT * FROM user_ignores WHERE user_id=? AND guild_id=? AND target=?", params + ): + await ctx.send(f"Target `{repr(target)}` is not currently being ignored") + return + + await ctx.bot.db.execute("DELETE FROM user_ignores WHERE user_id=? AND guild_id=? AND target=?", params) + await ctx.bot.db.commit() + await ctx.send(f"No longer ignoring `{repr(target)}`") + + @keyword.command() + async def block(self, ctx, target: User): + """ + Block a user from triggering your notifications + + This prevents you from receiving notifications from that user in any guild + NOTE: They will still receive notifications from you + """ + await ctx.message.delete(delay=0) # Delete invoking message if able to + + params = (ctx.author.id, ctx.target.id) + if await ctx.bot.db.fetch_exists("SELECT * FROM user_blocks WHERE user_id=? AND target=?", params): + await ctx.author.send(f"Target {target} already blocked") + return + + await ctx.bot.db.execute( + "INSERT INTO user_blocks VALUES(?, ?)", + (ctx.author.id, target.id), + ) + await ctx.bot.db.commit() + await ctx.author.send(f"Blocked {target}") + + @keyword.command() + async def unblock(self, ctx, target: User): + """ + Unblock a user + + This allows you to receive notifications from that user again + """ + + await ctx.message.delete(delay=0) # Delete invoking message if able to + + params = (ctx.author.id, ctx.target.id) + if await ctx.bot.db.fetch_exists("SELECT * FROM user_blocks WHERE user_id=? AND target=?", params): + await ctx.author.send(f"Target {target} already blocked") + return + + await ctx.bot.db.execute("DELETE FROM user_ignores WHERE user_id=? AND target=?", params) + await ctx.bot.db.commit() + await ctx.author.send(f"Unblocked {target}") From 40e55ac316e584fc2925f466e5493473d0ea3529 Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 18:15:11 -0500 Subject: [PATCH 12/22] allow members with manage guild to change guild prefix --- nomen/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nomen/main.py b/nomen/main.py index 84710cd..c3f5b2e 100644 --- a/nomen/main.py +++ b/nomen/main.py @@ -247,8 +247,7 @@ async def ping(ctx): @bot.command() -@commands.guild_only() -@commands.is_owner() +@commands.has_guild_permissions(manage_guild=True) async def prefix(ctx, prefix=None): if prefix is None: prefix = await ctx.bot.get_guild_prefix(ctx.guild) From 77d329ac45a8692a641b98bb1aa66e9a2e9ccb25 Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 18:15:33 -0500 Subject: [PATCH 13/22] fix before invoke hook to properly add user default settings --- nomen/notifications.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nomen/notifications.py b/nomen/notifications.py index 93bc234..b84fe18 100644 --- a/nomen/notifications.py +++ b/nomen/notifications.py @@ -145,8 +145,8 @@ class Notifications(Cog): await handle_triggers(ctx, message) - async def cog_before_invoke(ctx): - await ctx.bot.db.execute("INSERT OR IGNORE INTO users (userid) VALUE(?)", (ctx.author.id,)) + async def cog_before_invoke(self, ctx): + await ctx.bot.db.execute("INSERT OR IGNORE INTO users (user_id) VALUES(?)", (ctx.author.id,)) @group( aliases=["kw", "notification", "notifications", "notif", "noti"], From 2bdf4f3e258e647a01b4c6309e2b472ffed756f1 Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 18:15:52 -0500 Subject: [PATCH 14/22] add todo for regex names --- nomen/notifications.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nomen/notifications.py b/nomen/notifications.py index b84fe18..d18d343 100644 --- a/nomen/notifications.py +++ b/nomen/notifications.py @@ -237,6 +237,7 @@ class Notifications(Cog): Use quotes for a regex with spaces! """ + # TODO: Add regex names to make notifications cleaner await self._add_keyword(ctx, keyword, True) @keyword.command(aliases=["delete", "del"]) From f3b40986becb282b2ff1899ba45914ecd0f94a3b Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 18:18:42 -0500 Subject: [PATCH 15/22] implement settings command --- nomen/settings.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/nomen/settings.py b/nomen/settings.py index ce6c5fa..dca39af 100644 --- a/nomen/settings.py +++ b/nomen/settings.py @@ -1,5 +1,7 @@ import logging +from typing import Literal +import disnake from disnake.ext.commands import Cog, group, guild_only from .utils import confirm @@ -108,3 +110,42 @@ You may want to `{ctx.clean_prefix}nomen export` first""" await self.bot.db.execute("REPLACE INTO users (user_id, disabled) VALUES(?, 0)", (ctx.author.id,)) await self.bot.db.commit() + + @group(invoke_without_command=True) + async def settings( + self, + ctx, + setting: Literal["use-embed", "notify-self", "bots-notify"], + value: bool, + ): + """ + Change settings for Nomen + + Valid settings are: + - `use-embed` + - `notify-self` + - `bots-notify` + """ + setting_db = setting.replace("-", "_") + + await ctx.bot.set_setting(ctx.author.id, setting_db, value) + await self.settings_list(ctx) + + @settings.command(name="list") + async def settings_list(self, ctx): + """ + Print all of your current settings + """ + + settings = await ctx.bot.db.execute_fetchall( + "SELECT use_embed, notify_self, bots_notify FROM users WHERE user_id=?", (ctx.author.id,) + ) + + settings = ["Yes" if v else "No" for v in settings[0]] + + embed = disnake.Embed(title="Current Settings") + embed.add_field("Use Embeds in Notifications (`use-embed`)", settings[0], inline=False) + embed.add_field("Notify Self (`notify-self`)", settings[1], inline=False) + embed.add_field("Notifications from Bots (`bots-notify`)", settings[2], inline=False) + + await ctx.send(embed=embed) From d7863f2a4941021d53f86b567e6f5069c5260689 Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 18:19:11 -0500 Subject: [PATCH 16/22] refactor purge to use foreign key cascade --- nomen/settings.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/nomen/settings.py b/nomen/settings.py index dca39af..6e4f2c2 100644 --- a/nomen/settings.py +++ b/nomen/settings.py @@ -37,14 +37,8 @@ You may want to `{ctx.clean_prefix}nomen export` first""" to_purge = await confirm(ctx, msg) if to_purge: - await self.bot.db.execute( - """ - DELETE FROM keywords WHERE user_id=?; - DELETE FROM users WHERE user_id=?; - DELETE FROM user_ignores WHERE user_id=?; - """, - (ctx.author.id,), - ) + # Foreign key constraints will automatically cascade to delete dependent data + await self.bot.db.execute("DELETE FROM users WHERE user_id=?", (ctx.author.id,)) await self.bot.db.commit() await ctx.send("Deleted all user data.") else: From 91594c15b775b5689f3732a36e971263db837339 Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 18:19:28 -0500 Subject: [PATCH 17/22] change nomen command description --- nomen/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nomen/settings.py b/nomen/settings.py index 6e4f2c2..0ad9508 100644 --- a/nomen/settings.py +++ b/nomen/settings.py @@ -17,7 +17,7 @@ class Settings(Cog): @group(invoke_without_command=True) async def nomen(self, ctx): """ - Settings for Nomen + Managing your data in Nomen """ await ctx.send_help(self.nomen) From 4fa6b25971b564e9b00676d092f372237b12152b Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Tue, 31 Dec 2024 18:19:42 -0500 Subject: [PATCH 18/22] comment out export c --- nomen/settings.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/nomen/settings.py b/nomen/settings.py index 0ad9508..e22ea31 100644 --- a/nomen/settings.py +++ b/nomen/settings.py @@ -65,13 +65,13 @@ You may want to `{ctx.clean_prefix}nomen export` first""" ], } - @nomen.command() - @guild_only() - async def export(self, ctx): - """ - Exports a JSON of all of your user data - """ - pass + # @nomen.command() + # @guild_only() + # async def export(self, ctx): + # """ + # Exports a JSON of all of your user data + # """ + # pass @nomen.command(name="opt-out") async def opt_out(self, ctx): From 3d903765354987e513caa1c5b3cd94ce0eaab996 Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Wed, 1 Jan 2025 00:49:51 -0500 Subject: [PATCH 19/22] paginate error handling messages --- nomen/main.py | 54 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/nomen/main.py b/nomen/main.py index c3f5b2e..ed8e449 100644 --- a/nomen/main.py +++ b/nomen/main.py @@ -9,7 +9,7 @@ from contextlib import redirect_stdout from disnake import Guild, Intents, Message from disnake.ext import commands -from disnake.ext.commands import Bot +from disnake.ext.commands import Bot, Paginator from dotenv import find_dotenv, load_dotenv from .db import run_db_migrations, setup_db @@ -142,13 +142,19 @@ async def on_command_error(ctx, error): log.error(error, exc_info=error) timestamp = int(ctx.message.created_at.timestamp()) + paginator = Paginator() + ptraceback = "".join(traceback.format_exception(error)) + for line in ptraceback.split("\n"): + paginator.add_line(line) await ctx.bot.owner.send( - f"# Command Error Occurred: {error}\n\n" - f"```\n{"".join(traceback.format_exception(error))}```\n\n" + f"# Command Error Occurred: {error}\n" f"## Context\n" f"**From:** {ctx.author.mention} ({ctx.author.name})\n" f", | [Jump to Command]({ctx.message.jump_url}) | <#{ctx.channel.id}>\n" + f"## Traceback" ) + for page in paginator.pages: + await ctx.bot.owner.send(page) await ctx.send( f"Error: {error}\n\n**Note:** Infinidoge (Developer) has already been sent a notification that an error occurred and will come investigate when available." ) @@ -156,18 +162,23 @@ async def on_command_error(ctx, error): @bot.event async def on_error(event, *args, **kwargs): - ptraceback = "".join(traceback.format_exc()) pargs = pprint.pformat(args) pkwargs = pprint.pformat(kwargs) + try: + paginator = Paginator() + for line in traceback.format_exc().split("\n"): + paginator.add_line(line) await bot.owner.send( - f"# Error Occurred: Event `{event}`" - f"```\n{ptraceback}```" - f"## args" - f"```\n{pargs}\n```" - f"## kwargs" - f"```\n{pkwargs}\n```" + f"# Error Occurred: Event `{event[:100]}`\n" + f"## args\n" + f"```\n{pargs}\n```\n" + f"## kwargs\n" + f"```\n{pkwargs}\n```\n" + f"## Traceback" ) + for page in paginator.pages: + await bot.owner.send(page) except: # Don't want recursive errors pass @@ -226,7 +237,11 @@ async def admin_eval(ctx, *, body: str): ret = await func() except Exception: value = stdout.getvalue() - await ctx.send("```py\n{}{}\n```".format(value, traceback.format_exc())) + ret = traceback.format_exc() + try: + await ctx.message.add_reaction("\u2705") + except: + pass else: value = stdout.getvalue() try: @@ -234,11 +249,18 @@ async def admin_eval(ctx, *, body: str): except: pass - if ret is None: - if value: - await ctx.send("```py\n{}\n```".format(value)) - else: - await ctx.send("```py\n{}{}\n```".format(value, ret)) + paginator = Paginator(prefix="```py", suffix="```") + if value: + for line in str(value).split("\n"): + paginator.add_line(line) + paginator.close_page() + if ret: + for line in str(ret).split("\n"): + paginator.add_line(line) + paginator.close_page() + + for page in paginator.pages: + await ctx.send(page) @bot.command() From 76521c9d82a7b228d47aff1cda8eceb59efeda4d Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Wed, 1 Jan 2025 00:50:12 -0500 Subject: [PATCH 20/22] refactor keyword adding using exceptions --- nomen/notifications.py | 94 ++++++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 44 deletions(-) diff --git a/nomen/notifications.py b/nomen/notifications.py index d18d343..f49060e 100644 --- a/nomen/notifications.py +++ b/nomen/notifications.py @@ -22,6 +22,19 @@ class NotifierLogAdapter(logging.LoggerAdapter): ) +class KeywordError(Exception): + def __init__(self, msg=None, dm_msg=None, *args): + self.msg = msg + self.dm_msg = dm_msg + super().__init__(msg, dm_msg, *args) + + async def send(self, ctx): + if self.msg: + await ctx.send(self.msg + " (check DMs)") + if self.dm_msg: + await ctx.author.send(self.dm_msg) + + async def handle_notification(db_updates, ctx, message, keyword, user_id, use_embed): """ Async task to dispatch a notification @@ -158,64 +171,47 @@ class Notifications(Cog): """ await ctx.send_help(self.keyword) - async def _add_keyword(self, ctx, keyword: str, regex: bool): - log.debug( - f"Adding {'regex' if regex else 'keyword'}: {keyword} of {ctx.author} ({ctx.author.id}) on {ctx.guild} ({ctx.guild.id})" + async def _check_conflicts(self, guild_id, user_id, keyword): + return await self.bot.db.fetch_unpacked( + "SELECT keyword FROM keywords WHERE guild_id=? AND user_id=? AND contains(?, keyword, regex)", + (guild_id, user_id, keyword), ) - params = { - "keyword": keyword, - "guild_id": ctx.guild.id, - "user_id": ctx.author.id, - "regex": regex, - } + async def _check_redundants(self, guild_id, user_id, keyword, regex): + return await self.bot.db.fetch_unpacked( + "SELECT keyword FROM keywords WHERE guild_id=? AND user_id=? AND contains(keyword, ?, ?)", + (guild_id, user_id, keyword, regex), + ) - existing = """ - SELECT keyword FROM keywords - WHERE guild_id=:guild_id - AND user_id=:user_id - AND contains(:keyword, keyword, regex) - """ - redundant = """ - SELECT keyword FROM keywords - WHERE guild_id=:guild_id - AND user_id=:user_id - AND contains(keyword, :keyword, :regex) - """ + async def add_keyword(self, guild_id: int, user_id: int, keyword: str, regex: bool, initial_count: int = 0): + log.debug( + f"Adding {'regex' if regex else 'keyword'}: {keyword} of {user_id} on {guild_id} with count {initial_count}" + ) if test_keyword(keyword, regex): log.debug("Keyword too common") - await ctx.send(f"{'Regex' if regex else 'Keyword'} matches a word that is too common") - return + raise KeywordError(f"{'Regex' if regex else 'Keyword'} matches a word that is too common") - conflicts = await ctx.bot.db.fetch_unpacked(existing, params) - - if conflicts: + if conflicts := await self._check_conflicts(guild_id, user_id, keyword): log.debug("Keyword conflicts with existing keyword") - await ctx.send(f"Any instance of `{keyword}` would be matched by existing keywords (check DMs)") - await ctx.author.send( - f"Conflicts with keyword `{keyword}`:\n" + "\n".join(f"- `{conflict}`" for conflict in conflicts) + raise KeywordError( + f"Any instance of `{keyword}` would be matched by existing keywords", + f"Conflicts with keyword `{keyword}`:\n" + "\n".join(f"- `{conflict}`" for conflict in conflicts), ) - return - conflicts = await ctx.bot.db.fetch_unpacked(redundant, params) - - if conflicts: + if redundants := await self._check_redundants(guild_id, user_id, keyword, regex): log.debug("Keyword renders existing redundant") - await ctx.send(f"Adding `{keyword}` will cause existing keywords to never match (check DMs)") - await ctx.author.send( - f"Keywords redundant from `{keyword}`:\n" + "\n".join(f" - `{conflict}`" for conflict in conflicts) + raise KeywordError( + f"Adding `{keyword}` will cause existing keywords to never match", + f"Keywords redundant from `{keyword}`:\n" + "\n".join(f" - `{conflict}`" for conflict in redundants), ) - return log.debug("Keyword valid, adding") - await ctx.bot.db.execute( - "INSERT INTO keywords (guild_id, keyword, user_id, regex) VALUES (:guild_id, :keyword, :user_id, :regex)", - params, + await self.bot.db.execute( + "INSERT INTO keywords VALUES (?, ?, ?, ?, ?)", (guild_id, keyword, user_id, regex, initial_count) ) - await ctx.bot.db.commit() - await ctx.send(f"Added `{keyword}` to your list of keywords") + await self.bot.db.commit() @keyword.command() @guild_only() @@ -226,7 +222,12 @@ class Notifications(Cog): Use quotes for a keyword with spaces! """ - await self._add_keyword(ctx, keyword, False) + try: + await self.add_keyword(ctx.guild.id, ctx.author.id, keyword, False) + except KeywordError as e: + await e.send(ctx) + else: + await ctx.send(f"Added `{keyword}` to your list of keywords") @keyword.command() @guild_only() @@ -238,7 +239,12 @@ class Notifications(Cog): """ # TODO: Add regex names to make notifications cleaner - await self._add_keyword(ctx, keyword, True) + try: + await self.add_keyword(ctx.guild.id, ctx.author.id, keyword, True) + except KeywordError as e: + await e.send(ctx) + else: + await ctx.send(f"Added regex `{keyword}` to your list of keywords") @keyword.command(aliases=["delete", "del"]) @guild_only() From 347aa76de5e32a492ad1523fbfecd23454036795 Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Wed, 1 Jan 2025 00:50:43 -0500 Subject: [PATCH 21/22] use execute_fetchall instead of cursor --- nomen/notifications.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/nomen/notifications.py b/nomen/notifications.py index f49060e..f1a0bf3 100644 --- a/nomen/notifications.py +++ b/nomen/notifications.py @@ -272,11 +272,9 @@ class Notifications(Cog): log.debug(f"Listing keywords: {ctx.author} ({ctx.author.id}) on {ctx.guild} ({ctx.guild.id})") - async with ctx.bot.db.execute( - "SELECT keyword, count, regex FROM keywords WHERE guild_id=? AND user_id=?", - (ctx.guild.id, ctx.author.id), - ) as cur: - keywords = await cur.fetchall() + keywords = await ctx.bot.db.execute_fetchall( + "SELECT keyword, count, regex FROM keywords WHERE guild_id=? AND user_id=?", (ctx.guild.id, ctx.author.id) + ) embed = Embed( description="\n".join( From bd5706c0ce46b811ed67f4a69e8a120fcb22a17e Mon Sep 17 00:00:00 2001 From: Infinidoge Date: Wed, 1 Jan 2025 00:51:41 -0500 Subject: [PATCH 22/22] add basic import functionality --- nomen/settings.py | 71 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/nomen/settings.py b/nomen/settings.py index e22ea31..b86434c 100644 --- a/nomen/settings.py +++ b/nomen/settings.py @@ -1,14 +1,23 @@ +import json import logging from typing import Literal import disnake +import re2 as re from disnake.ext.commands import Cog, group, guild_only +from .notifications import KeywordError from .utils import confirm log = logging.getLogger("nomen.settings") log.setLevel(logging.DEBUG) +DUSTY_DATA_LINE = re.compile(r"^(.+) – notified (\d+) times$") + + +class InvalidImportFormat(Exception): + pass + class Settings(Cog): def __init__(self, bot): @@ -44,11 +53,57 @@ You may want to `{ctx.clean_prefix}nomen export` first""" else: await ctx.send("Cancelled.") + async def import_json(self, ctx, data): + try: + data = json.loads(data) + except json.JSONDecodeError as e: + raise InvalidImportFormat from e + await ctx.send("Importing JSON") + + async def import_dusty(self, ctx, data): + lines = map(str.strip, data.split("\n")) + to_add = [] + + for line in lines: + if match := DUSTY_DATA_LINE.fullmatch(line): + to_add.append((match.group(1), int(match.group(2)))) + else: + raise InvalidImportFormat + + await ctx.send(f"Importing from Dusty notification list to {ctx.guild}...") + + added = [] + errors = [] + + async with ctx.typing(): + notifications = self.bot.get_cog("Notifications") + for keyword, count in to_add: + try: + await notifications.add_keyword(ctx.guild.id, ctx.author.id, keyword, False, count) + added.append(keyword) + except KeywordError as e: + errors.append(e) + + if not errors: + await ctx.send("Successfully imported all keywords!") + else: + await ctx.send( + f"Import successful, with some errors\n\n" + f"Sucessfully imported:\n{"\n".join(f"- {keyword}" for keyword in added)}\n" + f"Errors (check PMs):\n{"\n".join(f"- {e.msg}" for e in errors)}\n\n" + f"If you want to import erroring keywords, delete conflicting keywords and modify the list to only include the ones that failed to import, and run the command again." + ) + for e in errors: + await ctx.author.send(e.dm_msg) + + async def import_raw(self, data): + pass + @nomen.command(name="import") @guild_only() - async def _import(self, ctx): + async def _import(self, ctx, *, data): """ - Imports a JSON of all of your user data + Imports user data, either from JSON or from a simple list """ schema = { "disabled": bool, @@ -65,6 +120,18 @@ You may want to `{ctx.clean_prefix}nomen export` first""" ], } + notifications = self.bot.get_cog("Notifications") + + try: + await self.import_json(ctx, data) + await ctx.send("Sorry, JSON imports and exports haven't been implemented yet.") + return + except InvalidImportFormat: + try: + await self.import_dusty(ctx, data) + except InvalidImportFormat: + await ctx.send("Invalid import format") + # @nomen.command() # @guild_only() # async def export(self, ctx):