Merge branch 'staging' of cgithub:StudyLions/StudyLion into staging
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
CONFIG_FILE = "config/bot.conf"
|
CONFIG_FILE = "config/bot.conf"
|
||||||
DATA_VERSION = 7
|
DATA_VERSION = 8
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ guild_config = RowTable(
|
|||||||
'accountability_reward', 'accountability_price',
|
'accountability_reward', 'accountability_price',
|
||||||
'video_studyban', 'video_grace_period',
|
'video_studyban', 'video_grace_period',
|
||||||
'greeting_channel', 'greeting_message', 'returning_message',
|
'greeting_channel', 'greeting_message', 'returning_message',
|
||||||
'starting_funds', 'persist_roles'),
|
'starting_funds', 'persist_roles',
|
||||||
|
'pomodoro_channel'),
|
||||||
'guildid',
|
'guildid',
|
||||||
cache=TTLCache(2500, ttl=60*5)
|
cache=TTLCache(2500, ttl=60*5)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from .module import module
|
|||||||
|
|
||||||
# Set the command groups to appear in the help
|
# Set the command groups to appear in the help
|
||||||
group_hints = {
|
group_hints = {
|
||||||
|
'🆕 Pomodoro': "*Stay in sync with your friends using our timers!*",
|
||||||
'Productivity': "*Use these to help you stay focused and productive!*",
|
'Productivity': "*Use these to help you stay focused and productive!*",
|
||||||
'Statistics': "*StudyLion leaderboards and study statistics.*",
|
'Statistics': "*StudyLion leaderboards and study statistics.*",
|
||||||
'Economy': "*Buy, sell, and trade with your hard-earned coins!*",
|
'Economy': "*Buy, sell, and trade with your hard-earned coins!*",
|
||||||
@@ -20,22 +21,22 @@ group_hints = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
standard_group_order = (
|
standard_group_order = (
|
||||||
('Productivity', 'Statistics', 'Economy', 'Personal Settings', 'Meta'),
|
('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings', 'Meta')
|
||||||
)
|
)
|
||||||
|
|
||||||
mod_group_order = (
|
mod_group_order = (
|
||||||
('Moderation', 'Meta'),
|
('Moderation', 'Meta'),
|
||||||
('Productivity', 'Statistics', 'Economy', 'Personal Settings')
|
('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings')
|
||||||
)
|
)
|
||||||
|
|
||||||
admin_group_order = (
|
admin_group_order = (
|
||||||
('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
|
('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
|
||||||
('Productivity', 'Statistics', 'Economy', 'Personal Settings')
|
('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings')
|
||||||
)
|
)
|
||||||
|
|
||||||
bot_admin_group_order = (
|
bot_admin_group_order = (
|
||||||
('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
|
('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
|
||||||
('Productivity', 'Statistics', 'Economy', 'Personal Settings')
|
('🆕 Pomodoro', 'Productivity', 'Statistics', 'Economy', 'Personal Settings')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Help embed format
|
# Help embed format
|
||||||
|
|||||||
432
bot/modules/study/timers/Timer.py
Normal file
432
bot/modules/study/timers/Timer.py
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
import math
|
||||||
|
import asyncio
|
||||||
|
import discord
|
||||||
|
from collections import namedtuple
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from utils.lib import utc_now
|
||||||
|
from utils.interactive import discord_shield
|
||||||
|
from meta import client
|
||||||
|
from settings import GuildSettings
|
||||||
|
from data.conditions import THIS_SHARD
|
||||||
|
|
||||||
|
|
||||||
|
from ..module import module
|
||||||
|
|
||||||
|
from .data import timers as timer_table
|
||||||
|
|
||||||
|
|
||||||
|
Stage = namedtuple('Stage', ['name', 'start', 'duration', 'end'])
|
||||||
|
|
||||||
|
|
||||||
|
class Timer:
|
||||||
|
timers = {} # channelid -> Timer
|
||||||
|
|
||||||
|
def __init__(self, channelid):
|
||||||
|
self.channelid = channelid
|
||||||
|
self.last_seen = {
|
||||||
|
} # Memberid -> timestamps
|
||||||
|
|
||||||
|
self.reaction_message = None
|
||||||
|
|
||||||
|
self._state = None
|
||||||
|
self._last_voice_update = None
|
||||||
|
|
||||||
|
self._voice_update_task = None
|
||||||
|
self._run_task = None
|
||||||
|
self._runloop_task = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, channel, focus_length, break_length, **kwargs):
|
||||||
|
timer_table.create_row(
|
||||||
|
channelid=channel.id,
|
||||||
|
guildid=channel.guild.id,
|
||||||
|
focus_length=focus_length,
|
||||||
|
break_length=break_length,
|
||||||
|
last_started=kwargs.pop('last_started', utc_now()),
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
return cls(channel.id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def fetch_timer(cls, channelid):
|
||||||
|
return cls.timers.get(channelid, None)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def fetch_guild_timers(cls, guildid):
|
||||||
|
timers = []
|
||||||
|
guild = client.get_guild(guildid)
|
||||||
|
if guild:
|
||||||
|
for channel in guild.voice_channels:
|
||||||
|
if (timer := cls.timers.get(channel.id, None)):
|
||||||
|
timers.append(timer)
|
||||||
|
|
||||||
|
return timers
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data(self):
|
||||||
|
return timer_table.fetch(self.channelid)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def focus_length(self):
|
||||||
|
return self.data.focus_length
|
||||||
|
|
||||||
|
@property
|
||||||
|
def break_length(self):
|
||||||
|
return self.data.break_length
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inactivity_threshold(self):
|
||||||
|
return self.data.inactivity_threshold or 3
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_stage(self):
|
||||||
|
if (last_start := self.data.last_started) is None:
|
||||||
|
# Timer hasn't been started
|
||||||
|
return None
|
||||||
|
now = utc_now()
|
||||||
|
diff = (now - last_start).total_seconds()
|
||||||
|
diff %= (self.focus_length + self.break_length)
|
||||||
|
if diff > self.focus_length:
|
||||||
|
return Stage(
|
||||||
|
'BREAK',
|
||||||
|
now - timedelta(seconds=(diff - self.focus_length)),
|
||||||
|
self.break_length,
|
||||||
|
now + timedelta(seconds=(- diff + self.focus_length + self.break_length))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Stage(
|
||||||
|
'FOCUS',
|
||||||
|
now - timedelta(seconds=diff),
|
||||||
|
self.focus_length,
|
||||||
|
now + timedelta(seconds=(self.focus_length - diff))
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def guild(self):
|
||||||
|
return client.get_guild(self.data.guildid)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channel(self):
|
||||||
|
return client.get_channel(self.channelid)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text_channel(self):
|
||||||
|
if (channelid := self.data.text_channelid) and (channel := self.guild.get_channel(channelid)):
|
||||||
|
return channel
|
||||||
|
else:
|
||||||
|
return GuildSettings(self.data.guildid).pomodoro_channel.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def members(self):
|
||||||
|
if (channel := self.channel):
|
||||||
|
return [member for member in channel.members if not member.bot]
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channel_name(self):
|
||||||
|
"""
|
||||||
|
Current name for the voice channel
|
||||||
|
"""
|
||||||
|
stage = self.current_stage
|
||||||
|
name_format = self.data.channel_name or "{remaining} {stage} -- {name}"
|
||||||
|
return name_format.replace(
|
||||||
|
'{remaining}', "{}m".format(
|
||||||
|
int(5 * math.ceil((stage.end - utc_now()).total_seconds() / 300)),
|
||||||
|
)
|
||||||
|
).replace(
|
||||||
|
'{stage}', stage.name.lower()
|
||||||
|
).replace(
|
||||||
|
'{members}', str(len(self.channel.members))
|
||||||
|
).replace(
|
||||||
|
'{name}', self.data.pretty_name or "WORK ROOM"
|
||||||
|
).replace(
|
||||||
|
'{pattern}',
|
||||||
|
"{}/{}".format(
|
||||||
|
int(self.focus_length // 60), int(self.break_length // 60)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def notify_change_stage(self, old_stage, new_stage):
|
||||||
|
# Update channel name
|
||||||
|
asyncio.create_task(self._update_channel_name())
|
||||||
|
|
||||||
|
# Kick people if they need kicking
|
||||||
|
to_warn = []
|
||||||
|
to_kick = []
|
||||||
|
warn_threshold = (self.inactivity_threshold - 1) * (self.break_length + self.focus_length)
|
||||||
|
kick_threshold = self.inactivity_threshold * (self.break_length + self.focus_length)
|
||||||
|
for member in self.members:
|
||||||
|
if member.id in self.last_seen:
|
||||||
|
diff = (utc_now() - self.last_seen[member.id]).total_seconds()
|
||||||
|
if diff >= kick_threshold:
|
||||||
|
to_kick.append(member)
|
||||||
|
elif diff > warn_threshold:
|
||||||
|
to_warn.append(member)
|
||||||
|
else:
|
||||||
|
# Shouldn't really happen, but
|
||||||
|
self.last_seen[member.id] = utc_now()
|
||||||
|
|
||||||
|
content = []
|
||||||
|
if to_kick:
|
||||||
|
# Do kick
|
||||||
|
await asyncio.gather(
|
||||||
|
*(member.edit(voice_channel=None) for member in to_kick),
|
||||||
|
return_exceptions=True
|
||||||
|
)
|
||||||
|
kick_string = (
|
||||||
|
"**Kicked due to inactivity:** {}".format(', '.join(member.mention for member in to_kick))
|
||||||
|
)
|
||||||
|
content.append(kick_string)
|
||||||
|
|
||||||
|
if to_warn:
|
||||||
|
warn_string = (
|
||||||
|
"**Please react to avoid being kicked:** {}".format(
|
||||||
|
', '.join(member.mention for member in to_warn)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
content.append(warn_string)
|
||||||
|
|
||||||
|
# Send a new status/reaction message
|
||||||
|
if self.text_channel and self.members:
|
||||||
|
old_reaction_message = self.reaction_message
|
||||||
|
|
||||||
|
# Send status image, add reaction
|
||||||
|
self.reaction_message = await self.text_channel.send(
|
||||||
|
content='\n'.join(content),
|
||||||
|
**(await self.status())
|
||||||
|
)
|
||||||
|
await self.reaction_message.add_reaction('✅')
|
||||||
|
|
||||||
|
if old_reaction_message:
|
||||||
|
asyncio.create_task(discord_shield(old_reaction_message.delete()))
|
||||||
|
|
||||||
|
# Ping people
|
||||||
|
members = self.members
|
||||||
|
blocks = [
|
||||||
|
''.join(member.mention for member in members[i:i+90])
|
||||||
|
for i in range(0, len(members), 90)
|
||||||
|
]
|
||||||
|
await asyncio.gather(
|
||||||
|
*(self.text_channel.send(block, delete_after=0.5) for block in blocks),
|
||||||
|
return_exceptions=True
|
||||||
|
)
|
||||||
|
elif not self.members:
|
||||||
|
await self.update_last_status()
|
||||||
|
# TODO: DM task if anyone has notifications on
|
||||||
|
|
||||||
|
# Mute or unmute everyone in the channel as needed
|
||||||
|
# Not possible, due to Discord restrictions
|
||||||
|
# overwrite = self.channel.overwrites_for(self.channel.guild.default_role)
|
||||||
|
# overwrite.speak = (new_stage.name == 'BREAK')
|
||||||
|
# try:
|
||||||
|
# await self.channel.set_permissions(
|
||||||
|
# self.channel.guild.default_role,
|
||||||
|
# overwrite=overwrite
|
||||||
|
# )
|
||||||
|
# except discord.HTTPException:
|
||||||
|
# pass
|
||||||
|
|
||||||
|
# Run the notify hook
|
||||||
|
await self.notify_hook(old_stage, new_stage)
|
||||||
|
|
||||||
|
async def notify_hook(self, old_stage, new_stage):
|
||||||
|
"""
|
||||||
|
May be overridden to provide custom actions during notification.
|
||||||
|
For example, for voice alerts.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def _update_channel_name(self):
|
||||||
|
# Attempt to update the voice channel name
|
||||||
|
# Ensures that only one update is pending at any time
|
||||||
|
# Attempts to wait until the next viable channel update
|
||||||
|
if self._voice_update_task:
|
||||||
|
self._voice_update_task.cancel()
|
||||||
|
|
||||||
|
if self.channel.name == self.channel_name:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._last_voice_update:
|
||||||
|
to_wait = ((self._last_voice_update + timedelta(minutes=5)) - utc_now()).total_seconds()
|
||||||
|
if to_wait > 0:
|
||||||
|
self._voice_update_task = asyncio.create_task(asyncio.sleep(to_wait))
|
||||||
|
try:
|
||||||
|
await self._voice_update_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
self._voice_update_task = asyncio.create_task(
|
||||||
|
self.channel.edit(name=self.channel_name)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._voice_update_task
|
||||||
|
self._last_voice_update = utc_now()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def status(self):
|
||||||
|
"""
|
||||||
|
Returns argument dictionary compatible with `discord.Channel.send`.
|
||||||
|
"""
|
||||||
|
# Generate status message
|
||||||
|
stage = self.current_stage
|
||||||
|
stage_str = "**{}** minutes focus with **{}** minutes break".format(
|
||||||
|
self.focus_length // 60,
|
||||||
|
self.break_length // 60
|
||||||
|
)
|
||||||
|
remaining = (stage.end - utc_now()).total_seconds()
|
||||||
|
|
||||||
|
memberstr = ', '.join(member.mention for member in self.members[:20])
|
||||||
|
if len(self.members) > 20:
|
||||||
|
memberstr += '...'
|
||||||
|
|
||||||
|
description = (
|
||||||
|
("{}: {}\n"
|
||||||
|
"Currently in `{}`, with `{:02}:{:02}` remaining.\n"
|
||||||
|
"{}").format(
|
||||||
|
self.channel.mention,
|
||||||
|
stage_str,
|
||||||
|
stage.name,
|
||||||
|
int(remaining // 3600),
|
||||||
|
int((remaining // 60) % 60),
|
||||||
|
memberstr
|
||||||
|
)
|
||||||
|
)
|
||||||
|
embed = discord.Embed(
|
||||||
|
colour=discord.Colour.orange(),
|
||||||
|
description=description
|
||||||
|
)
|
||||||
|
return {'embed': embed}
|
||||||
|
|
||||||
|
async def update_last_status(self):
|
||||||
|
"""
|
||||||
|
Update the last posted status message, if it exists.
|
||||||
|
"""
|
||||||
|
args = await self.status()
|
||||||
|
repost = True
|
||||||
|
if self.reaction_message:
|
||||||
|
try:
|
||||||
|
await self.reaction_message.edit(**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
|
||||||
|
|
||||||
|
async def destroy(self):
|
||||||
|
"""
|
||||||
|
Remove the timer.
|
||||||
|
"""
|
||||||
|
# Remove timer from cache
|
||||||
|
self.timers.pop(self.channelid)
|
||||||
|
|
||||||
|
# Cancel the loop
|
||||||
|
if self._run_task:
|
||||||
|
self._run_task.cancel()
|
||||||
|
|
||||||
|
# Delete the reaction message
|
||||||
|
if self.reaction_message:
|
||||||
|
try:
|
||||||
|
await self.reaction_message.delete()
|
||||||
|
except discord.HTTPException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Remove the timer from data
|
||||||
|
timer_table.delete_where(channelid=self.channelid)
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""
|
||||||
|
Runloop
|
||||||
|
"""
|
||||||
|
timer = self.timers.pop(self.channelid, None)
|
||||||
|
if timer and timer._run_task:
|
||||||
|
timer._run_task.cancel()
|
||||||
|
self.timers[self.channelid] = self
|
||||||
|
|
||||||
|
if not self.data.last_started:
|
||||||
|
self.data.last_started = utc_now()
|
||||||
|
asyncio.create_task(self.notify_change_stage(None, self.current_stage))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
stage = self._state = self.current_stage
|
||||||
|
to_next_stage = (stage.end - utc_now()).total_seconds()
|
||||||
|
|
||||||
|
# Allow updating with 10 seconds of drift to stage change
|
||||||
|
if to_next_stage > 10 * 60 - 10:
|
||||||
|
time_to_sleep = 5 * 60
|
||||||
|
else:
|
||||||
|
time_to_sleep = to_next_stage
|
||||||
|
|
||||||
|
self._run_task = asyncio.create_task(asyncio.sleep(time_to_sleep))
|
||||||
|
try:
|
||||||
|
await self._run_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
|
||||||
|
if self._state.end < utc_now():
|
||||||
|
asyncio.create_task(self.notify_change_stage(self._state, self.current_stage))
|
||||||
|
else:
|
||||||
|
asyncio.create_task(self._update_channel_name())
|
||||||
|
asyncio.create_task(self.update_last_status())
|
||||||
|
|
||||||
|
def runloop(self):
|
||||||
|
self._runloop_task = asyncio.create_task(self.run())
|
||||||
|
|
||||||
|
|
||||||
|
# Loading logic
|
||||||
|
@module.launch_task
|
||||||
|
async def load_timers(client):
|
||||||
|
timer_rows = timer_table.fetch_rows_where(
|
||||||
|
guildid=THIS_SHARD
|
||||||
|
)
|
||||||
|
count = 0
|
||||||
|
for row in timer_rows:
|
||||||
|
if client.get_channel(row.channelid):
|
||||||
|
# Channel exists
|
||||||
|
# Create the timer
|
||||||
|
timer = Timer(row.channelid)
|
||||||
|
|
||||||
|
# Populate the members
|
||||||
|
timer.last_seen = {
|
||||||
|
member.id: utc_now()
|
||||||
|
for member in timer.members
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start the timer
|
||||||
|
timer.runloop()
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
client.log(
|
||||||
|
"Loaded and start '{}' timers!".format(count),
|
||||||
|
context="TIMERS"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Hooks
|
||||||
|
@client.add_after_event('raw_reaction_add')
|
||||||
|
async def reaction_tracker(client, payload):
|
||||||
|
if payload.guild_id and payload.member and not payload.member.bot and payload.member.voice:
|
||||||
|
if (channel := payload.member.voice.channel) and (timer := Timer.fetch_timer(channel.id)):
|
||||||
|
if timer.reaction_message and payload.message_id == timer.reaction_message.id:
|
||||||
|
timer.last_seen[payload.member.id] = utc_now()
|
||||||
|
|
||||||
|
|
||||||
|
@client.add_after_event('voice_state_update')
|
||||||
|
async def touch_member(client, member, before, after):
|
||||||
|
if not member.bot and after.channel != before.channel:
|
||||||
|
if after.channel and (timer := Timer.fetch_timer(after.channel.id)):
|
||||||
|
timer.last_seen[member.id] = utc_now()
|
||||||
|
await timer.update_last_status()
|
||||||
|
|
||||||
|
if before.channel and (timer := Timer.fetch_timer(before.channel.id)):
|
||||||
|
timer.last_seen.pop(member.id, None)
|
||||||
|
await timer.update_last_status()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .Timer import Timer
|
||||||
|
from . import commands
|
||||||
|
from . import settings
|
||||||
|
|||||||
441
bot/modules/study/timers/commands.py
Normal file
441
bot/modules/study/timers/commands.py
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
import asyncio
|
||||||
|
import discord
|
||||||
|
from cmdClient import Context
|
||||||
|
from cmdClient.checks import in_guild
|
||||||
|
from cmdClient.lib import SafeCancellation
|
||||||
|
|
||||||
|
from wards import guild_admin
|
||||||
|
from utils.lib import utc_now, tick
|
||||||
|
|
||||||
|
from ..module import module
|
||||||
|
|
||||||
|
from .Timer import Timer
|
||||||
|
|
||||||
|
|
||||||
|
config_flags = ('name==', 'threshold=', 'channelname==', 'text==')
|
||||||
|
MAX_TIMERS_PER_GUILD = 10
|
||||||
|
|
||||||
|
|
||||||
|
@module.cmd(
|
||||||
|
"timer",
|
||||||
|
group="🆕 Pomodoro",
|
||||||
|
desc="View your study room timer.",
|
||||||
|
flags=config_flags,
|
||||||
|
aliases=('timers',)
|
||||||
|
)
|
||||||
|
@in_guild()
|
||||||
|
async def cmd_timer(ctx: Context, flags):
|
||||||
|
"""
|
||||||
|
Usage``:
|
||||||
|
{prefix}timer
|
||||||
|
{prefix}timers
|
||||||
|
Description:
|
||||||
|
Display your current study room timer status.
|
||||||
|
If you aren't in a study room, instead shows a list of timers you can join.
|
||||||
|
Use `{prefix}timers` to always show the list of timers instead.
|
||||||
|
"""
|
||||||
|
channel = ctx.author.voice.channel if ctx.author.voice and ctx.alias.lower() != 'timers' else None
|
||||||
|
if ctx.args:
|
||||||
|
if len(ctx.args.split()) > 1:
|
||||||
|
# Multiple arguments provided
|
||||||
|
# Assume configuration attempt
|
||||||
|
return await _pomo_admin(ctx, flags)
|
||||||
|
else:
|
||||||
|
# Single argument provided, assume channel reference
|
||||||
|
channel = await ctx.find_channel(
|
||||||
|
ctx.args,
|
||||||
|
interactive=True,
|
||||||
|
chan_type=discord.ChannelType.voice,
|
||||||
|
)
|
||||||
|
if channel is None:
|
||||||
|
return
|
||||||
|
if channel is None:
|
||||||
|
# Author is not in a voice channel, and they did not select a channel
|
||||||
|
# Display the server timers they can see
|
||||||
|
timers = Timer.fetch_guild_timers(ctx.guild.id)
|
||||||
|
timers = [
|
||||||
|
timer for timer in timers
|
||||||
|
if timer.channel and timer.channel.permissions_for(ctx.author).view_channel
|
||||||
|
]
|
||||||
|
if not timers:
|
||||||
|
if await guild_admin.run(ctx):
|
||||||
|
return await ctx.error_reply(
|
||||||
|
"No timers are running yet!\n"
|
||||||
|
f"Start a timer by joining a voice channel and running e.g. `{ctx.best_prefix}pomodoro 50, 10`.\n"
|
||||||
|
f"See `{ctx.best_prefix}help pomodoro for detailed usage."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return await ctx.error_reply(
|
||||||
|
"No timers are running!\n"
|
||||||
|
f"You can ask an admin to start one using `{ctx.best_prefix}pomodoro`."
|
||||||
|
)
|
||||||
|
# Build a summary list
|
||||||
|
timer_strings = []
|
||||||
|
for timer in timers:
|
||||||
|
stage = timer.current_stage
|
||||||
|
stage_str = "(**`{}m`** focus, **`{}m`** break)".format(
|
||||||
|
int(timer.focus_length // 60), int(timer.break_length // 60)
|
||||||
|
)
|
||||||
|
if len(timer.members) > 1:
|
||||||
|
member_str = "**{}** members are ".format(len(timer.members))
|
||||||
|
elif len(timer.members) == 1:
|
||||||
|
member_str = "{} is ".format(timer.members[0].mention)
|
||||||
|
else:
|
||||||
|
member_str = ""
|
||||||
|
remaining = (stage.end - utc_now()).total_seconds()
|
||||||
|
|
||||||
|
timer_strings.append(
|
||||||
|
("{} {}\n"
|
||||||
|
"{}urrently **{}** with `{:02}:{:02}` left.").format(
|
||||||
|
timer.channel.mention,
|
||||||
|
stage_str,
|
||||||
|
member_str + 'c' if member_str else 'C',
|
||||||
|
"focusing" if stage.name == "FOCUS" else "resting",
|
||||||
|
int(remaining // 3600),
|
||||||
|
int((remaining // 60) % 60),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
blocks = [
|
||||||
|
'\n\n'.join(timer_strings[i:i+10])
|
||||||
|
for i in range(0, len(timer_strings), 10)
|
||||||
|
]
|
||||||
|
embeds = [
|
||||||
|
discord.Embed(
|
||||||
|
title="Study Timers",
|
||||||
|
description=block,
|
||||||
|
colour=discord.Colour.orange()
|
||||||
|
)
|
||||||
|
for block in blocks
|
||||||
|
]
|
||||||
|
await ctx.pager(embeds)
|
||||||
|
else:
|
||||||
|
# We have a channel
|
||||||
|
# Get the associated timer
|
||||||
|
timer = Timer.fetch_timer(channel.id)
|
||||||
|
if timer is None:
|
||||||
|
# No timer in this channel
|
||||||
|
return await ctx.error_reply(
|
||||||
|
f"{channel.mention} doesn't have a timer running!"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# We have a timer
|
||||||
|
# Show the timer status
|
||||||
|
await ctx.reply(**await timer.status())
|
||||||
|
|
||||||
|
|
||||||
|
@module.cmd(
|
||||||
|
"pomodoro",
|
||||||
|
group="🆕 Pomodoro",
|
||||||
|
desc="Add and configure timers for your study rooms.",
|
||||||
|
flags=config_flags
|
||||||
|
)
|
||||||
|
async def cmd_pomodoro(ctx, flags):
|
||||||
|
"""
|
||||||
|
Usage``:
|
||||||
|
{prefix}pomodoro [channelid] <focus time>, <break time> [channel name]
|
||||||
|
{prefix}pomodoro [channelid] [options]
|
||||||
|
{prefix}pomodoro [channelid] delete
|
||||||
|
Description:
|
||||||
|
Get started by joining a study voice channel and writing e.g. `{prefix}pomodoro 50, 10`.
|
||||||
|
The timer will start automatically and continue forever.
|
||||||
|
See the options and examples below for configuration.
|
||||||
|
Options::
|
||||||
|
--name: The timer name (as shown in alerts and `{prefix}timer`).
|
||||||
|
--channelname: The name of the voice channel, see below for substitutions.
|
||||||
|
--threshold: How many focus+break cycles before a member is kicked.
|
||||||
|
--text: Text channel to send timer alerts in (defaults to value of `{prefix}config pomodoro_channel`).
|
||||||
|
Channel name substitutions::
|
||||||
|
{{remaining}}: The time left in the current focus or break session, e.g. `10m`.
|
||||||
|
{{stage}}: The name of the current stage (`focus` or `break`).
|
||||||
|
{{name}}: The configured timer name.
|
||||||
|
{{pattern}}: The timer pattern in the form `focus/break` (e.g. `50/10`).
|
||||||
|
Examples:
|
||||||
|
Add a timer to your study room with `50` minutes focus, `10` minutes break.
|
||||||
|
> `{prefix}pomodoro 50, 10`
|
||||||
|
Add a timer with a custom updating channel name
|
||||||
|
> `{prefix}pomodoro 50, 10 {{remaining}} {{stage}} -- {{pattern}} room`
|
||||||
|
Change the name on the `{prefix}timer` status
|
||||||
|
> `{prefix}pomodoro --name 50/10 study room`
|
||||||
|
Change the updating channel name
|
||||||
|
> `{prefix}pomodoro --channelname {{remaining}} left -- {{name}}`
|
||||||
|
"""
|
||||||
|
await _pomo_admin(ctx, flags)
|
||||||
|
|
||||||
|
|
||||||
|
async def _pomo_admin(ctx, flags):
|
||||||
|
# Extract target channel
|
||||||
|
if ctx.author.voice:
|
||||||
|
channel = ctx.author.voice.channel
|
||||||
|
else:
|
||||||
|
channel = None
|
||||||
|
|
||||||
|
args = ctx.args
|
||||||
|
if ctx.args:
|
||||||
|
splits = ctx.args.split(maxsplit=1)
|
||||||
|
assume_channel = not (',' in splits[0])
|
||||||
|
assume_channel = assume_channel and not (channel and len(splits[0]) < 5)
|
||||||
|
assume_channel = assume_channel or (splits[0].strip('#<>').isdigit() and len(splits[0]) > 10)
|
||||||
|
if assume_channel:
|
||||||
|
# Assume first argument is a channel specifier
|
||||||
|
channel = await ctx.find_channel(
|
||||||
|
splits[0], interactive=True, chan_type=discord.ChannelType.voice
|
||||||
|
)
|
||||||
|
if not channel:
|
||||||
|
# Invalid channel provided
|
||||||
|
# find_channel already gave a message, just return silently
|
||||||
|
return
|
||||||
|
args = splits[1] if len(splits) > 1 else ""
|
||||||
|
|
||||||
|
if not args and not any(flags.values()):
|
||||||
|
# No arguments given to the `pomodoro` command.
|
||||||
|
# TODO: If we have a channel, replace this with timer setting information
|
||||||
|
return await ctx.error_reply(
|
||||||
|
f"See `{ctx.best_prefix}help pomodoro` for usage and examples."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not channel:
|
||||||
|
return await ctx.error_reply(
|
||||||
|
f"No channel specified!\n"
|
||||||
|
"Please join a voice channel or pass the channel id as the first argument.\n"
|
||||||
|
f"See `{ctx.best_prefix}help pomodoro` for usage and examples."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now we have a channel and configuration arguments
|
||||||
|
# Next check the user has authority to modify the timer
|
||||||
|
if not await guild_admin.run(ctx):
|
||||||
|
# TODO: The channel is a room they own?
|
||||||
|
return await ctx.error_reply(
|
||||||
|
"You need to be a guild admin to set up the pomodoro timers!"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the associated timer, if it exists
|
||||||
|
timer = Timer.fetch_timer(channel.id)
|
||||||
|
|
||||||
|
# Parse required action
|
||||||
|
if args.lower() == 'delete':
|
||||||
|
if timer:
|
||||||
|
await timer.destroy()
|
||||||
|
await ctx.embed_reply(
|
||||||
|
"Destroyed the timer in {}.".format(channel.mention)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await ctx.error_reply(
|
||||||
|
"{} doesn't have a timer to delete!".format(channel.mention)
|
||||||
|
)
|
||||||
|
elif args or timer:
|
||||||
|
if args:
|
||||||
|
# Any provided arguments should be for setting up a new timer pattern
|
||||||
|
# Check the pomodoro channel exists
|
||||||
|
if not (timer and timer.text_channel) and not ctx.guild_settings.pomodoro_channel.value:
|
||||||
|
return await ctx.error_reply(
|
||||||
|
"Please set the pomodoro alerts channel first, "
|
||||||
|
f"with `{ctx.best_prefix}config pomodoro_channel <channel>`.\n"
|
||||||
|
f"For example: {ctx.best_prefix}config pomodoro_channel {ctx.ch.mention}"
|
||||||
|
)
|
||||||
|
# First validate input
|
||||||
|
try:
|
||||||
|
# Ensure no trailing commas
|
||||||
|
args = args.strip(',')
|
||||||
|
if ',' not in args:
|
||||||
|
raise SafeCancellation("Couldn't parse work and break times!")
|
||||||
|
|
||||||
|
timesplits = args.split(',', maxsplit=1)
|
||||||
|
if not timesplits[0].isdigit() or len(timesplits[0]) > 3:
|
||||||
|
raise SafeCancellation(f"Couldn't parse the provided work period length `{timesplits[0]}`.")
|
||||||
|
|
||||||
|
breaksplits = timesplits[1].split(maxsplit=1)
|
||||||
|
if not breaksplits[0].isdigit() or len(breaksplits[0]) > 3:
|
||||||
|
raise SafeCancellation(f"Couldn't parse the provided break period length `{breaksplits[0]}`.")
|
||||||
|
except SafeCancellation as e:
|
||||||
|
usage = discord.Embed(
|
||||||
|
title="Couldn't understand arguments!",
|
||||||
|
colour=discord.Colour.red()
|
||||||
|
)
|
||||||
|
usage.add_field(
|
||||||
|
name="Usage",
|
||||||
|
value=(
|
||||||
|
f"`{ctx.best_prefix}{ctx.alias} [channelid] <work time>, <break time> [channel name template]"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
usage.add_field(
|
||||||
|
name="Examples",
|
||||||
|
value=(
|
||||||
|
f"`{ctx.best_prefix}{ctx.alias} 50, 10`\n"
|
||||||
|
f"`{ctx.best_prefix}{ctx.alias} {channel.id} 50, 10`\n"
|
||||||
|
f"`{ctx.best_prefix}{ctx.alias} {channel.id} 50, 10 {{remaining}} - {channel.name}`\n"
|
||||||
|
),
|
||||||
|
inline=False
|
||||||
|
)
|
||||||
|
usage.set_footer(
|
||||||
|
text=f"For detailed usage and examples see {ctx.best_prefix}help pomodoro"
|
||||||
|
)
|
||||||
|
if e.msg:
|
||||||
|
usage.description = e.msg
|
||||||
|
return await ctx.reply(embed=usage)
|
||||||
|
|
||||||
|
# Input validation complete, assign values
|
||||||
|
focus_length = int(timesplits[0])
|
||||||
|
break_length = int(breaksplits[0])
|
||||||
|
channelname = breaksplits[1].strip() if len(breaksplits) > 1 else None
|
||||||
|
|
||||||
|
# Check the stages aren't too short
|
||||||
|
if focus_length < 5:
|
||||||
|
return await ctx.error_reply("The focus duration must be at least 5 minutes!")
|
||||||
|
if break_length < 5:
|
||||||
|
return await ctx.error_reply("The break duration must be at least 5 minutes!")
|
||||||
|
|
||||||
|
# Create or update the timer
|
||||||
|
if not timer:
|
||||||
|
# Create timer
|
||||||
|
# First check number of timers
|
||||||
|
timers = Timer.fetch_guild_timers(ctx.guild.id)
|
||||||
|
if len(timers) >= MAX_TIMERS_PER_GUILD:
|
||||||
|
return await ctx.error_reply(
|
||||||
|
"Cannot create another timer!\n"
|
||||||
|
"This server already has the maximum of `{}` timers.".format(MAX_TIMERS_PER_GUILD)
|
||||||
|
)
|
||||||
|
# First check permissions
|
||||||
|
if not channel.permissions_for(ctx.guild.me).send_messages:
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Could not create timer!",
|
||||||
|
description=f"I do not have sufficient guild permissions to join {channel.mention}!",
|
||||||
|
colour=discord.Colour.red()
|
||||||
|
)
|
||||||
|
return await ctx.reply(embed=embed)
|
||||||
|
|
||||||
|
# Create timer
|
||||||
|
timer = Timer.create(
|
||||||
|
channel,
|
||||||
|
focus_length * 60,
|
||||||
|
break_length * 60,
|
||||||
|
channel_name=channelname or None,
|
||||||
|
pretty_name=channel.name
|
||||||
|
)
|
||||||
|
timer.last_seen = {
|
||||||
|
member.id: utc_now()
|
||||||
|
for member in timer.members
|
||||||
|
}
|
||||||
|
timer.runloop()
|
||||||
|
|
||||||
|
# Post a new status message
|
||||||
|
await timer.update_last_status()
|
||||||
|
|
||||||
|
await ctx.embed_reply(
|
||||||
|
f"Started a timer in {channel.mention} with **{focus_length}** minutes focus "
|
||||||
|
f"and **{break_length}** minutes break."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Update timer and restart
|
||||||
|
stage = timer.current_stage
|
||||||
|
|
||||||
|
timer.last_seen = {
|
||||||
|
member.id: utc_now()
|
||||||
|
for member in timer.members
|
||||||
|
}
|
||||||
|
|
||||||
|
with timer.data.batch_update():
|
||||||
|
timer.data.focus_length = focus_length * 60
|
||||||
|
timer.data.break_length = break_length * 60
|
||||||
|
timer.data.last_started = utc_now()
|
||||||
|
if channelname:
|
||||||
|
timer.data.channel_name = channelname
|
||||||
|
|
||||||
|
await timer.notify_change_stage(stage, timer.current_stage)
|
||||||
|
timer.runloop()
|
||||||
|
|
||||||
|
await ctx.embed_reply(
|
||||||
|
f"Started a timer in {channel.mention} with **{focus_length}** "
|
||||||
|
f"minutes focus and **{break_length}** minutes break."
|
||||||
|
)
|
||||||
|
|
||||||
|
to_set = []
|
||||||
|
if flags['name']:
|
||||||
|
# Handle name update
|
||||||
|
to_set.append((
|
||||||
|
'pretty_name',
|
||||||
|
flags['name'],
|
||||||
|
f"The timer will now appear as `{flags['name']}` in the status."
|
||||||
|
))
|
||||||
|
if flags['threshold']:
|
||||||
|
# Handle threshold update
|
||||||
|
if not flags['threshold'].isdigit():
|
||||||
|
return await ctx.error_reply(
|
||||||
|
"The provided threshold must be a number!"
|
||||||
|
)
|
||||||
|
to_set.append((
|
||||||
|
'inactivity_threshold',
|
||||||
|
int(flags['threshold']),
|
||||||
|
"Members will be unsubscribed after being inactive for more than `{}` focus+break stages.".format(
|
||||||
|
flags['threshold']
|
||||||
|
)
|
||||||
|
))
|
||||||
|
if flags['channelname']:
|
||||||
|
# Handle channel name update
|
||||||
|
to_set.append((
|
||||||
|
'channel_name',
|
||||||
|
flags['channelname'],
|
||||||
|
f"The voice channel name template is now `{flags['channelname']}`."
|
||||||
|
))
|
||||||
|
if flags['text']:
|
||||||
|
# Handle text channel update
|
||||||
|
flag = flags['text']
|
||||||
|
if flag.lower() == 'none':
|
||||||
|
# Check if there is a default channel
|
||||||
|
channel = ctx.guild_settings.pomodoro_channel.value
|
||||||
|
if channel:
|
||||||
|
# Unset the channel to the default
|
||||||
|
msg = f"The custom text channel has been unset! (Alerts will be sent to {channel.mention})"
|
||||||
|
to_set.append((
|
||||||
|
'text_channelid',
|
||||||
|
None,
|
||||||
|
msg
|
||||||
|
))
|
||||||
|
# Remove the last reaction message and send a new one
|
||||||
|
timer.reaction_message = None
|
||||||
|
# Ensure this happens after the data update
|
||||||
|
asyncio.create_task(timer.update_last_status())
|
||||||
|
else:
|
||||||
|
return await ctx.error_reply(
|
||||||
|
"The text channel cannot be unset because there is no `pomodoro_channel` set up!\n"
|
||||||
|
f"See `{ctx.best_prefix}config pomodoro_channel` for setting a default pomodoro channel."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Attempt to parse the provided channel
|
||||||
|
channel = await ctx.find_channel(flag, interactive=True, chan_type=discord.ChannelType.text)
|
||||||
|
if channel:
|
||||||
|
if not channel.permissions_for(ctx.guild.me).send_messages:
|
||||||
|
return await ctx.error_reply(
|
||||||
|
f"Cannot send pomodoro alerts to {channel.mention}! "
|
||||||
|
"I don't have permission to send messages there."
|
||||||
|
)
|
||||||
|
to_set.append((
|
||||||
|
'text_channelid',
|
||||||
|
channel.id,
|
||||||
|
f"Timer alerts and updates will now be sent to {channel.mention}."
|
||||||
|
))
|
||||||
|
# Remove the last reaction message and send a new one
|
||||||
|
timer.reaction_message = None
|
||||||
|
# Ensure this happens after the data update
|
||||||
|
asyncio.create_task(timer.update_last_status())
|
||||||
|
else:
|
||||||
|
# Ack has already been sent, just ignore
|
||||||
|
return
|
||||||
|
|
||||||
|
if to_set:
|
||||||
|
to_update = {item[0]: item[1] for item in to_set}
|
||||||
|
timer.data.update(**to_update)
|
||||||
|
desc = '\n'.join(f"{tick} {item[2]}" for item in to_set)
|
||||||
|
embed = discord.Embed(
|
||||||
|
title=f"Timer option{'s' if len(to_update) > 1 else ''} updated!",
|
||||||
|
description=desc,
|
||||||
|
colour=discord.Colour.green()
|
||||||
|
)
|
||||||
|
await ctx.reply(embed=embed)
|
||||||
|
else:
|
||||||
|
# Flags were provided, but there is no timer, and no timer was created
|
||||||
|
await ctx.error_reply(
|
||||||
|
f"No timer exists in {channel.mention} to set up!\n"
|
||||||
|
f"Create one with, for example, ```{ctx.best_prefix}pomodoro {channel.id} 50, 10```"
|
||||||
|
f"See `{ctx.best_prefix}help pomodoro` for more examples and usage."
|
||||||
|
)
|
||||||
15
bot/modules/study/timers/data.py
Normal file
15
bot/modules/study/timers/data.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from data import RowTable
|
||||||
|
|
||||||
|
|
||||||
|
timers = RowTable(
|
||||||
|
'timers',
|
||||||
|
('channelid', 'guildid',
|
||||||
|
'text_channelid',
|
||||||
|
'focus_length', 'break_length',
|
||||||
|
'inactivity_threshold',
|
||||||
|
'last_started',
|
||||||
|
'text_channelid',
|
||||||
|
'channel_name', 'pretty_name'),
|
||||||
|
'channelid',
|
||||||
|
cache={}
|
||||||
|
)
|
||||||
47
bot/modules/study/timers/settings.py
Normal file
47
bot/modules/study/timers/settings.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from settings import GuildSettings, GuildSetting
|
||||||
|
import settings
|
||||||
|
|
||||||
|
from . import Timer
|
||||||
|
|
||||||
|
|
||||||
|
@GuildSettings.attach_setting
|
||||||
|
class pomodoro_channel(settings.TextChannel, GuildSetting):
|
||||||
|
category = "Study Tracking"
|
||||||
|
|
||||||
|
attr_name = "pomodoro_channel"
|
||||||
|
_data_column = "pomodoro_channel"
|
||||||
|
|
||||||
|
display_name = "pomodoro_channel"
|
||||||
|
desc = "Channel to send pomodoro timer status updates and alerts."
|
||||||
|
|
||||||
|
_default = None
|
||||||
|
|
||||||
|
long_desc = (
|
||||||
|
"Channel to send pomodoro status updates to.\n"
|
||||||
|
"Members studying in rooms with an attached timer will need to be able to see "
|
||||||
|
"this channel to get notifications and react to the status messages."
|
||||||
|
)
|
||||||
|
_accepts = "Any text channel I can write to, or `None` to unset."
|
||||||
|
|
||||||
|
@property
|
||||||
|
def success_response(self):
|
||||||
|
timers = Timer.fetch_guild_timers(self.id)
|
||||||
|
if self.value:
|
||||||
|
for timer in timers:
|
||||||
|
if timer.reaction_message and timer.reaction_message.channel != self.value:
|
||||||
|
timer.reaction_message = None
|
||||||
|
asyncio.create_task(timer.update_last_status())
|
||||||
|
return f"The pomodoro alerts and updates will now be sent to {self.value.mention}"
|
||||||
|
else:
|
||||||
|
deleted = 0
|
||||||
|
for timer in timers:
|
||||||
|
if not timer.text_channel:
|
||||||
|
deleted += 1
|
||||||
|
asyncio.create_task(timer.destroy())
|
||||||
|
|
||||||
|
msg = "The pomodoro alert channel has been unset."
|
||||||
|
if deleted:
|
||||||
|
msg += f" `{deleted}` timers were subsequently deactivated."
|
||||||
|
return msg
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
from . import data
|
from . import data
|
||||||
from . import settings
|
from . import settings
|
||||||
from . import session_tracker
|
from . import session_tracker
|
||||||
|
from . import commands
|
||||||
|
|||||||
167
bot/modules/study/tracking/commands.py
Normal file
167
bot/modules/study/tracking/commands.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
from cmdClient import Context
|
||||||
|
from cmdClient.checks import in_guild
|
||||||
|
|
||||||
|
from core import Lion
|
||||||
|
from wards import is_guild_admin
|
||||||
|
|
||||||
|
from ..module import module
|
||||||
|
|
||||||
|
|
||||||
|
MAX_TAG_LENGTH = 10
|
||||||
|
|
||||||
|
|
||||||
|
@module.cmd(
|
||||||
|
"now",
|
||||||
|
group="🆕 Pomodoro",
|
||||||
|
desc="What are you working on?",
|
||||||
|
aliases=('studying', 'workingon'),
|
||||||
|
flags=('clear', 'new')
|
||||||
|
)
|
||||||
|
@in_guild()
|
||||||
|
async def cmd_now(ctx: Context, flags):
|
||||||
|
"""
|
||||||
|
Usage``:
|
||||||
|
{prefix}now [tag]
|
||||||
|
{prefix}now @mention
|
||||||
|
{prefix}now --clear
|
||||||
|
Description:
|
||||||
|
Describe the subject or goal you are working on this session with, for example, `{prefix}now Maths`.
|
||||||
|
Mention someone else to view what they are working on!
|
||||||
|
Flags::
|
||||||
|
clear: Remove your current tag.
|
||||||
|
Examples:
|
||||||
|
> {prefix}now Biology
|
||||||
|
> {prefix}now {ctx.author.mention}
|
||||||
|
"""
|
||||||
|
if flags['clear']:
|
||||||
|
if ctx.msg.mentions and is_guild_admin(ctx.author):
|
||||||
|
# Assume an admin is trying to clear another user's tag
|
||||||
|
for target in ctx.msg.mentions:
|
||||||
|
lion = Lion.fetch(ctx.guild.id, target.id)
|
||||||
|
if lion.session:
|
||||||
|
lion.session.data.tag = None
|
||||||
|
|
||||||
|
if len(ctx.msg.mentions) == 1:
|
||||||
|
await ctx.embed_reply(
|
||||||
|
f"Cleared session tags for {ctx.msg.mentions[0].mention}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await ctx.embed_reply(
|
||||||
|
f"Cleared session tags for:\n{', '.join(target.mention for target in ctx.msg.mentions)}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Assume the user is clearing their own session tag
|
||||||
|
if (session := ctx.alion.session):
|
||||||
|
session.data.tag = None
|
||||||
|
await ctx.embed_reply(
|
||||||
|
"Removed your session study tag!"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await ctx.embed_reply(
|
||||||
|
"You aren't studying right now, so there is nothing to clear!"
|
||||||
|
)
|
||||||
|
elif ctx.args:
|
||||||
|
if ctx.msg.mentions:
|
||||||
|
# Assume peeking at user's current session
|
||||||
|
|
||||||
|
# Smoll easter egg
|
||||||
|
target = ctx.msg.mentions[0]
|
||||||
|
if target == ctx.guild.me:
|
||||||
|
student_count, guild_count = ctx.client.data.current_sessions.select_one_where(
|
||||||
|
select_columns=("COUNT(*) AS studying_count", "COUNT(DISTINCT(guildid)) AS guild_count"),
|
||||||
|
)
|
||||||
|
if ctx.alion.session:
|
||||||
|
if (tag := ctx.alion.session.data.tag):
|
||||||
|
tail = f"Good luck with your **{tag}**!"
|
||||||
|
else:
|
||||||
|
tail = "Good luck with your study, I believe in you!"
|
||||||
|
else:
|
||||||
|
tail = "Do you want to join? Hop in a study channel and let's get to work!"
|
||||||
|
return await ctx.embed_reply(
|
||||||
|
"Thanks for asking!\n"
|
||||||
|
f"I'm just helping out the **{student_count}** "
|
||||||
|
f"dedicated people currently working across **{guild_count}** fun communities!\n"
|
||||||
|
f"{tail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
lion = Lion.fetch(ctx.guild.id, target.id)
|
||||||
|
if not lion.session:
|
||||||
|
await ctx.embed_reply(
|
||||||
|
f"{target.mention} isn't working right now!"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
duration = lion.session.duration
|
||||||
|
if duration > 3600:
|
||||||
|
dur_str = "{}h {}m".format(
|
||||||
|
int(duration // 3600),
|
||||||
|
int((duration % 3600) // 60)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
dur_str = "{} minutes".format(int((duration % 3600) // 60))
|
||||||
|
|
||||||
|
if not lion.session.data.tag:
|
||||||
|
await ctx.embed_reply(
|
||||||
|
f"{target.mention} has been working in <#{lion.session.data.channelid}> for **{dur_str}**!"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await ctx.embed_reply(
|
||||||
|
f"{target.mention} has been working on **{lion.session.data.tag}**"
|
||||||
|
f" in <#{lion.session.data.channelid}> for **{dur_str}**!"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Assume setting tag
|
||||||
|
tag = ctx.args
|
||||||
|
|
||||||
|
if not (session := ctx.alion.session):
|
||||||
|
return await ctx.error_reply(
|
||||||
|
"You aren't working right now! Join a study channel and try again!"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(tag) > MAX_TAG_LENGTH:
|
||||||
|
return await ctx.error_reply(
|
||||||
|
f"Please keep your tag under `{MAX_TAG_LENGTH}` characters long!"
|
||||||
|
)
|
||||||
|
|
||||||
|
old_tag = session.data.tag
|
||||||
|
session.data.tag = tag
|
||||||
|
if old_tag:
|
||||||
|
await ctx.embed_reply(
|
||||||
|
f"You have updated your session study tag. Good luck with **{tag}**!"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await ctx.embed_reply(
|
||||||
|
"You have set your session study tag!\nIt will be reset when you leave, or join another channel.\n"
|
||||||
|
f"Good luck with **{tag}**!"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# View current session, stats, and guide.
|
||||||
|
if (session := ctx.alion.session):
|
||||||
|
duration = session.duration
|
||||||
|
if duration > 3600:
|
||||||
|
dur_str = "{}h {}m".format(
|
||||||
|
int(duration // 3600),
|
||||||
|
int((duration % 3600) // 60)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
dur_str = "{} minutes".format(int((duration % 3600) / 60))
|
||||||
|
if not session.data.tag:
|
||||||
|
await ctx.embed_reply(
|
||||||
|
f"You have been working in <#{session.data.channelid}> for **{dur_str}**!\n"
|
||||||
|
f"Describe what you are working on with "
|
||||||
|
f"`{ctx.best_prefix}now <tag>`, e.g. `{ctx.best_prefix}now Maths`"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await ctx.embed_reply(
|
||||||
|
f"You have been working on **{session.data.tag}**"
|
||||||
|
f" in <#{session.data.channelid}> for **{dur_str}**!"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await ctx.embed_reply(
|
||||||
|
f"Join a study channel and describe what you are working on with e.g. `{ctx.best_prefix}now Maths`"
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: Favourite tags listing
|
||||||
|
# Get tag history ranking top 5
|
||||||
|
# If there are any, display top 5
|
||||||
|
# Otherwise do nothing
|
||||||
|
...
|
||||||
@@ -20,6 +20,7 @@ session_history = Table('session_history')
|
|||||||
current_sessions = RowTable(
|
current_sessions = RowTable(
|
||||||
'current_sessions',
|
'current_sessions',
|
||||||
('guildid', 'userid', 'channelid', 'channel_type',
|
('guildid', 'userid', 'channelid', 'channel_type',
|
||||||
|
'rating', 'tag',
|
||||||
'start_time',
|
'start_time',
|
||||||
'live_duration', 'live_start',
|
'live_duration', 'live_start',
|
||||||
'stream_duration', 'stream_start',
|
'stream_duration', 'stream_start',
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
from cmdClient import check
|
from cmdClient import check
|
||||||
from cmdClient.checks import in_guild
|
from cmdClient.checks import in_guild
|
||||||
|
|
||||||
|
from meta import client
|
||||||
|
|
||||||
from data import tables
|
from data import tables
|
||||||
|
|
||||||
|
|
||||||
def is_guild_admin(member):
|
def is_guild_admin(member):
|
||||||
|
if member.id in client.owners:
|
||||||
|
return True
|
||||||
|
|
||||||
# First check guild admin permissions
|
# First check guild admin permissions
|
||||||
admin = member.guild_permissions.administrator
|
admin = member.guild_permissions.administrator
|
||||||
|
|
||||||
|
|||||||
67
data/migration/v7-v8/migration.sql
Normal file
67
data/migration/v7-v8/migration.sql
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
ALTER TABLE guild_config ADD COLUMN pomodoro_channel BIGINT;
|
||||||
|
|
||||||
|
-- Timer Data {{{
|
||||||
|
create TABLE timers(
|
||||||
|
channelid BIGINT PRIMARY KEY,
|
||||||
|
guildid BIGINT NOT NULL REFERENCES guild_config (guildid),
|
||||||
|
text_channelid BIGINT,
|
||||||
|
focus_length INTEGER NOT NULL,
|
||||||
|
break_length INTEGER NOT NULL,
|
||||||
|
last_started TIMESTAMPTZ NOT NULL,
|
||||||
|
inactivity_threshold INTEGER,
|
||||||
|
channel_name TEXT,
|
||||||
|
pretty_name TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX timers_guilds ON timers (guildid);
|
||||||
|
-- }}}
|
||||||
|
|
||||||
|
-- Session tags {{{
|
||||||
|
ALTER TABLE current_sessions
|
||||||
|
ADD COLUMN rating INTEGER,
|
||||||
|
ADD COLUMN tag TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE session_history
|
||||||
|
ADD COLUMN rating INTEGER,
|
||||||
|
ADD COLUMN tag TEXT;
|
||||||
|
|
||||||
|
DROP FUNCTION close_study_session(_guildid BIGINT, _userid BIGINT);
|
||||||
|
|
||||||
|
CREATE FUNCTION close_study_session(_guildid BIGINT, _userid BIGINT)
|
||||||
|
RETURNS SETOF members
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
WITH
|
||||||
|
current_sesh AS (
|
||||||
|
DELETE FROM current_sessions
|
||||||
|
WHERE guildid=_guildid AND userid=_userid
|
||||||
|
RETURNING
|
||||||
|
*,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - start_time)) AS total_duration,
|
||||||
|
stream_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - stream_start)), 0) AS total_stream_duration,
|
||||||
|
video_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - video_start)), 0) AS total_video_duration,
|
||||||
|
live_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - live_start)), 0) AS total_live_duration
|
||||||
|
), saved_sesh AS (
|
||||||
|
INSERT INTO session_history (
|
||||||
|
guildid, userid, channelid, rating, tag, channel_type, start_time,
|
||||||
|
duration, stream_duration, video_duration, live_duration,
|
||||||
|
coins_earned
|
||||||
|
) SELECT
|
||||||
|
guildid, userid, channelid, rating, tag, channel_type, start_time,
|
||||||
|
total_duration, total_stream_duration, total_video_duration, total_live_duration,
|
||||||
|
(total_duration * hourly_coins + live_duration * hourly_live_coins) / 3600
|
||||||
|
FROM current_sesh
|
||||||
|
RETURNING *
|
||||||
|
)
|
||||||
|
UPDATE members
|
||||||
|
SET
|
||||||
|
tracked_time=(tracked_time + saved_sesh.duration),
|
||||||
|
coins=(coins + saved_sesh.coins_earned)
|
||||||
|
FROM saved_sesh
|
||||||
|
WHERE members.guildid=saved_sesh.guildid AND members.userid=saved_sesh.userid
|
||||||
|
RETURNING members.*;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE PLPGSQL;
|
||||||
|
-- }}}
|
||||||
|
|
||||||
|
INSERT INTO VersionHistory (version, author) VALUES (8, 'v7-v8 migration');
|
||||||
@@ -4,7 +4,7 @@ CREATE TABLE VersionHistory(
|
|||||||
time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
author TEXT
|
author TEXT
|
||||||
);
|
);
|
||||||
INSERT INTO VersionHistory (version, author) VALUES (7, 'Initial Creation');
|
INSERT INTO VersionHistory (version, author) VALUES (8, 'Initial Creation');
|
||||||
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION update_timestamp_column()
|
CREATE OR REPLACE FUNCTION update_timestamp_column()
|
||||||
@@ -78,7 +78,8 @@ CREATE TABLE guild_config(
|
|||||||
returning_message TEXT,
|
returning_message TEXT,
|
||||||
starting_funds INTEGER,
|
starting_funds INTEGER,
|
||||||
persist_roles BOOLEAN,
|
persist_roles BOOLEAN,
|
||||||
daily_study_cap INTEGER
|
daily_study_cap INTEGER,
|
||||||
|
pomodoro_channel BIGINT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE ignored_members(
|
CREATE TABLE ignored_members(
|
||||||
@@ -426,6 +427,8 @@ CREATE TABLE session_history(
|
|||||||
userid BIGINT NOT NULL,
|
userid BIGINT NOT NULL,
|
||||||
channelid BIGINT,
|
channelid BIGINT,
|
||||||
channel_type SessionChannelType,
|
channel_type SessionChannelType,
|
||||||
|
rating INTEGER,
|
||||||
|
tag TEXT,
|
||||||
start_time TIMESTAMPTZ NOT NULL,
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
duration INTEGER NOT NULL,
|
duration INTEGER NOT NULL,
|
||||||
coins_earned INTEGER NOT NULL,
|
coins_earned INTEGER NOT NULL,
|
||||||
@@ -441,6 +444,8 @@ CREATE TABLE current_sessions(
|
|||||||
userid BIGINT NOT NULL,
|
userid BIGINT NOT NULL,
|
||||||
channelid BIGINT,
|
channelid BIGINT,
|
||||||
channel_type SessionChannelType,
|
channel_type SessionChannelType,
|
||||||
|
rating INTEGER,
|
||||||
|
tag TEXT,
|
||||||
start_time TIMESTAMPTZ DEFAULT now(),
|
start_time TIMESTAMPTZ DEFAULT now(),
|
||||||
live_duration INTEGER DEFAULT 0,
|
live_duration INTEGER DEFAULT 0,
|
||||||
live_start TIMESTAMPTZ,
|
live_start TIMESTAMPTZ,
|
||||||
@@ -509,11 +514,11 @@ AS $$
|
|||||||
live_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - live_start)), 0) AS total_live_duration
|
live_duration + COALESCE(EXTRACT(EPOCH FROM (NOW() - live_start)), 0) AS total_live_duration
|
||||||
), saved_sesh AS (
|
), saved_sesh AS (
|
||||||
INSERT INTO session_history (
|
INSERT INTO session_history (
|
||||||
guildid, userid, channelid, channel_type, start_time,
|
guildid, userid, channelid, rating, tag, channel_type, start_time,
|
||||||
duration, stream_duration, video_duration, live_duration,
|
duration, stream_duration, video_duration, live_duration,
|
||||||
coins_earned
|
coins_earned
|
||||||
) SELECT
|
) SELECT
|
||||||
guildid, userid, channelid, channel_type, start_time,
|
guildid, userid, channelid, rating, tag, channel_type, start_time,
|
||||||
total_duration, total_stream_duration, total_video_duration, total_live_duration,
|
total_duration, total_stream_duration, total_video_duration, total_live_duration,
|
||||||
(total_duration * hourly_coins + live_duration * hourly_live_coins) / 3600
|
(total_duration * hourly_coins + live_duration * hourly_live_coins) / 3600
|
||||||
FROM current_sesh
|
FROM current_sesh
|
||||||
@@ -746,4 +751,19 @@ CREATE INDEX member_monthly_goal_tasks_members_monthly ON member_monthly_goal_ta
|
|||||||
|
|
||||||
-- }}}
|
-- }}}
|
||||||
|
|
||||||
|
-- Timer Data {{{
|
||||||
|
create TABLE timers(
|
||||||
|
channelid BIGINT PRIMARY KEY,
|
||||||
|
guildid BIGINT NOT NULL REFERENCES guild_config (guildid),
|
||||||
|
text_channelid BIGINT,
|
||||||
|
focus_length INTEGER NOT NULL,
|
||||||
|
break_length INTEGER NOT NULL,
|
||||||
|
last_started TIMESTAMPTZ NOT NULL,
|
||||||
|
inactivity_threshold INTEGER,
|
||||||
|
channel_name TEXT,
|
||||||
|
pretty_name TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX timers_guilds ON timers (guildid);
|
||||||
|
-- }}}
|
||||||
|
|
||||||
-- vim: set fdm=marker:
|
-- vim: set fdm=marker:
|
||||||
|
|||||||
Reference in New Issue
Block a user