Add module.
This commit is contained in:
703
bot/modules/todo/Tasklist.py
Normal file
703
bot/modules/todo/Tasklist.py
Normal 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
|
||||||
|
)
|
||||||
111
bot/modules/todo/admin.py
Normal file
111
bot/modules/todo/admin.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from settings import GuildSettings, GuildSetting
|
||||||
|
import settings
|
||||||
|
|
||||||
|
from wards import guild_admin
|
||||||
|
|
||||||
|
from .data import tasklist_channels
|
||||||
|
|
||||||
|
|
||||||
|
@GuildSettings.attach_setting
|
||||||
|
class task_limit(settings.Integer, GuildSetting):
|
||||||
|
category = "TODO List"
|
||||||
|
|
||||||
|
attr_name = "task_limit"
|
||||||
|
_data_column = "max_tasks"
|
||||||
|
|
||||||
|
display_name = "task_limit"
|
||||||
|
desc = "Maximum number of tasks each user may have."
|
||||||
|
|
||||||
|
_default = 30
|
||||||
|
|
||||||
|
long_desc = (
|
||||||
|
"Maximum number of tasks each user may have in the todo system."
|
||||||
|
)
|
||||||
|
_accepts = "An integer number of tasks."
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
return "The task limit is now `{}`.".format(self.formatted)
|
||||||
|
|
||||||
|
|
||||||
|
@GuildSettings.attach_setting
|
||||||
|
class task_reward(settings.Integer, GuildSetting):
|
||||||
|
category = "TODO List"
|
||||||
|
|
||||||
|
attr_name = "task_reward"
|
||||||
|
_data_column = "task_reward"
|
||||||
|
|
||||||
|
display_name = "task_reward"
|
||||||
|
desc = "Number of LionCoins given for each completed TODO task."
|
||||||
|
|
||||||
|
_default = 250
|
||||||
|
|
||||||
|
long_desc = (
|
||||||
|
"LionCoin reward given for completing each task on the TODO list."
|
||||||
|
)
|
||||||
|
_accepts = "An integer number of coins."
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
return "Task completion will now reward `{}` LionCoins.".format(self.formatted)
|
||||||
|
|
||||||
|
|
||||||
|
@GuildSettings.attach_setting
|
||||||
|
class task_reward_limit(settings.Integer, GuildSetting):
|
||||||
|
category = "TODO List"
|
||||||
|
|
||||||
|
attr_name = "task_reward_limit"
|
||||||
|
_data_column = "task_reward_limit"
|
||||||
|
|
||||||
|
display_name = "task_reward_limit"
|
||||||
|
desc = "Maximum number of task rewards given in each 24h period."
|
||||||
|
|
||||||
|
_default = 10
|
||||||
|
|
||||||
|
long_desc = (
|
||||||
|
"Maximum number of times in each 24h period that TODO task completion can reward LionCoins."
|
||||||
|
)
|
||||||
|
_accepts = "An integer number of times."
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
return "LionCoins will only be reward `{}` timers per 24h".format(self.formatted)
|
||||||
|
|
||||||
|
|
||||||
|
@GuildSettings.attach_setting
|
||||||
|
class tasklist_channels_setting(settings.ChannelList, settings.ListData, settings.Setting):
|
||||||
|
category = "TODO List"
|
||||||
|
|
||||||
|
attr_name = 'tasklist_channels'
|
||||||
|
|
||||||
|
_table_interface = tasklist_channels
|
||||||
|
_id_column = 'guildid'
|
||||||
|
_data_column = 'channelid'
|
||||||
|
_setting = settings.TextChannel
|
||||||
|
|
||||||
|
write_ward = guild_admin
|
||||||
|
display_name = "todo_channels"
|
||||||
|
desc = "Channels where members may use the todo list."
|
||||||
|
|
||||||
|
_force_unique = True
|
||||||
|
|
||||||
|
long_desc = (
|
||||||
|
"Members will only be allowed to use the `todo` command in these channels."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Flat cache, no need to expire objects
|
||||||
|
_cache = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
if self.value:
|
||||||
|
return "The todo channels have been updated:\n{}".format(self.formatted)
|
||||||
|
else:
|
||||||
|
return "The `todo` command may now be used anywhere."
|
||||||
|
|
||||||
|
@property
|
||||||
|
def formatted(self):
|
||||||
|
if not self.data:
|
||||||
|
return "All channels!"
|
||||||
|
else:
|
||||||
|
return super().formatted
|
||||||
115
bot/modules/todo/commands.py
Normal file
115
bot/modules/todo/commands.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import asyncio
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from cmdClient.checks import in_guild
|
||||||
|
|
||||||
|
from .module import module
|
||||||
|
from .Tasklist import Tasklist
|
||||||
|
|
||||||
|
|
||||||
|
@module.cmd(
|
||||||
|
name="todo",
|
||||||
|
desc="Display and edit your personal TODO list.",
|
||||||
|
group="Productivity",
|
||||||
|
flags=('add==', 'delete==', 'check==', 'uncheck==', 'edit==')
|
||||||
|
)
|
||||||
|
@in_guild()
|
||||||
|
async def cmd_todo(ctx, flags):
|
||||||
|
"""
|
||||||
|
Usage``:
|
||||||
|
{prefix}todo
|
||||||
|
{prefix}todo <tasks>
|
||||||
|
{prefix}todo add <tasks>
|
||||||
|
{prefix}todo delete <taskids>
|
||||||
|
{prefix}todo check <taskids>
|
||||||
|
{prefix}todo uncheck <taskids>
|
||||||
|
{prefix}todo edit <taskid> <new task>
|
||||||
|
Description:
|
||||||
|
Open your personal interactive TODO list with `{prefix}todo`, \
|
||||||
|
and start adding tasks by sending `add your_task_here`. \
|
||||||
|
Press ❔ to see more ways to use the interactive list.
|
||||||
|
You can also use the commands above to modify your TODOs (see the examples below).
|
||||||
|
|
||||||
|
You may add several tasks at once by writing them on different lines \
|
||||||
|
(type Shift-Enter to make a new line on the desktop client).
|
||||||
|
Examples::
|
||||||
|
{prefix}todo: Open your TODO list.
|
||||||
|
{prefix}todo My New task: Add `My New task`.
|
||||||
|
{prefix}todo delete 1, 3-5: Delete tasks `1, 3, 4, 5`.
|
||||||
|
{prefix}todo check 1, 2: Mark tasks `1` and `2` as done.
|
||||||
|
{prefix}todo edit 1 My new task: Edit task `1`.
|
||||||
|
"""
|
||||||
|
tasklist_channels = ctx.guild_settings.tasklist_channels.value
|
||||||
|
if tasklist_channels and ctx.ch not in tasklist_channels:
|
||||||
|
visible = [channel for channel in tasklist_channels if channel.permissions_for(ctx.author).read_messages]
|
||||||
|
if not visible:
|
||||||
|
prompt = "The `todo` command may not be used here!"
|
||||||
|
elif len(visible) == 1:
|
||||||
|
prompt = (
|
||||||
|
"The `todo` command may not be used here! "
|
||||||
|
"Please go to {}."
|
||||||
|
).format(visible[0].mention)
|
||||||
|
else:
|
||||||
|
prompt = (
|
||||||
|
"The `todo` command may not be used here! "
|
||||||
|
"Please go to one of the following.\n{}"
|
||||||
|
).format(' '.join(vis.mention for vis in visible))
|
||||||
|
out_msg = await ctx.msg.reply(
|
||||||
|
embed=discord.Embed(
|
||||||
|
description=prompt,
|
||||||
|
colour=discord.Colour.red()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
try:
|
||||||
|
await out_msg.delete()
|
||||||
|
await ctx.msg.delete()
|
||||||
|
except discord.HTTPException:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
# TODO: Custom module, with pre-command hooks
|
||||||
|
tasklist = Tasklist.fetch_or_create(ctx.author, ctx.ch)
|
||||||
|
|
||||||
|
keys = {
|
||||||
|
'add': (('add', ), tasklist.parse_add),
|
||||||
|
'check': (('check', 'done', 'complete'), tasklist.parse_check),
|
||||||
|
'uncheck': (('uncheck', 'uncomplete'), tasklist.parse_uncheck),
|
||||||
|
'edit': (('edit',), tasklist.parse_edit),
|
||||||
|
'delete': (('delete',), tasklist.parse_delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle subcommands
|
||||||
|
cmd = None
|
||||||
|
args = ctx.args
|
||||||
|
splits = args.split(maxsplit=1)
|
||||||
|
if len(splits) > 1:
|
||||||
|
maybe_cmd = splits[0].lower()
|
||||||
|
for key, (aliases, _) in keys.items():
|
||||||
|
if maybe_cmd in aliases:
|
||||||
|
cmd = key
|
||||||
|
break
|
||||||
|
|
||||||
|
# Default to adding if no command given
|
||||||
|
if cmd:
|
||||||
|
args = splits[1].strip()
|
||||||
|
elif args:
|
||||||
|
cmd = 'add'
|
||||||
|
|
||||||
|
async with tasklist.interaction_lock:
|
||||||
|
# Run required parsers
|
||||||
|
for key, (_, func) in keys.items():
|
||||||
|
if flags[key] or cmd == key:
|
||||||
|
await func((flags[key] or args).strip())
|
||||||
|
|
||||||
|
if not (any(flags.values()) or args):
|
||||||
|
# Force a repost if no flags were provided
|
||||||
|
await tasklist.update(repost=True)
|
||||||
|
else:
|
||||||
|
# Delete if the tasklist already had a message
|
||||||
|
if tasklist.message:
|
||||||
|
try:
|
||||||
|
await ctx.msg.delete()
|
||||||
|
except discord.HTTPException:
|
||||||
|
pass
|
||||||
|
await tasklist.update()
|
||||||
37
bot/modules/todo/data.py
Normal file
37
bot/modules/todo/data.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from data import RowTable, Table
|
||||||
|
|
||||||
|
tasklist = RowTable(
|
||||||
|
'tasklist',
|
||||||
|
('taskid', 'guildid', 'userid', 'content', 'complete', 'rewarded', 'created_at', 'last_updated_at'),
|
||||||
|
'taskid'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tasklist.save_query
|
||||||
|
def expire_old_tasks():
|
||||||
|
with tasklist.conn:
|
||||||
|
with tasklist.conn.cursor() as curs:
|
||||||
|
curs.execute(
|
||||||
|
"DELETE FROM tasklist WHERE "
|
||||||
|
"last_updated_at < timezone('utc', NOW()) - INTERVAL '7d' "
|
||||||
|
"RETURNING *"
|
||||||
|
)
|
||||||
|
return curs.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
tasklist_channels = Table('tasklist_channels')
|
||||||
|
|
||||||
|
tasklist_rewards = Table('tasklist_reward_history')
|
||||||
|
|
||||||
|
|
||||||
|
@tasklist_rewards.save_query
|
||||||
|
def count_recent_for(guildid, userid, interval='24h'):
|
||||||
|
with tasklist_rewards.conn:
|
||||||
|
with tasklist_rewards.conn.cursor() as curs:
|
||||||
|
curs.execute(
|
||||||
|
"SELECT SUM(reward_count) FROM tasklist_reward_history "
|
||||||
|
"WHERE "
|
||||||
|
"guildid = {} AND userid = {}"
|
||||||
|
"AND reward_time > timezone('utc', NOW()) - INTERVAL '{}'".format(guildid, userid, interval)
|
||||||
|
)
|
||||||
|
return curs.fetchone()[0] or 0
|
||||||
3
bot/modules/todo/module.py
Normal file
3
bot/modules/todo/module.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from LionModule import LionModule
|
||||||
|
|
||||||
|
module = LionModule("Todo")
|
||||||
Reference in New Issue
Block a user