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 diff --git a/nomen/db.py b/nomen/db.py index b9f69f3..6eb708d 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, @@ -35,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; @@ -73,6 +70,37 @@ 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 + + +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) @@ -80,8 +108,13 @@ 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("Setting row factory") + db.row_factory = Row + log.debug("Adding contains function") await db.create_function("contains", 3, contains, deterministic=True) diff --git a/nomen/main.py b/nomen/main.py index 214f69d..ed8e449 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 @@ -8,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 @@ -89,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() @@ -120,18 +121,68 @@ 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()) + 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" + 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." + ) + + +@bot.event +async def on_error(event, *args, **kwargs): + 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[: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 + + log.error(f"Error in event {event}.\nargs={pargs}\nkwargs={pkwargs}", exc_info=sys.exc_info()) @bot.command(hidden=True) @@ -158,13 +209,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, } @@ -186,7 +237,11 @@ async def admin_eval(self, 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: @@ -194,11 +249,18 @@ async def admin_eval(self, 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() @@ -207,8 +269,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) @@ -226,8 +287,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: diff --git a/nomen/notifications.py b/nomen/notifications.py index 48e5de1..f1a0bf3 100644 --- a/nomen/notifications.py +++ b/nomen/notifications.py @@ -3,10 +3,11 @@ 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, fetch_exists, fetch_unpacked, test_keyword +from .utils import can_view, confirm, test_keyword log = logging.getLogger("nomen.notifications") log.setLevel(logging.DEBUG) @@ -21,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 @@ -68,13 +82,18 @@ 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, } - disabled = await fetch_exists( - ctx.bot.db, "SELECT * FROM users WHERE user_id=:author AND unlikely(disabled IS 1)", params + 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 ) if disabled: @@ -94,11 +113,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 @@ -138,8 +158,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"], @@ -151,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 fetch_unpacked(ctx.bot.db, 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 fetch_unpacked(ctx.bot.db, 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() @@ -219,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() @@ -230,7 +238,13 @@ class Notifications(Cog): Use quotes for a regex with spaces! """ - await self._add_keyword(ctx, keyword, True) + # TODO: Add regex names to make notifications cleaner + 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() @@ -258,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( @@ -303,32 +315,110 @@ class Notifications(Cog): @keyword.command() @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=?"): - 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}") + """ + 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.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}") diff --git a/nomen/settings.py b/nomen/settings.py index ce6c5fa..b86434c 100644 --- a/nomen/settings.py +++ b/nomen/settings.py @@ -1,12 +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): @@ -15,7 +26,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) @@ -35,24 +46,64 @@ 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: 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, @@ -69,13 +120,25 @@ 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 + 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): + # """ + # Exports a JSON of all of your user data + # """ + # pass @nomen.command(name="opt-out") async def opt_out(self, ctx): @@ -108,3 +171,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) 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())