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