rewrite: Tasklist module.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = ('=', '!=')
|
||||
|
||||
@@ -173,6 +173,10 @@ class RowModel:
|
||||
|
||||
return obj
|
||||
|
||||
@classmethod
|
||||
def as_tuple(cls):
|
||||
return (cls.table.identifier, ())
|
||||
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,9 +2,12 @@ this_package = 'modules'
|
||||
|
||||
active = [
|
||||
'.sysadmin',
|
||||
'.test',
|
||||
'.reminders',
|
||||
'.config',
|
||||
'.economy',
|
||||
'.reminders',
|
||||
'.shop',
|
||||
'.tasklist',
|
||||
'.test',
|
||||
]
|
||||
|
||||
|
||||
|
||||
10
bot/modules/config/__init__.py
Normal file
10
bot/modules/config/__init__.py
Normal 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
30
bot/modules/config/cog.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -1,6 +0,0 @@
|
||||
from .module import module
|
||||
|
||||
from . import Tasklist
|
||||
from . import admin
|
||||
from . import data
|
||||
from . import commands
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
module = LionModule("Todo")
|
||||
11
bot/modules/tasklist/__init__.py
Normal file
11
bot/modules/tasklist/__init__.py
Normal 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
762
bot/modules/tasklist/cog.py
Normal 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()
|
||||
45
bot/modules/tasklist/data.py
Normal file
45
bot/modules/tasklist/data.py
Normal 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')
|
||||
280
bot/modules/tasklist/settings.py
Normal file
280
bot/modules/tasklist/settings.py
Normal 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
|
||||
259
bot/modules/tasklist/tasklist.py
Normal file
259
bot/modules/tasklist/tasklist.py
Normal 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
656
bot/modules/tasklist/ui.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
12
bot/wards.py
12
bot/wards.py
@@ -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
|
||||
|
||||
@@ -48,3 +48,6 @@ tick = :✅:
|
||||
clock = :⏱️:
|
||||
|
||||
coin = <:coin:975880967485022239>
|
||||
|
||||
task_checked = :🟢:
|
||||
task_unchecked = :⚫:
|
||||
|
||||
@@ -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;
|
||||
|
||||
137
data/schema.sql
137
data/schema.sql
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user