Add module.

This commit is contained in:
2021-09-12 11:36:22 +03:00
parent d6b3adc27e
commit d2357f0e53
5 changed files with 969 additions and 0 deletions

View File

@@ -0,0 +1,703 @@
import re
import datetime
import discord
import asyncio
from cmdClient.lib import SafeCancellation
from meta import client
from core import Lion
from settings import GuildSettings
from utils.lib import parse_ranges
from . import data
# from .module import module
class Tasklist:
"""
Class representing an interactive updating tasklist.
"""
max_task_length = 100
active = {} # Map (userid, channelid) -> Tasklist
messages = {} # Map messageid -> Tasklist
checkmark = ""
block_size = 15
next_emoji = ""
prev_emoji = ""
question_emoji = ""
cancel_emoji = ""
refresh_emoji = "🔄"
paged_reaction_order = (
prev_emoji, cancel_emoji, question_emoji, refresh_emoji, next_emoji
)
non_paged_reaction_order = (
question_emoji, cancel_emoji, refresh_emoji
)
reaction_hint = "*Press {} for info, {} to exit and {} to refresh.*".format(
question_emoji,
cancel_emoji,
refresh_emoji
)
_re_flags = re.DOTALL | re.IGNORECASE | re.VERBOSE
add_regex = re.compile(
r"^(?: (?:add) | \+) \s+ (.+)",
_re_flags
)
delete_regex = re.compile(
r"^(?: d(?:el(?:ete)?)? | (?: r(?:(?:emove)|m)?) | -) \s* ([0-9, -]+)$",
_re_flags
)
edit_regex = re.compile(
r"^e(?:dit)? \s+ (\d+ \s+ .+)",
_re_flags
)
check_regex = re.compile(
r"^(?: c(?:heck)? | (?: done) | (?: complete))\s* ([0-9, -]+)$",
_re_flags
)
uncheck_regex = re.compile(
r"^(?: u(?:ncheck)? | (?: undone) | (?: uncomplete)) \s* ([0-9, -]+)$",
_re_flags
)
toggle_regex = re.compile(
r"^([0-9, -]+)$",
_re_flags
)
cancel_regex = re.compile(
r"^(cancel)|(exit)|(quit)$",
_re_flags
)
interactive_help = """
Send the following to modify your tasks while the todolist is visible. \
`<taskids>` may be given as comma separated numbers and ranges.
`<taskids>` Toggle the status (checked/unchecked) of the provided tasks.
`add/+ <task>` Add a new TODO `task`. Each line is added as a separate task.
`d/rm/- <taskids>` Remove the specified tasks.
`c/check <taskids>` Check (mark complete) the specified tasks.
`u/uncheck <taskids>` Uncheck (mark incomplete) the specified tasks.
`cancel` Cancel the interactive tasklist mode.
**Examples**
`add Read chapter 1` Adds a new task `Read chapter 1`.
`e 1 Notes chapter 1` Edit task `1` to `Notes chapter 1`.
`d 1, 5-7, 9` Deletes tasks `1, 5, 6, 7, 9`.
`1, 2-5, 9` Toggle the completion status of tasks `1, 2, 3, 4, 5, 9`.
You may also edit your tasklist at any time with `{prefix}todo` (see `{prefix}help todo`).
Note that tasks expire after 24 hours.
""".format(prefix=client.prefix)
def __init__(self, member, channel, activate=True):
self.member = member # Discord Member owning the tasklist
self.channel = channel # Discord Channel for display and input
self.message = None # Discord Message currently displaying the tasklist
self.tasklist = [] # Displayed list of Row(tasklist)
self.pages = [] # Pages to display
self.current_page = None # Current displayed page. None means set automatically
self.show_help = False # Whether to show a help section in the pages
self.has_paging = None # Whether we have added paging reactions
self._refreshed_at = None # Timestamp of the last tasklist refresh
self._deactivation_task = None # Task for scheduled tasklist deactivation
self.interaction_lock = asyncio.Lock() # Lock to ensure interactions execute sequentially
self._deactivated = False # Flag for checking deactivation
if activate:
# Populate the tasklist
self._refresh()
# Add the tasklist to active tasklists
self.active[(member.id, channel.id)] = self
@classmethod
def fetch_or_create(cls, member, channel):
tasklist = cls.active.get((member.id, channel.id), None)
return tasklist if tasklist is not None else cls(member, channel)
def _refresh(self):
"""
Update the in-memory tasklist from data and regenerate the pages
"""
self.tasklist = data.tasklist.fetch_rows_where(
guildid=self.member.guild.id,
userid=self.member.id,
_extra=("AND last_updated_at > timezone('utc', NOW()) - INTERVAL '24h' "
"ORDER BY created_at ASC, taskid ASC")
)
self._refreshed_at = datetime.datetime.utcnow()
def _format_tasklist(self):
"""
Generates a sequence of pages from the tasklist
"""
# Format tasks
task_strings = [
"{num:>{numlen}}. [{mark}] {content}".format(
num=i,
numlen=((self.block_size * (i // self.block_size + 1) - 1) // 10) + 1,
mark=self.checkmark if task.complete else ' ',
content=task.content
)
for i, task in enumerate(self.tasklist)
]
# Split up tasklist into formatted blocks
task_pages = [task_strings[i:i+self.block_size] for i in range(0, len(task_strings), self.block_size)]
task_blocks = [
"```md\n{}```".format('\n'.join(page)) for page in task_pages
]
# Formatting strings and data
page_count = len(task_blocks) or 1
task_count = len(task_strings)
complete_count = len([task for task in self.tasklist if task.complete])
if task_count > 0:
title = "TODO list ({}/{} complete)".format(
complete_count,
task_count,
# ((complete_count * 100) // task_count),
)
if complete_count == task_count:
hint = "You have completed all your tasks! Well done!"
else:
hint = ""
else:
title = "TODO list"
hint = "Type `add <task>` to start adding tasks! E.g. `add Revise Maths Paper 1`."
task_blocks = [""] # Empty page so we can post
# Create formtted page embeds, adding help if required
pages = []
for i, block in enumerate(task_blocks):
embed = discord.Embed(
title=title,
description="{}\n{}\n{}".format(hint, block, self.reaction_hint),
timestamp=self._refreshed_at
).set_author(name=self.member.display_name, icon_url=self.member.avatar_url)
if page_count > 1:
embed.set_footer(text="Page {}/{}".format(i+1, page_count))
if self.show_help:
embed.add_field(
name="Cheatsheet",
value=self.interactive_help
)
pages.append(embed)
self.pages = pages
return pages
def _adjust_current_page(self):
"""
Update the current page number to point to a valid page.
"""
# Calculate or adjust the current page number
if self.current_page is None:
# First page with incomplete task, or the first page
first_incomplete = next((i for i, task in enumerate(self.tasklist) if not task.complete), 0)
self.current_page = first_incomplete // self.block_size
elif self.current_page >= len(self.pages):
self.current_page = len(self.pages) - 1
elif self.current_page < 0:
self.current_page %= len(self.pages)
async def _post(self):
"""
Post the interactive widget, add reactions, and update the message cache
"""
pages = self.pages
# Post the page
message = await self.channel.send(embed=pages[self.current_page])
# Add the reactions
self.has_paging = len(pages) > 1
for emoji in (self.paged_reaction_order if self.has_paging else self.non_paged_reaction_order):
await message.add_reaction(emoji)
# Register
if self.message:
self.messages.pop(self.message.id, None)
self.message = message
self.messages[message.id] = self
async def update(self, repost=None):
"""
Update the displayed tasklist.
If required, delete and repost the tasklist.
"""
if self._deactivated:
return
# Update data and make page list
self._refresh()
self._format_tasklist()
self._adjust_current_page()
if self.message and not repost:
# Read the channel history, see if we need to repost
height = 0
async for message in self.channel.history(limit=20):
if message.id == self.message.id:
break
height += len(message.content.splitlines())
if message.embeds or message.attachments or height > 20:
repost = True
break
if message.id < self.message.id:
# Our message was deleted?
repost = True
break
else:
repost = True
if not repost:
try:
await self.message.edit(embed=self.pages[self.current_page])
# Add or remove paging reactions as required
should_have_paging = len(self.pages) > 1
if self.has_paging != should_have_paging:
try:
await self.message.clear_reactions()
except discord.HTTPException:
pass
if should_have_paging:
reaction_order = self.paged_reaction_order
else:
reaction_order = self.non_paged_reaction_order
for emoji in reaction_order:
await self.message.add_reaction(emoji)
self.has_paging = should_have_paging
except discord.NotFound:
self.messages.pop(self.message.id, None)
self.message = None
repost = True
if not self.message or repost:
if self.message:
# Delete previous message
try:
await self.message.delete()
except discord.HTTPException:
pass
await self._post()
asyncio.create_task(self._schedule_deactivation())
async def deactivate(self, delete=False):
"""
Delete from active tasklists and message cache, and remove the reactions.
If `delete` is given, deletes any output message
"""
self._deactivated = True
if self._deactivation_task and not self._deactivation_task.cancelled():
self._deactivation_task.cancel()
self.active.pop((self.member.id, self.channel.id), None)
if self.message:
self.messages.pop(self.message.id, None)
try:
if delete:
await self.message.delete()
else:
await self.message.clear_reactions()
except discord.HTTPException:
pass
async def _reward_complete(self, *checked_rows):
# Fetch guild task reward settings
guild_settings = GuildSettings(self.member.guild.id)
task_reward = guild_settings.task_reward.value
task_reward_limit = guild_settings.task_reward_limit.value
# Select only tasks that haven't been rewarded before
unrewarded = [task for task in checked_rows if not task['rewarded']]
if unrewarded:
# Select tasks to reward up to the limit of rewards
recent_rewards = data.tasklist_rewards.queries.count_recent_for(self.member.guild.id, self.member.id)
max_to_reward = max((task_reward_limit - recent_rewards, 0))
reward_tasks = unrewarded[:max_to_reward]
rewarding_count = len(reward_tasks)
# reached_max = (recent_rewards + rewarding_count) >= task_reward_limit
reward_coins = task_reward * len(reward_tasks)
if reward_coins:
# Rewarding process, now that we know what we need to reward
# Add coins
user = Lion.fetch(self.member.guild.id, self.member.id)
user.addCoins(reward_coins)
# Mark tasks as rewarded
taskids = [task['taskid'] for task in reward_tasks]
data.tasklist.update_where(
{'rewarded': True},
taskid=taskids,
)
# Track reward
data.tasklist_rewards.insert(
guildid=self.member.guild.id,
userid=self.member.id,
reward_count=rewarding_count
)
# Log reward
client.log(
"Giving '{}' LionCoins to '{}' (uid:{}) for completing TODO tasks.".format(
reward_coins,
self.member,
self.member.id
)
)
# TODO: Message in channel? Might be too spammy?
pass
def _add_tasks(self, *tasks):
"""
Add provided tasks to the task list
"""
insert = [
(self.member.guild.id, self.member.id, task)
for task in tasks
]
return data.tasklist.insert_many(
*insert,
insert_keys=('guildid', 'userid', 'content')
)
def _delete_tasks(self, *indexes):
"""
Delete tasks from the task list
"""
taskids = [self.tasklist[i].taskid for i in indexes]
return data.tasklist.delete_where(
taskid=taskids
)
def _edit_task(self, index, new_content):
"""
Update the provided task with the new content
"""
taskid = self.tasklist[index].taskid
return data.tasklist.update_where(
{
'content': new_content,
'last_updated_at': datetime.datetime.utcnow()
},
taskid=taskid,
)
def _check_tasks(self, *indexes):
"""
Mark provided tasks as complete
"""
taskids = [self.tasklist[i].taskid for i in indexes]
return data.tasklist.update_where(
{
'complete': True,
'last_updated_at': datetime.datetime.utcnow()
},
taskid=taskids,
complete=False,
)
def _uncheck_tasks(self, *indexes):
"""
Mark provided tasks as incomplete
"""
taskids = [self.tasklist[i].taskid for i in indexes]
return data.tasklist.update_where(
{
'complete': False,
'last_updated_at': datetime.datetime.utcnow()
},
taskid=taskids,
complete=True,
)
def _index_range_parser(self, userstr):
"""
Parse user provided task indicies.
"""
try:
indexes = parse_ranges(userstr)
except SafeCancellation:
raise SafeCancellation(
"Couldn't parse the provided task numbers! "
"Please list the task numbers or ranges separated by a comma, e.g. `1, 3, 5-7, 11`."
) from None
return [index for index in indexes if index < len(self.tasklist)]
async def parse_add(self, userstr):
"""
Process arguments to an `add` request
"""
tasks = [line for line in userstr.splitlines() if line]
if not tasks:
# TODO: Maybe have interactive input here
return
# Fetch accurate count of current tasks
count = data.tasklist.select_one_where(
select_columns=("COUNT(*)",),
guildid=self.member.guild.id,
userid=self.member.id
)[0]
# Fetch maximum allowed count
max_task_count = GuildSettings(self.member.guild.id).task_limit.value
# Check if we are exceeding the count
if count + len(tasks) > max_task_count:
raise SafeCancellation("Too many tasks! You can have a maximum of `{}` todo items!".format(max_task_count))
# Check if any task is too long
if any(len(task) > self.max_task_length for task in tasks):
raise SafeCancellation("Please keep your tasks under `{}` characters long.".format(self.max_task_length))
# Finally, add the tasks
self._add_tasks(*tasks)
# Set the current page to the last one
self.current_page = -1
async def parse_delete(self, userstr):
"""
Process arguments to a `delete` request
"""
# Parse provided ranges
indexes = self._index_range_parser(userstr)
if indexes:
self._delete_tasks(*indexes)
async def parse_toggle(self, userstr):
"""
Process arguments to a `toggle` request
"""
# Parse provided ranges
indexes = self._index_range_parser(userstr)
to_check = [index for index in indexes if not self.tasklist[index].complete]
to_uncheck = [index for index in indexes if self.tasklist[index].complete]
if to_uncheck:
self._uncheck_tasks(*to_uncheck)
if to_check:
checked = self._check_tasks(*to_check)
await self._reward_complete(*checked)
async def parse_check(self, userstr):
"""
Process arguments to a `check` request
"""
# Parse provided ranges
indexes = self._index_range_parser(userstr)
if indexes:
checked = self._check_tasks(*indexes)
await self._reward_complete(*checked)
async def parse_uncheck(self, userstr):
"""
Process arguments to an `uncheck` request
"""
# Parse provided ranges
indexes = self._index_range_parser(userstr)
if indexes:
self._uncheck_tasks(*indexes)
async def parse_edit(self, userstr):
"""
Process arguments to an `edit` request
"""
splits = userstr.split(maxsplit=1)
if len(splits) < 2 or not splits[0].isdigit():
raise SafeCancellation("Please provide the task number and the new content, "
"e.g. `edit 1 Biology homework`.")
index = int(splits[0])
new_content = splits[1]
if index >= len(self.tasklist):
raise SafeCancellation(
"You do not have a task number `{}` to edit!".format(index)
)
if len(new_content) > self.max_task_length:
raise SafeCancellation("Please keep your tasks under `{}` characters long.".format(self.max_task_length))
self._edit_task(index, new_content)
self.current_page = index // self.block_size
async def handle_reaction(self, reaction, user, added):
"""
Reaction handler for reactions on our message.
"""
str_emoji = str(reaction.emoji)
if added and str_emoji in self.paged_reaction_order:
# Attempt to remove reaction
try:
await self.message.remove_reaction(reaction.emoji, user)
except discord.HTTPException:
pass
old_message_id = self.message.id
async with self.interaction_lock:
# Return if the message changed while we were waiting
if self.message.id != old_message_id:
return
if str_emoji == self.next_emoji and user.id == self.member.id:
self.current_page += 1
self.current_page %= len(self.pages)
if self.show_help:
self.show_help = False
self._format_tasklist()
await self.message.edit(embed=self.pages[self.current_page])
elif str_emoji == self.prev_emoji and user.id == self.member.id:
self.current_page -= 1
self.current_page %= len(self.pages)
if self.show_help:
self.show_help = False
self._format_tasklist()
await self.message.edit(embed=self.pages[self.current_page])
elif str_emoji == self.cancel_emoji and user.id == self.member.id:
await self.deactivate(delete=True)
elif str_emoji == self.question_emoji and user.id == self.member.id:
self.show_help = not self.show_help
self._format_tasklist()
await self.message.edit(embed=self.pages[self.current_page])
elif str_emoji == self.refresh_emoji and user.id == self.member.id:
await self.update()
async def handle_message(self, message, content=None):
"""
Message handler for messages from out member, in the correct channel.
"""
content = content or message.content
funcmap = {
self.add_regex: self.parse_add,
self.delete_regex: self.parse_delete,
self.check_regex: self.parse_check,
self.uncheck_regex: self.parse_uncheck,
self.toggle_regex: self.parse_toggle,
self.edit_regex: self.parse_edit,
self.cancel_regex: self.deactivate,
}
async with self.interaction_lock:
for reg, func in funcmap.items():
matches = re.search(reg, content)
if matches:
try:
await func(matches.group(1))
await self.update()
except SafeCancellation as e:
embed = discord.Embed(
description=e.msg,
colour=discord.Colour.red()
)
await message.reply(embed=embed)
else:
try:
await message.delete()
except discord.HTTPException:
pass
break
async def _schedule_deactivation(self):
"""
Automatically deactivate the tasklist after some time has passed, and many messages have been sent.
"""
delay = 5 * 10
# Remove previous scheduled task
if self._deactivation_task and not self._deactivation_task.cancelled():
self._deactivation_task.cancel()
# Schedule a new task
try:
self._deactivation_task = asyncio.create_task(asyncio.sleep(delay))
await self._deactivation_task
except asyncio.CancelledError:
return
# If we don't have a message, nothing to do
if not self.message:
return
# If we were updated in that time, go back to sleep
if datetime.datetime.utcnow().timestamp() - self._refreshed_at.timestamp() < delay:
asyncio.create_task(self._schedule_deactivation)
return
# Check if lots of content has been sent since
height = 0
async for message in self.channel.history(limit=20):
if message.id == self.message.id:
break
height += len(message.content.splitlines())
if message.embeds or message.attachments:
height += 10
if height >= 100:
break
if message.id < self.message.id:
# Our message was deleted?
return
else:
height = 100
if height >= 100:
await self.deactivate()
else:
asyncio.create_task(self._schedule_deactivation())
@client.add_after_event("message")
async def tasklist_message_handler(client, message):
key = (message.author.id, message.channel.id)
if key in Tasklist.active:
await Tasklist.active[key].handle_message(message)
@client.add_after_event("reaction_add")
async def tasklist_reaction_add_handler(client, reaction, user):
if user != client.user and reaction.message.id in Tasklist.messages:
await Tasklist.messages[reaction.message.id].handle_reaction(reaction, user, True)
# @module.launch_task
# Commented because we don't actually need to expire these
async def tasklist_expiry_watchdog(client):
removed = data.tasklist.queries.expire_old_tasks()
if removed:
client.log(
"Remove {} stale todo tasks.".format(len(removed)),
context="TASKLIST_EXPIRY",
post=True
)