rewrite: Tasklist module.

This commit is contained in:
2022-12-23 06:10:21 +02:00
parent 4014e0a3a6
commit 2b93354248
30 changed files with 2324 additions and 1020 deletions

View File

@@ -1,4 +1,4 @@
from .conditions import Condition, condition
from .conditions import Condition, condition, NULL
from .database import Database
from .models import RowModel, RowTable, WeakCache
from .table import Table
@@ -6,4 +6,4 @@ from .base import Expression, RawExpr
from .columns import ColumnExpr, Column, Integer, String
from .registry import Registry, AttachableClass, Attachable
from .adapted import RegisterEnum
from .queries import ORDER, NULLS
from .queries import ORDER, NULLS, JOINTYPE

View File

@@ -12,6 +12,8 @@ A Condition is a "logical" database expression, intended for use in Where statem
Conditions support bitwise logical operators ~, &, |, each producing another Condition.
"""
NULL = None
class Joiner(Enum):
EQUALS = ('=', '!=')

View File

@@ -173,6 +173,10 @@ class RowModel:
return obj
@classmethod
def as_tuple(cls):
return (cls.table.identifier, ())
def __init__(self, data):
self.data = data

View File

@@ -52,6 +52,7 @@ class LionCog(Cog):
for command in placeholder_group.commands:
placeholder_group.remove_command(command.name)
target_group.remove_command(command.name)
acmd = command.app_command._copy_with(parent=target_group.app_command, binding=self)
command.app_command = acmd
target_group.add_command(command)

View File

@@ -2,9 +2,12 @@ this_package = 'modules'
active = [
'.sysadmin',
'.test',
'.reminders',
'.config',
'.economy',
'.reminders',
'.shop',
'.tasklist',
'.test',
]

View File

@@ -0,0 +1,10 @@
import logging
from babel.translator import LocalBabel
logger = logging.getLogger(__name__)
babel = LocalBabel('config')
async def setup(bot):
from .cog import ConfigCog
await bot.add_cog(ConfigCog(bot))

30
bot/modules/config/cog.py Normal file
View File

@@ -0,0 +1,30 @@
import discord
from discord import app_commands as appcmds
from discord.ext import commands as cmds
from meta import LionBot, LionContext, LionCog
from . import babel
_p = babel._p
class ConfigCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
async def cog_load(self):
...
async def cog_unload(self):
...
@cmds.hybrid_group(
name=_p('group:configure', "configure"),
)
@appcmds.guild_only
async def configure_group(self, ctx: LionContext):
"""
Bare command group, has no function.
"""
return

View File

@@ -5,7 +5,8 @@ import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
from data import Registry, RowModel, RegisterEnum, ORDER
from psycopg import sql
from data import Registry, RowModel, RegisterEnum, ORDER, JOINTYPE, RawExpr
from data.columns import Integer, Bool, String, Column, Timestamp
from meta import LionCog, LionBot, LionContext
@@ -153,12 +154,14 @@ class EconomyData(Registry, name='economy'):
guildid: int, actorid: int,
userid: int, itemid: int, amount: int
):
row = await EconomyData.Transaction.execute_transaction(
TransactionType.PURCHASE,
guildid=guildid, actorid=actorid, from_account=userid, to_account=None,
amount=amount
)
return await cls.create(transactionid=row.transactionid, itemid=itemid)
conn = await cls._connector.get_connection()
async with conn.transaction():
row = await EconomyData.Transaction.execute_transaction(
TransactionType.PURCHASE,
guildid=guildid, actorid=actorid, from_account=userid, to_account=None,
amount=amount
)
return await cls.create(transactionid=row.transactionid, itemid=itemid)
class TaskTransaction(RowModel):
"""
@@ -174,6 +177,38 @@ class EconomyData(Registry, name='economy'):
transactionid = Integer(primary=True)
count = Integer()
@classmethod
async def count_recent_for(cls, userid, guildid, interval='24h'):
"""
Retrieve the number of tasks rewarded in the last `interval`.
"""
T = EconomyData.Transaction
query = cls.table.select_where().with_no_adapter()
query.join(T, using=(T.transactionid.name, ), join_type=JOINTYPE.LEFT)
query.select(recent=sql.SQL("SUM({})").format(cls.count.expr))
query.where(
T.to_account == userid,
T.guildid == guildid,
T.created_at > RawExpr(sql.SQL("timezone('utc', NOW()) - INTERVAL {}").format(interval), ()),
)
result = await query
return result[0]['recent'] or 0
@classmethod
async def reward_completed(cls, userid, guildid, count, amount):
"""
Reward the specified member `amount` coins for completing `count` tasks.
"""
# TODO: Bonus logic, perhaps apply_bonus(amount), or put this method in the economy cog?
conn = await cls._connector.get_connection()
async with conn.transaction():
row = await EconomyData.Transaction.execute_transaction(
TransactionType.TASKS,
guildid=guildid, actorid=userid, from_account=None, to_account=userid,
amount=amount
)
return await cls.create(transactionid=row.transactionid, count=count)
class SessionTransaction(RowModel):
"""
Schema

View File

@@ -1,710 +0,0 @@
import re
import datetime
import discord
import asyncio
from cmdClient.lib import SafeCancellation
from meta import client, conf
from core import Lion
from data import NULL, NOTNULL
from settings import GuildSettings
from utils.lib import parse_ranges, utc_now
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 = conf.emojis.forward
prev_emoji = conf.emojis.backward
question_emoji = conf.emojis.question
cancel_emoji = conf.emojis.cancel
refresh_emoji = conf.emojis.refresh
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, ctx, flags, 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(
userid=self.member.id,
deleted_at=NULL,
_extra="ORDER BY created_at ASC, taskid ASC"
)
self._refreshed_at = datetime.datetime.utcnow()
async 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.completed_at 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.completed_at])
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 formatted 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.completed_at), 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):
"""
Update the current message with the current page.
"""
await self.message.edit(embed=self.pages[self.current_page])
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()
await 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:
# TODO: Refactor into update method
await self._update()
# 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.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, bonus=True)
# 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(
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.id, task)
for task in tasks
]
return data.tasklist.insert_many(
*insert,
insert_keys=('userid', 'content')
)
def _delete_tasks(self, *indexes):
"""
Delete tasks from the task list
"""
taskids = [self.tasklist[i].taskid for i in indexes]
now = utc_now()
return data.tasklist.update_where(
{
'deleted_at': now,
'last_updated_at': now
},
taskid=taskids,
)
def _edit_task(self, index, new_content):
"""
Update the provided task with the new content
"""
taskid = self.tasklist[index].taskid
now = utc_now()
return data.tasklist.update_where(
{
'content': new_content,
'last_updated_at': now
},
taskid=taskid,
)
def _check_tasks(self, *indexes):
"""
Mark provided tasks as complete
"""
taskids = [self.tasklist[i].taskid for i in indexes]
now = utc_now()
return data.tasklist.update_where(
{
'completed_at': now,
'last_updated_at': now
},
taskid=taskids,
completed_at=NULL,
)
def _uncheck_tasks(self, *indexes):
"""
Mark provided tasks as incomplete
"""
taskids = [self.tasklist[i].taskid for i in indexes]
now = utc_now()
return data.tasklist.update_where(
{
'completed_at': None,
'last_updated_at': now
},
taskid=taskids,
completed_at=NOTNULL,
)
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.strip() for line in userstr.splitlines())
tasks = [task for task in tasks if task]
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(*)",),
userid=self.member.id,
deleted_at=NULL
)[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].completed_at]
to_uncheck = [index for index in indexes if self.tasklist[index].completed_at]
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 = 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
await self._format_tasklist()
await self._update()
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
await self._format_tasklist()
await self._update()
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
await self._format_tasklist()
await self._update()
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)

View File

@@ -1,6 +0,0 @@
from .module import module
from . import Tasklist
from . import admin
from . import data
from . import commands

View File

@@ -1,111 +0,0 @@
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 = 99
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 = 50
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

View File

@@ -1,115 +0,0 @@
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 To-Do list.",
group="Productivity",
flags=('add==', 'delete==', 'check==', 'uncheck==', 'edit==', 'text')
)
@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, flags, 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()

View File

@@ -1,25 +0,0 @@
from data import RowTable, Table
tasklist = RowTable(
'tasklist',
('taskid', 'userid', 'content', 'rewarded', 'created_at', 'completed_at', 'deleted_at', 'last_updated_at'),
'taskid'
)
tasklist_channels = Table('tasklist_channels')
tasklist_rewards = Table('tasklist_reward_history')
@tasklist_rewards.save_query
def count_recent_for(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 "
"userid = {}"
"AND reward_time > timezone('utc', NOW()) - INTERVAL '{}'".format(userid, interval)
)
return curs.fetchone()[0] or 0

View File

@@ -1,3 +0,0 @@
from LionModule import LionModule
module = LionModule("Todo")

View File

@@ -0,0 +1,11 @@
import logging
from babel.translator import LocalBabel
babel = LocalBabel('tasklist')
logger = logging.getLogger(__name__)
async def setup(bot):
from .cog import TasklistCog
await bot.add_cog(TasklistCog(bot))

762
bot/modules/tasklist/cog.py Normal file
View File

@@ -0,0 +1,762 @@
from typing import Optional
import datetime as dt
import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
from discord.app_commands.transformers import AppCommandOptionType as cmdopt
from meta import LionBot, LionCog, LionContext
from meta.errors import UserInputError
from utils.lib import utc_now, error_embed
from utils.ui import ChoicedEnum, Transformed
from data import Condition, NULL
from wards import low_management
from . import babel, logger
from .data import TasklistData
from .tasklist import Tasklist
from .ui import TasklistUI, SingleEditor, BulkEditor
from .settings import TasklistSettings, TasklistConfigUI
_p = babel._p
MAX_LENGTH = 100
class BeforeSelection(ChoicedEnum):
"""
Set of choices for the before arguments of `remove`.
"""
HOUR = _p('argtype:Before|opt:HOUR', "The last hour")
HALFDAY = _p('argtype:Before|opt:HALFDAY', "The last 12 hours")
DAY = _p('argtype:Before|opt:DAY', "The last 24 hours")
TODAY = _p('argtype:Before|opt:TODAY', "Today")
YESTERDAY = _p('argtype:Before|opt:YESTERDAY', "Yesterday")
MONDAY = _p('argtype:Before|opt:Monday', "This Monday")
THISMONTH = _p('argtype:Before|opt:THISMONTH', "This Month")
@property
def choice_name(self):
return self.value
@property
def choice_value(self):
return self.name
def needs_timezone(self):
return self in (
BeforeSelection.TODAY,
BeforeSelection.YESTERDAY,
BeforeSelection.MONDAY,
BeforeSelection.THISMONTH
)
def cutoff(self, timezone):
"""
Cut-off datetime for this period, in the given timezone.
"""
now = dt.datetime.now(tz=timezone)
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
if self is BeforeSelection.HOUR:
return now - dt.timedelta(hours=1)
elif self is BeforeSelection.HALFDAY:
return now - dt.timedelta(hours=12)
elif self is BeforeSelection.DAY:
return now - dt.timedelta(hours=24)
elif self is BeforeSelection.TODAY:
time = day_start
elif self is BeforeSelection.YESTERDAY:
time = day_start - dt.timedelta(days=1)
elif self is BeforeSelection.MONDAY:
time = day_start - dt.timedelta(days=now.weekday)
elif self is BeforeSelection.THISMONTH:
time = day_start.replace(day=0)
return time
class TasklistCog(LionCog):
"""
Command cog for the tasklist module.
All tasklist modification commands will summon the
member's TasklistUI, if currently in a tasklist-enabled channel,
or in a rented room channel (TODO).
Commands
--------
/tasklist open
Summon the TasklistUI panel for the current member.
/tasklist new <task> [parent:str]
Create a new task and add it to the tasklist.
/tasklist edit [taskid:int] [content:str] [parent:str]
With no arguments, opens up the task editor modal.
With only `taskid` given, opens up a single modal editor for that task.
With both `taskid` and `content` given, updates the given task content.
If only `content` is given, errors.
/tasklist clear
Clears the tasklist, after confirmation.
/tasklist remove [taskids:ranges] [created_before:dur] [updated_before:dur] [completed:bool] [cascade:bool]
Remove tasks described by a sequence of conditions.
Duration arguments use a time selector menu rather than a Duration type.
With no arguments, acts like `clear`.
/tasklist tick <taskids:ranges> [cascade:bool]
Tick a selection of taskids, accepting ranges.
/tasklist untick <taskids:ranges> [cascade:bool]
Untick a selection of taskids, accepting ranges.
Interface
---------
This cog does not expose a public interface.
Attributes
----------
bot: LionBot
The client which owns this Cog.
data: TasklistData
The tasklist data registry.
babel: LocalBabel
The LocalBabel instance for this module.
"""
depends = {'CoreCog', 'ConfigCog', 'Economy'}
def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.db.load_registry(TasklistData())
self.babel = babel
self.settings = TasklistSettings()
async def cog_load(self):
await self.data.init()
self.bot.core.guild_settings.attach(self.settings.task_reward)
self.bot.core.guild_settings.attach(self.settings.task_reward_limit)
# TODO: Better method for getting single load
# Or better, unloading crossloaded group
configcog = self.bot.get_cog('ConfigCog')
self.crossload_group(self.configure_group, configcog.configure_group)
@LionCog.listener('on_tasks_completed')
async def reward_tasks_completed(self, member: discord.Member, *taskids: int):
conn = await self.bot.db.get_connection()
async with conn.transaction():
tasklist = await Tasklist.fetch(self.bot, self.data, member.id)
tasks = await tasklist.fetch_tasks(*taskids)
unrewarded = [task for task in tasks if not task.rewarded]
if unrewarded:
reward = (await self.settings.task_reward.get(member.guild.id)).value
limit = (await self.settings.task_reward_limit.get(member.guild.id)).value
ecog = self.bot.get_cog('Economy')
recent = await ecog.data.TaskTransaction.count_recent_for(member.id, member.guild.id) or 0
max_to_reward = max(limit-recent, 0)
to_reward = unrewarded[:max_to_reward]
count = len(to_reward)
amount = count * reward
await ecog.data.TaskTransaction.reward_completed(member.id, member.guild.id, count, amount)
await tasklist.update_tasks(*(task.taskid for task in to_reward), rewarded=True)
logger.debug(
f"Rewarded <uid: {member.id}> in <gid: {member.guild.id}> "
f"'{amount}' coins for completing '{count}' tasks."
)
@cmds.hybrid_group(
name=_p('group:tasklist', "tasklist")
)
async def tasklist_group(self, ctx: LionContext):
raise NotImplementedError
async def task_acmpl(self, interaction: discord.Interaction, partial: str) -> list[appcmds.Choice]:
t = self.bot.translator.t
# Should usually be cached, so this won't trigger repetitive db access
tasklist = await Tasklist.fetch(self.bot, self.data, interaction.user.id)
labels = []
for label, task in tasklist.labelled.items():
labelstring = '.'.join(map(str, label)) + '.' * (len(label) == 1)
taskstring = f"{labelstring} {task.content}"
labels.append((labelstring, taskstring))
matching = [(label, task) for label, task in labels if label.startswith(partial)]
if not matching:
matching = [(label, task) for label, task in labels if partial.lower() in task.lower()]
if not matching:
options = [
appcmds.Choice(
name=t(_p(
'argtype:taskid|error:no_matching',
"No tasks matching {partial}!",
)).format(partial=partial),
value=partial
)
]
else:
options = [
appcmds.Choice(name=task_string, value=label)
for label, task_string in matching
]
return options[:25]
async def is_tasklist_channel(self, channel) -> bool:
if not channel.guild:
return True
channels = (await self.settings.tasklist_channels.get(channel.guild.id)).value
return (not channels) or (channel in channels) or (channel.category in channels)
@tasklist_group.command(
name=_p('cmd:tasklist_open', "open"),
description=_p(
'cmd:tasklist_open|desc',
"Open your tasklist."
)
)
async def tasklist_open_cmd(self, ctx: LionContext):
# TODO: Further arguments for style, e.g. gui/block/text
if await self.is_tasklist_channel(ctx.channel):
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id)
tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild)
await tasklistui.summon()
await ctx.interaction.delete_original_response()
else:
t = self.bot.translator.t
channels = (await self.settings.tasklist_channels.get(ctx.guild.id)).value
viewable = [
channel for channel in channels
if (channel.permissions_for(ctx.author).send_messages
or channel.permissions_for(ctx.author).send_messages_in_threads)
]
embed = discord.Embed(
title=t(_p('cmd:tasklist_open|error:tasklist_channel|title', "Sorry, I can't do that here")),
colour=discord.Colour.brand_red()
)
if viewable:
embed.description = t(_p(
'cmd:tasklist_open|error:tasklist_channel|desc',
"Please use direct messages or one of the following channels "
"or categories for managing your tasks:\n{channels}"
)).format(channels='\n'.join(channel.mention for channel in viewable))
else:
embed.description = t(_p(
'cmd:tasklist_open|error:tasklist_channel|desc',
"There are no channels available here where you may open your tasklist!"
))
await ctx.reply(embed=embed, ephemeral=True)
@tasklist_group.command(
name=_p('cmd:tasklist_new', "new"),
description=_p(
'cmd:tasklist_new|desc',
"Add a new task to your tasklist."
)
)
@appcmds.rename(
content=_p('cmd:tasklist_new|param:content', "task"),
parent=_p('cmd:tasklist_new|param:parent', 'parent')
)
@appcmds.describe(
content=_p('cmd:tasklist_new|param:content|desc', "Content of your new task."),
parent=_p('cmd:tasklist_new|param:parent', 'Parent of this task.')
)
async def tasklist_new_cmd(self, ctx: LionContext,
content: appcmds.Range[str, 1, MAX_LENGTH],
parent: Optional[str] = None):
t = self.bot.translator.t
if not ctx.interaction:
return
tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id)
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
# Fetch parent task if required
pid = tasklist.parse_label(parent) if parent else None
if parent and pid is None:
# Could not parse
await ctx.interaction.edit_original_response(
embed=error_embed(
t(_p(
'cmd:tasklist_new|error:parse_parent',
"Could not find task number `{input}` in your tasklist."
)).format(input=parent)
),
)
return
# Create task
await tasklist.create_task(content, parentid=pid)
if await self.is_tasklist_channel(ctx.interaction.channel):
# summon tasklist
tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild)
await tasklistui.summon()
await ctx.interaction.delete_original_response()
else:
# ack creation
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=t(_p(
'cmd:tasklist_new|resp:success',
"{tick} Task created successfully."
)).format(tick=self.bot.config.emojis.tick)
)
await ctx.interaction.edit_original_response(embed=embed)
@tasklist_new_cmd.autocomplete('parent')
async def tasklist_new_cmd_parent_acmpl(self, interaction: discord.Interaction, partial: str):
return await self.task_acmpl(interaction, partial)
@tasklist_group.command(
name=_p('cmd:tasklist_edit', "edit"),
description=_p(
'cmd:tasklist_edit|desc',
"Edit tasks in your tasklist."
)
)
@appcmds.rename(
taskstr=_p('cmd:tasklist_edit|param:taskstr', "task"),
new_content=_p('cmd:tasklist_edit|param:new_content', "new_task"),
new_parent=_p('cmd:tasklist_edit|param:new_parent', "new_parent"),
)
@appcmds.describe(
taskstr=_p('cmd:tasklist_edit|param:taskstr|desc', "Which task do you want to update?"),
new_content=_p('cmd:tasklist_edit|param:new_content|desc', "What do you want to change the task to?"),
new_parent=_p('cmd:tasklist_edit|param:new_parent|desc', "Which task do you want to be the new parent?"),
)
async def tasklist_edit_cmd(self, ctx: LionContext,
taskstr: str,
new_content: Optional[appcmds.Range[str, 1, MAX_LENGTH]] = None,
new_parent: Optional[str] = None):
t = self.bot.translator.t
if not ctx.interaction:
return
tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id)
# Fetch task to edit
tid = tasklist.parse_label(taskstr) if taskstr else None
if tid is None:
# Could not parse
await ctx.interaction.response.send_message(
embed=error_embed(
t(_p(
'cmd:tasklist_edit|error:parse_taskstr',
"Could not find task number `{input}` in your tasklist."
)).format(input=taskstr)
),
ephemeral=True,
)
return
async def handle_update(interaction, new_content, new_parent):
# Parse new parent if given
pid = tasklist.parse_label(new_parent) if new_parent else None
if new_parent and not pid:
# Could not parse
await interaction.response.send_message(
embed=error_embed(
t(_p(
'cmd:tasklist_edit|error:parse_parent',
"Could not find task number `{input}` in your tasklist."
)).format(input=new_parent)
),
ephemeral=True
)
return
args = {}
if new_content:
args['content'] = new_content
if new_parent:
args['parentid'] = pid
if args:
await tasklist.update_tasks(tid, **args)
if await self.is_tasklist_channel(ctx.channel):
tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild)
await tasklistui.summon()
else:
embed = discord.Embed(
colour=discord.Color.brand_green(),
description=t(_p(
'cmd:tasklist_edit|resp:success|desc',
"{tick} Task updated successfully."
)).format(tick=self.bot.config.emojis.tick),
)
await interaction.response.send_message(embed=embed, ephemeral=True)
if new_content or new_parent:
# Manual edit route
await handle_update(ctx.interaction, new_content, new_parent)
if not ctx.interaction.response.is_done():
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
await ctx.interaction.delete_original_response()
else:
# Modal edit route
task = tasklist.tasklist[tid]
parent_label = tasklist.labelid(task.parentid) if task.parentid else None
editor = SingleEditor(
title=t(_p('ui:tasklist_single_editor|title', "Edit Task"))
)
editor.task.default = task.content
editor.parent.default = tasklist.format_label(parent_label) if parent_label else None
@editor.submit_callback()
async def update_task(interaction: discord.Interaction):
await handle_update(interaction, editor.task.value, editor.parent.value)
if not interaction.response.is_done():
await interaction.response.defer()
await ctx.interaction.response.send_modal(editor)
@tasklist_edit_cmd.autocomplete('taskstr')
async def tasklist_edit_cmd_taskstr_acmpl(self, interaction: discord.Interaction, partial: str):
return await self.task_acmpl(interaction, partial)
@tasklist_edit_cmd.autocomplete('new_parent')
async def tasklist_edit_cmd_new_parent_acmpl(self, interaction: discord.Interaction, partial: str):
return await self.task_acmpl(interaction, partial)
@tasklist_group.command(
name=_p('cmd:tasklist_clear', "clear"),
description=_p('cmd:tasklist_clear|desc', "Clear your tasklist.")
)
async def tasklist_clear_cmd(self, ctx: LionContext):
t = ctx.bot.translator.t
tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id)
await tasklist.update_tasklist(deleted_at=utc_now())
await ctx.reply(
t(_p(
'cmd:tasklist_clear|resp:success',
"Your tasklist has been cleared."
)),
ephemeral=True
)
tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild)
await tasklistui.summon()
@tasklist_group.command(
name=_p('cmd:tasklist_remove', "remove"),
description=_p(
'cmd:tasklist_remove|desc',
"Remove tasks matching all the provided conditions. (E.g. remove tasks completed before today)."
)
)
@appcmds.rename(
taskidstr=_p('cmd:tasklist_remove|param:taskidstr', "tasks"),
created_before=_p('cmd:tasklist_remove|param:created_before', "created_before"),
updated_before=_p('cmd:tasklist_remove|param:updated_before', "updated_before"),
completed=_p('cmd:tasklist_remove|param:completed', "completed"),
cascade=_p('cmd:tasklist_remove|param:cascade', "cascade")
)
@appcmds.describe(
taskidstr=_p(
'cmd:tasklist_remove|param:taskidstr|desc',
"List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)."
),
created_before=_p(
'cmd:tasklist_remove|param:created_before|desc',
"Only delete tasks created before the selected time."
),
updated_before=_p(
'cmd:tasklist_remove|param:updated_before|desc',
"Only deleted tasks update (i.e. completed or edited) before the selected time."
),
completed=_p(
'cmd:tasklist_remove|param:completed',
"Only delete tasks which are (not) complete."
),
cascade=_p(
'cmd:tasklist_remove|param:cascade',
"Whether to recursively remove subtasks of removed tasks."
)
)
async def tasklist_remove_cmd(self, ctx: LionContext,
taskidstr: str,
created_before: Optional[Transformed[BeforeSelection, cmdopt.string]] = None,
updated_before: Optional[Transformed[BeforeSelection, cmdopt.string]] = None,
completed: Optional[bool] = None,
cascade: bool = True):
t = self.bot.translator.t
if not ctx.interaction:
return
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id)
conditions = []
if taskidstr:
try:
taskids = tasklist.parse_labels(taskidstr)
except UserInputError as error:
await ctx.interaction.edit_original_response(
embed=error_embed(error.msg)
)
return
if not taskids:
# Explicitly error if none of the ranges matched
await ctx.interaction.edit_original_response(
embed=error_embed(
'cmd:tasklist_remove_cmd|error:no_matching',
"No tasks on your tasklist match `{input}`"
).format(input=taskidstr)
)
return
conditions.append(self.data.Task.taskid == taskids)
if created_before is not None or updated_before is not None:
# TODO: Extract timezone from user settings
timezone = None
if created_before is not None:
conditions.append(self.data.Task.created_at <= created_before.cutoff(timezone))
if updated_before is not None:
conditions.append(self.data.Task.last_updated_at <= updated_before.cutoff(timezone))
if completed is True:
conditions.append(self.data.Task.completed_at != NULL)
elif completed is False:
conditions.append(self.data.Task.completed_at == NULL)
tasks = await self.data.Task.fetch_where(*conditions, userid=ctx.author.id)
if not tasks:
await ctx.interaction.edit_original_response(
embed=error_embed(
'cmd:tasklist_remove_cmd|error:no_matching',
"No tasks on your tasklist matching all the given conditions!"
).format(input=taskidstr)
)
return
taskids = [task.taskid for task in tasks]
await tasklist.update_tasks(*taskids, cascade=cascade, deleted_at=utc_now())
# Ack changes or summon tasklist
if await self.is_tasklist_channel(ctx.channel):
# Summon tasklist
tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild)
await tasklistui.summon()
await ctx.interaction.delete_original_response()
else:
# Ack deletion
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=t(_p(
'cmd:tasklist_remove|resp:success',
"{tick} tasks deleted."
)).format(tick=self.bot.config.emojis.tick)
)
await ctx.interaction.edit_original_response(embed=embed)
@tasklist_group.command(
name=_p('cmd:tasklist_tick', "tick"),
description=_p('cmd:tasklist_tick|desc', "Mark the given tasks as completed.")
)
@appcmds.rename(
taskidstr=_p('cmd:tasklist_tick|param:taskidstr', "tasks"),
cascade=_p('cmd:tasklist_tick|param:cascade', "cascade")
)
@appcmds.describe(
taskidstr=_p(
'cmd:tasklist_tick|param:taskidstr|desc',
"List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)."
),
cascade=_p(
'cmd:tasklist_tick|param:cascade|desc',
"Whether to also mark all subtasks as complete."
)
)
async def tasklist_tick_cmd(self, ctx: LionContext, taskidstr: str, cascade: bool = True):
t = self.bot.translator.t
if not ctx.interaction:
return
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id)
try:
taskids = tasklist.parse_labels(taskidstr)
except UserInputError as error:
await ctx.interaction.edit_original_response(
embed=error_embed(error.msg)
)
return
if not taskids:
if not taskids:
# Explicitly error if none of the ranges matched
await ctx.interaction.edit_original_response(
embed=error_embed(
'cmd:tasklist_remove_cmd|error:no_matching',
"No tasks on your tasklist match `{input}`"
).format(input=taskidstr)
)
return
tasks = [tasklist.tasklist[taskid] for taskid in taskids]
tasks = [task for task in tasks if task.completed_at is None]
taskids = [task.taskid for task in tasks]
if taskids:
await tasklist.update_tasks(*taskids, cascade=cascade, completed_at=utc_now())
if ctx.guild:
self.bot.dispatch('tasks_completed', ctx.author, *taskids)
# Ack changes or summon tasklist
if await self.is_tasklist_channel(ctx.channel):
# Summon tasklist
tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild)
await tasklistui.summon()
await ctx.interaction.delete_original_response()
else:
# Ack edit
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=t(_p(
'cmd:tasklist_tick|resp:success',
"{tick} tasks marked as complete."
)).format(tick=self.bot.config.emojis.tick)
)
await ctx.interaction.edit_original_response(embed=embed)
@tasklist_group.command(
name=_p('cmd:tasklist_untick', "untick"),
description=_p('cmd:tasklist_untick|desc', "Mark the given tasks as incomplete.")
)
@appcmds.rename(
taskidstr=_p('cmd:tasklist_untick|param:taskidstr', "taskids"),
cascade=_p('cmd:tasklist_untick|param:cascade', "cascade")
)
@appcmds.describe(
taskidstr=_p(
'cmd:tasklist_untick|param:taskidstr|desc',
"List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)."
),
cascade=_p(
'cmd:tasklist_untick|param:cascade|desc',
"Whether to also mark all subtasks as incomplete."
)
)
async def tasklist_untick_cmd(self, ctx: LionContext, taskidstr: str, cascade: Optional[bool] = False):
t = self.bot.translator.t
if not ctx.interaction:
return
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id)
try:
taskids = tasklist.parse_labels(taskidstr)
except UserInputError as error:
await ctx.interaction.edit_original_response(
embed=error_embed(error.msg)
)
return
if not taskids:
# Explicitly error if none of the ranges matched
await ctx.interaction.edit_original_response(
embed=error_embed(
'cmd:tasklist_remove_cmd|error:no_matching',
"No tasks on your tasklist match `{input}`"
).format(input=taskidstr)
)
return
tasks = [tasklist.tasklist[taskid] for taskid in taskids]
tasks = [task for task in tasks if task.completed_at is not None]
taskids = [task.taskid for task in tasks]
if taskids:
await tasklist.update_tasks(*taskids, cascade=cascade, completed_at=None)
# Ack changes or summon tasklist
if await self.is_tasklist_channel(ctx.channel):
# Summon tasklist
tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild)
await tasklistui.summon()
await ctx.interaction.delete_original_response()
else:
# Ack edit
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=t(_p(
'cmd:tasklist_untick|resp:success',
"{tick} tasks marked as incomplete."
)).format(tick=self.bot.config.emojis.tick)
)
await ctx.interaction.edit_original_response(embed=embed)
# Setting Commands
@LionCog.placeholder_group
@cmds.hybrid_group('configure', with_app_command=False)
async def configure_group(self, ctx: LionContext):
...
@configure_group.command(
name=_p('cmd:configure_tasklist', "tasklist"),
description=_p('cmd:configure_tasklist|desc', "Configuration panel")
)
@appcmds.rename(
reward=_p('cmd:configure_tasklist|param:reward', "reward"),
reward_limit=_p('cmd:configure_tasklist|param:reward_limit', "reward_limit")
)
@appcmds.describe(
reward=TasklistSettings.task_reward._desc,
reward_limit=TasklistSettings.task_reward_limit._desc
)
@appcmds.check(low_management)
async def configure_tasklist_cmd(self, ctx: LionContext,
reward: Optional[int] = None,
reward_limit: Optional[int] = None):
t = self.bot.translator.t
if not ctx.guild:
return
if not ctx.interaction:
return
task_reward = await self.settings.task_reward.get(ctx.guild.id)
task_reward_limit = await self.settings.task_reward_limit.get(ctx.guild.id)
# TODO: Batch properly
updated = False
if reward is not None:
task_reward.data = reward
await task_reward.write()
updated = True
if reward_limit is not None:
task_reward_limit.data = reward_limit
await task_reward_limit.write()
updated = True
# Send update ack if required
if updated:
description = t(_p(
'cmd:configure_tasklist|resp:success|desc',
"Members will now be rewarded {coin}**{amount}** for "
"each task they complete up to a maximum of `{limit}` tasks per 24h."
)).format(
coin=self.bot.config.emojis.coin,
amount=task_reward.data,
limit=task_reward_limit.data
)
await ctx.reply(
embed=discord.Embed(
colour=discord.Colour.brand_green(),
description=description
)
)
if ctx.channel.id not in TasklistConfigUI._listening or not ctx.interaction.response.is_done():
# Launch setting group UI
configui = TasklistConfigUI(self.bot, self.settings, ctx.guild.id, ctx.channel.id)
await configui.run(ctx.interaction)
await configui.wait()

View File

@@ -0,0 +1,45 @@
from psycopg import sql
from data import RowModel, Registry, Table
from data.columns import Integer, String, Timestamp, Bool
class TasklistData(Registry):
class Task(RowModel):
"""
Row model describing a single task in a tasklist.
Schema
------
CREATE TABLE tasklist(
taskid SERIAL PRIMARY KEY,
userid BIGINT NOT NULL REFERENCES user_config ON DELETE CASCADE,
parentid INTEGER REFERENCES tasklist (taskid) ON DELETE SET NULL,
content TEXT NOT NULL,
rewarded BOOL DEFAULT FALSE,
deleted_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ,
last_updated_at TIMESTAMPTZ
);
CREATE INDEX tasklist_users ON tasklist (userid);
CREATE TABLE tasklist_channels(
guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE,
channelid BIGINT NOT NULL
);
CREATE INDEX tasklist_channels_guilds ON tasklist_channels (guildid);
"""
_tablename_ = "tasklist"
taskid = Integer(primary=True)
userid = Integer()
parentid = Integer()
rewarded = Bool()
content = String()
completed_at = Timestamp()
created_at = Timestamp()
deleted_at = Timestamp()
last_updated_at = Timestamp()
channels = Table('tasklist_channels')

View File

@@ -0,0 +1,280 @@
from typing import Optional
import discord
from discord.ui.select import select, Select, SelectOption, ChannelSelect
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.text_input import TextInput, TextStyle
from settings import ListData, ModelData
from settings.setting_types import StringSetting, BoolSetting, ChannelListSetting, IntegerSetting
from settings.groups import SettingGroup
from meta import conf, LionBot
from utils.lib import tabulate
from utils.ui import LeoUI, FastModal, error_handler_for, ModalRetryUI
from core.data import CoreData
from babel.translator import ctx_translator
from . import babel
from .data import TasklistData
_p = babel._p
class TasklistSettings(SettingGroup):
class task_reward(ModelData, IntegerSetting):
"""
Guild configuration for the task completion economy award.
Exposed via `/configure tasklist`, and the standard configuration interface.
"""
setting_id = 'task_reward'
_display_name = _p('guildset:task_reward', "task_reward")
_desc = _p(
'guildset:task_reward|desc',
"Number of LionCoins given for each completed task."
)
_long_desc = _p(
'guildset:task_reward|long_desc',
"The number of coins members will be rewarded each time they complete a task on their tasklist."
)
_default = 50
_model = CoreData.Guild
_column = CoreData.Guild.task_reward.name
@property
def update_message(self):
t = ctx_translator.get().t
return t(_p(
'guildset:task_reward|response',
"Members will now be rewarded {coin}**{amount}** for each completed task."
)).format(coin=conf.emojis.coin, amount=self.data)
@property
def set_str(self):
return '</configure tasklist:1038560947666694144>'
@classmethod
def _format_data(cls, parent_id, data, **kwargs):
if data is not None:
return "{coin}**{amount}** per task.".format(
coin=conf.emojis.coin,
amount=data
)
class task_reward_limit(ModelData, IntegerSetting):
setting_id = 'task_reward_limit'
_display_name = _p('guildset:task_reward_limit', "task_reward_limit")
_desc = _p(
'guildset:task_reward_limit|desc',
"Maximum number of task rewards given per 24h."
)
_long_desc = _p(
'guildset:task_reward_limit|long_desc',
"Maximum number of times in each 24h period that members will be rewarded "
"for completing a task."
)
_default = 10
_model = CoreData.Guild
_column = CoreData.Guild.task_reward_limit.name
@property
def update_message(self):
t = ctx_translator.get().t
return t(_p(
'guildset:task_reward_limit|response',
"Members will now be rewarded for task completion at most **{amount}** times per 24h."
)).format(amount=self.data)
@property
def set_str(self):
return '</configure tasklist:1038560947666694144>'
@classmethod
def _format_data(cls, parent_id, data, **kwargs):
if data is not None:
return "`{number}` per 24 hours.".format(
number=data
)
class tasklist_channels(ListData, ChannelListSetting):
setting_id = 'tasklist_channels'
_display_name = _p('guildset:tasklist_channels', "tasklist_channels")
_desc = _p(
'guildset:tasklist_channels|desc',
"Channels in which to allow the tasklist."
)
_long_desc = _p(
'guildset:tasklist_channels|long_desc',
"If set, members will only be able to open their tasklist in these channels.\n"
"If a category is selected, this will allow all channels under that category."
)
_default = None
_table_interface = TasklistData.channels
_id_column = 'guildid'
_data_column = 'channelid'
_order_column = 'channelid'
_cache = {}
@property
def set_str(self):
return "Channel selector below."
class TasklistConfigUI(LeoUI):
# TODO: Back option to globall guild config
# TODO: Cohesive edit
_listening = {}
def __init__(self, bot: LionBot, settings: TasklistSettings, guildid: int, channelid: int,**kwargs):
super().__init__(**kwargs)
self.bot = bot
self.settings = settings
self.guildid = guildid
self.channelid = channelid
# Original interaction, used when the UI runs as an initial interaction response
self._original: Optional[discord.Interaction] = None
# UI message, used when UI run as a followup message
self._message: Optional[discord.Message] = None
self.task_reward = None
self.task_reward_limit = None
self.tasklist_channels = None
self.embed: Optional[discord.Embed] = None
self.set_labels()
@property
def instances(self):
return (self.task_reward, self.task_reward_limit, self.tasklist_channels)
@button(label='CLOSE_PLACEHOLDER')
async def close_pressed(self, interaction: discord.Interaction, pressed):
"""
Close the configuration UI.
"""
try:
if self._message:
await self._message.delete()
self._message = None
elif self._original:
await self._original.delete_original_response()
self._original = None
except discord.HTTPException:
pass
await self.close()
@button(label='RESET_PLACEHOLDER')
async def reset_pressed(self, interaction: discord.Interaction, pressed):
"""
Reset the tasklist configuration.
"""
await interaction.response.defer()
self.task_reward.data = None
await self.task_reward.write()
self.task_reward_limit.data = None
await self.task_reward_limit.write()
self.tasklist_channels.data = None
await self.tasklist_channels.write()
await self.refresh()
await self.redraw()
@select(cls=ChannelSelect, placeholder="CHANNEL_SELECTOR_PLACEHOLDER", min_values=0, max_values=25)
async def channels_selected(self, interaction: discord.Interaction, selected: Select):
"""
Multi-channel selector to select the tasklist channels in the Guild.
Allows any channel type.
Selected category channels will apply to their children.
"""
await interaction.response.defer()
self.tasklist_channels.value = selected.values
await self.tasklist_channels.write()
await self.refresh()
await self.redraw()
async def cleanup(self):
self._listening.pop(self.channelid, None)
self.task_reward.deregister_callback(self.id)
self.task_reward_limit.deregister_callback(self.id)
try:
if self._original is not None:
await self._original.delete_original_response()
self._original = None
if self._message is not None:
await self._message.delete()
self._message = None
except discord.HTTPException:
pass
async def run(self, interaction: discord.Interaction):
if old := self._listening.get(self.channelid, None):
await old.close()
await self.refresh()
if interaction.response.is_done():
# Use followup
self._message = await interaction.followup.send(embed=self.embed, view=self)
else:
# Use interaction response
self._original = interaction
await interaction.response.send_message(embed=self.embed, view=self)
self.task_reward.register_callback(self.id)(self.reload)
self.task_reward_limit.register_callback(self.id)(self.reload)
self._listening[self.channelid] = self
async def reload(self, *args, **kwargs):
await self.refresh()
await self.redraw()
async def refresh(self):
self.task_reward = await self.settings.task_reward.get(self.guildid)
self.task_reward_limit = await self.settings.task_reward_limit.get(self.guildid)
self.tasklist_channels = await self.settings.tasklist_channels.get(self.guildid)
self._layout = [
(self.channels_selected,),
(self.reset_pressed, self.close_pressed)
]
self.embed = await self.make_embed()
def set_labels(self):
t = self.bot.translator.t
self.close_pressed.label = t(_p('ui:tasklist_config|button:close|label', "Close"))
self.reset_pressed.label = t(_p('ui:tasklist_config|button:reset|label', "Reset"))
self.channels_selected.placeholder = t(_p(
'ui:tasklist_config|menu:channels|placeholder',
"Set Tasklist Channels"
))
async def redraw(self):
try:
if self._message:
await self._message.edit(embed=self.embed, view=self)
elif self._original:
await self._original.edit_original_response(embed=self.embed, view=self)
except discord.HTTPException:
pass
async def make_embed(self):
t = self.bot.translator.t
embed = discord.Embed(
colour=discord.Colour.orange(),
title=t(_p(
'ui:tasklist_config|embed|title',
"Tasklist Configuration Panel"
))
)
for setting in self.instances:
embed.add_field(**setting.embed_field, inline=False)
return embed

View File

@@ -0,0 +1,259 @@
from typing import Optional
from weakref import WeakValueDictionary
import re
from meta import LionBot
from meta.errors import UserInputError
from utils.lib import utc_now
from .data import TasklistData
class Tasklist:
"""
Class representing a single user's tasklist.
Attributes
----------
bot: LionBot
Client which controls this tasklist.
data: TasklistData
Initialised tasklist data registry.
userid: int
The user who owns this tasklist.
tasklist: dict[int, TasklistData.Task]
A local cache map of tasks the user owns.
May or may not contain deleted tasks.
"""
_cache_ = WeakValueDictionary()
label_range_re = re.compile(
r"^(?P<start>(\d+\.)*\d+)\.?((\s*(?P<range>-)\s*)(?P<end>(\d+\.)*\d*\.?))?$"
)
def __init__(self, bot: LionBot, data: TasklistData, userid: int):
self.bot = bot
self.data = data
self.userid = userid
self.tasklist: dict[int, TasklistData.Task] = {}
@classmethod
async def fetch(cls, bot: LionBot, data: TasklistData, userid: int) -> 'Tasklist':
"""
Fetch and initialise a Tasklist, using cache where possible.
"""
if userid not in cls._cache_:
cls = cls(bot, data, userid)
await cls.refresh()
cls._cache_[userid] = cls
return cls._cache_[userid]
def _label(self, task, taskmap, labels, counters) -> tuple[int, ...]:
tid = task.taskid
if tid in labels:
label = labels[tid]
else:
pid = task.parentid
counters[pid] = i = counters.get(pid, 0) + 1
if pid is not None and (parent := taskmap.get(pid, None)) is not None:
plabel = self._label(parent, taskmap, labels, counters)
else:
plabel = ()
labels[tid] = label = (*plabel, i)
return label
@property
def labelled(self) -> dict[tuple[int, ...], TasklistData.Task]:
"""
A sorted map of task string ids to tasks.
This is the tasklist that is visible to the user.
"""
taskmap = {
task.taskid: task
for task in sorted(self.tasklist.values(), key=lambda t: t.taskid)
if task.deleted_at is None
}
labels = {}
counters = {}
for task in taskmap.values():
self._label(task, taskmap, labels, counters)
labelmap = {
label: taskmap[taskid]
for taskid, label in sorted(labels.items(), key=lambda lt: lt[1])
}
return labelmap
def labelid(self, taskid) -> Optional[tuple[int, ...]]:
"""
Relatively expensive method to get the label for a given task, if it exists.
"""
task = self.tasklist.get(taskid, None)
if task is None:
return None
labelled = self.labelled
mapper = {t.taskid: label for label, t in labelled.items()}
return mapper[taskid]
async def refresh(self):
"""
Update the `tasklist` from data.
"""
tasks = await self.data.Task.fetch_where(userid=self.userid, deleted_at=None)
self.tasklist = {task.taskid: task for task in tasks}
async def _owner_check(self, *taskids: int) -> bool:
"""
Check whether all of the given tasks are owned by this tasklist user.
Applies cache where possible.
"""
missing = [tid for tid in taskids if tid not in self.tasklist]
if missing:
missing = [tid for tid in missing if (tid, ) not in self.data.Task._cache_]
if missing:
tasks = await self.data.Task.fetch_where(taskid=missing)
missing = [task.taskid for task in tasks if task.userid != self.userid]
return not bool(missing)
async def fetch_tasks(self, *taskids: int) -> list[TasklistData.Task]:
"""
Fetch the tasks from the tasklist with the given taskids.
Raises a ValueError if the tasks are not owned by the tasklist user.
"""
# Check the tasklist user owns all the tasks
# Also ensures the Row objects are in cache
if not await self._owner_check(*taskids):
raise ValueError("The given tasks are not in this tasklist!")
return [await self.data.Task.fetch(tid) for tid in taskids]
async def create_task(self, content: str, **kwargs) -> TasklistData.Task:
"""
Create a new task with the given content.
"""
task = await self.data.Task.create(userid=self.userid, content=content, **kwargs)
self.tasklist[task.taskid] = task
return task
async def update_tasks(self, *taskids: int, cascade=False, **kwargs):
"""
Update the given taskids with the provided new values.
If `cascade` is True, also applies the updates to all children.
"""
if not taskids:
raise ValueError("No tasks provided to update.")
if cascade:
taskids = self.children_cascade(*taskids)
# Ensure the taskids exist and belong to this user
await self.fetch_tasks(*taskids)
# Update the tasks
kwargs.setdefault('last_updated_at', utc_now())
tasks = await self.data.Task.table.update_where(
userid=self.userid,
taskid=taskids,
).set(**kwargs)
# Return the updated tasks
return tasks
async def update_tasklist(self, **kwargs):
"""
Update every task in the tasklist, regardless of cache.
"""
kwargs.setdefault('last_updated_at', utc_now())
tasks = await self.data.Task.table.update_where(userid=self.userid).set(**kwargs)
return tasks
def children_cascade(self, *taskids) -> list[int]:
"""
Return the provided taskids with all their descendants.
Only checks the current tasklist cache for descendants.
"""
taskids = set(taskids)
added = True
while added:
added = False
for task in self.tasklist.values():
if task.deleted_at is None and task.taskid not in taskids and task.parentid in taskids:
taskids.add(task.taskid)
added = True
return list(taskids)
def parse_label(self, labelstr: str) -> Optional[int]:
"""
Parse a provided label string into a taskid, if it can be found.
Returns None if no matching taskids are found.
"""
splits = [s for s in labelstr.split('.') if s]
if all(split.isdigit() for split in splits):
tasks = self.labelled
label = tuple(map(int, splits))
if label in tasks:
return tasks[label].taskid
def format_label(self, label: tuple[int, ...]) -> str:
"""
Format the provided label tuple into the standard number format.
"""
return '.'.join(map(str, label)) + '.' * (len(label) == 1)
def parse_labels(self, labelstr: str) -> Optional[list[str]]:
"""
Parse a comma separated list of labels and label ranges into a list of labels.
E.g. `1, 2, 3`, `1, 2-5, 7`, `1, 2.1, 3`, `1, 2.1-3`, `1, 2.1-`
May raise `UserInputError`.
"""
labelmap = {label: task.taskid for label, task in self.labelled.items()}
splits = labelstr.split(',')
splits = [split.strip(' ,.') for split in splits]
splits = [split for split in splits if split]
taskids = set()
for split in splits:
match = self.label_range_re.match(split)
if match:
start = match['start']
ranged = match['range']
end = match['end']
start_label = tuple(map(int, start.split('.')))
head = start_label[:-1]
start_tail = start_label[-1]
if end:
end_label = tuple(map(int, end.split('.')))
end_tail = end_label[-1]
if len(end_label) > 1 and head != end_label[:-1]:
# Error: Parents don't match in range ...
raise UserInputError("Parents don't match in range `{range}`")
for tail in range(max(start_tail, 1), end_tail + 1):
label = (*head, tail)
if label not in labelmap:
break
taskids.add(labelmap[label])
elif ranged:
# No end but still ranged
for label, taskid in labelmap.items():
if (label[:-1] == head) and (label[-1] >= start_tail):
taskids.add(taskid)
elif start_label in labelmap:
taskids.add(labelmap[start_label])
else:
# Error
raise UserInputError("Could not parse `{range}` as a task number or range.")
return list(taskids)

656
bot/modules/tasklist/ui.py Normal file
View File

@@ -0,0 +1,656 @@
from typing import Optional
import re
import discord
from discord.ui.select import select, Select, SelectOption
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.text_input import TextInput, TextStyle
from meta import conf
from meta.errors import UserInputError
from utils.lib import MessageArgs, utc_now
from utils.ui import LeoUI, LeoModal, FastModal, error_handler_for, ModalRetryUI
from utils.ui.pagers import BasePager, Pager
from babel.translator import ctx_translator
from . import babel, logger
from .tasklist import Tasklist
from .data import TasklistData
_p = babel._p
checkmark = ""
checked_emoji = conf.emojis.task_checked
unchecked_emoji = conf.emojis.task_unchecked
class SingleEditor(FastModal):
task: TextInput = TextInput(
label='',
max_length=100,
required=True
)
def setup_task(self):
t = ctx_translator.get().t
self.task.label = t(_p('modal:tasklist_single_editor|field:task|label', "Task content"))
parent: TextInput = TextInput(
label='',
max_length=10,
required=False
)
def setup_parent(self):
t = ctx_translator.get().t
self.parent.label = t(_p(
'modal:tasklist_single_editor|field:parent|label',
"Parent Task"
))
self.parent.placeholder = t(_p(
'modal:tasklist_single_editor|field:parent|placeholder',
"Enter a task number, e.g. 2.1"
))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setup()
def setup(self):
self.setup_task()
self.setup_parent()
@error_handler_for(UserInputError)
async def rerequest(self, interaction: discord.Interaction, error: UserInputError):
await ModalRetryUI(self, error.msg).respond_to(interaction)
class BulkEditor(LeoModal):
"""
Error-handling modal for bulk-editing a tasklist.
"""
line_regex = re.compile(r"(?P<depth>\s*)-?\s*(\[\s*(?P<check>[^]]?)\s*\]\s*)?(?P<content>.*)")
tasklist_editor: TextInput = TextInput(
label='',
style=TextStyle.long,
max_length=4000,
required=False
)
def setup_tasklist_editor(self):
t = ctx_translator.get().t
self.tasklist_editor.label = t(_p(
'modal:tasklist_bulk_editor|field:tasklist|label', "Tasklist"
))
self.tasklist_editor.placeholder = t(_p(
'modal:tasklist_bulk_editor|field:tasklist|placeholder',
"- [ ] This is task 1, unfinished.\n"
"- [x] This is task 2, finished.\n"
" - [ ] This is subtask 2.1."
))
def __init__(self, tasklist: Tasklist, **kwargs):
self.setup()
super().__init__(**kwargs)
self.tasklist = tasklist
self.bot = tasklist.bot
self.userid = tasklist.userid
self.lines = self.format_tasklist()
self.tasklist_editor.default = '\n'.join(self.lines.values())
self._callbacks = []
def setup(self):
t = ctx_translator.get().t
self.title = t(_p(
'modal:tasklist_bulk_editor', "Tasklist Editor"
))
self.setup_tasklist_editor()
def add_callback(self, coro):
self._callbacks.append(coro)
return coro
def format_tasklist(self):
"""
Format the tasklist into lines of editable text.
"""
labelled = self.tasklist.labelled
lines = {}
total_len = 0
for label, task in labelled.items():
prefix = ' ' * (len(label) - 1)
box = '- [ ]' if task.completed_at is None else '- [x]'
line = f"{prefix}{box} {task.content}"
if total_len + len(line) > 4000:
break
lines[task.taskid] = line
total_len += len(line)
return lines
async def on_submit(self, interaction: discord.Interaction):
try:
await self.parse_editor()
for coro in self._callbacks:
await coro(interaction)
await interaction.response.defer()
except UserInputError as error:
await ModalRetryUI(self, error.msg).respond_to(interaction)
def _parser(self, task_lines):
t = ctx_translator.get().t
taskinfo = [] # (parent, truedepth, ticked, content)
depthtree = [] # (depth, index)
for line in task_lines:
match = self.line_regex.match(line)
if not match:
raise UserInputError(
t(_p(
'modal:tasklist_bulk_editor|error:parse_task',
"Malformed taskline!\n`{input}`"
)).format(input=line)
)
depth = len(match['depth'])
check = bool(match['check'])
content = match['content']
if not content:
continue
if len(content) > 100:
raise UserInputError(
t(_p(
'modal:tasklist_bulk_editor|error:task_too_long',
"Please keep your tasks under 100 characters!"
))
)
for i in range(len(depthtree)):
lastdepth = depthtree[-1][0]
if lastdepth >= depth:
depthtree.pop()
if lastdepth <= depth:
break
parent = depthtree[-1][1] if depthtree else None
depthtree.append((depth, len(taskinfo)))
taskinfo.append((parent, len(depthtree) - 1, check, content))
return taskinfo
async def parse_editor(self):
# First parse each line
new_lines = self.tasklist_editor.value.splitlines()
taskinfo = self._parser(new_lines)
old_info = self._parser(self.lines.values())
same_layout = (
len(old_info) == len(taskinfo)
and all(info[:2] == oldinfo[:2] for (info, oldinfo) in zip(taskinfo, old_info))
)
# TODO: Incremental/diff editing
conn = await self.bot.db.get_connection()
async with conn.transaction():
now = utc_now()
if same_layout:
# if the layout has not changed, just edit the tasks
for taskid, (oldinfo, newinfo) in zip(self.lines.keys(), zip(old_info, taskinfo)):
args = {}
if oldinfo[2] != newinfo[2]:
args['completed_at'] = now if newinfo[2] else None
if oldinfo[3] != newinfo[3]:
args['content'] = newinfo[3]
if args:
await self.tasklist.update_tasks(taskid, **args)
else:
# Naive implementation clearing entire tasklist
# Clear tasklist
await self.tasklist.update_tasklist(deleted_at=now)
# Create tasklist
created = {}
target_depth = 0
while True:
to_insert = {}
for i, (parent, truedepth, ticked, content) in enumerate(taskinfo):
if truedepth == target_depth:
to_insert[i] = (
self.tasklist.userid,
content,
created[parent] if parent is not None else None,
now if ticked else None
)
if to_insert:
# Batch insert
tasks = await self.tasklist.data.Task.table.insert_many(
('userid', 'content', 'parentid', 'completed_at'),
*to_insert.values()
)
for i, task in zip(to_insert.keys(), tasks):
created[i] = task['taskid']
target_depth += 1
else:
# Reached maximum depth
break
class TasklistUI(BasePager):
"""
Paged UI panel for managing the tasklist.
"""
# Cache of live tasklist widgets
# (channelid, userid) -> Tasklist
_live_ = {}
def __init__(self,
tasklist: Tasklist,
channel: discord.abc.Messageable, guild: Optional[discord.Guild] = None, **kwargs):
kwargs.setdefault('timeout', 3600)
super().__init__(**kwargs)
self.tasklist = tasklist
self.bot = tasklist.bot
self.userid = tasklist.userid
self.channel = channel
self.guild = guild
# List of lists of (label, task) pairs
self._pages = []
self.page_num = -1
self._channelid = channel.id
self.current_page = None
self._deleting = False
self._message: Optional[discord.Message] = None
self.button_labels()
self.set_active()
@classmethod
async def fetch(cls, tasklist, channel, *args, **kwargs):
key = (channel.id, tasklist.userid)
if key not in cls._live_:
self = cls(tasklist, channel, *args, **kwargs)
cls._live_[key] = self
return cls._live_[key]
def access_check(self, userid):
return userid == self.userid
async def interaction_check(self, interaction: discord.Interaction):
t = self.bot.translator.t
if not self.access_check(interaction.user.id):
embed = discord.Embed(
description=t(_p(
'ui:tasklist|error:wrong_user',
"This is not your tasklist!"
)),
colour=discord.Colour.brand_red()
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return False
else:
return True
async def cleanup(self):
self.set_inactive()
self._live_.pop((self.channel.id, self.userid), None)
if self._message is not None:
try:
await self._message.edit(view=None)
except discord.HTTPException:
pass
self._message = None
try:
if self._message is not None:
await self._message.edit(view=None)
except discord.HTTPException:
pass
async def summon(self):
"""
Refresh and re-display the tasklist widget as required.
"""
await self.refresh()
resend = not await self._check_recent()
if resend and self._message:
# Delete our current message if possible
try:
await self._message.delete()
except discord.HTTPException:
# If we cannot delete, it has probably already been deleted
# Or we don't have permission somehow
pass
self._message = None
# Redraw
try:
await self.redraw()
except discord.HTTPException:
if self._message:
self._message = None
await self.redraw()
async def _check_recent(self) -> bool:
"""
Check whether the tasklist message is a "recent" message in the channel.
"""
if self._message is not None:
height = 0
async for message in self.channel.history(limit=5):
if message.id == self._message.id:
return True
if message.id < self._message.id:
return False
if message.attachments or message.embeds or height > 20:
return False
height += len(message.content.count('\n'))
return False
return False
async def get_page(self, page_id) -> MessageArgs:
t = self.bot.translator.t
tasks = [t for t in self.tasklist.tasklist.values() if t.deleted_at is None]
total = len(tasks)
completed = sum(t.completed_at is not None for t in tasks)
if self.guild:
user = self.guild.get_member(self.userid)
else:
user = self.bot.get_user(self.userid)
user_name = user.name if user else str(self.userid)
user_colour = user.colour if user else discord.Color.orange()
author = t(_p(
'ui:tasklist|embed|author',
"{name}'s tasklist ({completed}/{total} complete)"
)).format(
name=user_name,
completed=completed,
total=total
)
embed = discord.Embed(
colour=user_colour,
)
embed.set_author(
name=author,
icon_url=user.avatar if user else None
)
if self._pages:
page = self._pages[page_id % len(self._pages)]
block = self._format_page(page)
embed.description = "{task_block}".format(task_block=block)
else:
embed.description = t(_p(
'ui:tasklist|embed|description',
"**You have no tasks on your tasklist!**\n"
"Add a task with `/tasklist new`, or by pressing the `New` button below."
))
page_args = MessageArgs(embed=embed)
return page_args
async def page_cmd(self, interaction: discord.Interaction, value: str):
return await Pager.page_cmd(self, interaction, value)
async def page_acmpl(self, interaction: discord.Interaction, partial: str):
return await Pager.page_acmpl(self, interaction, partial)
def _format_page(self, page: list[tuple[tuple[int, ...], TasklistData.Task]]) -> str:
"""
Format a single block of page data into the task codeblock.
"""
lines = []
numpad = max(sum(len(str(counter)) - 1 for counter in label) for label, _ in page)
for label, task in page:
label_string = '.'.join(map(str, label)) + '.' * (len(label) == 1)
number = f"**`{label_string}`**"
if len(label) > 1:
depth = sum(len(str(c)) + 1 for c in label[:-1]) * ' '
depth = f"`{depth}`"
else:
depth = ''
task_string = "{depth}{cross}{number} {content}{cross}".format(
depth=depth,
number=number,
emoji=unchecked_emoji if task.completed_at is None else checked_emoji,
content=task.content,
cross='~~' if task.completed_at is not None else ''
)
lines.append(task_string)
return '\n'.join(lines)
def _format_page_text(self, page: list[tuple[tuple[int, ...], TasklistData.Task]]) -> str:
"""
Format a single block of page data into the task codeblock.
"""
lines = []
numpad = max(sum(len(str(counter)) - 1 for counter in label) for label, _ in page)
for label, task in page:
box = '[ ]' if task.completed_at is None else f"[{checkmark}]"
task_string = "{prepad} {depth} {content}".format(
prepad=' ' * numpad,
depth=(len(label) - 1) * ' ',
content=task.content
)
label_string = '.'.join(map(str, label)) + '.' * (len(label) == 1)
taskline = box + ' ' + label_string + task_string[len(label_string):]
lines.append(taskline)
return "```md\n{}```".format('\n'.join(lines))
def refresh_pages(self):
labelled = list(self.tasklist.labelled.items())
count = len(labelled)
pages = []
if count > 0:
# Break into pages
edges = [0]
line_ptr = 0
while line_ptr < count:
line_ptr += 20
if line_ptr < count:
# Seek backwards to find the best parent
i = line_ptr - 5
minlabel = (i, len(labelled[i][0]))
while i < line_ptr:
i += 1
ilen = len(labelled[i][0])
if ilen <= minlabel[1]:
minlabel = (i, ilen)
line_ptr = minlabel[0]
else:
line_ptr = count
edges.append(line_ptr)
pages = [labelled[edges[i]:edges[i+1]] for i in range(len(edges) - 1)]
self._pages = pages
return pages
@select(placeholder="TOGGLE_PLACEHOLDER")
async def toggle_selector(self, interaction: discord.Interaction, selected: Select):
await interaction.response.defer()
taskids = list(map(int, selected.values))
tasks = await self.tasklist.fetch_tasks(*taskids)
to_complete = [task for task in tasks if task.completed_at is None]
to_uncomplete = [task for task in tasks if task.completed_at is not None]
if to_complete:
await self.tasklist.update_tasks(
*(t.taskid for t in to_complete),
cascade=True,
completed_at=utc_now()
)
if self.guild:
if (member := self.guild.get_member(self.userid)):
self.bot.dispatch('tasks_completed', member, *(t.taskid for t in to_complete))
if to_uncomplete:
await self.tasklist.update_tasks(
*(t.taskid for t in to_uncomplete),
completed_at=None
)
await self.refresh()
await self.redraw()
async def toggle_selector_refresh(self):
t = self.bot.translator.t
self.toggle_selector.placeholder = t(_p(
'ui:tasklist|menu:toggle_selector|placeholder',
"Select to Toggle"
))
options = []
block = self._pages[self.page_num % len(self._pages)]
colwidth = max(sum(len(str(c)) + 1 for c in lbl) for lbl, _ in block)
for lbl, task in block:
value = str(task.taskid)
lblstr = '.'.join(map(str, lbl)) + '.' * (len(lbl) == 1)
name = f"{lblstr:<{colwidth}} {task.content}"
emoji = unchecked_emoji if task.completed_at is None else checked_emoji
options.append(SelectOption(label=name, value=value, emoji=emoji))
self.toggle_selector.options = options
self.toggle_selector.min_values = 0
self.toggle_selector.max_values = len(options)
@button(label="NEW_PLACEHOLDER", style=ButtonStyle.green)
async def new_pressed(self, interaction: discord.Interaction, pressed: Button):
t = self.bot.translator.t
editor = SingleEditor(
title=t(_p('ui:tasklist_single_editor|title', "Add task"))
)
@editor.submit_callback()
async def create_task(interaction):
new_task = editor.task.value
parent = editor.parent.value
pid = self.tasklist.parse_label(parent) if parent else None
if parent and pid is None:
# Could not parse
raise UserInputError(
t(_p(
'ui:tasklist_single_editor|error:parse_parent',
"Could not find the given parent task number `{input}` in your tasklist."
)).format(input=parent)
)
await interaction.response.defer()
await self.tasklist.create_task(new_task, parentid=pid)
await self.refresh()
await self.redraw()
await interaction.response.send_modal(editor)
@button(label="EDITOR_PLACEHOLDER", style=ButtonStyle.blurple)
async def edit_pressed(self, interaction: discord.Interaction, pressed: Button):
editor = BulkEditor(self.tasklist)
@editor.add_callback
async def editor_callback(interaction: discord.Interaction):
await self.refresh()
await self.redraw()
await interaction.response.send_modal(editor)
@button(label="DELETE_PLACEHOLDER", style=ButtonStyle.red)
async def del_pressed(self, interaction: discord.Interaction, pressed: Button):
self._deleting = 1 - self._deleting
await interaction.response.defer()
await self.refresh()
await self.redraw()
@select(placeholder="DELETE_SELECT_PLACEHOLDER")
async def delete_selector(self, interaction: discord.Interaction, selected: Select):
await interaction.response.defer()
taskids = list(map(int, selected.values))
if taskids:
await self.tasklist.update_tasks(
*taskids,
cascade=True,
deleted_at=utc_now()
)
await self.refresh()
await self.redraw()
async def delete_selector_refresh(self):
self.delete_selector.placeholder = t(_p('ui:tasklist|menu:delete|placeholder', "Select to Delete"))
self.delete_selector.options = self.toggle_selector.options
@button(label="ClOSE_PLACEHOLDER", style=ButtonStyle.red)
async def close_pressed(self, interaction: discord.Interaction, pressed: Button):
await interaction.response.defer()
if self._message is not None:
try:
await self._message.delete()
except discord.HTTPException:
pass
await self.close()
@button(label="CLEAR_PLACEHOLDER", style=ButtonStyle.red)
async def clear_pressed(self, interaction: discord.Interaction, pressed: Button):
await interaction.response.defer()
await self.tasklist.update_tasklist(
deleted_at=utc_now(),
)
await self.refresh()
await self.redraw()
def button_labels(self):
t = self.bot.translator.t
self.new_pressed.label = t(_p('ui:tasklist|button:new', "New"))
self.edit_pressed.label = t(_p('ui:tasklist|button:edit', "Edit"))
self.del_pressed.label = t(_p('ui:tasklist|button:delete', "Delete"))
self.clear_pressed.label = t(_p('ui:tasklist|button:clear', "Clear"))
self.close_pressed.label = t(_p('ui:tasklist|button:close', "Close"))
async def refresh(self):
# Refresh data
await self.tasklist.refresh()
self.refresh_pages()
async def redraw(self):
self.current_page = await self.get_page(self.page_num)
# Refresh the layout
if len(self._pages) > 1:
# Paged layout
await self.toggle_selector_refresh()
self._layout = [
(self.new_pressed, self.edit_pressed, self.del_pressed),
(self.toggle_selector,),
(self.prev_page_button, self.close_pressed, self.next_page_button)
]
if self._deleting:
await self.delete_selector_refresh()
self._layout.append((self.delete_selector,))
self._layout[0] = (*self._layout[0], self.clear_pressed)
elif len(self.tasklist.tasklist) > 0:
# Single page, with tasks
await self.toggle_selector_refresh()
self._layout = [
(self.new_pressed, self.edit_pressed, self.del_pressed, self.close_pressed),
(self.toggle_selector,),
]
if self._deleting:
await self.delete_selector_refresh()
self._layout[0] = (*self._layout[0], self.clear_pressed)
self._layout.append((self.delete_selector,))
else:
# With no tasks, nothing to select
self._layout = [
(self.new_pressed, self.edit_pressed, self.close_pressed)
]
# Resend
if not self._message:
self._message = await self.channel.send(**self.current_page.send_args, view=self)
else:
await self._message.edit(**self.current_page.edit_args, view=self)

View File

@@ -1,7 +1,7 @@
from babel.translator import LocalBabel
babel = LocalBabel('settings_base')
from .data import ModelData
from .data import ModelData, ListData
from .base import BaseSetting
from .ui import SettingWidget, InteractiveSetting
from .groups import SettingDotDict, SettingGroup, ModelSettings, ModelSetting

View File

@@ -111,7 +111,7 @@ class ListData:
table = cls._table_interface # type: Table
query = table.select_where(**{cls._id_column: parent_id}).select(cls._data_column)
if cls._order_column:
query.order_by(cls._order_column, order=cls._order_type)
query.order_by(cls._order_column, direction=cls._order_type)
rows = await query
data = [row[cls._data_column] for row in rows]
@@ -128,7 +128,7 @@ class ListData:
"""
table = cls._table_interface
conn = await table.connector.get_connection()
with conn.transaction():
async with conn.transaction():
# Handle None input as an empty list
if data is None:
data = []

View File

@@ -73,8 +73,8 @@ class SettingGroup:
"""
rows = []
for setting in self.settings.values():
name = f"{setting.display_name}"
set = await setting.get(parent_id)
name = set.display_name
value = set.formatted
rows.append((name, value, set.hover_desc))
table_rows = tabulate(

View File

@@ -1002,7 +1002,7 @@ class ListSetting:
Format the list by adding `,` between each formatted item
"""
if not data:
return None
return 'Not Set'
else:
formatted_items = []
for item in data:

View File

@@ -11,6 +11,7 @@ from discord.ui.text_input import TextInput
from utils.lib import tabulate, recover_context
from utils.ui import FastModal
from meta.config import conf
from babel.translator import ctx_translator
from .base import BaseSetting, ParentID, SettingData, SettingValue
@@ -166,10 +167,10 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
__slots__ = ('_widget',)
# Configuration interface descriptions
display_name: str # User readable name of the setting
desc: str # User readable brief description of the setting
long_desc: str # User readable long description of the setting
accepts: str # User readable description of the acceptable values
_display_name: str # User readable name of the setting
_desc: str # User readable brief description of the setting
_long_desc: str # User readable long description of the setting
_accepts: str # User readable description of the acceptable values
Widget = SettingWidget
@@ -184,6 +185,26 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
self._widget: Optional[SettingWidget] = None
@property
def long_desc(self):
t = ctx_translator.get().t
return t(self._long_desc)
@property
def display_name(self):
t = ctx_translator.get().t
return t(self._display_name)
@property
def desc(self):
t = ctx_translator.get().t
return t(self._desc)
@property
def accepts(self):
t = ctx_translator.get().t
return t(self._accepts)
async def write(self, **kwargs) -> None:
await super().write(**kwargs)
for listener in self._listeners_.values():
@@ -249,9 +270,13 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
Returns a {name, value} pair for use in an Embed field.
"""
name = self.display_name
value = f"{self.long_dec}\n{self.desc_table}"
value = f"{self.long_desc}\n{self.desc_table}"
return {'name': name, 'value': value}
@property
def set_str(self):
return None
@property
def embed(self):
"""
@@ -265,10 +290,14 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
@property
def desc_table(self):
return tabulate(
("Current Value", self.formatted or "Not Set"),
("Default Value", self._format_data(self.parent_id, self.default) or "None"),
)
lines = []
lines.append(('Currently', self.formatted or "Not Set"))
if (default := self.default) is not None:
lines.append(('By Default', self._format_data(self.parent_id, default) or "No Default"))
if (set_str := self.set_str) is not None:
lines.append(('Set Using', set_str))
return '\n'.join(tabulate(*lines))
@property
def input_field(self) -> TextInput:

View File

@@ -46,6 +46,17 @@ class BasePager(LeoUI):
representing all `BasePager`s that are currently running.
This allows access from external page controlling utilities, e.g. the `/page` command.
"""
# List of valid keys indicating movement to the next page
next_list = _p('cmd:page|pager:Pager|options:next', "n, nxt, next, forward, +")
# List of valid keys indicating movement to the previous page
prev_list = _p('cmd:page|pager:Pager|options:prev', "p, prev, back, -")
# List of valid keys indicating movement to the first page
first_list = _p('cmd:page|pager:Pager|options:first', "f, first, one, start")
# List of valid keys indicating movement to the last page
last_list = _p('cmd:page|pager:Pager|options:last', "l, last, end")
# channelid -> pager.id -> list of active pagers in this channel
active_pagers: dict[int, dict[int, 'BasePager']] = defaultdict(dict)
@@ -152,17 +163,6 @@ class Pager(BasePager):
locked: bool
Whether to only allow the author to use the paging interface.
"""
# List of valid keys indicating movement to the next page
next_list = _p('cmd:page|pager:Pager|options:next', "n, nxt, next, forward, +")
# List of valid keys indicating movement to the previous page
prev_list = _p('cmd:page|pager:Pager|options:prev', "p, prev, back, -")
# List of valid keys indicating movement to the first page
first_list = _p('cmd:page|pager:Pager|options:first', "f, first, one, start")
# List of valid keys indicating movement to the last page
last_list = _p('cmd:page|pager:Pager|options:last', "l, last, end")
def __init__(self, pages: list[MessageArgs],
start_from=0,

View File

@@ -7,3 +7,15 @@ async def sys_admin(ctx: LionContext) -> bool:
"""
admins = ctx.bot.config.bot.getintlist('admins')
return ctx.author.id in admins
async def high_management(ctx: LionContext) -> bool:
if await sys_admin(ctx):
return True
if not ctx.guild:
return False
return ctx.author.guild_permissions.administrator
async def low_management(ctx: LionContext) -> bool:
return (await high_management(ctx)) or ctx.author.guild_permissions.manage_guild

View File

@@ -48,3 +48,6 @@ tick = :✅:
clock = :⏱️:
coin = <:coin:975880967485022239>
task_checked = :🟢:
task_unchecked = :⚫:

View File

@@ -53,6 +53,8 @@ CREATE TABLE bot_config_presence(
activity_type ActivityType,
activity_name Text
);
-- DROP TABLE AppData CASCADE;
-- DROP TABLE AppConfig CASCADE;
-- }}}
@@ -243,6 +245,27 @@ CREATE VIEW member_inventory_info AS
ORDER BY itemid ASC;
-- }}}
-- Task Data {{{
ALTER TABLE tasklist_channels
ADD CONSTRAINT fk_tasklist_channels_guilds
FOREIGN KEY (guildid)
REFERENCES guild_config (guildid)
ON DELETE CASCADE
NOT VALID;
ALTER TABLE tasklist
ADD CONSTRAINT fk_tasklist_users
FOREIGN KEY (userid)
REFEREnCES user_config (userid)
ON DELETE CASCADE
NOT VALID;
ALTER TABLE tasklist
ADD COLUMN parentid INTEGER REFERENCES tasklist (taskid) ON DELETE SET NULL;
-- DROP TABLE tasklist_reward_history CASCADE;
-- }}}
INSERT INTO VersionHistory (version, author) VALUES (13, 'v12-v13 migration');
COMMIT;

View File

@@ -4,7 +4,7 @@ CREATE TABLE VersionHistory(
time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
author TEXT
);
INSERT INTO VersionHistory (version, author) VALUES (12, 'Initial Creation');
INSERT INTO VersionHistory (version, author) VALUES (13, 'Initial Creation');
CREATE OR REPLACE FUNCTION update_timestamp_column()
@@ -17,6 +17,46 @@ $$ language 'plpgsql';
-- }}}
-- App metadata {{{
CREATE TABLE app_config(
appname TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE bot_config(
appname TEXT PRIMARY KEY REFERENCES app_config(appname) ON DELETE CASCADE,
default_skin TEXT
);
CREATE TABLE shard_data(
shardname TEXT PRIMARY KEY,
appname TEXT REFERENCES bot_config(appname) ON DELETE CASCADE,
shard_id INTEGER NOT NULL,
shard_count INTEGER NOT NULL,
last_login TIMESTAMPTZ,
guild_count INTEGER
);
CREATE TYPE OnlineStatus AS ENUM(
'ONLINE',
'IDLE',
'DND',
'OFFLINE'
);
CREATE TYPE ActivityType AS ENUM(
'PLAYING',
'WATCHING',
'LISTENING',
'STREAMING'
);
CREATE TABLE bot_config_presence(
appname TEXT PRIMARY KEY REFERENCES bot_config(appname) ON DELETE CASCADE,
online_status OnlineStatus,
activity_type ActivityType,
activity_name Text
);
CREATE TABLE AppData(
appid TEXT PRIMARY KEY,
last_study_badge_scan TIMESTAMP
@@ -44,6 +84,71 @@ CREATE TABLE global_guild_blacklist(
);
-- }}}
-- Analytics data {{{
CREATE SCHEMA "analytics";
CREATE TABLE analytics.snapshots(
snapshotid SERIAL PRIMARY KEY,
appname TEXT NOT NULL REFERENCES bot_config (appname),
guild_count INTEGER NOT NULL,
member_count INTEGER NOT NULL,
user_count INTEGER NOT NULL,
in_voice INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'utc')
);
CREATE TABLE analytics.events(
eventid SERIAL PRIMARY KEY,
appname TEXT NOT NULL REFERENCES bot_config (appname),
ctxid BIGINT,
guildid BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT (now() at time zone 'utc')
);
CREATE TYPE analytics.CommandStatus AS ENUM(
'COMPLETED',
'CANCELLED'
'FAILED'
);
CREATE TABLE analytics.commands(
cmdname TEXT NOT NULL,
cogname TEXT,
userid BIGINT NOT NULL,
status analytics.CommandStatus NOT NULL,
error TEXT,
execution_time REAL NOT NULL
) INHERITS (analytics.events);
CREATE TYPE analytics.GuildAction AS ENUM(
'JOINED',
'LEFT'
);
CREATE TABLE analytics.guilds(
guildid BIGINT NOT NULL,
action analytics.GuildAction NOT NULL
) INHERITS (analytics.events);
CREATE TYPE analytics.VoiceAction AS ENUM(
'JOINED',
'LEFT'
);
CREATE TABLE analytics.voice_sessions(
userid BIGINT NOT NULL,
action analytics.VoiceAction NOT NULL
) INHERITS (analytics.events);
CREATE TABLE analytics.gui_renders(
cardname TEXT NOT NULL,
duration INTEGER NOT NULL
) INHERITS (analytics.events);
--- }}}
-- User configuration data {{{
CREATE TABLE user_config(
userid BIGINT PRIMARY KEY,
@@ -51,7 +156,11 @@ CREATE TABLE user_config(
topgg_vote_reminder BOOLEAN,
avatar_hash TEXT,
name TEXT,
first_seen TIMESTAMPTZ DEFAULT now(),
last_seen TIMESTAMPTZ,
API_timestamp BIGINT,
locale_hint TEXT,
locale TEXT,
gems INTEGER DEFAULT 0
);
-- }}}
@@ -91,7 +200,11 @@ CREATE TABLE guild_config(
persist_roles BOOLEAN,
daily_study_cap INTEGER,
pomodoro_channel BIGINT,
name TEXT
name TEXT,
first_joined_at TIMESTAMPTZ DEFAULT now(),
left_at TIMESTAMPTZ,
locale TEXT,
force_locale BOOLEAN
);
CREATE TABLE ignored_members(
@@ -146,7 +259,7 @@ CREATE INDEX workout_sessions_members ON workout_sessions (guildid, userid);
-- Tasklist data {{{
CREATE TABLE tasklist(
taskid SERIAL PRIMARY KEY,
userid BIGINT NOT NULL,
userid BIGINT NOT NULL REFERENCES user_config (userid) ON DELETE CASCADE,
content TEXT NOT NULL,
rewarded BOOL DEFAULT FALSE,
deleted_at TIMESTAMPTZ,
@@ -157,28 +270,22 @@ CREATE TABLE tasklist(
CREATE INDEX tasklist_users ON tasklist (userid);
CREATE TABLE tasklist_channels(
guildid BIGINT NOT NULL,
guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE,
channelid BIGINT NOT NULL
);
CREATE INDEX tasklist_channels_guilds ON tasklist_channels (guildid);
CREATE TABLE tasklist_reward_history(
userid BIGINT NOT NULL,
reward_time TIMESTAMP DEFAULT (now() at time zone 'utc'),
reward_count INTEGER
);
CREATE INDEX tasklist_reward_history_users ON tasklist_reward_history (userid, reward_time);
-- }}}
-- Reminder data {{{
CREATE TABLE reminders(
reminderid SERIAL PRIMARY KEY,
userid BIGINT NOT NULL,
userid BIGINT NOT NULL REFERENCES user_config ON DELETE CASCADE,
remind_at TIMESTAMP NOT NULL,
content TEXT NOT NULL,
message_link TEXT,
interval INTEGER,
created_at TIMESTAMP DEFAULT (now() at time zone 'utc'),
failed BOOLEAN,
title TEXT,
footer TEXT
);
@@ -234,6 +341,8 @@ CREATE TABLE member_inventory(
inventoryid SERIAL PRIMARY KEY,
guildid BIGINT NOT NULL,
userid BIGINT NOT NULL,
first_joined TIMESTAMPTZ DEFAULT now(),
last_left TIMESTAMPTZ,
transactionid INTEGER REFERENCES coin_transactions(transactionid) ON DELETE SET NULL,
itemid INTEGER NOT NULL REFERENCES shop_items(itemid) ON DELETE CASCADE
);
@@ -408,8 +517,8 @@ CREATE INDEX studyban_durations_guilds ON studyban_durations (guildid);
-- Member configuration and stored data {{{
CREATE TABLE members(
guildid BIGINT,
userid BIGINT,
guildid BIGINT REFERENCES guild_config ON DELETE CASCADE,
userid BIGINT ON DELETE CASCADE,
tracked_time INTEGER DEFAULT 0,
coins INTEGER DEFAULT 0,
workout_count INTEGER DEFAULT 0,