265 lines
8.2 KiB
Python
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
|