Compare commits

...

22 commits

Author SHA1 Message Date
bd5706c0ce
add basic import functionality 2025-01-01 00:51:41 -05:00
347aa76de5
use execute_fetchall instead of cursor 2025-01-01 00:50:43 -05:00
76521c9d82
refactor keyword adding using exceptions 2025-01-01 00:50:12 -05:00
3d90376535
paginate error handling messages 2025-01-01 00:49:51 -05:00
4fa6b25971
comment out export c 2024-12-31 18:19:42 -05:00
91594c15b7
change nomen command description 2024-12-31 18:19:28 -05:00
d7863f2a49
refactor purge to use foreign key cascade 2024-12-31 18:19:11 -05:00
f3b40986be
implement settings command 2024-12-31 18:18:42 -05:00
2bdf4f3e25
add todo for regex names 2024-12-31 18:15:52 -05:00
77d329ac45
fix before invoke hook to properly add user default settings 2024-12-31 18:15:33 -05:00
40e55ac316
allow members with manage guild to change guild prefix 2024-12-31 18:15:11 -05:00
8b538ba78d
implement ignoring, blocking, and pausing 2024-12-31 18:15:00 -05:00
c0d535c8df
remove redundant db migration log 2024-12-31 18:13:54 -05:00
42281010c5
fix admin eval command 2024-12-31 18:10:28 -05:00
d868427fc6
add short helpers for getting/changing user settings 2024-12-31 18:09:58 -05:00
1573d0b046
improve error logging 2024-12-31 18:09:46 -05:00
3de920f54b
add repr to db rows 2024-12-31 18:09:11 -05:00
e0001625ae
use sqlite3 row class for rows 2024-12-31 14:15:20 -05:00
e31d19bcca
convert db helpers to methods 2024-12-31 14:14:46 -05:00
3a4e537828
properly check ignore_active value in schema 2024-12-31 14:08:57 -05:00
5390181cfa
set synchronous and foreign_keys on startup 2024-12-31 14:08:33 -05:00
08c4187d70
add pycache to gitignore 2024-12-30 14:21:13 -05:00
6 changed files with 410 additions and 136 deletions

1
.gitignore vendored
View file

@ -10,6 +10,7 @@ result
# Python # Python
.ruff_cache .ruff_cache
.ropeproject .ropeproject
**/__pycache__/
# Runtime # Runtime
*.sqlite3 *.sqlite3

View file

@ -12,9 +12,6 @@ log.setLevel(logging.INFO)
schema = """ schema = """
PRAGMA user_version = 1; PRAGMA user_version = 1;
PRAGMA main.synchronous = NORMAL;
PRAGMA foreign_keys = ON;
CREATE TABLE keywords ( CREATE TABLE keywords (
guild_id INTEGER NOT NULL, guild_id INTEGER NOT NULL,
keyword TEXT 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)), 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)), 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)), 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; WITHOUT ROWID;
@ -73,6 +70,37 @@ def run_db_migrations(db_file):
log.debug("Finished running automatic migration") 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): async def setup_db(db_file):
log.debug(f"Connecting to {db_file}") log.debug(f"Connecting to {db_file}")
db = await aiosqlite.connect(db_file) db = await aiosqlite.connect(db_file)
@ -80,8 +108,13 @@ async def setup_db(db_file):
log.debug("Running start script") log.debug("Running start script")
await db.executescript(""" await db.executescript("""
PRAGMA optimize(0x10002); 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") log.debug("Adding contains function")
await db.create_function("contains", 3, contains, deterministic=True) await db.create_function("contains", 3, contains, deterministic=True)

View file

@ -1,6 +1,7 @@
import io import io
import logging import logging
import os import os
import pprint
import sys import sys
import textwrap import textwrap
import traceback import traceback
@ -8,7 +9,7 @@ from contextlib import redirect_stdout
from disnake import Guild, Intents, Message from disnake import Guild, Intents, Message
from disnake.ext import commands 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 dotenv import find_dotenv, load_dotenv
from .db import run_db_migrations, setup_db from .db import run_db_migrations, setup_db
@ -89,11 +90,11 @@ class Nomen(Bot):
await super().close() await super().close()
await self.db.close() await self.db.close()
async def user_toggle(self, user_id, item): async def get_setting(self, user_id, setting):
await self.db.execute( return await self.db.fetch_singleton(f"SELECT {setting} FROM users WHERE user_id=?", (user_id,))
"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 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() await self.db.commit()
@ -120,18 +121,68 @@ async def on_ready():
log.info(bot.user.id) log.info(bot.user.id)
log.info("-------------") log.info("-------------")
bot.app_info = await bot.application_info()
bot.owner = bot.app_info.owner
@bot.event @bot.event
async def on_command_error(ctx, error): async def on_command_error(ctx, error):
if isinstance(error, commands.CommandNotFound): if isinstance(error, commands.CommandNotFound):
return return
if isinstance(error, commands.NoPrivateMessage): if isinstance(
error,
(
commands.UserInputError,
commands.CheckFailure,
),
):
await ctx.send(error) await ctx.send(error)
return return
log.exception(error) log.error(error, exc_info=error)
await ctx.send(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"<t:{timestamp}>, <t:{timestamp}:R> | [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) @bot.command(hidden=True)
@ -158,13 +209,13 @@ async def sql(ctx, *, query):
@bot.command(hidden=True, name="eval") @bot.command(hidden=True, name="eval")
@commands.is_owner() @commands.is_owner()
async def admin_eval(self, ctx, *, body: str): async def admin_eval(ctx, *, body: str):
env = { env = {
"bot": self.bot, "bot": ctx.bot,
"ctx": ctx, "ctx": ctx,
"channel": ctx.message.channel, "channel": ctx.channel,
"author": ctx.message.author, "author": ctx.author,
"guild": ctx.message.guild, "guild": ctx.guild,
"message": ctx.message, "message": ctx.message,
} }
@ -186,7 +237,11 @@ async def admin_eval(self, ctx, *, body: str):
ret = await func() ret = await func()
except Exception: except Exception:
value = stdout.getvalue() 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: else:
value = stdout.getvalue() value = stdout.getvalue()
try: try:
@ -194,11 +249,18 @@ async def admin_eval(self, ctx, *, body: str):
except: except:
pass pass
if ret is None: paginator = Paginator(prefix="```py", suffix="```")
if value: if value:
await ctx.send("```py\n{}\n```".format(value)) for line in str(value).split("\n"):
else: paginator.add_line(line)
await ctx.send("```py\n{}{}\n```".format(value, ret)) 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() @bot.command()
@ -207,8 +269,7 @@ async def ping(ctx):
@bot.command() @bot.command()
@commands.guild_only() @commands.has_guild_permissions(manage_guild=True)
@commands.is_owner()
async def prefix(ctx, prefix=None): async def prefix(ctx, prefix=None):
if prefix is None: if prefix is None:
prefix = await ctx.bot.get_guild_prefix(ctx.guild) prefix = await ctx.bot.get_guild_prefix(ctx.guild)
@ -226,8 +287,7 @@ async def prefix(ctx, prefix=None):
def run(): def run():
try: try:
if run_db_migrations(DB_FILE): run_db_migrations(DB_FILE)
log.info(f"Migrated DB {DB_FILE}")
except RuntimeError: except RuntimeError:
pass pass
else: else:

View file

@ -3,10 +3,11 @@ from asyncio import TaskGroup
from textwrap import indent from textwrap import indent
from typing import Union 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 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 = logging.getLogger("nomen.notifications")
log.setLevel(logging.DEBUG) 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 def handle_notification(db_updates, ctx, message, keyword, user_id, use_embed):
""" """
Async task to dispatch a notification Async task to dispatch a notification
@ -68,13 +82,18 @@ async def handle_triggers(ctx, message):
params = { params = {
"author": ctx.author.id, "author": ctx.author.id,
"channel": ctx.channel.id, "channel": ctx.channel.id,
"category": ctx.channel.category_id,
"guild": ctx.guild.id, "guild": ctx.guild.id,
"content": message.content, "content": message.content,
"is_bot": ctx.author.bot, "is_bot": ctx.author.bot,
"parent": 0,
} }
disabled = await fetch_exists( if isinstance(ctx.channel, Thread) and ctx.channel.parent:
ctx.bot.db, "SELECT * FROM users WHERE user_id=:author AND unlikely(disabled IS 1)", params 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: 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 (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... AND likely(NOT EXISTS(SELECT * FROM ( -- Don't notify if...
SELECT target FROM user_ignores -- Author or channel is ignored in this guild 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 UNION
SELECT target FROM user_blocks -- Author is blocked SELECT target FROM user_blocks -- Author is blocked
WHERE user_id=user_id 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)) AND guild_id=:guild AND unlikely(contains(:content, keyword, regex))
) )
WHERE n=1 WHERE n=1
@ -138,8 +158,8 @@ class Notifications(Cog):
await handle_triggers(ctx, message) await handle_triggers(ctx, message)
async def cog_before_invoke(ctx): async def cog_before_invoke(self, ctx):
await ctx.bot.db.execute("INSERT OR IGNORE INTO users (userid) VALUE(?)", (ctx.author.id,)) await ctx.bot.db.execute("INSERT OR IGNORE INTO users (user_id) VALUES(?)", (ctx.author.id,))
@group( @group(
aliases=["kw", "notification", "notifications", "notif", "noti"], aliases=["kw", "notification", "notifications", "notif", "noti"],
@ -151,64 +171,47 @@ class Notifications(Cog):
""" """
await ctx.send_help(self.keyword) await ctx.send_help(self.keyword)
async def _add_keyword(self, ctx, keyword: str, regex: bool): async def _check_conflicts(self, guild_id, user_id, keyword):
log.debug( return await self.bot.db.fetch_unpacked(
f"Adding {'regex' if regex else 'keyword'}: {keyword} of {ctx.author} ({ctx.author.id}) on {ctx.guild} ({ctx.guild.id})" "SELECT keyword FROM keywords WHERE guild_id=? AND user_id=? AND contains(?, keyword, regex)",
(guild_id, user_id, keyword),
) )
params = { async def _check_redundants(self, guild_id, user_id, keyword, regex):
"keyword": keyword, return await self.bot.db.fetch_unpacked(
"guild_id": ctx.guild.id, "SELECT keyword FROM keywords WHERE guild_id=? AND user_id=? AND contains(keyword, ?, ?)",
"user_id": ctx.author.id, (guild_id, user_id, keyword, regex),
"regex": regex, )
}
existing = """ async def add_keyword(self, guild_id: int, user_id: int, keyword: str, regex: bool, initial_count: int = 0):
SELECT keyword FROM keywords log.debug(
WHERE guild_id=:guild_id f"Adding {'regex' if regex else 'keyword'}: {keyword} of {user_id} on {guild_id} with count {initial_count}"
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)
"""
if test_keyword(keyword, regex): if test_keyword(keyword, regex):
log.debug("Keyword too common") log.debug("Keyword too common")
await ctx.send(f"{'Regex' if regex else 'Keyword'} matches a word that is too common") raise KeywordError(f"{'Regex' if regex else 'Keyword'} matches a word that is too common")
return
conflicts = await fetch_unpacked(ctx.bot.db, existing, params) if conflicts := await self._check_conflicts(guild_id, user_id, keyword):
if conflicts:
log.debug("Keyword conflicts with existing keyword") log.debug("Keyword conflicts with existing keyword")
await ctx.send(f"Any instance of `{keyword}` would be matched by existing keywords (check DMs)") raise KeywordError(
await ctx.author.send( 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) 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 redundants := await self._check_redundants(guild_id, user_id, keyword, regex):
if conflicts:
log.debug("Keyword renders existing redundant") log.debug("Keyword renders existing redundant")
await ctx.send(f"Adding `{keyword}` will cause existing keywords to never match (check DMs)") raise KeywordError(
await ctx.author.send( f"Adding `{keyword}` will cause existing keywords to never match",
f"Keywords redundant from `{keyword}`:\n" + "\n".join(f" - `{conflict}`" for conflict in conflicts) f"Keywords redundant from `{keyword}`:\n" + "\n".join(f" - `{conflict}`" for conflict in redundants),
) )
return
log.debug("Keyword valid, adding") log.debug("Keyword valid, adding")
await ctx.bot.db.execute( await self.bot.db.execute(
"INSERT INTO keywords (guild_id, keyword, user_id, regex) VALUES (:guild_id, :keyword, :user_id, :regex)", "INSERT INTO keywords VALUES (?, ?, ?, ?, ?)", (guild_id, keyword, user_id, regex, initial_count)
params,
) )
await ctx.bot.db.commit() await self.bot.db.commit()
await ctx.send(f"Added `{keyword}` to your list of keywords")
@keyword.command() @keyword.command()
@guild_only() @guild_only()
@ -219,7 +222,12 @@ class Notifications(Cog):
Use quotes for a keyword with spaces! 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() @keyword.command()
@guild_only() @guild_only()
@ -230,7 +238,13 @@ class Notifications(Cog):
Use quotes for a regex with spaces! 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"]) @keyword.command(aliases=["delete", "del"])
@guild_only() @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})") log.debug(f"Listing keywords: {ctx.author} ({ctx.author.id}) on {ctx.guild} ({ctx.guild.id})")
async with ctx.bot.db.execute( keywords = await ctx.bot.db.execute_fetchall(
"SELECT keyword, count, regex FROM keywords WHERE guild_id=? AND user_id=?", "SELECT keyword, count, regex FROM keywords WHERE guild_id=? AND user_id=?", (ctx.guild.id, ctx.author.id)
(ctx.guild.id, ctx.author.id), )
) as cur:
keywords = await cur.fetchall()
embed = Embed( embed = Embed(
description="\n".join( description="\n".join(
@ -303,32 +315,110 @@ class Notifications(Cog):
@keyword.command() @keyword.command()
@guild_only() @guild_only()
async def pause(self, ctx): 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=?"): Pause notifications in this guild
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}")
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: else:
await ctx.bot.db.execute("INSERT INTO user_pauses VALUES(?, ?)", params) 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.bot.db.commit()
await ctx.send(f"Now ignoring {target}") await ctx.send(f"Paused notifications in {ctx.guild}")
@keyword.command() @keyword.command()
@guild_only() @guild_only()
async def uningnore(self, ctx, target: Union[TextChannel, Member]): async def resume(self, ctx):
pass """
# TODO: Unignore 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() @guild_only()
async def active(self, ctx): async def ignore(self, ctx, target: Union[GuildChannel, Member]):
await ctx.bot.user_toggle(ctx.author.id, "ignore_active") """
await ctx.bot.send("Toggled ignore active channel") # TODO: Send current state 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}")

View file

@ -1,12 +1,23 @@
import json
import logging import logging
from typing import Literal
import disnake
import re2 as re
from disnake.ext.commands import Cog, group, guild_only from disnake.ext.commands import Cog, group, guild_only
from .notifications import KeywordError
from .utils import confirm from .utils import confirm
log = logging.getLogger("nomen.settings") log = logging.getLogger("nomen.settings")
log.setLevel(logging.DEBUG) log.setLevel(logging.DEBUG)
DUSTY_DATA_LINE = re.compile(r"^(.+) notified (\d+) times$")
class InvalidImportFormat(Exception):
pass
class Settings(Cog): class Settings(Cog):
def __init__(self, bot): def __init__(self, bot):
@ -15,7 +26,7 @@ class Settings(Cog):
@group(invoke_without_command=True) @group(invoke_without_command=True)
async def nomen(self, ctx): async def nomen(self, ctx):
""" """
Settings for Nomen Managing your data in Nomen
""" """
await ctx.send_help(self.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) to_purge = await confirm(ctx, msg)
if to_purge: if to_purge:
await self.bot.db.execute( # Foreign key constraints will automatically cascade to delete dependent data
""" await self.bot.db.execute("DELETE FROM users WHERE user_id=?", (ctx.author.id,))
DELETE FROM keywords WHERE user_id=?;
DELETE FROM users WHERE user_id=?;
DELETE FROM user_ignores WHERE user_id=?;
""",
(ctx.author.id,),
)
await self.bot.db.commit() await self.bot.db.commit()
await ctx.send("Deleted all user data.") await ctx.send("Deleted all user data.")
else: else:
await ctx.send("Cancelled.") 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") @nomen.command(name="import")
@guild_only() @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 = { schema = {
"disabled": bool, "disabled": bool,
@ -69,13 +120,25 @@ You may want to `{ctx.clean_prefix}nomen export` first"""
], ],
} }
@nomen.command() notifications = self.bot.get_cog("Notifications")
@guild_only()
async def export(self, ctx): try:
""" await self.import_json(ctx, data)
Exports a JSON of all of your user data await ctx.send("Sorry, JSON imports and exports haven't been implemented yet.")
""" return
pass 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") @nomen.command(name="opt-out")
async def opt_out(self, ctx): 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.execute("REPLACE INTO users (user_id, disabled) VALUES(?, 0)", (ctx.author.id,))
await self.bot.db.commit() 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)

View file

@ -111,18 +111,6 @@ def unpack(lst_of_tpl):
return list(map(first, 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): async def in_thread(member, thread):
# FIXME: Currently overlooks the situation where a moderator isn't in a thread but has manage threads # 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()) return any(member.id == thread_member.id for thread_member in await thread.fetch_members())