nomen/nomen/notifications.py

265 lines
8.2 KiB
Python

import logging
from asyncio import TaskGroup
from disnake import Embed
from disnake.ext.commands import Cog, group, guild_only
from .utils import can_view, confirm, fetch_unpacked, test_keyword
log = logging.getLogger("nomen.notifications")
log.setLevel(logging.INFO)
async def handle_notification(db_updates, ctx, message, keyword, user_id):
"""
Async task to dispatch a notification
"""
log.debug(f"- Handling `{keyword}` for {user_id}")
member = await ctx.guild.getch_member(user_id)
if not await can_view(ctx, member):
log.debug(f"- - Missing permission {user_id}")
return
log.debug(f"- - Notifying {user_id}")
db_updates.append((ctx.guild.id, keyword, user_id))
footer = f"\n\n<t:{int(message.created_at.timestamp())}:R> | [Show]({message.jump_url}) | <#{ctx.channel.id}>"
embed = Embed(
description=message.content + footer,
)
embed.set_author(name=message.author, icon_url=message.author.display_avatar)
await member.send(
f":bell: `{message.author}` mentioned `{keyword}` on `{ctx.guild}`:",
embed=embed,
)
async def handle_triggers(ctx, message):
"""
Main function that handles message triggers
"""
params = {
"author": ctx.author.id,
"channel": ctx.channel.id,
"guild": ctx.guild.id,
"content": message.content,
"is_bot": ctx.author.bot,
}
disabled = await ctx.bot.db.execute_fetchall(
"SELECT EXISTS(SELECT * FROM users WHERE user_id=:author AND disabled IS 1)", params
)[0]
if disabled:
log.debug(f"User {ctx.author} ({ctx.author.id}) opted out")
return
db_updates = []
search_query = """
SELECT keyword, user_id, use_embed
FROM ( -- Deduplicate notification recipients by user_id
SELECT keyword, user_id, use_embed, row_number() over (partition by user_id) n
FROM keywords
LEFT JOIN users USING (user_id)
WHERE disabled IS NOT 1 -- Don't notify users who opted out
AND (notify_self IS 1 OR user_id IS NOT :author) -- Don't notify author unless wanted
AND (bots_notify IS 1 OR :is_bot IS NOT 1) -- Don't notify from bots unless wanted
AND :author NOT IN ( -- Don't notify if...
SELECT target FROM user_ignores -- Author is ignored in this guilde
WHERE user_id=user_id AND guild_id=guild_id
UNION
SELECT target FROM user_blocks -- Author is blocked
WHERE user_id=user_id
)
AND guild_id=:guild AND contains(:content, keyword, regex)
)
WHERE n=1
"""
async with TaskGroup() as tg:
async with ctx.bot.db.execute(search_query, params) as cur:
async for keyword, user_id, use_embed in cur:
log.debug(f"- Creating notification task: '{keyword}' for {user_id}")
tg.create_task(handle_notification(db_updates, ctx, message, keyword, user_id))
await ctx.bot.db.executemany(
"UPDATE keywords SET count = count + 1 WHERE guild_id=? AND keyword=? AND user_id=?",
db_updates,
)
await ctx.bot.db.commit()
class Notifications(Cog):
"""
Notifications!
"""
def __init__(self, bot):
self.bot = bot
@Cog.listener()
async def on_message(self, message):
if message.author == self.bot.user:
return
ctx = await self.bot.get_context(message)
if ctx.valid or ctx.guild is None:
return
await handle_triggers(ctx, message)
@group(
aliases=["kw", "notification", "notifications", "notif", "noti"],
invoke_without_command=True,
)
async def keyword(self, ctx):
"""
Notification keywords
"""
await ctx.send_help(self.keyword)
async def _add_keyword(self, ctx, keyword, regex):
log.debug(
f"Adding {'regex' if regex else 'keyword'}: {keyword} of {ctx.author} ({ctx.author.id}) on {ctx.guild} ({ctx.guild.id})"
)
if test_keyword(keyword, regex):
await ctx.send(f"{'Regex' if regex else 'Keyword'} matches a word that is too common")
return
cur = await ctx.bot.db.execute(
"SELECT keyword FROM keywords WHERE guild_id=? AND user_id=? AND contains(?, keyword, regex)",
(ctx.guild.id, ctx.author.id, keyword),
)
conflicts = unpack(await cur.fetchall())
if conflicts:
await ctx.send(f"Any instance of keyword `{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)
)
return
await ctx.bot.db.execute(
"INSERT INTO keywords (guild_id, keyword, user_id, regex) VALUES (?, ?, ?, ?)",
(ctx.guild.id, keyword, ctx.author.id, regex),
)
await ctx.bot.db.commit()
await ctx.send(f"Added `{keyword}` to your list of keywords")
@keyword.command()
@guild_only()
async def add(self, ctx, keyword):
"""
Adds a notification keyword
Use quotes for a keyword with spaces!
"""
await self._add_keyword(ctx, keyword, False)
@keyword.command()
@guild_only()
async def regex(self, ctx, keyword):
"""
Adds a notification regex
Use quotes for a regex with spaces!
"""
await self._add_keyword(ctx, keyword, True)
@keyword.command()
@guild_only()
async def remove(self, ctx, keyword):
"""
Removes a keyword or regex
Must be identical to the keyword or regex you intend on removing
"""
log.debug(f"Removing keyword: {keyword} of {ctx.author} (ctx.author.id) on {ctx.guild} ({ctx.guild.id})")
await ctx.bot.db.execute(
"DELETE FROM keywords WHERE guild_id=? AND keyword=? AND user_id=?",
(ctx.guild.id, keyword, ctx.author.id),
)
await ctx.bot.db.commit()
await ctx.send(f"Removed `{keyword}` from your list of keywords")
@keyword.command()
@guild_only()
async def list(self, ctx):
"""
Lists keywords and regexes, with trigger count
"""
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()
embed = Embed(
description="\n".join(
f"{'regex '*regex}`{keyword}`: notified `{count}` times" for keyword, count, regex in keywords
)
)
embed.set_author(name=f"Your keywords on {ctx.guild}:", icon_url=ctx.guild.icon)
await ctx.author.send(embed=embed)
await ctx.send("Please check your direct messages")
@keyword.command()
@guild_only()
async def clear(self, ctx):
"""
Clears keywords and regexes for this server
"""
log.debug(f"Clearing keywords: {ctx.author} ({ctx.author.id}) on {ctx.guild} ({ctx.guild.id})")
to_clear = await confirm(
ctx, "Are you sure you want to clear your keywords on {ctx.guild}? (Reply with yes/no)", True
)
if to_clear is None:
return await ctx.send("Timed out or received an invalid response", delete_after=10)
elif not to_clear:
return await ctx.send("Canceled", delete_after=10)
await ctx.send("Clearing keywords")
await ctx.bot.db.execute(
"DELETE FROM KEYWORDS WHERE guild_id=? AND user_id=?",
(ctx.guild.id, ctx.author.id),
)
await ctx.bot.db.commit()
@keyword.command()
@guild_only()
async def pause(self, ctx):
pass
# TODO: Pause guild notifications
@keyword.group(invoke_without_command=True)
@guild_only()
async def ignore(self, ctx, channel):
pass
# TODO: Ignore channels
@ignore.command()
@guild_only()
async def active(self, ctx):
pass
# TODO: Ignore active channel