rewrite: Move GUI cards into main repo.

This commit is contained in:
2022-12-23 07:02:26 +02:00
parent f328324747
commit 1cef97cdd4
20 changed files with 6110 additions and 0 deletions

View File

@@ -0,0 +1,320 @@
from datetime import timedelta
import asyncio
from data.conditions import GEQ
from modules.stats import goals
from ..module import module, ratelimit
from ...cards import WeeklyGoalCard, MonthlyGoalCard
from ...cards import WeeklyStatsCard, MonthlyStatsCard
from ...utils import get_avatar_key, image_as_file
async def _get_weekly_goals(ctx):
# Fetch goal data
goal_row = ctx.client.data.weekly_goals.fetch_or_create(
(ctx.guild.id, ctx.author.id, ctx.alion.week_timestamp)
)
tasklist_rows = ctx.client.data.member_weekly_goal_tasks.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
weekid=ctx.alion.week_timestamp,
_extra="ORDER BY taskid ASC"
)
tasklist = [
(i, task['content'], task['completed'])
for i, task in enumerate(tasklist_rows)
]
day_start = ctx.alion.day_start
week_start = day_start - timedelta(days=day_start.weekday())
# Fetch study data
week_study_time = ctx.client.data.session_history.queries.study_time_since(
ctx.guild.id, ctx.author.id, week_start
)
study_hours = week_study_time // 3600
# Fetch task data
tasks_done = ctx.client.data.tasklist.select_one_where(
userid=ctx.author.id,
completed_at=GEQ(week_start),
select_columns=('COUNT(*)',)
)[0]
# Fetch accountability data
accountability = ctx.client.data.accountability_member_info.select_where(
userid=ctx.author.id,
start_at=GEQ(week_start),
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
)
if len(accountability):
acc_attended = sum(row['attended'] for row in accountability)
acc_total = len(accountability)
acc_rate = acc_attended / acc_total
else:
acc_rate = None
goalpage = await WeeklyGoalCard.request(
name=ctx.author.name,
discrim=f"#{ctx.author.discriminator}",
avatar=get_avatar_key(ctx.client, ctx.author.id),
badges=ctx.alion.profile_tags,
tasks_done=tasks_done,
studied_hours=study_hours,
attendance=acc_rate,
tasks_goal=goal_row.task_goal,
studied_goal=goal_row.study_goal,
goals=tasklist,
date=ctx.alion.day_start,
skin=WeeklyGoalCard.skin_args_for(ctx)
)
return goalpage
@ratelimit.ward()
async def show_weekly_goals(ctx):
image = await _get_weekly_goals(ctx)
await ctx.reply(file=image_as_file(image, 'weekly_stats_1.png'))
goals.display_weekly_goals_for = show_weekly_goals
@module.cmd(
"weekly",
group="Statistics",
desc="View your weekly study statistics!"
)
@ratelimit.ward()
async def cmd_weekly(ctx):
"""
Usage``:
{prefix}weekly
Description:
View your weekly study profile.
See `{prefix}weeklygoals` to edit your goals!
"""
day_start = ctx.alion.day_start
last_week_start = day_start - timedelta(days=7 + day_start.weekday())
history = ctx.client.data.session_history.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
start_time=GEQ(last_week_start - timedelta(days=1)),
select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time ASC"
)
timezone = ctx.alion.timezone
sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history]
page_1_task = asyncio.create_task(_get_weekly_goals(ctx))
page_2_task = asyncio.create_task(
WeeklyStatsCard.request(
ctx.author.name,
f"#{ctx.author.discriminator}",
sessions,
day_start,
skin=WeeklyStatsCard.skin_args_for(ctx)
)
)
await asyncio.gather(page_1_task, page_2_task)
page_1 = page_1_task.result()
page_2 = page_2_task.result()
await ctx.reply(
files=[
image_as_file(page_1, "weekly_stats_1.png"),
image_as_file(page_2, "weekly_stats_2.png")
]
)
async def _get_monthly_goals(ctx):
# Fetch goal data
goal_row = ctx.client.data.monthly_goals.fetch_or_create(
(ctx.guild.id, ctx.author.id, ctx.alion.month_timestamp)
)
tasklist_rows = ctx.client.data.member_monthly_goal_tasks.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
monthid=ctx.alion.month_timestamp,
_extra="ORDER BY taskid ASC"
)
tasklist = [
(i, task['content'], task['completed'])
for i, task in enumerate(tasklist_rows)
]
day_start = ctx.alion.day_start
month_start = day_start.replace(day=1)
# Fetch study data
month_study_time = ctx.client.data.session_history.queries.study_time_since(
ctx.guild.id, ctx.author.id, month_start
)
study_hours = month_study_time // 3600
# Fetch task data
tasks_done = ctx.client.data.tasklist.select_one_where(
userid=ctx.author.id,
completed_at=GEQ(month_start),
select_columns=('COUNT(*)',)
)[0]
# Fetch accountability data
accountability = ctx.client.data.accountability_member_info.select_where(
userid=ctx.author.id,
start_at=GEQ(month_start),
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
)
if len(accountability):
acc_attended = sum(row['attended'] for row in accountability)
acc_total = len(accountability)
acc_rate = acc_attended / acc_total
else:
acc_rate = None
goalpage = await MonthlyGoalCard.request(
name=ctx.author.name,
discrim=f"#{ctx.author.discriminator}",
avatar=get_avatar_key(ctx.client, ctx.author.id),
badges=ctx.alion.profile_tags,
tasks_done=tasks_done,
studied_hours=study_hours,
attendance=acc_rate,
tasks_goal=goal_row.task_goal,
studied_goal=goal_row.study_goal,
goals=tasklist,
date=ctx.alion.day_start,
skin=MonthlyGoalCard.skin_args_for(ctx)
)
return goalpage
@ratelimit.ward()
async def show_monthly_goals(ctx):
image = await _get_monthly_goals(ctx)
await ctx.reply(file=image_as_file(image, 'monthly_stats_1.png'))
goals.display_monthly_goals_for = show_monthly_goals
@module.cmd(
"monthly",
group="Statistics",
desc="View your monthly study statistics!"
)
async def cmd_monthly(ctx):
"""
Usage``:
{prefix}monthly
Description:
View your monthly study profile.
See `{prefix}monthlygoals` to edit your goals!
"""
day_start = ctx.alion.day_start
period_start = day_start - timedelta(days=31*4)
history = ctx.client.data.session_history.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time DESC"
)
timezone = ctx.alion.timezone
sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history]
if not sessions:
return await ctx.error_reply(
"No statistics to show, because you have never studied in this server before!"
)
# Streak statistics
streak = 0
current_streak = None
max_streak = 0
day_attended = True if 'sessions' in ctx.client.objects and ctx.alion.session else None
date = day_start
daydiff = timedelta(days=1)
periods = sessions
i = 0
while i < len(periods):
row = periods[i]
i += 1
if row[1] > date:
# They attended this day
day_attended = True
continue
elif day_attended is None:
# Didn't attend today, but don't break streak
day_attended = False
date -= daydiff
i -= 1
continue
elif not day_attended:
# Didn't attend the day, streak broken
date -= daydiff
i -= 1
pass
else:
# Attended the day
streak += 1
# Move window to the previous day and try the row again
day_attended = False
prev_date = date
date -= daydiff
i -= 1
# Special case, when the last session started in the previous day
# Then the day is already attended
if i > 1 and date < periods[i-2][0] <= prev_date:
day_attended = True
continue
max_streak = max(max_streak, streak)
if current_streak is None:
current_streak = streak
streak = 0
# Handle loop exit state, i.e. the last streak
if day_attended:
streak += 1
max_streak = max(max_streak, streak)
if current_streak is None:
current_streak = streak
first_session_start = sessions[-1][0]
sessions = [session for session in sessions if session[1] > period_start]
page_1_task = asyncio.create_task(_get_monthly_goals(ctx))
page_2_task = asyncio.create_task(MonthlyStatsCard.request(
ctx.author.name,
f"#{ctx.author.discriminator}",
sessions,
day_start.date(),
current_streak or 0,
max_streak or 0,
first_session_start,
skin=MonthlyStatsCard.skin_args_for(ctx)
))
await asyncio.gather(page_1_task, page_2_task)
page_1 = page_1_task.result()
page_2 = page_2_task.result()
await ctx.reply(
files=[
image_as_file(page_1, "monthly_stats_1.png"),
image_as_file(page_2, "monthly_stats_2.png")
]
)

View File

@@ -0,0 +1,198 @@
import gc
import asyncio
import discord
from cmdClient.checks import in_guild
import data
from data import tables
from utils.interactive import discord_shield
from meta import conf
from ...cards import LeaderboardCard
from ...utils import image_as_file, edit_files, get_avatar_key
from ..module import module, ratelimit
next_emoji = conf.emojis.forward
my_emoji = conf.emojis.person
prev_emoji = conf.emojis.backward
@module.cmd(
"top",
desc="View the Study Time leaderboard.",
group="Statistics",
aliases=('ttop', 'toptime', 'top100')
)
@in_guild()
@ratelimit.ward(member=False)
async def cmd_top(ctx):
"""
Usage``:
{prefix}top
{prefix}top100
Description:
Display the study time leaderboard, or the top 100.
"""
# Handle args
if ctx.args and not ctx.args == "100":
return await ctx.error_reply(
"**Usage:**`{prefix}top` or `{prefix}top100`.".format(prefix=ctx.best_prefix)
)
top100 = (ctx.args == "100" or ctx.alias == "top100")
# Fetch the leaderboard
exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members)
exclude.update(ctx.client.user_blacklist())
exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id])
args = {
'guildid': ctx.guild.id,
'select_columns': ('userid', 'total_tracked_time::INTEGER', 'display_name'),
'_extra': "AND total_tracked_time > 0 ORDER BY total_tracked_time DESC " + ("LIMIT 100" if top100 else "")
}
if exclude:
args['userid'] = data.NOT(list(exclude))
user_data = tables.members_totals.select_where(**args)
# Quit early if the leaderboard is empty
if not user_data:
return await ctx.reply("No leaderboard entries yet!")
# Extract entries
author_rank = None
entries = []
for i, (userid, time, display_name) in enumerate(user_data):
if (member := ctx.guild.get_member(userid)):
name = member.display_name
elif display_name:
name = display_name
else:
name = str(userid)
entries.append(
(userid, i + 1, time, name, get_avatar_key(ctx.client, userid))
)
if ctx.author.id == userid:
author_rank = i + 1
# Break into pages
entry_pages = [entries[i:i+10] for i in range(0, len(entries), 10)]
page_count = len(entry_pages)
author_page = (author_rank - 1) // 10 if author_rank is not None else None
if page_count == 1:
image = await LeaderboardCard.request(
ctx.guild.name,
entries=entry_pages[0],
highlight=author_rank,
skin=LeaderboardCard.skin_args_for(ctx)
)
_file = image_as_file(image, "leaderboard.png")
await ctx.reply(file=_file)
del image
else:
page_i = 0
page_futures = {}
def submit_page_request(i):
if (_existing := page_futures.get(i, None)) is not None:
# A future was already submitted
_future = _existing
else:
_future = asyncio.create_task(
LeaderboardCard.request(
ctx.guild.name,
entries=entry_pages[i % page_count],
highlight=author_rank,
skin=LeaderboardCard.skin_args_for(ctx)
)
)
page_futures[i] = _future
return _future
# Draw first page
out_msg = await ctx.reply(file=image_as_file(await submit_page_request(0), "leaderboard.png"))
# Prefetch likely next page
submit_page_request(author_page or 1)
# Add reactions
try:
await out_msg.add_reaction(prev_emoji)
if author_page is not None:
await out_msg.add_reaction(my_emoji)
await out_msg.add_reaction(next_emoji)
except discord.Forbidden:
perms = ctx.ch.permissions_for(ctx.guild.me)
if not perms.add_reactions:
await ctx.error_reply(
"Cannot page leaderboard because I do not have the `add_reactions` permission!"
)
elif not perms.read_message_history:
await ctx.error_reply(
"Cannot page leaderboard because I do not have the `read_message_history` permission!"
)
else:
await ctx.error_reply(
"Cannot page leaderboard due to insufficient permissions!"
)
return
def reaction_check(reaction, user):
result = reaction.message.id == out_msg.id
result = result and reaction.emoji in [next_emoji, my_emoji, prev_emoji]
result = result and not (user.id == ctx.client.user.id)
return result
while True:
try:
reaction, user = await ctx.client.wait_for('reaction_add', check=reaction_check, timeout=60)
except asyncio.TimeoutError:
break
asyncio.create_task(discord_shield(out_msg.remove_reaction(reaction.emoji, user)))
# Change the page number
if reaction.emoji == next_emoji:
page_i += 1
page_i %= page_count
elif reaction.emoji == prev_emoji:
page_i -= 1
page_i %= page_count
else:
page_i = author_page
# Edit the message
image = await submit_page_request(page_i)
image_file = image_as_file(image, f"leaderboard_{page_i}.png")
await edit_files(
ctx.client._connection.http,
ctx.ch.id,
out_msg.id,
files=[image_file]
)
# Prefetch surrounding pages
submit_page_request((page_i + 1) % page_count)
submit_page_request((page_i - 1) % page_count)
# Clean up reactions
try:
await out_msg.clear_reactions()
except discord.Forbidden:
try:
await out_msg.remove_reaction(next_emoji, ctx.client.user)
await out_msg.remove_reaction(prev_emoji, ctx.client.user)
except discord.NotFound:
pass
except discord.NotFound:
pass
# Delete the image cache and explicit garbage collect
del page_futures
gc.collect()

View File

@@ -0,0 +1,90 @@
import logging
import time
import traceback
import discord
from LionModule import LionModule
from meta import client
from utils.ratelimits import RateLimit
from ..client import EmptyResponse, request
class PluginModule(LionModule):
def cmd(self, name, **kwargs):
# Remove any existing command with this name
for module in client.modules:
for i, cmd in enumerate(module.cmds):
if cmd.name == name:
module.cmds.pop(i)
return super().cmd(name, **kwargs)
async def on_exception(self, ctx, exception):
try:
raise exception
except (ConnectionError, EmptyResponse) as e:
full_traceback = traceback.format_exc()
only_error = "".join(traceback.TracebackException.from_exception(e).format_exception_only())
client.log(
("Caught a communication exception while "
"executing command '{cmdname}' from module '{module}' "
"from user '{message.author}' (uid:{message.author.id}) "
"in guild '{message.guild}' (gid:{guildid}) "
"in channel '{message.channel}' (cid:{message.channel.id}).\n"
"Message Content:\n"
"{content}\n"
"{traceback}\n\n"
"{flat_ctx}").format(
cmdname=ctx.cmd.name,
module=ctx.cmd.module.name,
message=ctx.msg,
guildid=ctx.guild.id if ctx.guild else None,
content='\n'.join('\t' + line for line in ctx.msg.content.splitlines()),
traceback=full_traceback,
flat_ctx=ctx.flatten()
),
context="mid:{}".format(ctx.msg.id),
level=logging.ERROR
)
error_embed = discord.Embed(title="Sorry, something went wrong!")
error_embed.description = (
"An unexpected error occurred while communicating with our rendering server!\n"
"Our development team has been notified, and the issue should be fixed soon.\n"
)
if logging.getLogger().getEffectiveLevel() < logging.INFO:
error_embed.add_field(
name="Exception",
value="`{}`".format(only_error)
)
await ctx.reply(embed=error_embed)
except Exception:
await super().on_exception(ctx, exception)
module = PluginModule("GUI")
ratelimit = RateLimit(5, 30)
logging.getLogger('PIL').setLevel(logging.WARNING)
@module.launch_task
async def ping_server(client):
start = time.time()
try:
await request('ping')
except Exception:
logging.error(
"Failed to ping the rendering server!",
exc_info=True
)
else:
end = time.time()
client.log(
f"Rendering server responded in {end-start:.6f} seconds!",
context="GUI INIT",
)

View File

@@ -0,0 +1,24 @@
import importlib
from .. import drawing
from . import goals, leaderboard, stats, tasklist
from cmdClient import cmd, checks
@cmd("reloadgui",
desc="Reload all GUI drawing modules.")
@checks.is_owner()
async def cmd_reload_gui(ctx):
importlib.reload(drawing.goals)
importlib.reload(drawing.leaderboard)
importlib.reload(drawing.profile)
importlib.reload(drawing.stats)
importlib.reload(drawing.tasklist)
importlib.reload(drawing.weekly)
importlib.reload(drawing.monthly)
importlib.reload(goals)
importlib.reload(leaderboard)
importlib.reload(stats)
importlib.reload(tasklist)
await ctx.reply("GUI plugin reloaded.")

View File

@@ -0,0 +1,278 @@
import asyncio
import time
from datetime import datetime, timedelta
from cmdClient.checks import in_guild
from utils.lib import utc_now
from data import tables
from data.conditions import LEQ
from core import Lion
from LionContext import LionContext as Context
from modules.study.tracking.data import session_history
from modules.stats.achievements import get_achievements_for
from ...cards import StatsCard, ProfileCard
from ...utils import get_avatar_key, image_as_file
from ..module import module, ratelimit
async def get_stats_card_for(ctx: Context, target):
lion = Lion.fetch(ctx.guild.id, target.id)
history = session_history.select_where(
guildid=ctx.guild.id,
userid=target.id,
select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time DESC"
)
# Current economy balance (accounting for current session)
workout_total = lion.data.workout_count
# Leaderboard ranks
exclude = set(m.id for m in ctx.guild_settings.unranked_roles.members)
exclude.update(ctx.client.user_blacklist())
exclude.update(ctx.client.objects['ignored_members'][ctx.guild.id])
if target.bot or target.id in exclude:
time_rank = None
coin_rank = None
else:
time_rank, coin_rank = tables.lions.queries.get_member_rank(ctx.guild.id, target.id, list(exclude or [0]))
# Study time
# First get the all/month/week/day timestamps
day_start = lion.day_start
month_start = day_start.replace(day=1)
period_timestamps = (
datetime(1970, 1, 1),
month_start,
day_start - timedelta(days=day_start.weekday()),
day_start
)
study_times = [0, 0, 0, 0]
for i, timestamp in enumerate(period_timestamps):
study_time = tables.session_history.queries.study_time_since(ctx.guild.id, target.id, timestamp)
if not study_time:
# So we don't make unecessary database calls
break
study_times[i] = study_time
# Streak data for the study run view
streaks = []
streak = 0
streak_end = None
date = day_start
daydiff = timedelta(days=1)
if 'sessions' in ctx.client.objects and lion.session:
day_attended = True
streak_end = day_start.day
else:
day_attended = None
periods = [(row['start_time'], row['end_time']) for row in history]
i = 0
while i < len(periods):
row = periods[i]
i += 1
if row[1] > date:
# They attended this day
day_attended = True
if streak_end is None:
streak_end = (date - month_start).days + 1
continue
elif day_attended is None:
# Didn't attend today, but don't break streak
day_attended = False
date -= daydiff
i -= 1
continue
elif not day_attended:
# Didn't attend the day, streak broken
date -= daydiff
i -= 1
pass
else:
# Attended the day
streak += 1
# Move window to the previous day and try the row again
day_attended = False
prev_date = date
date -= daydiff
i -= 1
# Special case, when the last session started in the previous day
# Then the day is already attended
if i > 1 and date < periods[i-2][0] <= prev_date:
day_attended = True
if streak_end is None:
streak_end = (date - month_start).days + 1
continue
if streak_end:
streaks.append((streak_end - streak + 1, streak_end))
streak_end = None
streak = 0
if date.month != day_start.month:
break
# Handle loop exit state, i.e. the last streak
if day_attended:
streak += 1
streaks.append((streak_end - streak + 1, streak_end))
# We have all the data for the stats card
return await StatsCard.request(
(time_rank, coin_rank),
list(reversed(study_times)),
workout_total,
streaks,
skin=StatsCard.skin_args_for(ctx)
)
async def get_profile_card_for(ctx: Context, target):
lion = Lion.fetch(ctx.guild.id, target.id)
# Current economy balance (accounting for current session)
coins = lion.coins
season_time = lion.time
# Study League
guild_badges = tables.study_badges.fetch_rows_where(guildid=ctx.guild.id)
if lion.data.last_study_badgeid:
current_badge = tables.study_badges.fetch(lion.data.last_study_badgeid)
else:
current_badge = None
next_badge = min(
(badge for badge in guild_badges
if badge.required_time > (current_badge.required_time if current_badge else 0)),
key=lambda badge: badge.required_time,
default=None
)
if current_badge:
current_rank = (
role.name if (role := ctx.guild.get_role(current_badge.roleid)) else str(current_badge.roleid),
current_badge.required_time // 3600,
next_badge.required_time // 3600 if next_badge else None
)
else:
current_rank = None
if next_badge:
next_next_badge = min(
(badge for badge in guild_badges if badge.required_time > next_badge.required_time),
key=lambda badge: badge.required_time,
default=None
)
next_rank = (
role.name if (role := ctx.guild.get_role(next_badge.roleid)) else str(next_badge.roleid),
next_badge.required_time // 3600,
next_next_badge.required_time // 3600 if next_next_badge else None
)
else:
next_rank = None
achievements = await get_achievements_for(target)
# We have all the data for the profile card
avatar = get_avatar_key(ctx.client, target.id)
return await ProfileCard.request(
target.name,
'#{}'.format(target.discriminator),
coins,
season_time,
avatar=avatar,
gems=ctx.client.data.gem_transactions.queries.get_gems_for(target.id),
gifts=ctx.client.data.gem_transactions.queries.get_gifts_for(target.id),
achievements=[i for i, ach in enumerate(achievements) if ach.level_id > 0],
current_rank=current_rank,
next_rank=next_rank,
badges=lion.profile_tags,
skin=ProfileCard.skin_args_for(ctx)
)
@module.cmd(
"stats",
group="Statistics",
desc="View your server study statistics!"
)
@in_guild()
@ratelimit.ward(member=False)
async def cmd_stats(ctx):
"""
Usage``:
{prefix}stats
{prefix}stats <mention>
Description:
View your study statistics in this server, or those of the mentioned member.
"""
# Identify the target
if ctx.args:
if not ctx.msg.mentions:
return await ctx.error_reply("Please mention a user to view their statistics!")
target = ctx.msg.mentions[0]
else:
target = ctx.author
# System sync
Lion.sync()
# Fetch the cards
futures = (
asyncio.create_task(get_profile_card_for(ctx, target)),
asyncio.create_task(get_stats_card_for(ctx, target))
)
await futures[0]
await futures[1]
profile_image = futures[0].result()
stats_image = futures[1].result()
profile_file = image_as_file(profile_image, f"profile_{target.id}.png")
stats_file = image_as_file(stats_image, f"stats_{target.id}.png")
await ctx.reply(files=[profile_file, stats_file])
@module.cmd(
"profile",
group="Statistics",
desc="View your personal study profile!"
)
@in_guild()
@ratelimit.ward(member=False)
async def cmd_profile(ctx):
"""
Usage``:
{prefix}profile
{prefix}profile <mention>
Description:
View your server study profile, or that of the mentioned user.
"""
# Identify the target
if ctx.args:
if not ctx.msg.mentions:
return await ctx.error_reply("Please mention a user to view their profile!")
target = ctx.msg.mentions[0]
else:
target = ctx.author
# System sync
Lion.sync()
# Fetch the cards
profile_image = await get_profile_card_for(ctx, target)
profile_file = image_as_file(profile_image, f"profile_{target.id}.png")
await ctx.reply(file=profile_file)

View File

@@ -0,0 +1,111 @@
import asyncio
import discord
from core import Lion
from meta import client
from modules.todo.Tasklist import Tasklist as TextTasklist
from ...cards import TasklistCard
from ...utils import get_avatar_key, image_as_file, edit_files
widget_help = """
Open your interactive tasklist with `{prefix}todo`, \
then use the following commands to update your tasks. \
The `<taskids>` may be given as comma separated numbers and ranges.
`<taskids>` Toggle the status (done/notdone) 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 as done) the specified tasks.
`u/uncheck <taskids>` Uncheck (mark incomplete) the specified tasks.
`cancel` Cancel the interactive tasklist mode.
*You do not need to write `{prefix}todo` before each command when the list is visible.*
**Examples**
`add Read chapter 1` Add a new task `Read chapter 1`.
`e 0 Notes chapter 1` Edit task `0` to say `Notes chapter 1`.
`d 0, 5-7, 9` Delete tasks `0, 5, 6, 7, 9`.
`0, 2-5, 9` Toggle the completion status of tasks `0, 2, 3, 4, 5, 9`.
[Click here to jump back]({jump_link})
"""
class GUITasklist(TextTasklist):
async def _format_tasklist(self):
tasks = [
(i, task.content, bool(task.completed_at))
for (i, task) in enumerate(self.tasklist)
]
avatar = get_avatar_key(client, self.member.id)
lion = Lion.fetch(self.member.guild.id, self.member.id)
date = lion.day_start
self.pages = await TasklistCard.request(
self.member.name,
f"#{self.member.discriminator}",
tasks,
date,
avatar=avatar,
badges=lion.profile_tags,
skin=TasklistCard.skin_args_for(guildid=self.member.guild.id, userid=self.member.id)
)
return self.pages
async def _post(self):
pages = self.pages
message = await self.channel.send(file=image_as_file(pages[self.current_page], "tasklist.png"))
# 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):
if self.show_help:
embed = discord.Embed(
title="Tasklist widget guide",
description=widget_help.format(
prefix=client.prefix,
jump_link=self.message.jump_url
),
colour=discord.Colour.orange()
)
try:
await self.member.send(embed=embed)
except discord.Forbidden:
await self.channel.send("Could not send you the guide! Please open your DMs first.")
except discord.HTTPException:
pass
self.show_help = False
await edit_files(
self.message._state.http,
self.channel.id,
self.message.id,
files=[image_as_file(self.pages[self.current_page], "tasklist.png")]
)
# Monkey patch the Tasklist fetch method to conditionally point to the GUI tasklist
# TODO: Config setting for text/gui
@classmethod
def fetch_or_create(cls, ctx, flags, member, channel):
factory = TextTasklist if flags['text'] else GUITasklist
tasklist = GUITasklist.active.get((member.id, channel.id), None)
if type(tasklist) != factory:
tasklist = None
return tasklist if tasklist is not None else factory(member, channel)
TextTasklist.fetch_or_create = fetch_or_create

View File

@@ -0,0 +1,169 @@
import asyncio
import time
import logging
import traceback
from collections import defaultdict
import discord
from utils.lib import utc_now
from core import Lion
from meta import client
from modules.study.timers.Timer import Timer
from ...cards import FocusTimerCard, BreakTimerCard
from ...utils import get_avatar_key, image_as_file, edit_files, asset_path
async def status(self):
stage = self.current_stage
name = self.data.pretty_name
remaining = int((stage.end - utc_now()).total_seconds())
duration = int(stage.duration)
next_starts = int(stage.end.timestamp())
users = [
(get_avatar_key(client, member.id),
session.duration if (session := Lion.fetch(member.guild.id, member.id).session) else 0,
session.data.tag if session else None)
for member in self.members
]
if stage.name == 'FOCUS':
card_class = FocusTimerCard
content = f"**Focus!** Session ends <t:{next_starts}:R>."
else:
card_class = BreakTimerCard
content = f"**Have a rest!** Break finishes <t:{next_starts}:R>."
page = await card_class.request(
name,
remaining,
duration,
users=users,
skin=card_class.skin_args_for(guildid=self.data.guildid)
)
return {
'content': content,
'files': [image_as_file(page, name="timer.png")]
}
_guard_delay = 60
_guarded = {} # timer channel id -> (last_executed_time, currently_waiting)
async def guard_request(id):
if (result := _guarded.get(id, None)):
last, currently = result
if currently:
return False
else:
_guarded[id] = (last, True)
await asyncio.sleep(_guard_delay - (time.time() - last))
_guarded[id] = (time.time(), False)
return True
else:
_guarded[id] = (time.time(), False)
return True
async def update_last_status(self):
"""
Update the last posted status message, if it exists.
"""
old_message = self.reaction_message
if not await guard_request(self.channelid):
return
if old_message != self.reaction_message:
return
args = await self.status()
repost = True
if self.reaction_message:
try:
await edit_files(
client._connection.http,
self.reaction_message.channel.id,
self.reaction_message.id,
**args
)
except discord.HTTPException:
pass
else:
repost = False
if repost and self.text_channel:
try:
self.reaction_message = await self.text_channel.send(**args)
await self.reaction_message.add_reaction('')
except discord.HTTPException:
pass
return
guild_locks = defaultdict(asyncio.Lock)
async def play_alert(channel: discord.VoiceChannel, alert_file):
if not channel.members:
# Don't notify an empty channel
return
async with guild_locks[channel.guild.id]:
try:
vc = channel.guild.voice_client
if not vc:
vc = await asyncio.wait_for(
channel.connect(timeout=10, reconnect=False),
20
)
elif vc.channel != channel:
await vc.move_to(channel)
except asyncio.TimeoutError:
client.log(
f"Timed out while attempting to connect to '{channel.name}' (cid:{channel.id}) "
f"in '{channel.guild.name}' (gid:{channel.guild.id}).",
context="TIMER_ALERT",
level=logging.WARNING
)
vc = channel.guild.voice_client
if vc:
await vc.disconnect(force=True)
return
audio_stream = open(alert_file, 'rb')
try:
vc.play(discord.PCMAudio(audio_stream), after=lambda e: audio_stream.close())
except discord.HTTPException:
pass
count = 0
while vc.is_playing() and count < 10:
await asyncio.sleep(1)
count += 1
await vc.disconnect(force=True)
async def notify_hook(self, old_stage, new_stage):
try:
if new_stage.name == 'BREAK':
await play_alert(self.channel, asset_path('timer/voice/break_alert.wav'))
else:
await play_alert(self.channel, asset_path('timer/voice/focus_alert.wav'))
except Exception:
full_traceback = traceback.format_exc()
client.log(
f"Caught an unhandled exception while playing timer alert in '{self.channel.name}' (cid:{self.channel.id})"
f" in '{self.channel.guild.name}' (gid:{self.channel.guild.id}).\n"
f"{full_traceback}",
context="TIMER_ALERT",
level=logging.ERROR
)
Timer.status = status
Timer.update_last_status = update_last_status
Timer.notify_hook = notify_hook

View File

@@ -0,0 +1,43 @@
import importlib
from datetime import datetime, timedelta
from data.conditions import GEQ
from ..module import module
from .. import drawing
from ..utils import get_avatar, image_as_file
@module.cmd(
'tasktest'
)
async def cmd_tasktest(ctx):
importlib.reload(drawing.weekly)
WeeklyStatsPage = drawing.weekly.WeeklyStatsPage
day_start = ctx.alion.day_start
last_week_start = day_start - timedelta(days=7 + day_start.weekday())
history = ctx.client.data.session_history.select_where(
guildid=ctx.guild.id,
userid=ctx.author.id,
start_time=GEQ(last_week_start - timedelta(days=1)),
select_columns=(
"start_time",
"(start_time + duration * interval '1 second') AS end_time"
),
_extra="ORDER BY start_time ASC"
)
timezone = ctx.alion.timezone
sessions = [(row['start_time'].astimezone(timezone), row['end_time'].astimezone(timezone)) for row in history]
page = WeeklyStatsPage(
ctx.author.name,
f"#{ctx.author.discriminator}",
sessions,
day_start
)
image = page.draw()
await ctx.reply(file=image_as_file(image, 'weekly_stats.png'))