rewrite: Initial rewrite skeleton.
Remove modules that will no longer be required. Move pending modules to pending-rewrite folders.
This commit is contained in:
16
bot/modules/pending-rewrite/__init__.py
Normal file
16
bot/modules/pending-rewrite/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from .sysadmin import *
|
||||
from .guild_admin import *
|
||||
from .meta import *
|
||||
from .economy import *
|
||||
from .study import *
|
||||
from .stats import *
|
||||
from .user_config import *
|
||||
from .workout import *
|
||||
from .todo import *
|
||||
from .topgg import *
|
||||
from .reminders import *
|
||||
from .renting import *
|
||||
from .moderation import *
|
||||
from .accountability import *
|
||||
from .plugins import *
|
||||
from .sponsors import *
|
||||
477
bot/modules/pending-rewrite/accountability/TimeSlot.py
Normal file
477
bot/modules/pending-rewrite/accountability/TimeSlot.py
Normal file
@@ -0,0 +1,477 @@
|
||||
from typing import List, Dict
|
||||
import datetime
|
||||
import discord
|
||||
import asyncio
|
||||
import random
|
||||
|
||||
from settings import GuildSettings
|
||||
from utils.lib import tick, cross
|
||||
from core import Lion
|
||||
from meta import client
|
||||
|
||||
from .lib import utc_now
|
||||
from .data import accountability_members, accountability_rooms
|
||||
|
||||
|
||||
class SlotMember:
|
||||
"""
|
||||
Class representing a member booked into an accountability room.
|
||||
Mostly acts as an interface to the corresponding TableRow.
|
||||
But also stores the discord.Member associated, and has several computed properties.
|
||||
The member may be None.
|
||||
"""
|
||||
___slots__ = ('slotid', 'userid', 'guild')
|
||||
|
||||
def __init__(self, slotid, userid, guild):
|
||||
self.slotid = slotid
|
||||
self.userid = userid
|
||||
self.guild = guild
|
||||
|
||||
self._member = None
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
return (self.slotid, self.userid)
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return accountability_members.fetch(self.key)
|
||||
|
||||
@property
|
||||
def member(self):
|
||||
return self.guild.get_member(self.userid)
|
||||
|
||||
@property
|
||||
def has_attended(self):
|
||||
return self.data.duration > 0 or self.data.last_joined_at
|
||||
|
||||
|
||||
class TimeSlot:
|
||||
"""
|
||||
Class representing an accountability slot.
|
||||
"""
|
||||
__slots__ = (
|
||||
'guild',
|
||||
'start_time',
|
||||
'data',
|
||||
'lobby',
|
||||
'category',
|
||||
'channel',
|
||||
'message',
|
||||
'members'
|
||||
)
|
||||
|
||||
slots = {}
|
||||
|
||||
_member_overwrite = discord.PermissionOverwrite(
|
||||
view_channel=True,
|
||||
connect=True
|
||||
)
|
||||
|
||||
_everyone_overwrite = discord.PermissionOverwrite(
|
||||
view_channel=False,
|
||||
connect=False,
|
||||
speak=False
|
||||
)
|
||||
|
||||
happy_lion = "https://media.discordapp.net/stickers/898266283559227422.png"
|
||||
sad_lion = "https://media.discordapp.net/stickers/898266548421148723.png"
|
||||
|
||||
def __init__(self, guild, start_time, data=None):
|
||||
self.guild: discord.Guild = guild
|
||||
self.start_time: datetime.datetime = start_time
|
||||
self.data = data
|
||||
|
||||
self.lobby: discord.TextChannel = None # Text channel to post the slot status
|
||||
self.category: discord.CategoryChannel = None # Category to create the voice rooms in
|
||||
self.channel: discord.VoiceChannel = None # Text channel associated with this time slot
|
||||
self.message: discord.Message = None # Status message in lobby channel
|
||||
|
||||
self.members: Dict[int, SlotMember] = {} # memberid -> SlotMember
|
||||
|
||||
@property
|
||||
def open_embed(self):
|
||||
timestamp = int(self.start_time.timestamp())
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Session <t:{}:t> - <t:{}:t>".format(
|
||||
timestamp, timestamp + 3600
|
||||
),
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=self.start_time
|
||||
).set_footer(
|
||||
text="About to start!\nJoin the session with {}schedule book".format(client.prefix)
|
||||
)
|
||||
|
||||
if self.members:
|
||||
embed.description = "Starting <t:{}:R>.".format(timestamp)
|
||||
embed.add_field(
|
||||
name="Members",
|
||||
value=(
|
||||
', '.join('<@{}>'.format(key) for key in self.members.keys())
|
||||
)
|
||||
)
|
||||
else:
|
||||
embed.description = "No members scheduled this session!"
|
||||
|
||||
return embed
|
||||
|
||||
@property
|
||||
def status_embed(self):
|
||||
timestamp = int(self.start_time.timestamp())
|
||||
embed = discord.Embed(
|
||||
title="Session <t:{}:t> - <t:{}:t>".format(
|
||||
timestamp, timestamp + 3600
|
||||
),
|
||||
description="Finishing <t:{}:R>.".format(timestamp + 3600),
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=self.start_time
|
||||
).set_footer(text="Join the next session using {}schedule book".format(client.prefix))
|
||||
|
||||
if self.members:
|
||||
classifications = {
|
||||
"Attended": [],
|
||||
"Studying Now": [],
|
||||
"Waiting for": []
|
||||
}
|
||||
for memid, mem in self.members.items():
|
||||
mention = '<@{}>'.format(memid)
|
||||
if not mem.has_attended:
|
||||
classifications["Waiting for"].append(mention)
|
||||
elif mem.member in self.channel.members:
|
||||
classifications["Studying Now"].append(mention)
|
||||
else:
|
||||
classifications["Attended"].append(mention)
|
||||
|
||||
all_attended = all(mem.has_attended for mem in self.members.values())
|
||||
bonus_line = (
|
||||
"{tick} Everyone attended, and will get a `{bonus} LC` bonus!".format(
|
||||
tick=tick,
|
||||
bonus=GuildSettings(self.guild.id).accountability_bonus.value
|
||||
)
|
||||
if all_attended else ""
|
||||
)
|
||||
if all_attended:
|
||||
embed.set_thumbnail(url=self.happy_lion)
|
||||
|
||||
embed.description += "\n" + bonus_line
|
||||
for field, value in classifications.items():
|
||||
if value:
|
||||
embed.add_field(name=field, value='\n'.join(value))
|
||||
else:
|
||||
embed.description = "No members scheduled this session!"
|
||||
|
||||
return embed
|
||||
|
||||
@property
|
||||
def summary_embed(self):
|
||||
timestamp = int(self.start_time.timestamp())
|
||||
embed = discord.Embed(
|
||||
title="Session <t:{}:t> - <t:{}:t>".format(
|
||||
timestamp, timestamp + 3600
|
||||
),
|
||||
description="Finished <t:{}:R>.".format(timestamp + 3600),
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=self.start_time
|
||||
).set_footer(text="Completed!")
|
||||
|
||||
if self.members:
|
||||
classifications = {
|
||||
"Attended": [],
|
||||
"Missing": []
|
||||
}
|
||||
for memid, mem in sorted(self.members.items(), key=lambda mem: mem[1].data.duration, reverse=True):
|
||||
mention = '<@{}>'.format(memid)
|
||||
if mem.has_attended:
|
||||
classifications["Attended"].append(
|
||||
"{} ({}%)".format(mention, (mem.data.duration * 100) // 3600)
|
||||
)
|
||||
else:
|
||||
classifications["Missing"].append(mention)
|
||||
|
||||
all_attended = all(mem.has_attended for mem in self.members.values())
|
||||
|
||||
bonus_line = (
|
||||
"{tick} Everyone attended, and received a `{bonus} LC` bonus!".format(
|
||||
tick=tick,
|
||||
bonus=GuildSettings(self.guild.id).accountability_bonus.value
|
||||
)
|
||||
if all_attended else
|
||||
"{cross} Some members missed the session, so everyone missed out on the bonus!".format(
|
||||
cross=cross
|
||||
)
|
||||
)
|
||||
if all_attended:
|
||||
embed.set_thumbnail(url=self.happy_lion)
|
||||
else:
|
||||
embed.set_thumbnail(url=self.sad_lion)
|
||||
|
||||
embed.description += "\n" + bonus_line
|
||||
for field, value in classifications.items():
|
||||
if value:
|
||||
embed.add_field(name=field, value='\n'.join(value))
|
||||
else:
|
||||
embed.description = "No members scheduled this session!"
|
||||
|
||||
return embed
|
||||
|
||||
def load(self, memberids: List[int] = None):
|
||||
"""
|
||||
Load data and update applicable caches.
|
||||
"""
|
||||
if not self.guild:
|
||||
return self
|
||||
|
||||
# Load setting data
|
||||
self.category = GuildSettings(self.guild.id).accountability_category.value
|
||||
self.lobby = GuildSettings(self.guild.id).accountability_lobby.value
|
||||
|
||||
if self.data:
|
||||
# Load channel
|
||||
if self.data.channelid:
|
||||
self.channel = self.guild.get_channel(self.data.channelid)
|
||||
|
||||
# Load message
|
||||
if self.data.messageid and self.lobby:
|
||||
self.message = discord.PartialMessage(
|
||||
channel=self.lobby,
|
||||
id=self.data.messageid
|
||||
)
|
||||
|
||||
# Load members
|
||||
if memberids:
|
||||
self.members = {
|
||||
memberid: SlotMember(self.data.slotid, memberid, self.guild)
|
||||
for memberid in memberids
|
||||
}
|
||||
|
||||
return self
|
||||
|
||||
async def _reload_members(self, memberids=None):
|
||||
"""
|
||||
Reload the timeslot members from the provided list, or data.
|
||||
Also updates the channel overwrites if required.
|
||||
To be used before the session has started.
|
||||
"""
|
||||
if self.data:
|
||||
if memberids is None:
|
||||
member_rows = accountability_members.fetch_rows_where(slotid=self.data.slotid)
|
||||
memberids = [row.userid for row in member_rows]
|
||||
|
||||
self.members = members = {
|
||||
memberid: SlotMember(self.data.slotid, memberid, self.guild)
|
||||
for memberid in memberids
|
||||
}
|
||||
|
||||
if self.channel:
|
||||
# Check and potentially update overwrites
|
||||
current_overwrites = self.channel.overwrites
|
||||
overwrites = {
|
||||
mem.member: self._member_overwrite
|
||||
for mem in members.values()
|
||||
if mem.member
|
||||
}
|
||||
overwrites[self.guild.default_role] = self._everyone_overwrite
|
||||
if current_overwrites != overwrites:
|
||||
await self.channel.edit(overwrites=overwrites)
|
||||
|
||||
def _refresh(self):
|
||||
"""
|
||||
Refresh the stored data row and reload.
|
||||
"""
|
||||
rows = accountability_rooms.fetch_rows_where(
|
||||
guildid=self.guild.id,
|
||||
start_at=self.start_time
|
||||
)
|
||||
self.data = rows[0] if rows else None
|
||||
|
||||
memberids = []
|
||||
if self.data:
|
||||
member_rows = accountability_members.fetch_rows_where(
|
||||
slotid=self.data.slotid
|
||||
)
|
||||
memberids = [row.userid for row in member_rows]
|
||||
self.load(memberids=memberids)
|
||||
|
||||
async def open(self):
|
||||
"""
|
||||
Open the accountability room.
|
||||
Creates a new voice channel, and sends the status message.
|
||||
Event logs any issues.
|
||||
Adds the TimeSlot to cache.
|
||||
Returns the (channelid, messageid).
|
||||
"""
|
||||
# Cleanup any non-existent members
|
||||
for memid, mem in list(self.members.items()):
|
||||
if not mem.data or not mem.member:
|
||||
self.members.pop(memid)
|
||||
|
||||
# Calculate overwrites
|
||||
overwrites = {
|
||||
mem.member: self._member_overwrite
|
||||
for mem in self.members.values()
|
||||
}
|
||||
overwrites[self.guild.default_role] = self._everyone_overwrite
|
||||
|
||||
# Create the channel. Log and bail if something went wrong.
|
||||
if self.data and not self.channel:
|
||||
try:
|
||||
self.channel = await self.guild.create_voice_channel(
|
||||
"Upcoming Scheduled Session",
|
||||
overwrites=overwrites,
|
||||
category=self.category
|
||||
)
|
||||
except discord.HTTPException:
|
||||
GuildSettings(self.guild.id).event_log.log(
|
||||
"Failed to create the scheduled session voice channel. Skipping this session.",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
return None
|
||||
elif not self.data:
|
||||
self.channel = None
|
||||
|
||||
# Send the inital status message. Log and bail if something goes wrong.
|
||||
if not self.message:
|
||||
try:
|
||||
self.message = await self.lobby.send(
|
||||
embed=self.open_embed
|
||||
)
|
||||
except discord.HTTPException:
|
||||
GuildSettings(self.guild.id).event_log.log(
|
||||
"Failed to post the status message in the scheduled session lobby {}.\n"
|
||||
"Skipping this session.".format(self.lobby.mention),
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
return None
|
||||
if self.members:
|
||||
await self.channel_notify()
|
||||
return (self.channel.id if self.channel else None, self.message.id)
|
||||
|
||||
async def channel_notify(self, content=None):
|
||||
"""
|
||||
Ghost pings the session members in the lobby channel.
|
||||
"""
|
||||
if self.members:
|
||||
content = content or "Your scheduled session has started! Please join!"
|
||||
out = "{}\n\n{}".format(
|
||||
content,
|
||||
' '.join('<@{}>'.format(memid) for memid, mem in self.members.items() if not mem.has_attended)
|
||||
)
|
||||
out_msg = await self.lobby.send(out)
|
||||
await out_msg.delete()
|
||||
|
||||
async def start(self):
|
||||
"""
|
||||
Start the accountability room slot.
|
||||
Update the status message, and launch the DM reminder.
|
||||
"""
|
||||
dither = 15 * random.random()
|
||||
await asyncio.sleep(dither)
|
||||
if self.channel:
|
||||
try:
|
||||
await self.channel.edit(name="Scheduled Session Room")
|
||||
await self.channel.set_permissions(self.guild.default_role, view_channel=True, connect=False)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
asyncio.create_task(self.dm_reminder(delay=60))
|
||||
try:
|
||||
await self.message.edit(embed=self.status_embed)
|
||||
except discord.NotFound:
|
||||
try:
|
||||
self.message = await self.lobby.send(
|
||||
embed=self.status_embed
|
||||
)
|
||||
except discord.HTTPException:
|
||||
self.message = None
|
||||
|
||||
async def dm_reminder(self, delay=60):
|
||||
"""
|
||||
Notifies missing members with a direct message after 1 minute.
|
||||
"""
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="The scheduled session you booked has started!",
|
||||
description="Please join {}.".format(self.channel.mention),
|
||||
colour=discord.Colour.orange()
|
||||
).set_footer(
|
||||
text=self.guild.name,
|
||||
icon_url=self.guild.icon_url
|
||||
)
|
||||
|
||||
members = (mem.member for mem in self.members.values() if not mem.has_attended)
|
||||
members = (member for member in members if member)
|
||||
await asyncio.gather(
|
||||
*(member.send(embed=embed) for member in members),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
"""
|
||||
Delete the channel and update the status message to display a session summary.
|
||||
Unloads the TimeSlot from cache.
|
||||
"""
|
||||
dither = 15 * random.random()
|
||||
await asyncio.sleep(dither)
|
||||
if self.channel:
|
||||
try:
|
||||
await self.channel.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
if self.message:
|
||||
try:
|
||||
await self.message.edit(embed=self.summary_embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Reward members appropriately
|
||||
if self.guild:
|
||||
guild_settings = GuildSettings(self.guild.id)
|
||||
reward = guild_settings.accountability_reward.value
|
||||
if all(mem.has_attended for mem in self.members.values()):
|
||||
reward += guild_settings.accountability_bonus.value
|
||||
|
||||
for memid in self.members:
|
||||
Lion.fetch(self.guild.id, memid).addCoins(reward, bonus=True)
|
||||
|
||||
async def cancel(self):
|
||||
"""
|
||||
Cancel the slot, generally due to missing data.
|
||||
Updates the message and channel if possible, removes slot from cache, and also updates data.
|
||||
# TODO: Refund members
|
||||
"""
|
||||
if self.data:
|
||||
self.data.closed_at = utc_now()
|
||||
|
||||
if self.channel:
|
||||
try:
|
||||
await self.channel.delete()
|
||||
self.channel = None
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
if self.message:
|
||||
try:
|
||||
timestamp = int(self.start_time.timestamp())
|
||||
embed = discord.Embed(
|
||||
title="Session <t:{}:t> - <t:{}:t>".format(
|
||||
timestamp, timestamp + 3600
|
||||
),
|
||||
description="Session canceled!",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
await self.message.edit(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
async def update_status(self):
|
||||
"""
|
||||
Intelligently update the status message.
|
||||
"""
|
||||
if self.message:
|
||||
if utc_now() < self.start_time:
|
||||
await self.message.edit(embed=self.open_embed)
|
||||
elif utc_now() < self.start_time + datetime.timedelta(hours=1):
|
||||
await self.message.edit(embed=self.status_embed)
|
||||
else:
|
||||
await self.message.edit(embed=self.summary_embed)
|
||||
6
bot/modules/pending-rewrite/accountability/__init__.py
Normal file
6
bot/modules/pending-rewrite/accountability/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from .module import module
|
||||
|
||||
from . import data
|
||||
from . import admin
|
||||
from . import commands
|
||||
from . import tracker
|
||||
140
bot/modules/pending-rewrite/accountability/admin.py
Normal file
140
bot/modules/pending-rewrite/accountability/admin.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import asyncio
|
||||
import discord
|
||||
|
||||
import settings
|
||||
from settings import GuildSettings, GuildSetting
|
||||
|
||||
from .tracker import AccountabilityGuild as AG
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class accountability_category(settings.Channel, settings.GuildSetting):
|
||||
category = "Scheduled Sessions"
|
||||
|
||||
attr_name = "accountability_category"
|
||||
_data_column = "accountability_category"
|
||||
|
||||
display_name = "session_category"
|
||||
desc = "Category in which to make the scheduled session rooms."
|
||||
|
||||
_default = None
|
||||
|
||||
long_desc = (
|
||||
"\"Schedule session\" category channel.\n"
|
||||
"Scheduled sessions will be held in voice channels created under this category."
|
||||
)
|
||||
_accepts = "A category channel."
|
||||
|
||||
_chan_type = discord.ChannelType.category
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
# TODO Move this somewhere better
|
||||
if self.id not in AG.cache:
|
||||
AG(self.id)
|
||||
return "The session category has been changed to **{}**.".format(self.value.name)
|
||||
else:
|
||||
return "The scheduled session system has been started in **{}**.".format(self.value.name)
|
||||
else:
|
||||
if self.id in AG.cache:
|
||||
aguild = AG.cache.pop(self.id)
|
||||
if aguild.current_slot:
|
||||
asyncio.create_task(aguild.current_slot.cancel())
|
||||
if aguild.upcoming_slot:
|
||||
asyncio.create_task(aguild.upcoming_slot.cancel())
|
||||
return "The scheduled session system has been shut down."
|
||||
else:
|
||||
return "The scheduled session category has been unset."
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class accountability_lobby(settings.Channel, settings.GuildSetting):
|
||||
category = "Scheduled Sessions"
|
||||
|
||||
attr_name = "accountability_lobby"
|
||||
_data_column = attr_name
|
||||
|
||||
display_name = "session_lobby"
|
||||
desc = "Category in which to post scheduled session notifications updates."
|
||||
|
||||
_default = None
|
||||
|
||||
long_desc = (
|
||||
"Scheduled session updates will be posted here, and members will be notified in this channel.\n"
|
||||
"The channel will be automatically created in the configured `session_category` if it does not exist.\n"
|
||||
"Members do not need to be able to write in the channel."
|
||||
)
|
||||
_accepts = "Any text channel."
|
||||
|
||||
_chan_type = discord.ChannelType.text
|
||||
|
||||
async def auto_create(self):
|
||||
# TODO: FUTURE
|
||||
...
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class accountability_price(settings.Integer, GuildSetting):
|
||||
category = "Scheduled Sessions"
|
||||
|
||||
attr_name = "accountability_price"
|
||||
_data_column = attr_name
|
||||
|
||||
display_name = "session_price"
|
||||
desc = "Cost of booking a scheduled session."
|
||||
|
||||
_default = 500
|
||||
|
||||
long_desc = (
|
||||
"The price of booking each one hour scheduled session slot."
|
||||
)
|
||||
_accepts = "An integer number of coins."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "Scheduled session slots now cost `{}` coins.".format(self.value)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class accountability_bonus(settings.Integer, GuildSetting):
|
||||
category = "Scheduled Sessions"
|
||||
|
||||
attr_name = "accountability_bonus"
|
||||
_data_column = attr_name
|
||||
|
||||
display_name = "session_bonus"
|
||||
desc = "Bonus given when everyone attends a scheduled session slot."
|
||||
|
||||
_default = 750
|
||||
|
||||
long_desc = (
|
||||
"The extra bonus given to each scheduled session member when everyone who booked attended the session."
|
||||
)
|
||||
_accepts = "An integer number of coins."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "Scheduled session members will now get `{}` coins if everyone joins.".format(self.value)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class accountability_reward(settings.Integer, GuildSetting):
|
||||
category = "Scheduled Sessions"
|
||||
|
||||
attr_name = "accountability_reward"
|
||||
_data_column = attr_name
|
||||
|
||||
display_name = "session_reward"
|
||||
desc = "The individual reward given when a member attends their booked scheduled session."
|
||||
|
||||
_default = 500
|
||||
|
||||
long_desc = (
|
||||
"Reward given to a member who attends a booked scheduled session."
|
||||
)
|
||||
_accepts = "An integer number of coins."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "Members will now get `{}` coins when they attend their scheduled session.".format(self.value)
|
||||
596
bot/modules/pending-rewrite/accountability/commands.py
Normal file
596
bot/modules/pending-rewrite/accountability/commands.py
Normal file
@@ -0,0 +1,596 @@
|
||||
import re
|
||||
import datetime
|
||||
import discord
|
||||
import asyncio
|
||||
import contextlib
|
||||
from cmdClient.checks import in_guild
|
||||
|
||||
from meta import client
|
||||
from utils.lib import multiselect_regex, parse_ranges, prop_tabulate
|
||||
from data import NOTNULL
|
||||
from data.conditions import GEQ, LEQ
|
||||
|
||||
from .module import module
|
||||
from .lib import utc_now
|
||||
from .tracker import AccountabilityGuild as AGuild
|
||||
from .tracker import room_lock
|
||||
from .TimeSlot import SlotMember
|
||||
from .data import accountability_members, accountability_member_info, accountability_rooms
|
||||
|
||||
|
||||
hint_icon = "https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png"
|
||||
|
||||
|
||||
def time_format(time):
|
||||
diff = (time - utc_now()).total_seconds()
|
||||
if diff < 0:
|
||||
diffstr = "`Right Now!!`"
|
||||
elif diff < 600:
|
||||
diffstr = "`Very soon!!`"
|
||||
elif diff < 3600:
|
||||
diffstr = "`In <1 hour `"
|
||||
else:
|
||||
hours = round(diff / 3600)
|
||||
diffstr = "`In {:>2} hour{}`".format(hours, 's' if hours > 1 else ' ')
|
||||
|
||||
return "{} | <t:{:.0f}:t> - <t:{:.0f}:t>".format(
|
||||
diffstr,
|
||||
time.timestamp(),
|
||||
time.timestamp() + 3600,
|
||||
)
|
||||
|
||||
|
||||
user_locks = {} # Map userid -> ctx
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def ensure_exclusive(ctx):
|
||||
"""
|
||||
Cancel any existing exclusive contexts for the author.
|
||||
"""
|
||||
old_ctx = user_locks.pop(ctx.author.id, None)
|
||||
if old_ctx:
|
||||
[task.cancel() for task in old_ctx.tasks]
|
||||
|
||||
user_locks[ctx.author.id] = ctx
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
new_ctx = user_locks.get(ctx.author.id, None)
|
||||
if new_ctx and new_ctx.msg.id == ctx.msg.id:
|
||||
user_locks.pop(ctx.author.id)
|
||||
|
||||
|
||||
@module.cmd(
|
||||
name="schedule",
|
||||
desc="View your schedule, and get rewarded for attending scheduled sessions!",
|
||||
group="Productivity",
|
||||
aliases=('rooms', 'sessions')
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_rooms(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}schedule
|
||||
{prefix}schedule book
|
||||
{prefix}schedule cancel
|
||||
Description:
|
||||
View your schedule with `{prefix}schedule`.
|
||||
Use `{prefix}schedule book` to schedule a session at a selected time..
|
||||
Use `{prefix}schedule cancel` to cancel a scheduled session.
|
||||
"""
|
||||
lower = ctx.args.lower()
|
||||
splits = lower.split()
|
||||
command = splits[0] if splits else None
|
||||
|
||||
if not ctx.guild_settings.accountability_category.value:
|
||||
return await ctx.error_reply("The scheduled session system isn't set up!")
|
||||
|
||||
# First grab the sessions the member is booked in
|
||||
joined_rows = accountability_member_info.select_where(
|
||||
userid=ctx.author.id,
|
||||
start_at=GEQ(utc_now()),
|
||||
_extra="ORDER BY start_at ASC"
|
||||
)
|
||||
|
||||
if command == 'cancel':
|
||||
if not joined_rows:
|
||||
return await ctx.error_reply("You have no scheduled sessions to cancel!")
|
||||
|
||||
# Show unbooking menu
|
||||
lines = [
|
||||
"`[{:>2}]` | {}".format(i, time_format(row['start_at']))
|
||||
for i, row in enumerate(joined_rows)
|
||||
]
|
||||
out_msg = await ctx.reply(
|
||||
content="Please reply with the number(s) of the sessions you want to cancel. E.g. `1, 3, 5` or `1-3, 7-8`.",
|
||||
embed=discord.Embed(
|
||||
title="Please choose the sessions you want to cancel.",
|
||||
description='\n'.join(lines),
|
||||
colour=discord.Colour.orange()
|
||||
).set_footer(
|
||||
text=(
|
||||
"All times are in your own timezone! Hover over a time to see the date."
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await ctx.cancellable(
|
||||
out_msg,
|
||||
cancel_message="Cancel menu closed, no scheduled sessions were cancelled.",
|
||||
timeout=70
|
||||
)
|
||||
|
||||
def check(msg):
|
||||
valid = msg.channel == ctx.ch and msg.author == ctx.author
|
||||
valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c')
|
||||
return valid
|
||||
|
||||
with ensure_exclusive(ctx):
|
||||
try:
|
||||
message = await ctx.client.wait_for('message', check=check, timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
await out_msg.edit(
|
||||
content=None,
|
||||
embed=discord.Embed(
|
||||
description="Cancel menu timed out, no scheduled sessions were cancelled.",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
)
|
||||
await out_msg.clear_reactions()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await out_msg.delete()
|
||||
await message.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
if message.content.lower() == 'c':
|
||||
return
|
||||
|
||||
to_cancel = [
|
||||
joined_rows[index]
|
||||
for index in parse_ranges(message.content) if index < len(joined_rows)
|
||||
]
|
||||
if not to_cancel:
|
||||
return await ctx.error_reply("No valid sessions selected for cancellation.")
|
||||
elif any(row['start_at'] < utc_now() for row in to_cancel):
|
||||
return await ctx.error_reply("You can't cancel a running session!")
|
||||
|
||||
slotids = [row['slotid'] for row in to_cancel]
|
||||
async with room_lock:
|
||||
deleted = accountability_members.delete_where(
|
||||
userid=ctx.author.id,
|
||||
slotid=slotids
|
||||
)
|
||||
|
||||
# Handle case where the slot has already opened
|
||||
# TODO: Possible race condition if they open over the hour border? Might never cancel
|
||||
for row in to_cancel:
|
||||
aguild = AGuild.cache.get(row['guildid'], None)
|
||||
if aguild and aguild.upcoming_slot and aguild.upcoming_slot.data:
|
||||
if aguild.upcoming_slot.data.slotid in slotids:
|
||||
aguild.upcoming_slot.members.pop(ctx.author.id, None)
|
||||
if aguild.upcoming_slot.channel:
|
||||
try:
|
||||
await aguild.upcoming_slot.channel.set_permissions(
|
||||
ctx.author,
|
||||
overwrite=None
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
await aguild.upcoming_slot.update_status()
|
||||
break
|
||||
|
||||
ctx.alion.addCoins(sum(row[2] for row in deleted))
|
||||
|
||||
remaining = [row for row in joined_rows if row['slotid'] not in slotids]
|
||||
if not remaining:
|
||||
await ctx.embed_reply("Cancelled all your upcoming scheduled sessions!")
|
||||
else:
|
||||
next_booked_time = min(row['start_at'] for row in remaining)
|
||||
if len(to_cancel) > 1:
|
||||
await ctx.embed_reply(
|
||||
"Cancelled `{}` upcoming sessions!\nYour next session is at <t:{:.0f}>.".format(
|
||||
len(to_cancel),
|
||||
next_booked_time.timestamp()
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.embed_reply(
|
||||
"Cancelled your session at <t:{:.0f}>!\n"
|
||||
"Your next session is at <t:{:.0f}>.".format(
|
||||
to_cancel[0]['start_at'].timestamp(),
|
||||
next_booked_time.timestamp()
|
||||
)
|
||||
)
|
||||
elif command == 'book':
|
||||
# Show booking menu
|
||||
# Get attendee count
|
||||
rows = accountability_member_info.select_where(
|
||||
guildid=ctx.guild.id,
|
||||
userid=NOTNULL,
|
||||
select_columns=(
|
||||
'slotid',
|
||||
'start_at',
|
||||
'COUNT(*) as num'
|
||||
),
|
||||
_extra="GROUP BY start_at, slotid"
|
||||
)
|
||||
attendees = {row['start_at']: row['num'] for row in rows}
|
||||
attendee_pad = max((len(str(num)) for num in attendees.values()), default=1)
|
||||
|
||||
# Build lines
|
||||
already_joined_times = set(row['start_at'] for row in joined_rows)
|
||||
start_time = utc_now().replace(minute=0, second=0, microsecond=0)
|
||||
times = (
|
||||
start_time + datetime.timedelta(hours=n)
|
||||
for n in range(1, 25)
|
||||
)
|
||||
times = [
|
||||
time for time in times
|
||||
if time not in already_joined_times and (time - utc_now()).total_seconds() > 660
|
||||
]
|
||||
lines = [
|
||||
"`[{num:>2}]` | `{count:>{count_pad}}` attending | {time}".format(
|
||||
num=i,
|
||||
count=attendees.get(time, 0), count_pad=attendee_pad,
|
||||
time=time_format(time),
|
||||
)
|
||||
for i, time in enumerate(times)
|
||||
]
|
||||
# TODO: Nicer embed
|
||||
# TODO: Don't allow multi bookings if the member has a bad attendance rate
|
||||
out_msg = await ctx.reply(
|
||||
content=(
|
||||
"Please reply with the number(s) of the sessions you want to book. E.g. `1, 3, 5` or `1-3, 7-8`."
|
||||
),
|
||||
embed=discord.Embed(
|
||||
title="Please choose the sessions you want to schedule.",
|
||||
description='\n'.join(lines),
|
||||
colour=discord.Colour.orange()
|
||||
).set_footer(
|
||||
text=(
|
||||
"All times are in your own timezone! Hover over a time to see the date."
|
||||
)
|
||||
)
|
||||
)
|
||||
await ctx.cancellable(
|
||||
out_msg,
|
||||
cancel_message="Booking menu cancelled, no sessions were booked.",
|
||||
timeout=60
|
||||
)
|
||||
|
||||
def check(msg):
|
||||
valid = msg.channel == ctx.ch and msg.author == ctx.author
|
||||
valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c')
|
||||
return valid
|
||||
|
||||
with ensure_exclusive(ctx):
|
||||
try:
|
||||
message = await ctx.client.wait_for('message', check=check, timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
await out_msg.edit(
|
||||
content=None,
|
||||
embed=discord.Embed(
|
||||
description="Booking menu timed out, no sessions were booked.",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
)
|
||||
await out_msg.clear_reactions()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
await out_msg.delete()
|
||||
await message.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
if message.content.lower() == 'c':
|
||||
return
|
||||
|
||||
to_book = [
|
||||
times[index]
|
||||
for index in parse_ranges(message.content) if index < len(times)
|
||||
]
|
||||
if not to_book:
|
||||
return await ctx.error_reply("No valid sessions selected.")
|
||||
elif any(time < utc_now() for time in to_book):
|
||||
return await ctx.error_reply("You can't book a running session!")
|
||||
cost = len(to_book) * ctx.guild_settings.accountability_price.value
|
||||
if cost > ctx.alion.coins:
|
||||
return await ctx.error_reply(
|
||||
"Sorry, booking `{}` sessions costs `{}` coins, and you only have `{}`!".format(
|
||||
len(to_book),
|
||||
cost,
|
||||
ctx.alion.coins
|
||||
)
|
||||
)
|
||||
|
||||
# Add the member to data, creating the row if required
|
||||
slot_rows = accountability_rooms.fetch_rows_where(
|
||||
guildid=ctx.guild.id,
|
||||
start_at=to_book
|
||||
)
|
||||
slotids = [row.slotid for row in slot_rows]
|
||||
to_add = set(to_book).difference((row.start_at for row in slot_rows))
|
||||
if to_add:
|
||||
slotids.extend(row['slotid'] for row in accountability_rooms.insert_many(
|
||||
*((ctx.guild.id, start_at) for start_at in to_add),
|
||||
insert_keys=('guildid', 'start_at'),
|
||||
))
|
||||
accountability_members.insert_many(
|
||||
*((slotid, ctx.author.id, ctx.guild_settings.accountability_price.value) for slotid in slotids),
|
||||
insert_keys=('slotid', 'userid', 'paid')
|
||||
)
|
||||
|
||||
# Handle case where the slot has already opened
|
||||
# TODO: Fix this, doesn't always work
|
||||
aguild = AGuild.cache.get(ctx.guild.id, None)
|
||||
if aguild:
|
||||
if aguild.upcoming_slot and aguild.upcoming_slot.start_time in to_book:
|
||||
slot = aguild.upcoming_slot
|
||||
if not slot.data:
|
||||
# Handle slot activation
|
||||
slot._refresh()
|
||||
channelid, messageid = await slot.open()
|
||||
accountability_rooms.update_where(
|
||||
{'channelid': channelid, 'messageid': messageid},
|
||||
slotid=slot.data.slotid
|
||||
)
|
||||
else:
|
||||
slot.members[ctx.author.id] = SlotMember(slot.data.slotid, ctx.author.id, ctx.guild)
|
||||
# Also update the channel permissions
|
||||
try:
|
||||
await slot.channel.set_permissions(ctx.author, view_channel=True, connect=True)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
await slot.update_status()
|
||||
ctx.alion.addCoins(-cost)
|
||||
|
||||
# Ack purchase
|
||||
embed = discord.Embed(
|
||||
title="You have scheduled the following session{}!".format('s' if len(to_book) > 1 else ''),
|
||||
description=(
|
||||
"*Please attend all your scheduled sessions!*\n"
|
||||
"*If you can't attend, cancel with* `{}schedule cancel`\n\n{}"
|
||||
).format(
|
||||
ctx.best_prefix,
|
||||
'\n'.join(time_format(time) for time in to_book),
|
||||
),
|
||||
colour=discord.Colour.orange()
|
||||
).set_footer(
|
||||
text=(
|
||||
"Use {prefix}schedule to see your current schedule.\n"
|
||||
).format(prefix=ctx.best_prefix)
|
||||
)
|
||||
try:
|
||||
await ctx.reply(
|
||||
embed=embed,
|
||||
reference=ctx.msg
|
||||
)
|
||||
except discord.NotFound:
|
||||
await ctx.reply(embed=embed)
|
||||
else:
|
||||
# Show accountability room information for this user
|
||||
# Accountability profile
|
||||
# Author
|
||||
# Special case for no past bookings, emphasis hint
|
||||
# Hint on Bookings section for booking/cancelling as applicable
|
||||
# Description has stats
|
||||
# Footer says that all times are in their timezone
|
||||
# TODO: attendance requirement shouldn't be retroactive! Add attended data column
|
||||
# Attended `{}` out of `{}` booked (`{}%` attendance rate!)
|
||||
# Attendance streak: `{}` days attended with no missed sessions!
|
||||
# Add explanation for first time users
|
||||
|
||||
# Get all slots the member has ever booked
|
||||
history = accountability_member_info.select_where(
|
||||
userid=ctx.author.id,
|
||||
# start_at=LEQ(utc_now() - datetime.timedelta(hours=1)),
|
||||
start_at=LEQ(utc_now()),
|
||||
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
|
||||
_extra="ORDER BY start_at DESC"
|
||||
)
|
||||
|
||||
if not (history or joined_rows):
|
||||
# First-timer information
|
||||
about = (
|
||||
"You haven't scheduled any study sessions yet!\n"
|
||||
"Schedule a session by typing **`{}schedule book`** and selecting "
|
||||
"the hours you intend to study, "
|
||||
"then attend by joining the session voice channel when it starts!\n"
|
||||
"Only if everyone attends will they get the bonus of `{}` LionCoins!\n"
|
||||
"Let's all do our best and keep each other accountable 🔥"
|
||||
).format(
|
||||
ctx.best_prefix,
|
||||
ctx.guild_settings.accountability_bonus.value
|
||||
)
|
||||
embed = discord.Embed(
|
||||
description=about,
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
embed.set_footer(
|
||||
text="Please keep your DMs open so I can notify you when the session starts!\n",
|
||||
icon_url=hint_icon
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
else:
|
||||
# Build description with stats
|
||||
if history:
|
||||
# First get the counts
|
||||
attended_count = sum(row['attended'] for row in history)
|
||||
total_count = len(history)
|
||||
total_duration = sum(row['duration'] for row in history)
|
||||
|
||||
# Add current session to duration if it exists
|
||||
if history[0]['last_joined_at'] and (utc_now() - history[0]['start_at']).total_seconds() < 3600:
|
||||
total_duration += int((utc_now() - history[0]['last_joined_at']).total_seconds())
|
||||
|
||||
# Calculate the streak
|
||||
timezone = ctx.alion.settings.timezone.value
|
||||
|
||||
streak = 0
|
||||
current_streak = None
|
||||
max_streak = 0
|
||||
day_attended = None
|
||||
date = utc_now().astimezone(timezone).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
daydiff = datetime.timedelta(days=1)
|
||||
|
||||
i = 0
|
||||
while i < len(history):
|
||||
row = history[i]
|
||||
i += 1
|
||||
if not row['attended']:
|
||||
# Not attended, streak broken
|
||||
pass
|
||||
elif row['start_at'] > 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
|
||||
date -= daydiff
|
||||
day_attended = False
|
||||
i -= 1
|
||||
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
|
||||
|
||||
# Build the stats
|
||||
table = {
|
||||
"Sessions": "**{}** attended out of **{}**, `{:.0f}%` attendance rate.".format(
|
||||
attended_count,
|
||||
total_count,
|
||||
(attended_count * 100) / total_count,
|
||||
),
|
||||
"Time": "**{:02}:{:02}** in scheduled sessions.".format(
|
||||
total_duration // 3600,
|
||||
(total_duration % 3600) // 60
|
||||
),
|
||||
"Streak": "**{}** day{} with no missed sessions! (Longest: **{}** day{}.)".format(
|
||||
current_streak,
|
||||
's' if current_streak != 1 else '',
|
||||
max_streak,
|
||||
's' if max_streak != 1 else '',
|
||||
),
|
||||
}
|
||||
desc = prop_tabulate(*zip(*table.items()))
|
||||
else:
|
||||
desc = (
|
||||
"Good luck with your next session!\n"
|
||||
)
|
||||
|
||||
# Build currently booked list
|
||||
|
||||
if joined_rows:
|
||||
# TODO: (Future) calendar link
|
||||
# Get attendee counts for currently booked sessions
|
||||
rows = accountability_member_info.select_where(
|
||||
slotid=[row["slotid"] for row in joined_rows],
|
||||
userid=NOTNULL,
|
||||
select_columns=(
|
||||
'slotid',
|
||||
'guildid',
|
||||
'start_at',
|
||||
'COUNT(*) as num'
|
||||
),
|
||||
_extra="GROUP BY start_at, slotid, guildid ORDER BY start_at ASC"
|
||||
)
|
||||
attendees = {
|
||||
row['start_at']: (row['num'], row['guildid']) for row in rows
|
||||
}
|
||||
attendee_pad = max((len(str(num)) for num, _ in attendees.values()), default=1)
|
||||
|
||||
# TODO: Allow cancel to accept multiselect keys as args
|
||||
show_guild = any(guildid != ctx.guild.id for _, guildid in attendees.values())
|
||||
guild_map = {}
|
||||
if show_guild:
|
||||
for _, guildid in attendees.values():
|
||||
if guildid not in guild_map:
|
||||
guild = ctx.client.get_guild(guildid)
|
||||
if not guild:
|
||||
try:
|
||||
guild = await ctx.client.fetch_guild(guildid)
|
||||
except discord.HTTPException:
|
||||
guild = None
|
||||
guild_map[guildid] = guild
|
||||
|
||||
booked_list = '\n'.join(
|
||||
"`{:>{}}` attendees | {} {}".format(
|
||||
num,
|
||||
attendee_pad,
|
||||
time_format(start),
|
||||
"" if not show_guild else (
|
||||
"on this server" if guildid == ctx.guild.id else "in **{}**".format(
|
||||
guild_map[guildid] or "Unknown"
|
||||
)
|
||||
)
|
||||
) for start, (num, guildid) in attendees.items()
|
||||
)
|
||||
booked_field = (
|
||||
"{}\n\n"
|
||||
"*If you can't make your session, please cancel using `{}schedule cancel`!*"
|
||||
).format(booked_list, ctx.best_prefix)
|
||||
|
||||
# Temporary footer for acclimatisation
|
||||
# footer = "All times are displayed in your own timezone!"
|
||||
footer = "Book another session using {}schedule book".format(ctx.best_prefix)
|
||||
else:
|
||||
booked_field = (
|
||||
"Your schedule is empty!\n"
|
||||
"Book another session using `{}schedule book`."
|
||||
).format(ctx.best_prefix)
|
||||
footer = "Please keep your DMs open for notifications!"
|
||||
|
||||
# Finally, build embed
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
description=desc,
|
||||
).set_author(
|
||||
name="Schedule statistics for {}".format(ctx.author.name),
|
||||
icon_url=ctx.author.avatar_url
|
||||
).set_footer(
|
||||
text=footer,
|
||||
icon_url=hint_icon
|
||||
).add_field(
|
||||
name="Upcoming sessions",
|
||||
value=booked_field
|
||||
)
|
||||
|
||||
# And send it!
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
|
||||
# TODO: roomadmin
|
||||
34
bot/modules/pending-rewrite/accountability/data.py
Normal file
34
bot/modules/pending-rewrite/accountability/data.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from data import Table, RowTable
|
||||
|
||||
from cachetools import TTLCache
|
||||
|
||||
|
||||
accountability_rooms = RowTable(
|
||||
'accountability_slots',
|
||||
('slotid', 'channelid', 'guildid', 'start_at', 'messageid', 'closed_at'),
|
||||
'slotid',
|
||||
cache=TTLCache(5000, ttl=60*70),
|
||||
attach_as='accountability_rooms'
|
||||
)
|
||||
|
||||
|
||||
accountability_members = RowTable(
|
||||
'accountability_members',
|
||||
('slotid', 'userid', 'paid', 'duration', 'last_joined_at'),
|
||||
('slotid', 'userid'),
|
||||
cache=TTLCache(5000, ttl=60*70)
|
||||
)
|
||||
|
||||
accountability_member_info = Table('accountability_member_info')
|
||||
accountability_open_slots = Table('accountability_open_slots')
|
||||
|
||||
# @accountability_member_info.save_query
|
||||
# def user_streaks(userid, min_duration):
|
||||
# with accountability_member_info.conn as conn:
|
||||
# cursor = conn.cursor()
|
||||
# with cursor:
|
||||
# cursor.execute(
|
||||
# """
|
||||
|
||||
# """
|
||||
# )
|
||||
8
bot/modules/pending-rewrite/accountability/lib.py
Normal file
8
bot/modules/pending-rewrite/accountability/lib.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import datetime
|
||||
|
||||
|
||||
def utc_now():
|
||||
"""
|
||||
Return the current timezone-aware utc timestamp.
|
||||
"""
|
||||
return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
|
||||
4
bot/modules/pending-rewrite/accountability/module.py
Normal file
4
bot/modules/pending-rewrite/accountability/module.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
|
||||
module = LionModule("Accountability")
|
||||
515
bot/modules/pending-rewrite/accountability/tracker.py
Normal file
515
bot/modules/pending-rewrite/accountability/tracker.py
Normal file
@@ -0,0 +1,515 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import collections
|
||||
import traceback
|
||||
import logging
|
||||
import discord
|
||||
from typing import Dict
|
||||
from discord.utils import sleep_until
|
||||
|
||||
from meta import client
|
||||
from utils.interactive import discord_shield
|
||||
from data import NULL, NOTNULL, tables
|
||||
from data.conditions import LEQ, THIS_SHARD
|
||||
from settings import GuildSettings
|
||||
|
||||
from .TimeSlot import TimeSlot
|
||||
from .lib import utc_now
|
||||
from .data import accountability_rooms, accountability_members
|
||||
from .module import module
|
||||
|
||||
|
||||
voice_ignore_lock = asyncio.Lock()
|
||||
room_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def locker(lock):
|
||||
"""
|
||||
Function decorator to wrap the function in a provided Lock
|
||||
"""
|
||||
def decorator(func):
|
||||
async def wrapped(*args, **kwargs):
|
||||
async with lock:
|
||||
return await func(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
class AccountabilityGuild:
|
||||
__slots__ = ('guildid', 'current_slot', 'upcoming_slot')
|
||||
|
||||
cache: Dict[int, 'AccountabilityGuild'] = {} # Map guildid -> AccountabilityGuild
|
||||
|
||||
def __init__(self, guildid):
|
||||
self.guildid = guildid
|
||||
self.current_slot = None
|
||||
self.upcoming_slot = None
|
||||
|
||||
self.cache[guildid] = self
|
||||
|
||||
@property
|
||||
def guild(self):
|
||||
return client.get_guild(self.guildid)
|
||||
|
||||
@property
|
||||
def guild_settings(self):
|
||||
return GuildSettings(self.guildid)
|
||||
|
||||
def advance(self):
|
||||
self.current_slot = self.upcoming_slot
|
||||
self.upcoming_slot = None
|
||||
|
||||
|
||||
async def open_next(start_time):
|
||||
"""
|
||||
Open all the upcoming accountability rooms, and fire channel notify.
|
||||
To be executed ~5 minutes to the hour.
|
||||
"""
|
||||
# Pre-fetch the new slot data, also populating the table caches
|
||||
room_data = accountability_rooms.fetch_rows_where(
|
||||
start_at=start_time,
|
||||
guildid=THIS_SHARD
|
||||
)
|
||||
guild_rows = {row.guildid: row for row in room_data}
|
||||
member_data = accountability_members.fetch_rows_where(
|
||||
slotid=[row.slotid for row in room_data]
|
||||
) if room_data else []
|
||||
slot_memberids = collections.defaultdict(list)
|
||||
for row in member_data:
|
||||
slot_memberids[row.slotid].append(row.userid)
|
||||
|
||||
# Open a new slot in each accountability guild
|
||||
to_update = [] # Cache of slot update data to be applied at the end
|
||||
for aguild in list(AccountabilityGuild.cache.values()):
|
||||
guild = aguild.guild
|
||||
if guild:
|
||||
# Initialise next TimeSlot
|
||||
slot = TimeSlot(
|
||||
guild,
|
||||
start_time,
|
||||
data=guild_rows.get(aguild.guildid, None)
|
||||
)
|
||||
slot.load(memberids=slot_memberids[slot.data.slotid] if slot.data else None)
|
||||
|
||||
if not slot.category:
|
||||
# Log and unload guild
|
||||
aguild.guild_settings.event_log.log(
|
||||
"The scheduled session category couldn't be found!\n"
|
||||
"Shutting down the scheduled session system in this server.\n"
|
||||
"To re-activate, please reconfigure `config session_category`."
|
||||
)
|
||||
AccountabilityGuild.cache.pop(aguild.guildid, None)
|
||||
await slot.cancel()
|
||||
continue
|
||||
elif not slot.lobby:
|
||||
# TODO: Consider putting in TimeSlot.open().. or even better in accountability_lobby.create()
|
||||
# Create a new lobby
|
||||
try:
|
||||
channel = await guild.create_text_channel(
|
||||
name="session-lobby",
|
||||
category=slot.category,
|
||||
reason="Automatic creation of scheduled session lobby."
|
||||
)
|
||||
aguild.guild_settings.accountability_lobby.value = channel
|
||||
slot.lobby = channel
|
||||
except discord.HTTPException:
|
||||
# Event log failure and skip session
|
||||
aguild.guild_settings.event_log.log(
|
||||
"Failed to create the scheduled session lobby text channel.\n"
|
||||
"Please set the lobby channel manually with `config`."
|
||||
)
|
||||
await slot.cancel()
|
||||
continue
|
||||
|
||||
# Event log creation
|
||||
aguild.guild_settings.event_log.log(
|
||||
"Automatically created a scheduled session lobby channel {}.".format(channel.mention)
|
||||
)
|
||||
|
||||
results = await slot.open()
|
||||
if results is None:
|
||||
# Couldn't open the channel for some reason.
|
||||
# Should already have been logged in `open`.
|
||||
# Skip this session
|
||||
await slot.cancel()
|
||||
continue
|
||||
elif slot.data:
|
||||
to_update.append((results[0], results[1], slot.data.slotid))
|
||||
|
||||
# Time slot should now be open and ready to start
|
||||
aguild.upcoming_slot = slot
|
||||
else:
|
||||
# Unload guild from cache
|
||||
AccountabilityGuild.cache.pop(aguild.guildid, None)
|
||||
|
||||
# Update slot data
|
||||
if to_update:
|
||||
accountability_rooms.update_many(
|
||||
*to_update,
|
||||
set_keys=('channelid', 'messageid'),
|
||||
where_keys=('slotid',)
|
||||
)
|
||||
|
||||
|
||||
async def turnover():
|
||||
"""
|
||||
Switchover from the current accountability rooms to the next ones.
|
||||
To be executed as close as possible to the hour.
|
||||
"""
|
||||
now = utc_now()
|
||||
|
||||
# Open event lock so we don't read voice channel movement
|
||||
async with voice_ignore_lock:
|
||||
# Update session data for completed sessions
|
||||
last_slots = [
|
||||
aguild.current_slot for aguild in AccountabilityGuild.cache.values()
|
||||
if aguild.current_slot is not None
|
||||
]
|
||||
|
||||
to_update = [
|
||||
(mem.data.duration + int((now - mem.data.last_joined_at).total_seconds()), None, mem.slotid, mem.userid)
|
||||
for slot in last_slots for mem in slot.members.values()
|
||||
if mem.data and mem.data.last_joined_at
|
||||
]
|
||||
if to_update:
|
||||
accountability_members.update_many(
|
||||
*to_update,
|
||||
set_keys=('duration', 'last_joined_at'),
|
||||
where_keys=('slotid', 'userid'),
|
||||
cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)'
|
||||
)
|
||||
|
||||
# Close all completed rooms, update data
|
||||
await asyncio.gather(*(slot.close() for slot in last_slots), return_exceptions=True)
|
||||
update_slots = [slot.data.slotid for slot in last_slots if slot.data]
|
||||
if update_slots:
|
||||
accountability_rooms.update_where(
|
||||
{'closed_at': utc_now()},
|
||||
slotid=update_slots
|
||||
)
|
||||
|
||||
# Rotate guild sessions
|
||||
[aguild.advance() for aguild in AccountabilityGuild.cache.values()]
|
||||
|
||||
# TODO: (FUTURE) with high volume, we might want to start the sessions before moving the members.
|
||||
# We could break up the session starting?
|
||||
|
||||
# ---------- Start next session ----------
|
||||
current_slots = [
|
||||
aguild.current_slot for aguild in AccountabilityGuild.cache.values()
|
||||
if aguild.current_slot is not None
|
||||
]
|
||||
slotmap = {slot.data.slotid: slot for slot in current_slots if slot.data}
|
||||
|
||||
# Reload the slot members in case they cancelled from another shard
|
||||
member_data = accountability_members.fetch_rows_where(
|
||||
slotid=list(slotmap.keys())
|
||||
) if slotmap else []
|
||||
slot_memberids = {slotid: [] for slotid in slotmap}
|
||||
for row in member_data:
|
||||
slot_memberids[row.slotid].append(row.userid)
|
||||
reload_tasks = (
|
||||
slot._reload_members(memberids=slot_memberids[slotid])
|
||||
for slotid, slot in slotmap.items()
|
||||
)
|
||||
await asyncio.gather(
|
||||
*reload_tasks,
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# Move members of the next session over to the session channel
|
||||
movement_tasks = (
|
||||
mem.member.edit(
|
||||
voice_channel=slot.channel,
|
||||
reason="Moving to scheduled session."
|
||||
)
|
||||
for slot in current_slots
|
||||
for mem in slot.members.values()
|
||||
if mem.data and mem.member and mem.member.voice and mem.member.voice.channel != slot.channel
|
||||
)
|
||||
# We return exceptions here to ignore any permission issues that occur with moving members.
|
||||
# It's also possible (likely) that members will move while we are moving other members
|
||||
# Returning the exceptions ensures that they are explicitly ignored
|
||||
await asyncio.gather(
|
||||
*movement_tasks,
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
# Update session data of all members in new channels
|
||||
member_session_data = [
|
||||
(0, slot.start_time, mem.slotid, mem.userid)
|
||||
for slot in current_slots
|
||||
for mem in slot.members.values()
|
||||
if mem.data and mem.member and mem.member.voice and mem.member.voice.channel == slot.channel
|
||||
]
|
||||
if member_session_data:
|
||||
accountability_members.update_many(
|
||||
*member_session_data,
|
||||
set_keys=('duration', 'last_joined_at'),
|
||||
where_keys=('slotid', 'userid'),
|
||||
cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)'
|
||||
)
|
||||
|
||||
# Start all the current rooms
|
||||
await asyncio.gather(
|
||||
*(slot.start() for slot in current_slots),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
|
||||
@client.add_after_event('voice_state_update')
|
||||
async def room_watchdog(client, member, before, after):
|
||||
"""
|
||||
Update session data when a member joins or leaves an accountability room.
|
||||
Ignores events that occur while `voice_ignore_lock` is held.
|
||||
"""
|
||||
if not voice_ignore_lock.locked() and before.channel != after.channel:
|
||||
aguild = AccountabilityGuild.cache.get(member.guild.id)
|
||||
if aguild and aguild.current_slot and aguild.current_slot.channel:
|
||||
slot = aguild.current_slot
|
||||
if member.id in slot.members:
|
||||
if after.channel and after.channel.id != slot.channel.id:
|
||||
# Summon them back!
|
||||
asyncio.create_task(member.edit(voice_channel=slot.channel))
|
||||
|
||||
slot_member = slot.members[member.id]
|
||||
data = slot_member.data
|
||||
|
||||
if before.channel and before.channel.id == slot.channel.id:
|
||||
# Left accountability room
|
||||
with data.batch_update():
|
||||
data.duration += int((utc_now() - data.last_joined_at).total_seconds())
|
||||
data.last_joined_at = None
|
||||
await slot.update_status()
|
||||
elif after.channel and after.channel.id == slot.channel.id:
|
||||
# Joined accountability room
|
||||
with data.batch_update():
|
||||
data.last_joined_at = utc_now()
|
||||
await slot.update_status()
|
||||
|
||||
|
||||
async def _accountability_loop():
|
||||
"""
|
||||
Runloop in charge of executing the room update tasks at the correct times.
|
||||
"""
|
||||
# Wait until ready
|
||||
while not client.is_ready():
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Calculate starting next_time
|
||||
# Assume the resume logic has taken care of all events/tasks before current_time
|
||||
now = utc_now()
|
||||
if now.minute < 55:
|
||||
next_time = now.replace(minute=55, second=0, microsecond=0)
|
||||
else:
|
||||
next_time = now.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1)
|
||||
|
||||
# Executor loop
|
||||
while True:
|
||||
# TODO: (FUTURE) handle cases where we actually execute much late than expected
|
||||
await sleep_until(next_time)
|
||||
if next_time.minute == 55:
|
||||
next_time = next_time + datetime.timedelta(minutes=5)
|
||||
# Open next sessions
|
||||
try:
|
||||
async with room_lock:
|
||||
await open_next(next_time)
|
||||
except Exception:
|
||||
# Unknown exception. Catch it so the loop doesn't die.
|
||||
client.log(
|
||||
"Error while opening new scheduled sessions! "
|
||||
"Exception traceback follows.\n{}".format(
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="ACCOUNTABILITY_LOOP",
|
||||
level=logging.ERROR
|
||||
)
|
||||
elif next_time.minute == 0:
|
||||
# Start new sessions
|
||||
try:
|
||||
async with room_lock:
|
||||
await turnover()
|
||||
except Exception:
|
||||
# Unknown exception. Catch it so the loop doesn't die.
|
||||
client.log(
|
||||
"Error while starting scheduled sessions! "
|
||||
"Exception traceback follows.\n{}".format(
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="ACCOUNTABILITY_LOOP",
|
||||
level=logging.ERROR
|
||||
)
|
||||
next_time = next_time + datetime.timedelta(minutes=55)
|
||||
|
||||
|
||||
async def _accountability_system_resume():
|
||||
"""
|
||||
Logic for starting the accountability system from cold.
|
||||
Essentially, session and state resume logic.
|
||||
"""
|
||||
now = utc_now()
|
||||
|
||||
# Fetch the open room data, only takes into account currently running sessions.
|
||||
# May include sessions that were never opened, or opened but never started
|
||||
# Does not include sessions that were opened that start on the next hour
|
||||
open_room_data = accountability_rooms.fetch_rows_where(
|
||||
closed_at=NULL,
|
||||
start_at=LEQ(now),
|
||||
guildid=THIS_SHARD,
|
||||
_extra="ORDER BY start_at ASC"
|
||||
)
|
||||
|
||||
if open_room_data:
|
||||
# Extract member data of these rows
|
||||
member_data = accountability_members.fetch_rows_where(
|
||||
slotid=[row.slotid for row in open_room_data]
|
||||
)
|
||||
slot_members = collections.defaultdict(list)
|
||||
for row in member_data:
|
||||
slot_members[row.slotid].append(row)
|
||||
|
||||
# Filter these into expired rooms and current rooms
|
||||
expired_room_data = []
|
||||
current_room_data = []
|
||||
for row in open_room_data:
|
||||
if row.start_at + datetime.timedelta(hours=1) < now:
|
||||
expired_room_data.append(row)
|
||||
else:
|
||||
current_room_data.append(row)
|
||||
|
||||
session_updates = []
|
||||
|
||||
# TODO URGENT: Batch room updates here
|
||||
|
||||
# Expire the expired rooms
|
||||
for row in expired_room_data:
|
||||
if row.channelid is None or row.messageid is None:
|
||||
# TODO refunds here
|
||||
# If the rooms were never opened, close them and skip
|
||||
row.closed_at = now
|
||||
else:
|
||||
# If the rooms were opened and maybe started, make optimistic guesses on session data and close.
|
||||
session_end = row.start_at + datetime.timedelta(hours=1)
|
||||
session_updates.extend(
|
||||
(mow.duration + int((session_end - mow.last_joined_at).total_seconds()),
|
||||
None, mow.slotid, mow.userid)
|
||||
for mow in slot_members[row.slotid] if mow.last_joined_at
|
||||
)
|
||||
if client.get_guild(row.guildid):
|
||||
slot = TimeSlot(client.get_guild(row.guildid), row.start_at, data=row).load(
|
||||
memberids=[mow.userid for mow in slot_members[row.slotid]]
|
||||
)
|
||||
try:
|
||||
await slot.close()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
row.closed_at = now
|
||||
|
||||
# Load the in-progress room data
|
||||
if current_room_data:
|
||||
async with voice_ignore_lock:
|
||||
current_hour = now.replace(minute=0, second=0, microsecond=0)
|
||||
await open_next(current_hour)
|
||||
[aguild.advance() for aguild in AccountabilityGuild.cache.values()]
|
||||
|
||||
current_slots = [
|
||||
aguild.current_slot
|
||||
for aguild in AccountabilityGuild.cache.values()
|
||||
if aguild.current_slot
|
||||
]
|
||||
|
||||
session_updates.extend(
|
||||
(mem.data.duration + int((now - mem.data.last_joined_at).total_seconds()),
|
||||
None, mem.slotid, mem.userid)
|
||||
for slot in current_slots
|
||||
for mem in slot.members.values()
|
||||
if mem.data.last_joined_at and mem.member not in slot.channel.members
|
||||
)
|
||||
|
||||
session_updates.extend(
|
||||
(mem.data.duration,
|
||||
now, mem.slotid, mem.userid)
|
||||
for slot in current_slots
|
||||
for mem in slot.members.values()
|
||||
if not mem.data.last_joined_at and mem.member in slot.channel.members
|
||||
)
|
||||
|
||||
if session_updates:
|
||||
accountability_members.update_many(
|
||||
*session_updates,
|
||||
set_keys=('duration', 'last_joined_at'),
|
||||
where_keys=('slotid', 'userid'),
|
||||
cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)'
|
||||
)
|
||||
|
||||
await asyncio.gather(
|
||||
*(aguild.current_slot.start()
|
||||
for aguild in AccountabilityGuild.cache.values() if aguild.current_slot)
|
||||
)
|
||||
else:
|
||||
if session_updates:
|
||||
accountability_members.update_many(
|
||||
*session_updates,
|
||||
set_keys=('duration', 'last_joined_at'),
|
||||
where_keys=('slotid', 'userid'),
|
||||
cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)'
|
||||
)
|
||||
|
||||
# If we are in the last five minutes of the hour, open new rooms.
|
||||
# Note that these may already have been opened, or they may not have been.
|
||||
if now.minute >= 55:
|
||||
await open_next(
|
||||
now.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1)
|
||||
)
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def launch_accountability_system(client):
|
||||
"""
|
||||
Launcher for the accountability system.
|
||||
Resumes saved sessions, and starts the accountability loop.
|
||||
"""
|
||||
# Load the AccountabilityGuild cache
|
||||
guilds = tables.guild_config.fetch_rows_where(
|
||||
accountability_category=NOTNULL,
|
||||
guildid=THIS_SHARD
|
||||
)
|
||||
# Further filter out any guilds that we aren't in
|
||||
[AccountabilityGuild(guild.guildid) for guild in guilds if client.get_guild(guild.guildid)]
|
||||
await _accountability_system_resume()
|
||||
asyncio.create_task(_accountability_loop())
|
||||
|
||||
|
||||
async def unload_accountability(client):
|
||||
"""
|
||||
Save the current sessions and cancel the runloop in preparation for client shutdown.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@client.add_after_event('member_join')
|
||||
async def restore_accountability(client, member):
|
||||
"""
|
||||
Restore accountability channel permissions when a member rejoins the server, if applicable.
|
||||
"""
|
||||
aguild = AccountabilityGuild.cache.get(member.guild.id, None)
|
||||
if aguild:
|
||||
if aguild.current_slot and member.id in aguild.current_slot.members:
|
||||
# Restore member permission for current slot
|
||||
slot = aguild.current_slot
|
||||
if slot.channel:
|
||||
asyncio.create_task(discord_shield(
|
||||
slot.channel.set_permissions(
|
||||
member,
|
||||
overwrite=slot._member_overwrite
|
||||
)
|
||||
))
|
||||
if aguild.upcoming_slot and member.id in aguild.upcoming_slot.members:
|
||||
slot = aguild.upcoming_slot
|
||||
if slot.channel:
|
||||
asyncio.create_task(discord_shield(
|
||||
slot.channel.set_permissions(
|
||||
member,
|
||||
overwrite=slot._member_overwrite
|
||||
)
|
||||
))
|
||||
4
bot/modules/pending-rewrite/economy/__init__.py
Normal file
4
bot/modules/pending-rewrite/economy/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .module import module
|
||||
|
||||
from . import send_cmd
|
||||
from . import shop_cmds
|
||||
4
bot/modules/pending-rewrite/economy/module.py
Normal file
4
bot/modules/pending-rewrite/economy/module.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
|
||||
module = LionModule("Economy")
|
||||
91
bot/modules/pending-rewrite/economy/send_cmd.py
Normal file
91
bot/modules/pending-rewrite/economy/send_cmd.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import discord
|
||||
import datetime
|
||||
from cmdClient.checks import in_guild
|
||||
|
||||
from settings import GuildSettings
|
||||
from core import Lion
|
||||
|
||||
from .module import module
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"send",
|
||||
group="Economy",
|
||||
desc="Send some coins to another member."
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_send(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}send <user mention> <amount>
|
||||
Description:
|
||||
Send the given number of coins to the mentioned user.
|
||||
Example:
|
||||
{prefix}send {ctx.author.mention} 1000000
|
||||
"""
|
||||
# Extract target and amount
|
||||
# Handle a slightly more flexible input than stated
|
||||
splits = ctx.args.split()
|
||||
digits = [split.isdigit() for split in splits[:2]]
|
||||
mentions = ctx.msg.mentions
|
||||
if len(splits) < 2 or not any(digits) or not (all(digits) or mentions):
|
||||
return await _send_usage(ctx)
|
||||
|
||||
if all(digits):
|
||||
# Both are digits, hopefully one is a member id, and one is an amount.
|
||||
target, amount = ctx.guild.get_member(int(splits[0])), int(splits[1])
|
||||
if not target:
|
||||
amount, target = int(splits[0]), ctx.guild.get_member(int(splits[1]))
|
||||
if not target:
|
||||
return await _send_usage(ctx)
|
||||
elif digits[0]:
|
||||
amount, target = int(splits[0]), mentions[0]
|
||||
elif digits[1]:
|
||||
target, amount = mentions[0], int(splits[1])
|
||||
|
||||
# Fetch the associated lions
|
||||
target_lion = Lion.fetch(ctx.guild.id, target.id)
|
||||
source_lion = Lion.fetch(ctx.guild.id, ctx.author.id)
|
||||
|
||||
# Check sanity conditions
|
||||
if amount > source_lion.coins:
|
||||
return await ctx.error_reply(
|
||||
"Sorry {}, you do not have enough LionCoins to do that.".format(ctx.author.mention)
|
||||
)
|
||||
if target == ctx.author:
|
||||
return await ctx.embed_reply("What is this, tax evasion?")
|
||||
if target == ctx.client.user:
|
||||
return await ctx.embed_reply("Thanks, but Ari looks after all my needs!")
|
||||
if target.bot:
|
||||
return await ctx.embed_reply("We are still waiting for {} to open an account.".format(target.mention))
|
||||
|
||||
# Finally, send the amount and the ack message
|
||||
target_lion.addCoins(amount)
|
||||
source_lion.addCoins(-amount)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Funds transferred",
|
||||
description="You have sent **{}** LionCoins to {}!".format(amount, target.mention),
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
).set_footer(text=str(ctx.author), icon_url=ctx.author.avatar_url)
|
||||
|
||||
await ctx.reply(embed=embed, reference=ctx.msg)
|
||||
GuildSettings(ctx.guild.id).event_log.log(
|
||||
"{} sent {} `{}` LionCoins.".format(
|
||||
ctx.author.mention,
|
||||
target.mention,
|
||||
amount
|
||||
),
|
||||
title="Funds transferred"
|
||||
)
|
||||
|
||||
|
||||
async def _send_usage(ctx):
|
||||
return await ctx.error_reply(
|
||||
"**Usage:** `{prefix}send <mention> <amount>`\n"
|
||||
"**Example:** {prefix}send {ctx.author.mention} 1000000".format(
|
||||
prefix=ctx.best_prefix,
|
||||
ctx=ctx
|
||||
)
|
||||
)
|
||||
246
bot/modules/pending-rewrite/economy/shop_cmds.py
Normal file
246
bot/modules/pending-rewrite/economy/shop_cmds.py
Normal file
@@ -0,0 +1,246 @@
|
||||
import asyncio
|
||||
import discord
|
||||
from collections import defaultdict
|
||||
|
||||
from cmdClient.checks import in_guild
|
||||
|
||||
from .module import module
|
||||
from .shop_core import ShopItem, ShopItemType, ColourRole
|
||||
from wards import is_guild_admin
|
||||
|
||||
|
||||
class ShopSession:
|
||||
__slots__ = (
|
||||
'key', 'response',
|
||||
'_event', '_task'
|
||||
)
|
||||
_sessions = {}
|
||||
|
||||
def __init__(self, userid, channelid):
|
||||
# Build unique context key for shop session
|
||||
self.key = (userid, channelid)
|
||||
self.response = None
|
||||
|
||||
# Cancel any existing sessions
|
||||
if self.key in self._sessions:
|
||||
self._sessions[self.key].cancel()
|
||||
|
||||
self._event = asyncio.Event()
|
||||
self._task = None
|
||||
|
||||
# Add self to the session list
|
||||
self._sessions[self.key] = self
|
||||
|
||||
@classmethod
|
||||
def get(cls, userid, channelid) -> 'ShopSession':
|
||||
"""
|
||||
Get a ShopSession matching the given key, if it exists.
|
||||
Otherwise, returns None.
|
||||
"""
|
||||
return cls._sessions.get((userid, channelid), None)
|
||||
|
||||
async def wait(self, timeout=None):
|
||||
"""
|
||||
Wait for a buy response. Return the set response or raise an appropriate exception.
|
||||
"""
|
||||
self._task = asyncio.create_task(self._event.wait())
|
||||
try:
|
||||
await asyncio.wait_for(self._task, timeout=timeout)
|
||||
except asyncio.CancelledError:
|
||||
# Session was cancelled, likely due to creation of a new session
|
||||
raise
|
||||
except asyncio.TimeoutError:
|
||||
# Session timed out, likely due to reaching the timeout
|
||||
raise
|
||||
finally:
|
||||
if self._sessions.get(self.key, None) == self:
|
||||
self._sessions.pop(self.key, None)
|
||||
|
||||
return self.response
|
||||
|
||||
def set(self, response):
|
||||
"""
|
||||
Set response.
|
||||
"""
|
||||
self.response = response
|
||||
self._event.set()
|
||||
|
||||
def cancel(self):
|
||||
"""
|
||||
Cancel a session.
|
||||
"""
|
||||
if self._task:
|
||||
if self._sessions.get(self.key, None) == self:
|
||||
self._sessions.pop(self.key, None)
|
||||
self._task.cancel()
|
||||
else:
|
||||
raise ValueError("Cancelling a ShopSession that is already completed!")
|
||||
|
||||
|
||||
@module.cmd(
|
||||
'shop',
|
||||
group="Economy",
|
||||
desc="Open the guild shop.",
|
||||
flags=('add==', 'remove==', 'clear')
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_shop(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}shop
|
||||
{prefix}shop --add <price>, <item>
|
||||
{prefix}shop --remove
|
||||
{prefix}shop --remove itemid, itemid, ...
|
||||
{prefix}shop --remove itemname, itemname, ...
|
||||
{prefix}shop --clear
|
||||
Description:
|
||||
Opens the guild shop. Visible items may be bought using `{prefix}buy`.
|
||||
|
||||
*Modifying the guild shop requires administrator permissions.*
|
||||
"""
|
||||
# TODO: (FUTURE) Register session (and cancel previous sessions) so we can track for buy
|
||||
|
||||
# Whether we are modifying the shop
|
||||
modifying = any(value is not False for value in flags.values())
|
||||
if modifying and not is_guild_admin(ctx.author):
|
||||
return await ctx.error_reply(
|
||||
"You need to be a guild admin to modify the shop!"
|
||||
)
|
||||
|
||||
# Fetch all purchasable elements, this also populates the cache
|
||||
shop_items = ShopItem.fetch_where(guildid=ctx.guild.id, deleted=False, purchasable=True)
|
||||
|
||||
if not shop_items and not modifying:
|
||||
# TODO: (FUTURE) Add tip for guild admins about setting up
|
||||
return await ctx.error_reply(
|
||||
"Nothing to buy! Please come back later."
|
||||
)
|
||||
|
||||
# Categorise items
|
||||
item_cats = defaultdict(list)
|
||||
for item in shop_items:
|
||||
item_cats[item.item_type].append(item)
|
||||
|
||||
# If there is more than one category, ask for which category they want
|
||||
# All FUTURE TODO stuff, to be refactored into a shop widget
|
||||
# item_type = None
|
||||
# if ctx.args:
|
||||
# # Assume category has been entered
|
||||
# ...
|
||||
# elif len(item_cats) > 1:
|
||||
# # TODO: Show selection menu
|
||||
# item_type = ...
|
||||
# ...
|
||||
# else:
|
||||
# # Pick the next one automatically
|
||||
# item_type = next(iter(item_cats))
|
||||
|
||||
item_type = ShopItemType.COLOUR_ROLE
|
||||
item_class = ColourRole
|
||||
|
||||
if item_type is not None:
|
||||
items = [item for item in item_cats[item_type]]
|
||||
embeds = item_class.cat_shop_embeds(
|
||||
ctx.guild.id,
|
||||
[item.itemid for item in items]
|
||||
)
|
||||
|
||||
if modifying:
|
||||
if flags['add']:
|
||||
await item_class.parse_add(ctx, flags['add'])
|
||||
elif flags['remove'] is not False:
|
||||
await item_class.parse_remove(ctx, flags['remove'], items)
|
||||
elif flags['clear']:
|
||||
await item_class.parse_clear(ctx)
|
||||
return
|
||||
|
||||
# Present shop pages
|
||||
out_msg = await ctx.pager(embeds, add_cancel=True)
|
||||
await ctx.cancellable(out_msg, add_reaction=False)
|
||||
while True:
|
||||
try:
|
||||
response = await ShopSession(ctx.author.id, ctx.ch.id).wait(timeout=300)
|
||||
except asyncio.CancelledError:
|
||||
# User opened a new session
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
# Session timed out. Close the shop.
|
||||
# TODO: (FUTURE) time out shop session by removing hint.
|
||||
try:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.red(),
|
||||
description="Shop closed! (Session timed out.)"
|
||||
)
|
||||
await out_msg.edit(
|
||||
embed=embed
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
break
|
||||
|
||||
# Selection was made
|
||||
|
||||
# Sanity checks
|
||||
# TODO: (FUTURE) Handle more flexible ways of selecting items
|
||||
if int(response.args) >= len(items):
|
||||
await response.error_reply(
|
||||
"No item with this number exists! Please try again."
|
||||
)
|
||||
continue
|
||||
|
||||
item = items[int(response.args)]
|
||||
if item.price > ctx.alion.coins:
|
||||
await response.error_reply(
|
||||
"Sorry, {} costs `{}` LionCoins, but you only have `{}`!".format(
|
||||
item.display_name,
|
||||
item.price,
|
||||
ctx.alion.coins
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Run the selection and keep the shop open in case they want to buy more.
|
||||
# TODO: (FUTURE) The item may no longer exist
|
||||
success = await item.buy(response)
|
||||
if success and not item.allow_multi_select:
|
||||
try:
|
||||
await out_msg.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
break
|
||||
|
||||
|
||||
@module.cmd(
|
||||
'buy',
|
||||
group="Hidden",
|
||||
desc="Buy an item from the guild shop."
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_buy(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}buy <number>
|
||||
Description:
|
||||
Only usable while you have a shop open (see `{prefix}shop`).
|
||||
|
||||
Buys the selected item from the shop.
|
||||
"""
|
||||
# Check relevant session exists
|
||||
session = ShopSession.get(ctx.author.id, ctx.ch.id)
|
||||
if session is None:
|
||||
return await ctx.error_reply(
|
||||
"No shop open, nothing to buy!\n"
|
||||
"Please open a shop with `{prefix}shop` first.".format(prefix=ctx.best_prefix)
|
||||
)
|
||||
# Check input is an integer
|
||||
if not ctx.args.isdigit():
|
||||
return await ctx.error_reply(
|
||||
"**Usage:** `{prefix}buy <number>`, for example `{prefix}buy 1`.".format(prefix=ctx.best_prefix)
|
||||
)
|
||||
|
||||
# Pass context back to session
|
||||
session.set(ctx)
|
||||
|
||||
|
||||
# TODO: (FUTURE) Buy command short-circuiting the shop command and acting on shop sessions
|
||||
# TODO: (FUTURE) Inventory command showing the user's purchases
|
||||
377
bot/modules/pending-rewrite/economy/shop_core/ColourRole.py
Normal file
377
bot/modules/pending-rewrite/economy/shop_core/ColourRole.py
Normal file
@@ -0,0 +1,377 @@
|
||||
import re
|
||||
import asyncio
|
||||
from typing import List
|
||||
import datetime
|
||||
import discord
|
||||
|
||||
from cmdClient.lib import SafeCancellation
|
||||
from meta import client
|
||||
from utils.lib import multiselect_regex, parse_ranges
|
||||
|
||||
from .ShopItem import ShopItem, ShopItemType
|
||||
from .data import shop_items, shop_item_info, colour_roles
|
||||
|
||||
|
||||
@ShopItem.register_item_class
|
||||
class ColourRole(ShopItem):
|
||||
item_type = ShopItemType.COLOUR_ROLE
|
||||
|
||||
allow_multi_select = False
|
||||
buy_hint = (
|
||||
"Buy a colour by typing, e.g.,`{prefix}buy 0`.\n"
|
||||
"**Note: You may only own one colour at a time!**"
|
||||
).format(prefix=client.prefix)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
return "<@&{}>".format(self.data.roleid)
|
||||
|
||||
@property
|
||||
def roleid(self) -> int:
|
||||
return self.data.roleid
|
||||
|
||||
@property
|
||||
def role(self) -> discord.Role:
|
||||
return self.guild.get_role(self.roleid)
|
||||
|
||||
@classmethod
|
||||
async def create(cls, guildid, price, roleid, **kwargs):
|
||||
"""
|
||||
Create a new ColourRole item.
|
||||
"""
|
||||
shop_row = shop_items.insert(
|
||||
guildid=guildid,
|
||||
item_type=cls.item_type,
|
||||
price=price
|
||||
)
|
||||
colour_roles.insert(
|
||||
itemid=shop_row['itemid'],
|
||||
roleid=roleid
|
||||
)
|
||||
return cls(shop_row['itemid'])
|
||||
|
||||
# Shop interface
|
||||
@classmethod
|
||||
def _cat_shop_embed_items(cls, items: List['ColourRole'], hint: str = buy_hint, **kwargs) -> List[discord.Embed]:
|
||||
"""
|
||||
Embed a list of items specifically for displaying in the shop.
|
||||
Subclasses will usually extend or override this, if only to add metadata.
|
||||
"""
|
||||
if items:
|
||||
# TODO: prefix = items[0].guild_settings.prefix.value
|
||||
|
||||
embeds = cls._cat_embed_items(items, **kwargs)
|
||||
for embed in embeds:
|
||||
embed.title = "{} shop!".format(cls.item_type.desc)
|
||||
embed.description += "\n\n" + hint
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
title="{} shop!".format(cls.item_type.desc),
|
||||
description="No colours available at the moment! Please come back later."
|
||||
)
|
||||
embeds = [embed]
|
||||
return embeds
|
||||
|
||||
async def buy(self, ctx):
|
||||
"""
|
||||
Action when a member buys a color role.
|
||||
|
||||
Uses Discord as the source of truth (rather than the future inventory).
|
||||
Removes any existing colour roles, and adds the purchased role.
|
||||
Also notifies the user and logs the transaction.
|
||||
"""
|
||||
# TODO: Exclusivity should be handled at a higher level
|
||||
# TODO: All sorts of error handling
|
||||
member = ctx.author
|
||||
|
||||
# Fetch the set colour roles
|
||||
colour_rows = shop_item_info.fetch_rows_where(
|
||||
guildid=self.guildid,
|
||||
item_type=self.item_type
|
||||
)
|
||||
roleids = (row.roleid for row in colour_rows)
|
||||
roles = (self.guild.get_role(roleid) for roleid in roleids)
|
||||
roles = set(role for role in roles if role)
|
||||
|
||||
# Compute the roles to add and remove
|
||||
to_add = self.guild.get_role(self.roleid)
|
||||
member_has = roles.intersection(member.roles)
|
||||
if to_add in member_has:
|
||||
await ctx.error_reply("You already have this colour!")
|
||||
return False
|
||||
|
||||
to_remove = list(member_has)
|
||||
|
||||
# Role operations
|
||||
if to_add:
|
||||
try:
|
||||
await member.add_roles(to_add, reason="Updating purchased colour role")
|
||||
except discord.HTTPException:
|
||||
# TODO: Add to log
|
||||
to_add = None
|
||||
pass
|
||||
|
||||
if to_remove:
|
||||
try:
|
||||
await member.remove_roles(*to_remove, reason="Updating purchased colour role")
|
||||
except discord.HTTPException:
|
||||
# TODO: Add to log
|
||||
pass
|
||||
|
||||
# Only charge the member if everything went well
|
||||
if to_add:
|
||||
ctx.alion.addCoins(-self.price)
|
||||
|
||||
# Build strings for logging and response
|
||||
desc = None # Description of reply message to the member
|
||||
log_str = None # Description of event log message
|
||||
log_error = False # Whether to log an error
|
||||
|
||||
if to_add:
|
||||
if to_remove:
|
||||
if len(to_remove) > 1:
|
||||
rem_str = ', '.join(role.mention for role in to_remove[:-1]) + 'and' + to_remove[-1].mention
|
||||
else:
|
||||
rem_str = to_remove[0].mention
|
||||
desc = "You have exchanged {} for {}. Enjoy!".format(rem_str, to_add.mention)
|
||||
log_str = "{} exchanged {} for {}.".format(
|
||||
member.mention,
|
||||
rem_str,
|
||||
to_add.mention
|
||||
)
|
||||
else:
|
||||
desc = "You have bought {}. Enjoy!".format(to_add.mention)
|
||||
log_str = "{} bought {}.".format(member.mention, to_add.mention)
|
||||
else:
|
||||
desc = (
|
||||
"Something went wrong! Please try again later.\n"
|
||||
"(I don't have the server permissions to give this role to you!)"
|
||||
)
|
||||
log_str = (
|
||||
"{} bought `{}`, but I couldn't add the role!\n"
|
||||
"Please ensure that I have permission to manage this role.\n"
|
||||
"(I need to have the `manage_roles` permission, "
|
||||
"and my top role needs to be above the colour roles.)"
|
||||
).format(member.mention, self.roleid)
|
||||
log_error = True
|
||||
|
||||
# Build and send embeds
|
||||
reply_embed = discord.Embed(
|
||||
colour=to_add.colour if to_add else discord.Colour.red(),
|
||||
description=desc,
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
)
|
||||
if to_add:
|
||||
reply_embed.set_footer(
|
||||
text="New Balance: {} LC".format(ctx.alion.coins)
|
||||
)
|
||||
log_embed = discord.Embed(
|
||||
title="Colour Role Purchased" if not log_error else "Error purchasing colour role.",
|
||||
colour=discord.Colour.red() if log_error else discord.Colour.orange(),
|
||||
description=log_str
|
||||
)
|
||||
try:
|
||||
await ctx.reply(embed=reply_embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
event_log = ctx.guild_settings.event_log.value
|
||||
if event_log:
|
||||
try:
|
||||
await event_log.send(embed=log_embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
if not to_add:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# Shop admin interface
|
||||
@classmethod
|
||||
async def parse_add(cls, ctx, args):
|
||||
"""
|
||||
Parse a request to add colour roles.
|
||||
Syntax: `<price>, <role>`, with different lines treated as different entries.
|
||||
|
||||
Assumes the author is an admin.
|
||||
"""
|
||||
if not args:
|
||||
raise SafeCancellation("No arguments given, nothing to do!")
|
||||
|
||||
lines = args.splitlines()
|
||||
to_add = [] # List of (price, role) tuples to add
|
||||
for line in lines:
|
||||
# Syntax: <price>, <role>
|
||||
splits = line.split(',')
|
||||
splits = [split.strip() for split in splits]
|
||||
if len(splits) < 2 or not splits[0].isdigit():
|
||||
raise SafeCancellation("**Syntax:** `--add <price>, <role>`")
|
||||
price = int(splits[0])
|
||||
role = await ctx.find_role(splits[1], create=True, interactive=True, allow_notfound=False)
|
||||
to_add.append((price, role))
|
||||
|
||||
# Add the roles to data
|
||||
for price, role in to_add:
|
||||
# TODO: Batch update would be best
|
||||
await cls.create(ctx.guild.id, price, role.id)
|
||||
|
||||
# Report back
|
||||
if len(to_add) > 1:
|
||||
await ctx.reply(
|
||||
embed=discord.Embed(
|
||||
title="Shop Updated",
|
||||
description="Added `{}` new colours to the shop!".format(len(to_add))
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.reply(
|
||||
embed=discord.Embed(
|
||||
title="Shop Updated",
|
||||
description="Added {} to the shop for `{}` coins.".format(to_add[0][1].mention, to_add[0][0])
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def parse_remove(cls, ctx, args, items):
|
||||
"""
|
||||
Parse a request to remove colour roles.
|
||||
Syntax: `<ids>` or `<command separated roles>`
|
||||
|
||||
Assumes the author is an admin.
|
||||
"""
|
||||
if not items:
|
||||
raise SafeCancellation("Colour shop is empty, nothing to delete!")
|
||||
|
||||
to_delete = []
|
||||
if args:
|
||||
if re.search(multiselect_regex, args):
|
||||
# ids were selected
|
||||
indexes = parse_ranges(args)
|
||||
to_delete = [items[index] for index in indexes if index < len(items)]
|
||||
|
||||
if not to_delete:
|
||||
# Assume comma separated list of roles
|
||||
splits = args.split(',')
|
||||
splits = [split.strip() for split in splits]
|
||||
available_roles = (item.role for item in items)
|
||||
available_roles = [role for role in available_roles if role]
|
||||
roles = [
|
||||
await ctx.find_role(rolestr, collection=available_roles, interactive=True, allow_notfound=False)
|
||||
for rolestr in splits
|
||||
]
|
||||
roleids = set(role.id for role in roles)
|
||||
to_delete = [item for item in items if item.roleid in roleids]
|
||||
else:
|
||||
# Interactive multi-selector
|
||||
itemids = [item.itemid for item in items]
|
||||
embeds = cls.cat_shop_embeds(
|
||||
ctx.guild.id,
|
||||
itemids,
|
||||
hint=("Please select colour(s) to remove, or `c` to cancel.\n"
|
||||
"(Respond with e.g. `1, 2, 3` or `1-3`.)")
|
||||
)
|
||||
out_msg = await ctx.pager(embeds)
|
||||
|
||||
def check(msg):
|
||||
valid = msg.channel == ctx.ch and msg.author == ctx.author
|
||||
valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c')
|
||||
return valid
|
||||
|
||||
try:
|
||||
message = await ctx.client.wait_for('message', check=check, timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
await out_msg.delete()
|
||||
await ctx.error_reply("Session timed out. No colour roles were removed.")
|
||||
return
|
||||
|
||||
try:
|
||||
await out_msg.delete()
|
||||
await message.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
if message.content.lower() == 'c':
|
||||
return
|
||||
|
||||
to_delete = [
|
||||
items[index]
|
||||
for index in parse_ranges(message.content) if index < len(items)
|
||||
]
|
||||
if not to_delete:
|
||||
raise SafeCancellation("Nothing to delete!")
|
||||
|
||||
# Build an ack string before we delete
|
||||
rolestr = to_delete[0].role.mention if to_delete[0].role else "`{}`".format(to_delete[0].roleid)
|
||||
|
||||
# Delete the items
|
||||
shop_items.delete_where(itemid=[item.itemid for item in to_delete])
|
||||
|
||||
# Update the info cache
|
||||
[shop_item_info.row_cache.pop(item.itemid, None) for item in to_delete]
|
||||
|
||||
# Ack and log
|
||||
if len(to_delete) > 1:
|
||||
try:
|
||||
await ctx.reply(
|
||||
embed=discord.Embed(
|
||||
title="Colour Roles removed",
|
||||
description="You have removed `{}` colour roles.".format(len(to_delete)),
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
event_log = ctx.guild_settings.event_log.value
|
||||
if event_log:
|
||||
try:
|
||||
await event_log.send(
|
||||
embed=discord.Embed(
|
||||
title="Colour Roles deleted",
|
||||
description="{} removed `{}` colour roles from the shop.".format(
|
||||
ctx.author.mention,
|
||||
len(to_delete)
|
||||
),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
)
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
await ctx.reply(
|
||||
embed=discord.Embed(
|
||||
title="Colour Role removed",
|
||||
description="You have removed the colour role {}.".format(rolestr),
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
event_log = ctx.guild_settings.event_log.value
|
||||
if event_log:
|
||||
try:
|
||||
await event_log.send(
|
||||
embed=discord.Embed(
|
||||
title="Colour Role deleted",
|
||||
description="{} removed the colour role {} from the shop.".format(
|
||||
ctx.author.mention,
|
||||
rolestr
|
||||
),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
)
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
async def parse_clear(cls, ctx):
|
||||
"""
|
||||
Parse a request to clear colour roles.
|
||||
|
||||
Assumes the author is an admin.
|
||||
"""
|
||||
if await ctx.ask("Are you sure you want to remove all colour roles from the shop?"):
|
||||
shop_items.delete_where(guildid=ctx.guild.id, item_type=cls.item_type)
|
||||
await ctx.reply("All colour roles removed from the shop.")
|
||||
ctx.guild_settings.event_log.log("{} cleared the colour role shop.".format(ctx.author.mention))
|
||||
265
bot/modules/pending-rewrite/economy/shop_core/ShopItem.py
Normal file
265
bot/modules/pending-rewrite/economy/shop_core/ShopItem.py
Normal file
@@ -0,0 +1,265 @@
|
||||
import discord
|
||||
import datetime
|
||||
from typing import List
|
||||
|
||||
from meta import client
|
||||
from utils.lib import FieldEnum
|
||||
from data import Row
|
||||
from settings import GuildSettings
|
||||
|
||||
from .data import shop_items, shop_item_info
|
||||
|
||||
|
||||
class ShopItemType(FieldEnum):
|
||||
COLOUR_ROLE = 'COLOUR_ROLE', 'Colour'
|
||||
|
||||
|
||||
class ShopItem:
|
||||
"""
|
||||
Abstract base class representing an item in a guild shop.
|
||||
"""
|
||||
__slots__ = ('itemid', '_guild')
|
||||
|
||||
# Mapping of item types to class handlers
|
||||
_item_classes = {} # ShopItemType -> ShopItem subclass
|
||||
|
||||
# Item type handled by the current subclass
|
||||
item_type = None # type: ShopItemType
|
||||
|
||||
# Format string to use for each item of this type in the shop embed
|
||||
shop_fmt = "`[{num:<{num_len}}]` | `{item.price:<{price_len}} LC` {item.display_name}"
|
||||
|
||||
# Shop input modifiers
|
||||
allow_multi_select = True
|
||||
buy_hint = None
|
||||
|
||||
def __init__(self, itemid, *args, **kwargs):
|
||||
self.itemid = itemid # itemid in shop_items representing this info
|
||||
self._guild = None # cached Guild
|
||||
|
||||
# Meta
|
||||
@classmethod
|
||||
def register_item_class(cls, itemcls):
|
||||
"""
|
||||
Decorator to register a class as a handler for a given item type.
|
||||
The item type must be set as the `item_type` class attribute.
|
||||
"""
|
||||
cls._item_classes[itemcls.item_type] = itemcls
|
||||
return itemcls
|
||||
|
||||
@classmethod
|
||||
async def create(cls, guildid, price, *args, **kwargs):
|
||||
"""
|
||||
Create a new ShopItem of this type.
|
||||
Must be implemented by each item class.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def fetch_where(cls, **kwargs):
|
||||
"""
|
||||
Fetch ShopItems matching the given conditions.
|
||||
Automatically filters by `item_type` if set in class, and not provided.
|
||||
"""
|
||||
if cls.item_type is not None and 'item_type' not in kwargs:
|
||||
kwargs['item_type'] = cls.item_type
|
||||
rows = shop_item_info.fetch_rows_where(**kwargs)
|
||||
return [
|
||||
cls._item_classes[ShopItemType(row.item_type)](row.itemid)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, itemid):
|
||||
"""
|
||||
Fetch a single ShopItem by itemid.
|
||||
"""
|
||||
row = shop_item_info.fetch(itemid)
|
||||
if row:
|
||||
return cls._item_classes[ShopItemType(row.item_type)](row.itemid)
|
||||
else:
|
||||
return None
|
||||
|
||||
# Data and transparent data properties
|
||||
@property
|
||||
def data(self) -> Row:
|
||||
"""
|
||||
Return the cached data row for this item.
|
||||
This is not guaranteed to be up to date.
|
||||
This is also not guaranteed to keep existing during a session.
|
||||
"""
|
||||
return shop_item_info.fetch(self.itemid)
|
||||
|
||||
@property
|
||||
def guildid(self) -> int:
|
||||
return self.data.guildid
|
||||
|
||||
@property
|
||||
def price(self) -> int:
|
||||
return self.data.price
|
||||
|
||||
@property
|
||||
def purchasable(self) -> bool:
|
||||
return self.data.purchasable
|
||||
|
||||
# Computed properties
|
||||
@property
|
||||
def guild(self) -> discord.Guild:
|
||||
if not self._guild:
|
||||
self._guild = client.get_guild(self.guildid)
|
||||
return self._guild
|
||||
|
||||
@property
|
||||
def guild_settings(self) -> GuildSettings:
|
||||
return GuildSettings(self.guildid)
|
||||
|
||||
# Display properties
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""
|
||||
Short name to display after purchasing the item, and by default in the shop.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
# Data manipulation methods
|
||||
def refresh(self) -> Row:
|
||||
"""
|
||||
Refresh the stored data row.
|
||||
"""
|
||||
shop_item_info.row_cache.pop(self.itemid, None)
|
||||
return self.data
|
||||
|
||||
def _update(self, **kwargs):
|
||||
"""
|
||||
Updates the data with the provided kwargs.
|
||||
Subclasses are expected to override this is they provide their own updatable data.
|
||||
|
||||
This method does *not* refresh the data row. This is expect to be handled by `update`.
|
||||
"""
|
||||
handled = ('price', 'purchasable')
|
||||
|
||||
update = {key: kwargs[key] for key in handled}
|
||||
if update:
|
||||
shop_items.update_where(
|
||||
update,
|
||||
itemid=self.itemid
|
||||
)
|
||||
|
||||
async def update(self, **kwargs):
|
||||
"""
|
||||
Update the shop item with the given kwargs.
|
||||
"""
|
||||
self._update()
|
||||
self.refresh()
|
||||
|
||||
# Formatting
|
||||
@classmethod
|
||||
def _cat_embed_items(cls, items: List['ShopItem'], blocksize: int = 20,
|
||||
fmt: str = shop_fmt, **kwargs) -> List[discord.Embed]:
|
||||
"""
|
||||
Build a list of embeds for the current item type from a list of items.
|
||||
These embeds may be used anywhere multiple items may be shown,
|
||||
including confirmations and shop pages.
|
||||
Subclasses may extend or override.
|
||||
"""
|
||||
embeds = []
|
||||
if items:
|
||||
# Cut into blocks
|
||||
item_blocks = [items[i:i+blocksize] for i in range(0, len(items), blocksize)]
|
||||
for i, item_block in enumerate(item_blocks):
|
||||
# Compute lengths
|
||||
num_len = len(str((i * blocksize + len(item_block) - 1)))
|
||||
max_price = max(item.price for item in item_block)
|
||||
price_len = len(str(max_price))
|
||||
|
||||
# Format items
|
||||
string_block = '\n'.join(
|
||||
fmt.format(
|
||||
item=item,
|
||||
num=i * blocksize + j,
|
||||
num_len=num_len,
|
||||
price_len=price_len
|
||||
) for j, item in enumerate(item_block)
|
||||
)
|
||||
|
||||
# Build embed
|
||||
embed = discord.Embed(
|
||||
description=string_block,
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
)
|
||||
if len(item_blocks) > 1:
|
||||
embed.set_footer(text="Page {}/{}".format(i+1, len(item_blocks)))
|
||||
|
||||
embeds.append(embed)
|
||||
else:
|
||||
# Empty shop case, should generally be avoided
|
||||
embed = discord.Embed(
|
||||
description="Nothing to show!"
|
||||
)
|
||||
embeds.append(embed)
|
||||
|
||||
return embeds
|
||||
|
||||
# Shop interface
|
||||
@classmethod
|
||||
def _cat_shop_embed_items(cls, items: List['ShopItem'], **kwargs) -> List[discord.Embed]:
|
||||
"""
|
||||
Embed a list of items specifically for displaying in the shop.
|
||||
Subclasses will usually extend or override this, if only to add metadata.
|
||||
"""
|
||||
if items:
|
||||
# TODO: prefix = items[0].guild_settings.prefix.value
|
||||
prefix = client.prefix
|
||||
|
||||
embeds = cls._cat_embed_items(items, **kwargs)
|
||||
for embed in embeds:
|
||||
embed.title = "{} shop!".format(cls.item_type.desc)
|
||||
embed.description = "{}\n\n{}".format(
|
||||
embed.description,
|
||||
"Buy items with `{prefix}buy <numbers>`, e.g. `{prefix}buy 1, 2, 3`.".format(
|
||||
prefix=prefix
|
||||
)
|
||||
)
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
title="{} shop!".format(cls.item_type.desc),
|
||||
description="This shop is empty! Please come back later."
|
||||
)
|
||||
embeds = [embed]
|
||||
return embeds
|
||||
|
||||
@classmethod
|
||||
def cat_shop_embeds(cls, guildid: int, itemids: List[int] = None, **kwargs) -> List[discord.Embed]:
|
||||
"""
|
||||
Format the items of this type (i.e. this category) as one or more embeds.
|
||||
Subclasses may extend or override.
|
||||
"""
|
||||
if itemids is None:
|
||||
# Get itemids if not provided
|
||||
# TODO: Not using the row cache here, make sure we don't need an extended caching form
|
||||
rows = shop_item_info.fetch_rows_where(
|
||||
guildid=guildid,
|
||||
item_type=cls.item_type,
|
||||
purchasable=True,
|
||||
deleted=False
|
||||
)
|
||||
itemids = [row.itemid for row in rows]
|
||||
elif not all(itemid in shop_item_info.row_cache for itemid in itemids):
|
||||
# Ensure cache is populated
|
||||
shop_item_info.fetch_rows_where(itemid=itemids)
|
||||
|
||||
return cls._cat_shop_embed_items([cls(itemid) for itemid in itemids], **kwargs)
|
||||
|
||||
async def buy(self, ctx):
|
||||
"""
|
||||
Action to trigger when a member buys this item.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
# Shop admin interface
|
||||
@classmethod
|
||||
async def parse_new(self, ctx):
|
||||
"""
|
||||
Parse new shop items.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,4 @@
|
||||
from . import data
|
||||
|
||||
from .ShopItem import ShopItem, ShopItemType
|
||||
from .ColourRole import ColourRole
|
||||
19
bot/modules/pending-rewrite/economy/shop_core/data.py
Normal file
19
bot/modules/pending-rewrite/economy/shop_core/data.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from cachetools import LRUCache
|
||||
|
||||
from data import Table, RowTable
|
||||
|
||||
|
||||
shop_items = Table('shop_items')
|
||||
|
||||
colour_roles = Table('shop_items_colour_roles', attach_as='colour_roles')
|
||||
|
||||
|
||||
shop_item_info = RowTable(
|
||||
'shop_item_info',
|
||||
('itemid',
|
||||
'guildid', 'item_type', 'price', 'purchasable', 'deleted', 'created_at',
|
||||
'roleid', # Colour roles
|
||||
),
|
||||
'itemid',
|
||||
cache=LRUCache(1000)
|
||||
)
|
||||
0
bot/modules/pending-rewrite/guide/__init__.py
Normal file
0
bot/modules/pending-rewrite/guide/__init__.py
Normal file
7
bot/modules/pending-rewrite/guild_admin/__init__.py
Normal file
7
bot/modules/pending-rewrite/guild_admin/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .module import module
|
||||
|
||||
from . import guild_config
|
||||
from . import statreset
|
||||
from . import new_members
|
||||
from . import reaction_roles
|
||||
from . import economy
|
||||
@@ -0,0 +1,3 @@
|
||||
from ..module import module
|
||||
|
||||
from . import set_coins
|
||||
104
bot/modules/pending-rewrite/guild_admin/economy/set_coins.py
Normal file
104
bot/modules/pending-rewrite/guild_admin/economy/set_coins.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import discord
|
||||
import datetime
|
||||
from wards import guild_admin
|
||||
|
||||
from settings import GuildSettings
|
||||
from core import Lion
|
||||
|
||||
from ..module import module
|
||||
|
||||
POSTGRES_INT_MAX = 2147483647
|
||||
|
||||
@module.cmd(
|
||||
"set_coins",
|
||||
group="Guild Admin",
|
||||
desc="Set coins on a member."
|
||||
)
|
||||
@guild_admin()
|
||||
async def cmd_set(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}set_coins <user mention> <amount>
|
||||
Description:
|
||||
Sets the given number of coins on the mentioned user.
|
||||
If a number greater than 0 is mentioned, will add coins.
|
||||
If a number less than 0 is mentioned, will remove coins.
|
||||
Note: LionCoins on a member cannot be negative.
|
||||
Example:
|
||||
{prefix}set_coins {ctx.author.mention} 100
|
||||
{prefix}set_coins {ctx.author.mention} -100
|
||||
"""
|
||||
# Extract target and amount
|
||||
# Handle a slightly more flexible input than stated
|
||||
splits = ctx.args.split()
|
||||
digits = [isNumber(split) for split in splits[:2]]
|
||||
mentions = ctx.msg.mentions
|
||||
if len(splits) < 2 or not any(digits) or not (all(digits) or mentions):
|
||||
return await _send_usage(ctx)
|
||||
|
||||
if all(digits):
|
||||
# Both are digits, hopefully one is a member id, and one is an amount.
|
||||
target, amount = ctx.guild.get_member(int(splits[0])), int(splits[1])
|
||||
if not target:
|
||||
amount, target = int(splits[0]), ctx.guild.get_member(int(splits[1]))
|
||||
if not target:
|
||||
return await _send_usage(ctx)
|
||||
elif digits[0]:
|
||||
amount, target = int(splits[0]), mentions[0]
|
||||
elif digits[1]:
|
||||
target, amount = mentions[0], int(splits[1])
|
||||
|
||||
# Fetch the associated lion
|
||||
target_lion = Lion.fetch(ctx.guild.id, target.id)
|
||||
|
||||
# Check sanity conditions
|
||||
if target == ctx.client.user:
|
||||
return await ctx.embed_reply("Thanks, but Ari looks after all my needs!")
|
||||
if target.bot:
|
||||
return await ctx.embed_reply("We are still waiting for {} to open an account.".format(target.mention))
|
||||
|
||||
# Finally, send the amount and the ack message
|
||||
# Postgres `coins` column is `integer`, sanity check postgres int limits - which are smalled than python int range
|
||||
target_coins_to_set = target_lion.coins + amount
|
||||
if target_coins_to_set >= 0 and target_coins_to_set <= POSTGRES_INT_MAX:
|
||||
target_lion.addCoins(amount)
|
||||
elif target_coins_to_set < 0:
|
||||
target_coins_to_set = -target_lion.coins # Coins cannot go -ve, cap to 0
|
||||
target_lion.addCoins(target_coins_to_set)
|
||||
target_coins_to_set = 0
|
||||
else:
|
||||
return await ctx.embed_reply("Member coins cannot be more than {}".format(POSTGRES_INT_MAX))
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Funds Set",
|
||||
description="You have set LionCoins on {} to **{}**!".format(target.mention,target_coins_to_set),
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
).set_footer(text=str(ctx.author), icon_url=ctx.author.avatar_url)
|
||||
|
||||
await ctx.reply(embed=embed, reference=ctx.msg)
|
||||
GuildSettings(ctx.guild.id).event_log.log(
|
||||
"{} set {}'s LionCoins to`{}`.".format(
|
||||
ctx.author.mention,
|
||||
target.mention,
|
||||
target_coins_to_set
|
||||
),
|
||||
title="Funds Set"
|
||||
)
|
||||
|
||||
def isNumber(var):
|
||||
try:
|
||||
return isinstance(int(var), int)
|
||||
except:
|
||||
return False
|
||||
|
||||
async def _send_usage(ctx):
|
||||
return await ctx.error_reply(
|
||||
"**Usage:** `{prefix}set_coins <mention> <amount>`\n"
|
||||
"**Example:**\n"
|
||||
" {prefix}set_coins {ctx.author.mention} 100\n"
|
||||
" {prefix}set_coins {ctx.author.mention} -100".format(
|
||||
prefix=ctx.best_prefix,
|
||||
ctx=ctx
|
||||
)
|
||||
)
|
||||
163
bot/modules/pending-rewrite/guild_admin/guild_config.py
Normal file
163
bot/modules/pending-rewrite/guild_admin/guild_config.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import difflib
|
||||
import discord
|
||||
from cmdClient.lib import SafeCancellation
|
||||
|
||||
from wards import guild_admin, guild_moderator
|
||||
from settings import UserInputError, GuildSettings
|
||||
|
||||
from utils.lib import prop_tabulate
|
||||
import utils.ctx_addons # noqa
|
||||
|
||||
from .module import module
|
||||
|
||||
|
||||
# Pages of configuration categories to display
|
||||
cat_pages = {
|
||||
'Administration': ('Meta', 'Guild Roles', 'New Members'),
|
||||
'Moderation': ('Moderation', 'Video Channels'),
|
||||
'Productivity': ('Study Tracking', 'TODO List', 'Workout'),
|
||||
'Study Rooms': ('Rented Rooms', 'Scheduled Sessions'),
|
||||
}
|
||||
|
||||
# Descriptions of each configuration category
|
||||
descriptions = {
|
||||
}
|
||||
|
||||
|
||||
@module.cmd("config",
|
||||
desc="View and modify the server settings.",
|
||||
flags=('add', 'remove'),
|
||||
group="Guild Configuration")
|
||||
@guild_moderator()
|
||||
async def cmd_config(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}config
|
||||
{prefix}config info
|
||||
{prefix}config <setting>
|
||||
{prefix}config <setting> <value>
|
||||
Description:
|
||||
Display the server configuration panel, and view/modify the server settings.
|
||||
|
||||
Use `{prefix}config` to see the settings with their current values, or `{prefix}config info` to \
|
||||
show brief descriptions instead.
|
||||
Use `{prefix}config <setting>` (e.g. `{prefix}config event_log`) to view a more detailed description for each setting, \
|
||||
including the possible values.
|
||||
Finally, use `{prefix}config <setting> <value>` to set the setting to the given value.
|
||||
To unset a setting, or set it to the default, use `{prefix}config <setting> None`.
|
||||
|
||||
Additional usage for settings which accept a list of values:
|
||||
`{prefix}config <setting> <value1>, <value2>, ...`
|
||||
`{prefix}config <setting> --add <value1>, <value2>, ...`
|
||||
`{prefix}config <setting> --remove <value1>, <value2>, ...`
|
||||
Note that the first form *overwrites* the setting completely,\
|
||||
while the second two will only *add* and *remove* values, respectively.
|
||||
Examples``:
|
||||
{prefix}config event_log
|
||||
{prefix}config event_log {ctx.ch.name}
|
||||
{prefix}config autoroles Member, Level 0, Level 10
|
||||
{prefix}config autoroles --remove Level 10
|
||||
"""
|
||||
# Cache and map some info for faster access
|
||||
setting_displaynames = {setting.display_name.lower(): setting for setting in GuildSettings.settings.values()}
|
||||
|
||||
if not ctx.args or ctx.args.lower() in ('info', 'help'):
|
||||
# Fill the setting cats
|
||||
cats = {}
|
||||
for setting in GuildSettings.settings.values():
|
||||
cat = cats.get(setting.category, [])
|
||||
cat.append(setting)
|
||||
cats[setting.category] = cat
|
||||
|
||||
# Format the cats
|
||||
sections = {}
|
||||
for catname, cat in cats.items():
|
||||
catprops = {
|
||||
setting.display_name: setting.get(ctx.guild.id).summary if not ctx.args else setting.desc
|
||||
for setting in cat
|
||||
}
|
||||
# TODO: Add cat description here
|
||||
sections[catname] = prop_tabulate(*zip(*catprops.items()))
|
||||
|
||||
# Put the cats on the correct pages
|
||||
pages = []
|
||||
for page_name, cat_names in cat_pages.items():
|
||||
page = {
|
||||
cat_name: sections[cat_name] for cat_name in cat_names if cat_name in sections
|
||||
}
|
||||
if page:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title=page_name,
|
||||
description=(
|
||||
"View brief setting descriptions with `{prefix}config info`.\n"
|
||||
"Use e.g. `{prefix}config event_log` to see more details.\n"
|
||||
"Modify a setting with e.g. `{prefix}config event_log {ctx.ch.name}`.\n"
|
||||
"See the [Online Tutorial]({tutorial}) for a complete setup guide.".format(
|
||||
prefix=ctx.best_prefix,
|
||||
ctx=ctx,
|
||||
tutorial="https://discord.studylions.com/tutorial"
|
||||
)
|
||||
)
|
||||
)
|
||||
for name, value in page.items():
|
||||
embed.add_field(name=name, value=value, inline=False)
|
||||
|
||||
pages.append(embed)
|
||||
|
||||
if len(pages) > 1:
|
||||
[
|
||||
embed.set_footer(text="Page {} of {}".format(i+1, len(pages)))
|
||||
for i, embed in enumerate(pages)
|
||||
]
|
||||
await ctx.pager(pages)
|
||||
elif pages:
|
||||
await ctx.reply(embed=pages[0])
|
||||
else:
|
||||
await ctx.reply("No configuration options set up yet!")
|
||||
else:
|
||||
# Some args were given
|
||||
parts = ctx.args.split(maxsplit=1)
|
||||
|
||||
name = parts[0]
|
||||
setting = setting_displaynames.get(name.lower(), None)
|
||||
if setting is None:
|
||||
matches = difflib.get_close_matches(name, setting_displaynames.keys(), n=2)
|
||||
match = "`{}`".format('` or `'.join(matches)) if matches else None
|
||||
return await ctx.error_reply(
|
||||
"Couldn't find a setting called `{}`!\n"
|
||||
"{}"
|
||||
"Use `{}config info` to see all the server settings.".format(
|
||||
name,
|
||||
"Maybe you meant {}?\n".format(match) if match else "",
|
||||
ctx.best_prefix
|
||||
)
|
||||
)
|
||||
|
||||
if len(parts) == 1 and not ctx.msg.attachments:
|
||||
# config <setting>
|
||||
# View config embed for provided setting
|
||||
await setting.get(ctx.guild.id).widget(ctx, flags=flags)
|
||||
else:
|
||||
# config <setting> <value>
|
||||
# Ignoring the write ward currently and just enforcing admin
|
||||
# Check the write ward
|
||||
# if not await setting.write_ward.run(ctx):
|
||||
# raise SafeCancellation(setting.write_ward.msg)
|
||||
if not await guild_admin.run(ctx):
|
||||
raise SafeCancellation("You need to be a server admin to modify settings!")
|
||||
|
||||
# Attempt to set config setting
|
||||
try:
|
||||
parsed = await setting.parse(ctx.guild.id, ctx, parts[1] if len(parts) > 1 else '')
|
||||
parsed.write(add_only=flags['add'], remove_only=flags['remove'])
|
||||
except UserInputError as e:
|
||||
await ctx.reply(embed=discord.Embed(
|
||||
description="{} {}".format('❌', e.msg),
|
||||
colour=discord.Colour.red()
|
||||
))
|
||||
else:
|
||||
await ctx.reply(embed=discord.Embed(
|
||||
description="{} {}".format('✅', setting.get(ctx.guild.id).success_response),
|
||||
colour=discord.Colour.green()
|
||||
))
|
||||
4
bot/modules/pending-rewrite/guild_admin/module.py
Normal file
4
bot/modules/pending-rewrite/guild_admin/module.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
|
||||
module = LionModule("Guild_Admin")
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import settings
|
||||
from . import greetings
|
||||
from . import roles
|
||||
@@ -0,0 +1,6 @@
|
||||
from data import Table, RowTable
|
||||
|
||||
|
||||
autoroles = Table('autoroles')
|
||||
bot_autoroles = Table('bot_autoroles')
|
||||
past_member_roles = Table('past_member_roles')
|
||||
@@ -0,0 +1,29 @@
|
||||
import discord
|
||||
from LionContext import LionContext as Context
|
||||
|
||||
from meta import client
|
||||
|
||||
from .settings import greeting_message, greeting_channel, returning_message
|
||||
|
||||
|
||||
@client.add_after_event('member_join')
|
||||
async def send_greetings(client, member):
|
||||
guild = member.guild
|
||||
|
||||
returning = bool(client.data.lions.fetch((guild.id, member.id)))
|
||||
|
||||
# Handle greeting message
|
||||
channel = greeting_channel.get(guild.id).value
|
||||
if channel is not None:
|
||||
if channel == greeting_channel.DMCHANNEL:
|
||||
channel = member
|
||||
|
||||
ctx = Context(client, guild=guild, author=member)
|
||||
if returning:
|
||||
args = returning_message.get(guild.id).args(ctx)
|
||||
else:
|
||||
args = greeting_message.get(guild.id).args(ctx)
|
||||
try:
|
||||
await channel.send(**args)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
115
bot/modules/pending-rewrite/guild_admin/new_members/roles.py
Normal file
115
bot/modules/pending-rewrite/guild_admin/new_members/roles.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import asyncio
|
||||
import discord
|
||||
from collections import defaultdict
|
||||
|
||||
from meta import client
|
||||
from core import Lion
|
||||
from settings import GuildSettings
|
||||
|
||||
from .settings import autoroles, bot_autoroles, role_persistence
|
||||
from .data import past_member_roles
|
||||
|
||||
|
||||
# Locks to avoid storing the roles while adding them
|
||||
# The locking is cautious, leaving data unchanged upon collision
|
||||
locks = defaultdict(asyncio.Lock)
|
||||
|
||||
|
||||
@client.add_after_event('member_join')
|
||||
async def join_role_tracker(client, member):
|
||||
"""
|
||||
Add autoroles or saved roles as needed.
|
||||
"""
|
||||
guild = member.guild
|
||||
if not guild.me.guild_permissions.manage_roles:
|
||||
# We can't manage the roles here, don't try to give/restore the member roles
|
||||
return
|
||||
|
||||
async with locks[(guild.id, member.id)]:
|
||||
if role_persistence.get(guild.id).value and client.data.lions.fetch((guild.id, member.id)):
|
||||
# Lookup stored roles
|
||||
role_rows = past_member_roles.select_where(
|
||||
guildid=guild.id,
|
||||
userid=member.id
|
||||
)
|
||||
# Identify roles from roleids
|
||||
roles = (guild.get_role(row['roleid']) for row in role_rows)
|
||||
# Remove non-existent roles
|
||||
roles = (role for role in roles if role is not None)
|
||||
# Remove roles the client can't add
|
||||
roles = [role for role in roles if role < guild.me.top_role]
|
||||
if roles:
|
||||
try:
|
||||
await member.add_roles(
|
||||
*roles,
|
||||
reason="Restoring saved roles.",
|
||||
)
|
||||
except discord.HTTPException:
|
||||
# This shouldn't ususally happen, but there are valid cases where it can
|
||||
# E.g. the user left while we were restoring their roles
|
||||
pass
|
||||
# Event log!
|
||||
GuildSettings(guild.id).event_log.log(
|
||||
"Restored the following roles for returning member {}:\n{}".format(
|
||||
member.mention,
|
||||
', '.join(role.mention for role in roles)
|
||||
),
|
||||
title="Saved roles restored"
|
||||
)
|
||||
else:
|
||||
# Add autoroles
|
||||
roles = bot_autoroles.get(guild.id).value if member.bot else autoroles.get(guild.id).value
|
||||
# Remove roles the client can't add
|
||||
roles = [role for role in roles if role < guild.me.top_role]
|
||||
if roles:
|
||||
try:
|
||||
await member.add_roles(
|
||||
*roles,
|
||||
reason="Adding autoroles.",
|
||||
)
|
||||
except discord.HTTPException:
|
||||
# This shouldn't ususally happen, but there are valid cases where it can
|
||||
# E.g. the user left while we were adding autoroles
|
||||
pass
|
||||
# Event log!
|
||||
GuildSettings(guild.id).event_log.log(
|
||||
"Gave {} the guild autoroles:\n{}".format(
|
||||
member.mention,
|
||||
', '.join(role.mention for role in roles)
|
||||
),
|
||||
titles="Autoroles added"
|
||||
)
|
||||
|
||||
|
||||
@client.add_after_event('member_remove')
|
||||
async def left_role_tracker(client, member):
|
||||
"""
|
||||
Delete and re-store member roles when they leave the server.
|
||||
"""
|
||||
if (member.guild.id, member.id) in locks and locks[(member.guild.id, member.id)].locked():
|
||||
# Currently processing a join event
|
||||
# Which means the member left while we were adding their roles
|
||||
# Cautiously return, not modifying the saved role data
|
||||
return
|
||||
|
||||
# Delete existing member roles for this user
|
||||
# NOTE: Not concurrency-safe
|
||||
past_member_roles.delete_where(
|
||||
guildid=member.guild.id,
|
||||
userid=member.id,
|
||||
)
|
||||
if role_persistence.get(member.guild.id).value:
|
||||
# Make sure the user has an associated lion, so we can detect when they rejoin
|
||||
Lion.fetch(member.guild.id, member.id)
|
||||
|
||||
# Then insert the current member roles
|
||||
values = [
|
||||
(member.guild.id, member.id, role.id)
|
||||
for role in member.roles
|
||||
if not role.is_bot_managed() and not role.is_integration() and not role.is_default()
|
||||
]
|
||||
if values:
|
||||
past_member_roles.insert_many(
|
||||
*values,
|
||||
insert_keys=('guildid', 'userid', 'roleid')
|
||||
)
|
||||
303
bot/modules/pending-rewrite/guild_admin/new_members/settings.py
Normal file
303
bot/modules/pending-rewrite/guild_admin/new_members/settings.py
Normal file
@@ -0,0 +1,303 @@
|
||||
import datetime
|
||||
import discord
|
||||
|
||||
import settings
|
||||
from settings import GuildSettings, GuildSetting
|
||||
import settings.setting_types as stypes
|
||||
from wards import guild_admin
|
||||
|
||||
from .data import autoroles, bot_autoroles
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class greeting_channel(stypes.Channel, GuildSetting):
|
||||
"""
|
||||
Setting describing the destination of the greeting message.
|
||||
|
||||
Extended to support the following special values, with input and output supported.
|
||||
Data `None` corresponds to `Off`.
|
||||
Data `1` corresponds to `DM`.
|
||||
"""
|
||||
DMCHANNEL = object()
|
||||
|
||||
category = "New Members"
|
||||
|
||||
attr_name = 'greeting_channel'
|
||||
_data_column = 'greeting_channel'
|
||||
|
||||
display_name = "welcome_channel"
|
||||
desc = "Channel to send the welcome message in"
|
||||
|
||||
long_desc = (
|
||||
"Channel to post the `welcome_message` in when a new user joins the server. "
|
||||
"Accepts `DM` to indicate the welcome should be sent via direct message."
|
||||
)
|
||||
_accepts = (
|
||||
"Text Channel name/id/mention, or `DM`, or `None` to disable."
|
||||
)
|
||||
_chan_type = discord.ChannelType.text
|
||||
|
||||
@classmethod
|
||||
def _data_to_value(cls, id, data, **kwargs):
|
||||
if data is None:
|
||||
return None
|
||||
elif data == 1:
|
||||
return cls.DMCHANNEL
|
||||
else:
|
||||
return super()._data_to_value(id, data, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def _data_from_value(cls, id, value, **kwargs):
|
||||
if value is None:
|
||||
return None
|
||||
elif value == cls.DMCHANNEL:
|
||||
return 1
|
||||
else:
|
||||
return super()._data_from_value(id, value, **kwargs)
|
||||
|
||||
@classmethod
|
||||
async def _parse_userstr(cls, ctx, id, userstr, **kwargs):
|
||||
lower = userstr.lower()
|
||||
if lower in ('0', 'none', 'off'):
|
||||
return None
|
||||
elif lower == 'dm':
|
||||
return 1
|
||||
else:
|
||||
return await super()._parse_userstr(ctx, id, userstr, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, id, data, **kwargs):
|
||||
if data is None:
|
||||
return "Off"
|
||||
elif data == 1:
|
||||
return "DM"
|
||||
else:
|
||||
return "<#{}>".format(data)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
value = self.value
|
||||
if not value:
|
||||
return "Welcome messages are disabled."
|
||||
elif value == self.DMCHANNEL:
|
||||
return "Welcome messages will be sent via direct message."
|
||||
else:
|
||||
return "Welcome messages will be posted in {}".format(self.formatted)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class greeting_message(stypes.Message, GuildSetting):
|
||||
category = "New Members"
|
||||
|
||||
attr_name = 'greeting_message'
|
||||
_data_column = 'greeting_message'
|
||||
|
||||
display_name = 'welcome_message'
|
||||
desc = "Welcome message sent to welcome new members."
|
||||
|
||||
long_desc = (
|
||||
"Message to send to the configured `welcome_channel` when a member joins the server for the first time."
|
||||
)
|
||||
|
||||
_default = r"""
|
||||
{
|
||||
"embed": {
|
||||
"title": "Welcome!",
|
||||
"thumbnail": {"url": "{guild_icon}"},
|
||||
"description": "Hi {mention}!\nWelcome to **{guild_name}**! You are the **{member_count}**th member.\nThere are currently **{studying_count}** people studying.\nGood luck and stay productive!",
|
||||
"color": 15695665
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
_substitution_desc = {
|
||||
'{mention}': "Mention the new member.",
|
||||
'{user_name}': "Username of the new member.",
|
||||
'{user_avatar}': "Avatar of the new member.",
|
||||
'{guild_name}': "Name of this server.",
|
||||
'{guild_icon}': "Server icon url.",
|
||||
'{member_count}': "Number of members in the server.",
|
||||
'{studying_count}': "Number of current voice channel members.",
|
||||
}
|
||||
|
||||
def substitution_keys(self, ctx, **kwargs):
|
||||
return {
|
||||
'{mention}': ctx.author.mention,
|
||||
'{user_name}': ctx.author.name,
|
||||
'{user_avatar}': str(ctx.author.avatar_url),
|
||||
'{guild_name}': ctx.guild.name,
|
||||
'{guild_icon}': str(ctx.guild.icon_url),
|
||||
'{member_count}': str(len(ctx.guild.members)),
|
||||
'{studying_count}': str(len([member for ch in ctx.guild.voice_channels for member in ch.members]))
|
||||
}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "The welcome message has been set!"
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class returning_message(stypes.Message, GuildSetting):
|
||||
category = "New Members"
|
||||
|
||||
attr_name = 'returning_message'
|
||||
_data_column = 'returning_message'
|
||||
|
||||
display_name = 'returning_message'
|
||||
desc = "Welcome message sent to returning members."
|
||||
|
||||
long_desc = (
|
||||
"Message to send to the configured `welcome_channel` when a member returns to the server."
|
||||
)
|
||||
|
||||
_default = r"""
|
||||
{
|
||||
"embed": {
|
||||
"title": "Welcome Back {user_name}!",
|
||||
"thumbnail": {"url": "{guild_icon}"},
|
||||
"description": "Welcome back to **{guild_name}**!\nYou last studied with us <t:{last_time}:R>.\nThere are currently **{studying_count}** people studying.\nGood luck and stay productive!",
|
||||
"color": 15695665
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
_substitution_desc = {
|
||||
'{mention}': "Mention the returning member.",
|
||||
'{user_name}': "Username of the member.",
|
||||
'{user_avatar}': "Avatar of the member.",
|
||||
'{guild_name}': "Name of this server.",
|
||||
'{guild_icon}': "Server icon url.",
|
||||
'{member_count}': "Number of members in the server.",
|
||||
'{studying_count}': "Number of current voice channel members.",
|
||||
'{last_time}': "Unix timestamp of the last time the member studied.",
|
||||
}
|
||||
|
||||
def substitution_keys(self, ctx, **kwargs):
|
||||
return {
|
||||
'{mention}': ctx.author.mention,
|
||||
'{user_name}': ctx.author.name,
|
||||
'{user_avatar}': str(ctx.author.avatar_url),
|
||||
'{guild_name}': ctx.guild.name,
|
||||
'{guild_icon}': str(ctx.guild.icon_url),
|
||||
'{member_count}': str(len(ctx.guild.members)),
|
||||
'{studying_count}': str(len([member for ch in ctx.guild.voice_channels for member in ch.members])),
|
||||
'{last_time}': int(ctx.alion.data._timestamp.replace(tzinfo=datetime.timezone.utc).timestamp()),
|
||||
}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "The returning message has been set!"
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class starting_funds(stypes.Integer, GuildSetting):
|
||||
category = "New Members"
|
||||
|
||||
attr_name = 'starting_funds'
|
||||
_data_column = 'starting_funds'
|
||||
|
||||
display_name = 'starting_funds'
|
||||
desc = "Coins given when a user first joins."
|
||||
|
||||
long_desc = (
|
||||
"Members will be given this number of coins the first time they join the server."
|
||||
)
|
||||
|
||||
_default = 1000
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "Members will be given `{}` coins when they first join the server.".format(self.formatted)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class autoroles(stypes.RoleList, settings.ListData, settings.Setting):
|
||||
category = "New Members"
|
||||
write_ward = guild_admin
|
||||
|
||||
attr_name = 'autoroles'
|
||||
|
||||
_table_interface = autoroles
|
||||
_id_column = 'guildid'
|
||||
_data_column = 'roleid'
|
||||
|
||||
display_name = "autoroles"
|
||||
desc = "Roles to give automatically to new members."
|
||||
|
||||
_force_unique = True
|
||||
|
||||
long_desc = (
|
||||
"These roles will be given automatically to users when they join the server. "
|
||||
"If `role_persistence` is enabled, the roles will only be given the first time a user joins the server."
|
||||
)
|
||||
|
||||
# Flat cache, no need to expire
|
||||
_cache = {}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "New members will be given the following roles:\n{}".format(self.formatted)
|
||||
else:
|
||||
return "New members will not automatically be given any roles."
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class bot_autoroles(stypes.RoleList, settings.ListData, settings.Setting):
|
||||
category = "New Members"
|
||||
write_ward = guild_admin
|
||||
|
||||
attr_name = 'bot_autoroles'
|
||||
|
||||
_table_interface = bot_autoroles
|
||||
_id_column = 'guildid'
|
||||
_data_column = 'roleid'
|
||||
|
||||
display_name = "bot_autoroles"
|
||||
desc = "Roles to give automatically to new bots."
|
||||
|
||||
_force_unique = True
|
||||
|
||||
long_desc = (
|
||||
"These roles will be given automatically to bots when they join the server. "
|
||||
"If `role_persistence` is enabled, the roles will only be given the first time a bot joins the server."
|
||||
)
|
||||
|
||||
# Flat cache, no need to expire
|
||||
_cache = {}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "New bots will be given the following roles:\n{}".format(self.formatted)
|
||||
else:
|
||||
return "New bots will not automatically be given any roles."
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class role_persistence(stypes.Boolean, GuildSetting):
|
||||
category = "New Members"
|
||||
|
||||
attr_name = "role_persistence"
|
||||
|
||||
_data_column = 'persist_roles'
|
||||
|
||||
display_name = "role_persistence"
|
||||
desc = "Whether to remember member roles when they leave the server."
|
||||
_outputs = {True: "Enabled", False: "Disabled"}
|
||||
_default = True
|
||||
|
||||
long_desc = (
|
||||
"When enabled, restores member roles when they rejoin the server.\n"
|
||||
"This enables profile roles and purchased roles, such as field of study and colour roles, "
|
||||
"as well as moderation roles, "
|
||||
"such as the studyban and mute roles, to persist even when a member leaves and rejoins.\n"
|
||||
"Note: Members who leave while this is disabled will not have their roles restored."
|
||||
)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Roles will now be restored when a member rejoins."
|
||||
else:
|
||||
return "Member roles will no longer be saved or restored."
|
||||
@@ -0,0 +1,6 @@
|
||||
from .module import module
|
||||
|
||||
from . import data
|
||||
from . import settings
|
||||
from . import tracker
|
||||
from . import command
|
||||
@@ -0,0 +1,943 @@
|
||||
import asyncio
|
||||
import discord
|
||||
from discord import PartialEmoji
|
||||
|
||||
from cmdClient.lib import ResponseTimedOut, UserCancelled
|
||||
from wards import guild_admin
|
||||
from settings import UserInputError
|
||||
from utils.lib import tick, cross
|
||||
|
||||
from .module import module
|
||||
from .tracker import ReactionRoleMessage
|
||||
from .data import reaction_role_reactions, reaction_role_messages
|
||||
from . import settings
|
||||
|
||||
|
||||
example_emoji = "🧮"
|
||||
example_str = "🧮 mathematics, 🫀 biology, 💻 computer science, 🖼️ design, 🩺 medicine"
|
||||
|
||||
|
||||
def _parse_messageref(ctx):
|
||||
"""
|
||||
Parse a message reference from the context message and return it.
|
||||
Removes the parsed string from `ctx.args` if applicable.
|
||||
Supports the following reference types, in precedence order:
|
||||
- A Discord message reply reference.
|
||||
- A message link.
|
||||
- A message id.
|
||||
|
||||
Returns: (channelid, messageid)
|
||||
`messageid` will be `None` if a valid reference was not found.
|
||||
`channelid` will be `None` if the message was provided by pure id.
|
||||
"""
|
||||
target_id = None
|
||||
target_chid = None
|
||||
|
||||
if ctx.msg.reference:
|
||||
# True message reference extract message and return
|
||||
target_id = ctx.msg.reference.message_id
|
||||
target_chid = ctx.msg.reference.channel_id
|
||||
elif ctx.args:
|
||||
# Parse the first word of the message arguments
|
||||
splits = ctx.args.split(maxsplit=1)
|
||||
maybe_target = splits[0]
|
||||
|
||||
# Expect a message id or message link
|
||||
if maybe_target.isdigit():
|
||||
# Assume it is a message id
|
||||
target_id = int(maybe_target)
|
||||
elif '/' in maybe_target:
|
||||
# Assume it is a link
|
||||
# Split out the channelid and messageid, if possible
|
||||
link_splits = maybe_target.rsplit('/', maxsplit=2)
|
||||
if len(link_splits) > 1 and link_splits[-1].isdigit() and link_splits[-2].isdigit():
|
||||
target_id = int(link_splits[-1])
|
||||
target_chid = int(link_splits[-2])
|
||||
|
||||
# If we found a target id, truncate the arguments
|
||||
if target_id is not None:
|
||||
if len(splits) > 1:
|
||||
ctx.args = splits[1].strip()
|
||||
else:
|
||||
ctx.args = ""
|
||||
else:
|
||||
# Last-ditch attempt, see if the argument could be a stored reaction
|
||||
maybe_emoji = maybe_target.strip(',')
|
||||
guild_message_rows = reaction_role_messages.fetch_rows_where(guildid=ctx.guild.id)
|
||||
messages = [ReactionRoleMessage.fetch(row.messageid) for row in guild_message_rows]
|
||||
emojis = {reaction.emoji: message for message in messages for reaction in message.reactions}
|
||||
emoji_name_map = {emoji.name.lower(): emoji for emoji in emojis}
|
||||
emoji_id_map = {emoji.id: emoji for emoji in emojis if emoji.id}
|
||||
result = _parse_emoji(maybe_emoji, emoji_name_map, emoji_id_map)
|
||||
if result and result in emojis:
|
||||
message = emojis[result]
|
||||
target_id = message.messageid
|
||||
target_chid = message.data.channelid
|
||||
|
||||
# Return the message reference
|
||||
return (target_chid, target_id)
|
||||
|
||||
|
||||
def _parse_emoji(emoji_str, name_map, id_map):
|
||||
"""
|
||||
Extract a PartialEmoji from a user provided emoji string, given the accepted raw names and ids.
|
||||
"""
|
||||
emoji = None
|
||||
if len(emoji_str) < 10 and all(ord(char) >= 256 for char in emoji_str):
|
||||
# The string is pure unicode, we assume built in emoji
|
||||
emoji = PartialEmoji(name=emoji_str)
|
||||
elif emoji_str.lower() in name_map:
|
||||
emoji = name_map[emoji_str.lower()]
|
||||
elif emoji_str.isdigit() and int(emoji_str) in id_map:
|
||||
emoji = id_map[int(emoji_str)]
|
||||
else:
|
||||
# Attempt to parse as custom emoji
|
||||
# Accept custom emoji provided in the full form
|
||||
emoji_split = emoji_str.strip('<>:').split(':')
|
||||
if len(emoji_split) in (2, 3) and emoji_split[-1].isdigit():
|
||||
emoji_id = int(emoji_split[-1])
|
||||
emoji_name = emoji_split[-2]
|
||||
emoji_animated = emoji_split[0] == 'a'
|
||||
emoji = PartialEmoji(
|
||||
name=emoji_name,
|
||||
id=emoji_id,
|
||||
animated=emoji_animated
|
||||
)
|
||||
return emoji
|
||||
|
||||
|
||||
async def reaction_ask(ctx, question, timeout=120, timeout_msg=None, cancel_msg=None):
|
||||
"""
|
||||
Asks the author the provided question in an embed, and provides check/cross reactions for answering.
|
||||
"""
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
description=question
|
||||
)
|
||||
out_msg = await ctx.reply(embed=embed)
|
||||
|
||||
# Wait for a tick/cross
|
||||
asyncio.create_task(out_msg.add_reaction(tick))
|
||||
asyncio.create_task(out_msg.add_reaction(cross))
|
||||
|
||||
def check(reaction, user):
|
||||
result = True
|
||||
result = result and reaction.message == out_msg
|
||||
result = result and user == ctx.author
|
||||
result = result and (reaction.emoji == tick or reaction.emoji == cross)
|
||||
return result
|
||||
|
||||
try:
|
||||
reaction, _ = await ctx.client.wait_for(
|
||||
'reaction_add',
|
||||
check=check,
|
||||
timeout=120
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
await out_msg.edit(
|
||||
embed=discord.Embed(
|
||||
colour=discord.Colour.red(),
|
||||
description=timeout_msg or "Prompt timed out."
|
||||
)
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
raise ResponseTimedOut from None
|
||||
if reaction.emoji == cross:
|
||||
try:
|
||||
await out_msg.edit(
|
||||
embed=discord.Embed(
|
||||
colour=discord.Colour.red(),
|
||||
description=cancel_msg or "Cancelled."
|
||||
)
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
raise UserCancelled from None
|
||||
|
||||
try:
|
||||
await out_msg.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
_message_setting_flags = {
|
||||
'removable': settings.removable,
|
||||
'maximum': settings.maximum,
|
||||
'required_role': settings.required_role,
|
||||
'log': settings.log,
|
||||
'refunds': settings.refunds,
|
||||
'default_price': settings.default_price,
|
||||
}
|
||||
_reaction_setting_flags = {
|
||||
'price': settings.price,
|
||||
'duration': settings.duration
|
||||
}
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"reactionroles",
|
||||
group="Guild Configuration",
|
||||
desc="Create and configure reaction role messages.",
|
||||
aliases=('rroles',),
|
||||
flags=(
|
||||
'delete', 'remove==',
|
||||
'enable', 'disable',
|
||||
'required_role==', 'removable=', 'maximum=', 'refunds=', 'log=', 'default_price=',
|
||||
'price=', 'duration=='
|
||||
)
|
||||
)
|
||||
@guild_admin()
|
||||
async def cmd_reactionroles(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}rroles
|
||||
{prefix}rroles [enable|disable|delete] msglink
|
||||
{prefix}rroles msglink [emoji1 role1, emoji2 role2, ...]
|
||||
{prefix}rroles msglink --remove emoji1, emoji2, ...
|
||||
{prefix}rroles msglink --message_setting [value]
|
||||
{prefix}rroles msglink emoji --reaction_setting [value]
|
||||
Description:
|
||||
Create and configure "reaction roles", i.e. roles obtainable by \
|
||||
clicking reactions on a particular message.
|
||||
`msglink` is the link or message id of the message with reactions.
|
||||
`emoji` should be given as the emoji itself, or the name or id.
|
||||
`role` may be given by name, mention, or id.
|
||||
Getting started:
|
||||
First choose the message you want to add reaction roles to, \
|
||||
and copy the link or message id for that message. \
|
||||
Then run the command `{prefix}rroles link`, replacing `link` with the copied link, \
|
||||
and follow the prompts.
|
||||
For faster setup, use `{prefix}rroles link emoji1 role1, emoji2 role2` instead.
|
||||
Editing reaction roles:
|
||||
Remove roles with `{prefix}rroles link --remove emoji1, emoji2, ...`
|
||||
Add/edit roles with `{prefix}rroles link emoji1 role1, emoji2 role2, ...`
|
||||
Examples``:
|
||||
{prefix}rroles {ctx.msg.id} 🧮 mathematics, 🫀 biology, 🩺 medicine
|
||||
{prefix}rroles disable {ctx.msg.id}
|
||||
PAGEBREAK:
|
||||
Page 2
|
||||
Advanced configuration:
|
||||
Type `{prefix}rroles link` again to view the advanced setting window, \
|
||||
and use `{prefix}rroles link --setting value` to modify the settings. \
|
||||
See below for descriptions of each message setting.
|
||||
For example to disable event logging, run `{prefix}rroles link --log off`.
|
||||
|
||||
For per-reaction settings, instead use `{prefix}rroles link emoji --setting value`.
|
||||
|
||||
*(!) Replace `setting` with one of the settings below!*
|
||||
Message Settings::
|
||||
maximum: Maximum number of roles obtainable from this message.
|
||||
log: Whether to log reaction role usage into the event log.
|
||||
removable: Whether the reactions roles can be remove by unreacting.
|
||||
refunds: Whether to refund the role price when removing the role.
|
||||
default_price: The default price of each role on this message.
|
||||
required_role: The role required to use these reactions roles.
|
||||
Reaction Settings::
|
||||
price: The price of this reaction role. (May be negative for a reward.)
|
||||
tduration: How long this role will last after being selected or bought.
|
||||
Configuration Examples``:
|
||||
{prefix}rroles {ctx.msg.id} --maximum 5
|
||||
{prefix}rroles {ctx.msg.id} --default_price 20
|
||||
{prefix}rroles {ctx.msg.id} --required_role None
|
||||
{prefix}rroles {ctx.msg.id} 🧮 --price 1024
|
||||
{prefix}rroles {ctx.msg.id} 🧮 --duration 7 days
|
||||
"""
|
||||
if not ctx.args:
|
||||
# No target message provided, list the current reaction messages
|
||||
# Or give a brief guide if there are no current reaction messages
|
||||
guild_message_rows = reaction_role_messages.fetch_rows_where(guildid=ctx.guild.id)
|
||||
if guild_message_rows:
|
||||
# List messages
|
||||
|
||||
# First get the list of reaction role messages in the guild
|
||||
messages = [ReactionRoleMessage.fetch(row.messageid) for row in guild_message_rows]
|
||||
|
||||
# Sort them by channelid and messageid
|
||||
messages.sort(key=lambda m: (m.data.channelid, m.messageid))
|
||||
|
||||
# Build the message description strings
|
||||
message_strings = []
|
||||
for message in messages:
|
||||
header = (
|
||||
"`{}` in <#{}> ([Click to jump]({})){}".format(
|
||||
message.messageid,
|
||||
message.data.channelid,
|
||||
message.message_link,
|
||||
" (disabled)" if not message.enabled else ""
|
||||
)
|
||||
)
|
||||
role_strings = [
|
||||
"{} <@&{}>".format(reaction.emoji, reaction.data.roleid)
|
||||
for reaction in message.reactions
|
||||
]
|
||||
role_string = '\n'.join(role_strings) or "No reaction roles!"
|
||||
|
||||
message_strings.append("{}\n{}".format(header, role_string))
|
||||
|
||||
pages = []
|
||||
page = []
|
||||
page_len = 0
|
||||
page_chars = 0
|
||||
i = 0
|
||||
while i < len(message_strings):
|
||||
message_string = message_strings[i]
|
||||
chars = len(message_string)
|
||||
lines = len(message_string.splitlines())
|
||||
if (page and lines + page_len > 20) or (chars + page_chars > 2000):
|
||||
pages.append('\n\n'.join(page))
|
||||
page = []
|
||||
page_len = 0
|
||||
page_chars = 0
|
||||
else:
|
||||
page.append(message_string)
|
||||
page_len += lines
|
||||
page_chars += chars
|
||||
i += 1
|
||||
if page:
|
||||
pages.append('\n\n'.join(page))
|
||||
|
||||
page_count = len(pages)
|
||||
title = "Reaction Roles in {}".format(ctx.guild.name)
|
||||
embeds = [
|
||||
discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
description=page,
|
||||
title=title
|
||||
)
|
||||
for page in pages
|
||||
]
|
||||
if page_count > 1:
|
||||
[embed.set_footer(text="Page {} of {}".format(i + 1, page_count)) for i, embed in enumerate(embeds)]
|
||||
await ctx.pager(embeds)
|
||||
else:
|
||||
# Send a setup guide
|
||||
embed = discord.Embed(
|
||||
title="No Reaction Roles set up!",
|
||||
description=(
|
||||
"To setup reaction roles, first copy the link or message id of the message you want to "
|
||||
"add the roles to. Then run `{prefix}rroles link`, replacing `link` with the link you copied, "
|
||||
"and follow the prompts.\n"
|
||||
"See `{prefix}help rroles` for more information.".format(prefix=ctx.best_prefix)
|
||||
),
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
return
|
||||
|
||||
# Extract first word, look for a subcommand
|
||||
splits = ctx.args.split(maxsplit=1)
|
||||
subcmd = splits[0].lower()
|
||||
|
||||
if subcmd in ('enable', 'disable', 'delete'):
|
||||
# Truncate arguments and extract target
|
||||
if len(splits) > 1:
|
||||
ctx.args = splits[1]
|
||||
target_chid, target_id = _parse_messageref(ctx)
|
||||
else:
|
||||
target_chid = None
|
||||
target_id = None
|
||||
ctx.args = ''
|
||||
|
||||
# Handle subcommand special cases
|
||||
if subcmd == 'enable':
|
||||
if ctx.args and not target_id:
|
||||
await ctx.error_reply(
|
||||
"Couldn't find the message to enable!\n"
|
||||
"**Usage:** `{}rroles enable [message link or id]`.".format(ctx.best_prefix)
|
||||
)
|
||||
elif not target_id:
|
||||
# Confirm enabling of all reaction messages
|
||||
await reaction_ask(
|
||||
ctx,
|
||||
"Are you sure you want to enable all reaction role messages in this server?",
|
||||
timeout_msg="Prompt timed out, no reaction roles enabled.",
|
||||
cancel_msg="User cancelled, no reaction roles enabled."
|
||||
)
|
||||
reaction_role_messages.update_where(
|
||||
{'enabled': True},
|
||||
guildid=ctx.guild.id
|
||||
)
|
||||
await ctx.embed_reply(
|
||||
"All reaction role messages have been enabled.",
|
||||
colour=discord.Colour.green(),
|
||||
)
|
||||
else:
|
||||
# Fetch the target
|
||||
target = ReactionRoleMessage.fetch(target_id)
|
||||
if target is None:
|
||||
await ctx.error_reply(
|
||||
"This message doesn't have any reaction roles!\n"
|
||||
"Run the command again without `enable` to assign reaction roles."
|
||||
)
|
||||
else:
|
||||
# We have a valid target
|
||||
if target.enabled:
|
||||
await ctx.error_reply(
|
||||
"This message is already enabled!"
|
||||
)
|
||||
else:
|
||||
target.enabled = True
|
||||
await ctx.embed_reply(
|
||||
"The message has been enabled!"
|
||||
)
|
||||
elif subcmd == 'disable':
|
||||
if ctx.args and not target_id:
|
||||
await ctx.error_reply(
|
||||
"Couldn't find the message to disable!\n"
|
||||
"**Usage:** `{}rroles disable [message link or id]`.".format(ctx.best_prefix)
|
||||
)
|
||||
elif not target_id:
|
||||
# Confirm disabling of all reaction messages
|
||||
await reaction_ask(
|
||||
ctx,
|
||||
"Are you sure you want to disable all reaction role messages in this server?",
|
||||
timeout_msg="Prompt timed out, no reaction roles disabled.",
|
||||
cancel_msg="User cancelled, no reaction roles disabled."
|
||||
)
|
||||
reaction_role_messages.update_where(
|
||||
{'enabled': False},
|
||||
guildid=ctx.guild.id
|
||||
)
|
||||
await ctx.embed_reply(
|
||||
"All reaction role messages have been disabled.",
|
||||
colour=discord.Colour.green(),
|
||||
)
|
||||
else:
|
||||
# Fetch the target
|
||||
target = ReactionRoleMessage.fetch(target_id)
|
||||
if target is None:
|
||||
await ctx.error_reply(
|
||||
"This message doesn't have any reaction roles! Nothing to disable."
|
||||
)
|
||||
else:
|
||||
# We have a valid target
|
||||
if not target.enabled:
|
||||
await ctx.error_reply(
|
||||
"This message is already disabled!"
|
||||
)
|
||||
else:
|
||||
target.enabled = False
|
||||
await ctx.embed_reply(
|
||||
"The message has been disabled!"
|
||||
)
|
||||
elif subcmd == 'delete':
|
||||
if ctx.args and not target_id:
|
||||
await ctx.error_reply(
|
||||
"Couldn't find the message to remove!\n"
|
||||
"**Usage:** `{}rroles remove [message link or id]`.".format(ctx.best_prefix)
|
||||
)
|
||||
elif not target_id:
|
||||
# Confirm disabling of all reaction messages
|
||||
await reaction_ask(
|
||||
ctx,
|
||||
"Are you sure you want to remove all reaction role messages in this server?",
|
||||
timeout_msg="Prompt timed out, no messages removed.",
|
||||
cancel_msg="User cancelled, no messages removed."
|
||||
)
|
||||
reaction_role_messages.delete_where(
|
||||
guildid=ctx.guild.id
|
||||
)
|
||||
await ctx.embed_reply(
|
||||
"All reaction role messages have been removed.",
|
||||
colour=discord.Colour.green(),
|
||||
)
|
||||
else:
|
||||
# Fetch the target
|
||||
target = ReactionRoleMessage.fetch(target_id)
|
||||
if target is None:
|
||||
await ctx.error_reply(
|
||||
"This message doesn't have any reaction roles! Nothing to remove."
|
||||
)
|
||||
else:
|
||||
# We have a valid target
|
||||
target.delete()
|
||||
await ctx.embed_reply(
|
||||
"The message has been removed and is no longer a reaction role message."
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Just extract target
|
||||
target_chid, target_id = _parse_messageref(ctx)
|
||||
|
||||
# Handle target parsing issue
|
||||
if target_id is None:
|
||||
return await ctx.error_reply(
|
||||
"Couldn't parse `{}` as a message id or message link!\n"
|
||||
"See `{}help rroles` for detailed usage information.".format(ctx.args.split()[0], ctx.best_prefix)
|
||||
)
|
||||
|
||||
# Get the associated ReactionRoleMessage, if it exists
|
||||
target = ReactionRoleMessage.fetch(target_id)
|
||||
|
||||
# Get the target message
|
||||
if target:
|
||||
message = await target.fetch_message()
|
||||
if not message:
|
||||
# TODO: Consider offering some sort of `move` option here.
|
||||
await ctx.error_reply(
|
||||
"This reaction role message no longer exists!\n"
|
||||
"Use `{}rroles delete {}` to remove it from the list.".format(ctx.best_prefix, target.messageid)
|
||||
)
|
||||
else:
|
||||
message = None
|
||||
if target_chid:
|
||||
channel = ctx.guild.get_channel(target_chid)
|
||||
if not channel:
|
||||
await ctx.error_reply(
|
||||
"The provided channel no longer exists!"
|
||||
)
|
||||
elif not isinstance(channel, discord.TextChannel):
|
||||
await ctx.error_reply(
|
||||
"The provided channel is not a text channel!"
|
||||
)
|
||||
else:
|
||||
message = await channel.fetch_message(target_id)
|
||||
if not message:
|
||||
await ctx.error_reply(
|
||||
"Couldn't find the specified message in {}!".format(channel.mention)
|
||||
)
|
||||
else:
|
||||
out_msg = await ctx.embed_reply("Searching for `{}`".format(target_id))
|
||||
message = await ctx.find_message(target_id)
|
||||
try:
|
||||
await out_msg.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
if not message:
|
||||
await ctx.error_reply(
|
||||
"Couldn't find the message `{}`!".format(target_id)
|
||||
)
|
||||
if not message:
|
||||
return
|
||||
|
||||
# Handle the `remove` flag specially
|
||||
# In particular, all other flags are ignored
|
||||
if flags['remove']:
|
||||
if not target:
|
||||
await ctx.error_reply(
|
||||
"The specified message has no reaction roles! Nothing to remove."
|
||||
)
|
||||
else:
|
||||
# Parse emojis and remove from target
|
||||
target_emojis = {reaction.emoji: reaction for reaction in target.reactions}
|
||||
emoji_name_map = {emoji.name.lower(): emoji for emoji in target_emojis}
|
||||
emoji_id_map = {emoji.id: emoji for emoji in target_emojis}
|
||||
|
||||
items = [item.strip() for item in flags['remove'].split(',')]
|
||||
to_remove = [] # List of reactions to remove
|
||||
for emoji_str in items:
|
||||
emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map)
|
||||
if emoji is None:
|
||||
return await ctx.error_reply(
|
||||
"Couldn't parse `{}` as an emoji! No reactions were removed.".format(emoji_str)
|
||||
)
|
||||
if emoji not in target_emojis:
|
||||
return await ctx.error_reply(
|
||||
"{} is not a reaction role for this message!".format(emoji)
|
||||
)
|
||||
to_remove.append(target_emojis[emoji])
|
||||
|
||||
# Delete reactions from data
|
||||
description = '\n'.join("{} <@&{}>".format(reaction.emoji, reaction.data.roleid) for reaction in to_remove)
|
||||
reaction_role_reactions.delete_where(reactionid=[reaction.reactionid for reaction in to_remove])
|
||||
target.refresh()
|
||||
|
||||
# Ack
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.green(),
|
||||
title="Reaction Roles deactivated",
|
||||
description=description
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
return
|
||||
|
||||
# Any remaining arguments should be emoji specifications with optional role
|
||||
# Parse these now
|
||||
given_emojis = {} # Map PartialEmoji -> Optional[Role]
|
||||
existing_emojis = set() # Set of existing reaction emoji identifiers
|
||||
|
||||
if ctx.args:
|
||||
# First build the list of custom emojis we can accept by name
|
||||
# We do this by reverse precedence, so the highest priority emojis are added last
|
||||
custom_emojis = []
|
||||
custom_emojis.extend(ctx.guild.emojis) # Custom emojis in the guild
|
||||
if target:
|
||||
custom_emojis.extend([r.emoji for r in target.reactions]) # Configured reaction roles on the target
|
||||
custom_emojis.extend([r.emoji for r in message.reactions if r.custom_emoji]) # Actual reactions on the message
|
||||
|
||||
# Filter out the built in emojis and those without a name
|
||||
custom_emojis = (emoji for emoji in custom_emojis if emoji.name and emoji.id)
|
||||
|
||||
# Build the maps to lookup provided custom emojis
|
||||
emoji_name_map = {emoji.name.lower(): emoji for emoji in custom_emojis}
|
||||
emoji_id_map = {emoji.id: emoji for emoji in custom_emojis}
|
||||
|
||||
# Now parse the provided emojis
|
||||
# Assume that all-unicode strings are built-in emojis
|
||||
# We can't assume much else unless we have a list of such emojis
|
||||
splits = (split.strip() for line in ctx.args.splitlines() for split in line.split(',') if split)
|
||||
splits = (split.split(maxsplit=1) for split in splits if split)
|
||||
arg_emoji_strings = {
|
||||
split[0]: split[1] if len(split) > 1 else None
|
||||
for split in splits
|
||||
} # emoji_str -> Optional[role_str]
|
||||
|
||||
arg_emoji_map = {}
|
||||
for emoji_str, role_str in arg_emoji_strings.items():
|
||||
emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map)
|
||||
if emoji is None:
|
||||
return await ctx.error_reply(
|
||||
"Couldn't parse `{}` as an emoji!".format(emoji_str)
|
||||
)
|
||||
else:
|
||||
arg_emoji_map[emoji] = role_str
|
||||
|
||||
# Final pass extracts roles
|
||||
# If any new emojis were provided, their roles should be specified, we enforce this during role parsing
|
||||
# First collect the existing emoji strings
|
||||
if target:
|
||||
for reaction in target.reactions:
|
||||
emoji_id = reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id
|
||||
existing_emojis.add(emoji_id)
|
||||
|
||||
# Now parse and assign the roles, building the final map
|
||||
for emoji, role_str in arg_emoji_map.items():
|
||||
emoji_id = emoji.name if emoji.id is None else emoji.id
|
||||
role = None
|
||||
if role_str:
|
||||
role = await ctx.find_role(role_str, create=True, interactive=True, allow_notfound=False)
|
||||
elif emoji_id not in existing_emojis:
|
||||
return await ctx.error_reply(
|
||||
"New emoji {} was given without an associated role!".format(emoji)
|
||||
)
|
||||
given_emojis[emoji] = role
|
||||
|
||||
# Next manage target creation or emoji editing, if required
|
||||
if target is None:
|
||||
# Reaction message creation wizard
|
||||
# Confirm that they want to create a new reaction role message.
|
||||
await reaction_ask(
|
||||
ctx,
|
||||
question="Do you want to set up new reaction roles for [this message]({})?".format(
|
||||
message.jump_url
|
||||
),
|
||||
timeout_msg="Prompt timed out, no reaction roles created.",
|
||||
cancel_msg="Reaction Role creation cancelled."
|
||||
)
|
||||
|
||||
# Continue with creation
|
||||
# Obtain emojis if not already provided
|
||||
if not given_emojis:
|
||||
# Prompt for the initial emojis
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title="What reaction roles would you like to add?",
|
||||
description=(
|
||||
"Please now type the reaction roles you would like to add "
|
||||
"in the form `emoji role`, where `role` is given by partial name or id. For example:"
|
||||
"```{}```".format(example_str)
|
||||
)
|
||||
)
|
||||
out_msg = await ctx.reply(embed=embed)
|
||||
|
||||
# Wait for a response
|
||||
def check(msg):
|
||||
return msg.author == ctx.author and msg.channel == ctx.ch and msg.content
|
||||
|
||||
try:
|
||||
reply = await ctx.client.wait_for('message', check=check, timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
await out_msg.edit(
|
||||
embed=discord.Embed(
|
||||
colour=discord.Colour.red(),
|
||||
description="Prompt timed out, no reaction roles created."
|
||||
)
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return
|
||||
|
||||
rolestrs = reply.content
|
||||
|
||||
try:
|
||||
await reply.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Attempt to parse the emojis
|
||||
# First build the list of custom emojis we can accept by name
|
||||
custom_emojis = []
|
||||
custom_emojis.extend(ctx.guild.emojis) # Custom emojis in the guild
|
||||
custom_emojis.extend(
|
||||
r.emoji for r in message.reactions if r.custom_emoji
|
||||
) # Actual reactions on the message
|
||||
|
||||
# Filter out the built in emojis and those without a name
|
||||
custom_emojis = (emoji for emoji in custom_emojis if emoji.name and emoji.id)
|
||||
|
||||
# Build the maps to lookup provided custom emojis
|
||||
emoji_name_map = {emoji.name.lower(): emoji for emoji in custom_emojis}
|
||||
emoji_id_map = {emoji.id: emoji for emoji in custom_emojis}
|
||||
|
||||
# Now parse the provided emojis
|
||||
# Assume that all-unicode strings are built-in emojis
|
||||
# We can't assume much else unless we have a list of such emojis
|
||||
splits = (split.strip() for line in rolestrs.splitlines() for split in line.split(',') if split)
|
||||
splits = (split.split(maxsplit=1) for split in splits if split)
|
||||
arg_emoji_strings = {
|
||||
split[0]: split[1] if len(split) > 1 else None
|
||||
for split in splits
|
||||
} # emoji_str -> Optional[role_str]
|
||||
|
||||
# Check all the emojis have roles associated
|
||||
for emoji_str, role_str in arg_emoji_strings.items():
|
||||
if role_str is None:
|
||||
return await ctx.error_reply(
|
||||
"No role provided for `{}`! Reaction role creation cancelled.".format(emoji_str)
|
||||
)
|
||||
|
||||
# Parse the provided roles and emojis
|
||||
for emoji_str, role_str in arg_emoji_strings.items():
|
||||
emoji = _parse_emoji(emoji_str, emoji_name_map, emoji_id_map)
|
||||
if emoji is None:
|
||||
return await ctx.error_reply(
|
||||
"Couldn't parse `{}` as an emoji!".format(emoji_str)
|
||||
)
|
||||
else:
|
||||
given_emojis[emoji] = await ctx.find_role(
|
||||
role_str,
|
||||
create=True,
|
||||
interactive=True,
|
||||
allow_notfound=False
|
||||
)
|
||||
|
||||
if len(given_emojis) > 20:
|
||||
return await ctx.error_reply("A maximum of 20 reactions are possible per message! Cancelling creation.")
|
||||
|
||||
# Create the ReactionRoleMessage
|
||||
target = ReactionRoleMessage.create(
|
||||
message.id,
|
||||
message.guild.id,
|
||||
message.channel.id
|
||||
)
|
||||
|
||||
# Insert the reaction data directly
|
||||
reaction_role_reactions.insert_many(
|
||||
*((message.id, role.id, emoji.name, emoji.id, emoji.animated) for emoji, role in given_emojis.items()),
|
||||
insert_keys=('messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated')
|
||||
)
|
||||
|
||||
# Refresh the message to pick up the new reactions
|
||||
target.refresh()
|
||||
|
||||
# Add the reactions to the message, if possible
|
||||
existing_reactions = set(
|
||||
reaction.emoji if not reaction.custom_emoji else
|
||||
(reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id)
|
||||
for reaction in message.reactions
|
||||
)
|
||||
missing = [
|
||||
reaction.emoji for reaction in target.reactions
|
||||
if (reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id) not in existing_reactions
|
||||
]
|
||||
if not any(emoji.id not in set(cemoji.id for cemoji in ctx.guild.emojis) for emoji in missing if emoji.id):
|
||||
# We can add the missing emojis
|
||||
for emoji in missing:
|
||||
try:
|
||||
await message.add_reaction(emoji)
|
||||
except discord.HTTPException:
|
||||
break
|
||||
else:
|
||||
missing = []
|
||||
|
||||
# Ack the creation
|
||||
ack_msg = "Created `{}` new reaction roles on [this message]({})!".format(
|
||||
len(target.reactions),
|
||||
target.message_link
|
||||
)
|
||||
if missing:
|
||||
ack_msg += "\nPlease add the missing reactions to the message!"
|
||||
await ctx.embed_reply(
|
||||
ack_msg
|
||||
)
|
||||
elif given_emojis:
|
||||
# Update the target reactions
|
||||
# Create a map of the emojis that need to be added or updated
|
||||
needs_update = {
|
||||
emoji: role for emoji, role in given_emojis.items() if role
|
||||
}
|
||||
|
||||
# Fetch the existing target emojis to split the roles into inserts and updates
|
||||
target_emojis = {reaction.emoji: reaction for reaction in target.reactions}
|
||||
|
||||
# Handle the new roles
|
||||
insert_targets = {
|
||||
emoji: role for emoji, role in needs_update.items() if emoji not in target_emojis
|
||||
}
|
||||
if insert_targets:
|
||||
if len(insert_targets) + len(target_emojis) > 20:
|
||||
return await ctx.error_reply("Too many reactions! A maximum of 20 reactions are possible per message!")
|
||||
reaction_role_reactions.insert_many(
|
||||
*(
|
||||
(message.id, role.id, emoji.name, emoji.id, emoji.animated)
|
||||
for emoji, role in insert_targets.items()
|
||||
),
|
||||
insert_keys=('messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated')
|
||||
)
|
||||
# Handle the updated roles
|
||||
update_targets = {
|
||||
target_emojis[emoji]: role for emoji, role in needs_update.items() if emoji in target_emojis
|
||||
}
|
||||
if update_targets:
|
||||
reaction_role_reactions.update_many(
|
||||
*((role.id, reaction.reactionid) for reaction, role in update_targets.items()),
|
||||
set_keys=('roleid',),
|
||||
where_keys=('reactionid',),
|
||||
)
|
||||
|
||||
# Finally, refresh to load the new reactions
|
||||
target.refresh()
|
||||
|
||||
# Now that the target is created/updated, all the provided emojis should be reactions
|
||||
given_reactions = []
|
||||
if given_emojis:
|
||||
# Make a map of the existing reactions
|
||||
existing_reactions = {
|
||||
reaction.emoji.name if reaction.emoji.id is None else reaction.emoji.id: reaction
|
||||
for reaction in target.reactions
|
||||
}
|
||||
given_reactions = [
|
||||
existing_reactions[emoji.name if emoji.id is None else emoji.id]
|
||||
for emoji in given_emojis
|
||||
]
|
||||
|
||||
# Handle message setting updates
|
||||
update_lines = [] # Setting update lines to display
|
||||
update_columns = {} # Message data columns to update
|
||||
for flag in _message_setting_flags:
|
||||
if flags[flag]:
|
||||
setting_class = _message_setting_flags[flag]
|
||||
try:
|
||||
setting = await setting_class.parse(target.messageid, ctx, flags[flag])
|
||||
except UserInputError as e:
|
||||
return await ctx.error_reply(
|
||||
"{} {}\nNo settings were modified.".format(cross, e.msg),
|
||||
title="Couldn't save settings!"
|
||||
)
|
||||
else:
|
||||
update_lines.append(
|
||||
"{} {}".format(tick, setting.success_response)
|
||||
)
|
||||
update_columns[setting._data_column] = setting.data
|
||||
if update_columns:
|
||||
# First write the data
|
||||
reaction_role_messages.update_where(
|
||||
update_columns,
|
||||
messageid=target.messageid
|
||||
)
|
||||
# Then ack the setting update
|
||||
if len(update_lines) > 1:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.green(),
|
||||
title="Reaction Role message settings updated!",
|
||||
description='\n'.join(update_lines)
|
||||
)
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.green(),
|
||||
description=update_lines[0]
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
# Handle reaction setting updates
|
||||
update_lines = [] # Setting update lines to display
|
||||
update_columns = {} # Message data columns to update, for all given reactions
|
||||
reactions = given_reactions or target.reactions
|
||||
for flag in _reaction_setting_flags:
|
||||
for reaction in reactions:
|
||||
if flags[flag]:
|
||||
setting_class = _reaction_setting_flags[flag]
|
||||
try:
|
||||
setting = await setting_class.parse(reaction.reactionid, ctx, flags[flag])
|
||||
except UserInputError as e:
|
||||
return await ctx.error_reply(
|
||||
"{} {}\nNo reaction roles were modified.".format(cross, e.msg),
|
||||
title="Couldn't save reaction role settings!",
|
||||
)
|
||||
else:
|
||||
update_lines.append(
|
||||
setting.success_response.format(reaction=reaction)
|
||||
)
|
||||
update_columns[setting._data_column] = setting.data
|
||||
if update_columns:
|
||||
# First write the data
|
||||
reaction_role_reactions.update_where(
|
||||
update_columns,
|
||||
reactionid=[reaction.reactionid for reaction in reactions]
|
||||
)
|
||||
# Then ack the setting update
|
||||
if len(update_lines) > 1:
|
||||
blocks = ['\n'.join(update_lines[i:i+20]) for i in range(0, len(update_lines), 20)]
|
||||
embeds = [
|
||||
discord.Embed(
|
||||
colour=discord.Colour.green(),
|
||||
title="Reaction Role settings updated!",
|
||||
description=block
|
||||
) for block in blocks
|
||||
]
|
||||
await ctx.pager(embeds)
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.green(),
|
||||
description=update_lines[0]
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
# Show the reaction role message summary
|
||||
# Build the reaction fields
|
||||
reaction_fields = [] # List of tuples (name, value)
|
||||
for reaction in target.reactions:
|
||||
reaction_fields.append(
|
||||
(
|
||||
"{} {}".format(reaction.emoji.name, reaction.emoji if reaction.emoji.id else ''),
|
||||
"<@&{}>\n{}".format(reaction.data.roleid, reaction.settings.tabulated())
|
||||
)
|
||||
)
|
||||
|
||||
# Build the final setting pages
|
||||
description = (
|
||||
"{settings_table}\n"
|
||||
"To update a message setting: `{prefix}rroles messageid --setting value`\n"
|
||||
"To update an emoji setting: `{prefix}rroles messageid emoji --setting value`\n"
|
||||
"See examples and more usage information with `{prefix}help rroles`.\n"
|
||||
"**(!) Replace the `setting` with one of the settings on this page.**\n"
|
||||
).format(
|
||||
prefix=ctx.best_prefix,
|
||||
settings_table=target.settings.tabulated()
|
||||
)
|
||||
|
||||
field_blocks = [reaction_fields[i:i+6] for i in range(0, len(reaction_fields), 6)]
|
||||
page_count = len(field_blocks)
|
||||
embeds = []
|
||||
for i, block in enumerate(field_blocks):
|
||||
title = "Reaction role settings for message id `{}`".format(target.messageid)
|
||||
embed = discord.Embed(
|
||||
title=title,
|
||||
description=description
|
||||
).set_author(
|
||||
name="Click to jump to message",
|
||||
url=target.message_link
|
||||
)
|
||||
for name, value in block:
|
||||
embed.add_field(name=name, value=value)
|
||||
if page_count > 1:
|
||||
embed.set_footer(text="Page {} of {}".format(i+1, page_count))
|
||||
embeds.append(embed)
|
||||
|
||||
# Finally, send the reaction role information
|
||||
await ctx.pager(embeds)
|
||||
@@ -0,0 +1,22 @@
|
||||
from data import Table, RowTable
|
||||
|
||||
|
||||
reaction_role_messages = RowTable(
|
||||
'reaction_role_messages',
|
||||
('messageid', 'guildid', 'channelid',
|
||||
'enabled',
|
||||
'required_role', 'allow_deselction',
|
||||
'max_obtainable', 'allow_refunds',
|
||||
'event_log'),
|
||||
'messageid'
|
||||
)
|
||||
|
||||
|
||||
reaction_role_reactions = RowTable(
|
||||
'reaction_role_reactions',
|
||||
('reactionid', 'messageid', 'roleid', 'emoji_name', 'emoji_id', 'emoji_animated', 'price', 'timeout'),
|
||||
'reactionid'
|
||||
)
|
||||
|
||||
|
||||
reaction_role_expiring = Table('reaction_role_expiring')
|
||||
172
bot/modules/pending-rewrite/guild_admin/reaction_roles/expiry.py
Normal file
172
bot/modules/pending-rewrite/guild_admin/reaction_roles/expiry.py
Normal file
@@ -0,0 +1,172 @@
|
||||
import logging
|
||||
import traceback
|
||||
import asyncio
|
||||
import discord
|
||||
|
||||
from meta import client
|
||||
from utils.lib import utc_now
|
||||
from settings import GuildSettings
|
||||
|
||||
from .module import module
|
||||
from .data import reaction_role_expiring
|
||||
|
||||
_expiring = {}
|
||||
_wakeup_event = asyncio.Event()
|
||||
|
||||
|
||||
# TODO: More efficient data structure for min optimisation, e.g. pre-sorted with bisection insert
|
||||
|
||||
|
||||
# Public expiry interface
|
||||
def schedule_expiry(guildid, userid, roleid, expiry, reactionid=None):
|
||||
"""
|
||||
Schedule expiry of the given role for the given member at the given time.
|
||||
This will also cancel any existing expiry for this member, role pair.
|
||||
"""
|
||||
reaction_role_expiring.delete_where(
|
||||
guildid=guildid,
|
||||
userid=userid,
|
||||
roleid=roleid,
|
||||
)
|
||||
reaction_role_expiring.insert(
|
||||
guildid=guildid,
|
||||
userid=userid,
|
||||
roleid=roleid,
|
||||
expiry=expiry,
|
||||
reactionid=reactionid
|
||||
)
|
||||
key = (guildid, userid, roleid)
|
||||
_expiring[key] = expiry.timestamp()
|
||||
_wakeup_event.set()
|
||||
|
||||
|
||||
def cancel_expiry(*key):
|
||||
"""
|
||||
Cancel expiry for the given member and role, if it exists.
|
||||
"""
|
||||
guildid, userid, roleid = key
|
||||
reaction_role_expiring.delete_where(
|
||||
guildid=guildid,
|
||||
userid=userid,
|
||||
roleid=roleid,
|
||||
)
|
||||
if _expiring.pop(key, None) is not None:
|
||||
# Wakeup the expiry tracker for recalculation
|
||||
_wakeup_event.set()
|
||||
|
||||
|
||||
def _next():
|
||||
"""
|
||||
Calculate the next member, role pair to expire.
|
||||
"""
|
||||
if _expiring:
|
||||
key, _ = min(_expiring.items(), key=lambda pair: pair[1])
|
||||
return key
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
async def _expire(key):
|
||||
"""
|
||||
Execute reaction role expiry for the given member and role.
|
||||
This removes the role and logs the removal if applicable.
|
||||
If the user is no longer in the guild, it removes the role from the persistent roles instead.
|
||||
"""
|
||||
guildid, userid, roleid = key
|
||||
guild = client.get_guild(guildid)
|
||||
if guild:
|
||||
role = guild.get_role(roleid)
|
||||
if role:
|
||||
member = guild.get_member(userid)
|
||||
if member:
|
||||
log = GuildSettings(guildid).event_log.log
|
||||
if role in member.roles:
|
||||
# Remove role from member, and log if applicable
|
||||
try:
|
||||
await member.remove_roles(
|
||||
role,
|
||||
atomic=True,
|
||||
reason="Expiring temporary reaction role."
|
||||
)
|
||||
except discord.HTTPException:
|
||||
log(
|
||||
"Failed to remove expired reaction role {} from {}.".format(
|
||||
role.mention,
|
||||
member.mention
|
||||
),
|
||||
colour=discord.Colour.red(),
|
||||
title="Could not remove expired Reaction Role!"
|
||||
)
|
||||
else:
|
||||
log(
|
||||
"Removing expired reaction role {} from {}.".format(
|
||||
role.mention,
|
||||
member.mention
|
||||
),
|
||||
title="Reaction Role expired!"
|
||||
)
|
||||
else:
|
||||
# Remove role from stored persistent roles, if existent
|
||||
client.data.past_member_roles.delete_where(
|
||||
guildid=guildid,
|
||||
userid=userid,
|
||||
roleid=roleid
|
||||
)
|
||||
reaction_role_expiring.delete_where(
|
||||
guildid=guildid,
|
||||
userid=userid,
|
||||
roleid=roleid
|
||||
)
|
||||
|
||||
|
||||
async def _expiry_tracker(client):
|
||||
"""
|
||||
Track and launch role expiry.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
key = _next()
|
||||
diff = _expiring[key] - utc_now().timestamp() if key else None
|
||||
await asyncio.wait_for(_wakeup_event.wait(), timeout=diff)
|
||||
except asyncio.TimeoutError:
|
||||
# Timeout means next doesn't exist or is ready to expire
|
||||
if key and key in _expiring and _expiring[key] <= utc_now().timestamp() + 1:
|
||||
_expiring.pop(key)
|
||||
asyncio.create_task(_expire(key))
|
||||
except Exception:
|
||||
# This should be impossible, but catch and log anyway
|
||||
client.log(
|
||||
"Exception occurred while tracking reaction role expiry. Exception traceback follows.\n{}".format(
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="REACTION_ROLE_EXPIRY",
|
||||
level=logging.ERROR
|
||||
)
|
||||
else:
|
||||
# Wakeup event means that we should recalculate next
|
||||
_wakeup_event.clear()
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def launch_expiry_tracker(client):
|
||||
"""
|
||||
Launch the role expiry tracker.
|
||||
"""
|
||||
asyncio.create_task(_expiry_tracker(client))
|
||||
client.log("Reaction role expiry tracker launched.", context="REACTION_ROLE_EXPIRY")
|
||||
|
||||
|
||||
@module.init_task
|
||||
def load_expiring_roles(client):
|
||||
"""
|
||||
Initialise the expiring reaction role map, and attach it to the client.
|
||||
"""
|
||||
rows = reaction_role_expiring.select_where()
|
||||
_expiring.clear()
|
||||
_expiring.update({(row['guildid'], row['userid'], row['roleid']): row['expiry'].timestamp() for row in rows})
|
||||
client.objects['expiring_reaction_roles'] = _expiring
|
||||
if _expiring:
|
||||
client.log(
|
||||
"Loaded {} expiring reaction roles.".format(len(_expiring)),
|
||||
context="REACTION_ROLE_EXPIRY"
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
module = LionModule("Reaction_Roles")
|
||||
@@ -0,0 +1,257 @@
|
||||
from utils.lib import DotDict
|
||||
from wards import guild_admin
|
||||
from settings import ObjectSettings, ColumnData, Setting
|
||||
import settings.setting_types as setting_types
|
||||
|
||||
from .data import reaction_role_messages, reaction_role_reactions
|
||||
|
||||
|
||||
class RoleMessageSettings(ObjectSettings):
|
||||
settings = DotDict()
|
||||
|
||||
|
||||
class RoleMessageSetting(ColumnData, Setting):
|
||||
_table_interface = reaction_role_messages
|
||||
_id_column = 'messageid'
|
||||
_create_row = False
|
||||
|
||||
write_ward = guild_admin
|
||||
|
||||
|
||||
@RoleMessageSettings.attach_setting
|
||||
class required_role(setting_types.Role, RoleMessageSetting):
|
||||
attr_name = 'required_role'
|
||||
_data_column = 'required_role'
|
||||
|
||||
display_name = "required_role"
|
||||
desc = "Role required to use the reaction roles."
|
||||
|
||||
long_desc = (
|
||||
"Members will be required to have the specified role to use the reactions on this message."
|
||||
)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Members need {} to use these reaction roles.".format(self.formatted)
|
||||
else:
|
||||
return "All members can now use these reaction roles."
|
||||
|
||||
@classmethod
|
||||
def _get_guildid(cls, id: int, **kwargs):
|
||||
return reaction_role_messages.fetch(id).guildid
|
||||
|
||||
|
||||
@RoleMessageSettings.attach_setting
|
||||
class removable(setting_types.Boolean, RoleMessageSetting):
|
||||
attr_name = 'removable'
|
||||
_data_column = 'removable'
|
||||
|
||||
display_name = "removable"
|
||||
desc = "Whether the role is removable by deselecting the reaction."
|
||||
|
||||
long_desc = (
|
||||
"If enabled, the role will be removed when the reaction is deselected."
|
||||
)
|
||||
|
||||
_default = True
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Members will be able to remove roles by unreacting."
|
||||
else:
|
||||
return "Members will not be able to remove the reaction roles."
|
||||
|
||||
|
||||
@RoleMessageSettings.attach_setting
|
||||
class maximum(setting_types.Integer, RoleMessageSetting):
|
||||
attr_name = 'maximum'
|
||||
_data_column = 'maximum'
|
||||
|
||||
display_name = "maximum"
|
||||
desc = "The maximum number of roles a member can get from this message."
|
||||
|
||||
long_desc = (
|
||||
"The maximum number of roles that a member can get from this message. "
|
||||
"They will be notified by DM if they attempt to add more.\n"
|
||||
"The `removable` setting should generally be enabled with this setting."
|
||||
)
|
||||
|
||||
accepts = "An integer number of roles, or `None` to remove the maximum."
|
||||
|
||||
_min = 0
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, id, data, **kwargs):
|
||||
if data is None:
|
||||
return "No maximum!"
|
||||
else:
|
||||
return "`{}`".format(data)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Members can get a maximum of `{}` roles from this message.".format(self.value)
|
||||
else:
|
||||
return "Members can now get all the roles from this mesage."
|
||||
|
||||
|
||||
@RoleMessageSettings.attach_setting
|
||||
class refunds(setting_types.Boolean, RoleMessageSetting):
|
||||
attr_name = 'refunds'
|
||||
_data_column = 'refunds'
|
||||
|
||||
display_name = "refunds"
|
||||
desc = "Whether a user will be refunded when they deselect a role."
|
||||
|
||||
long_desc = (
|
||||
"Whether to give the user a refund when they deselect a role by reaction. "
|
||||
"This has no effect if `removable` is not enabled, or if the role removed has no cost."
|
||||
)
|
||||
|
||||
_default = True
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Members will get a refund when they remove a role."
|
||||
else:
|
||||
return "Members will not get a refund when they remove a role."
|
||||
|
||||
|
||||
@RoleMessageSettings.attach_setting
|
||||
class default_price(setting_types.Integer, RoleMessageSetting):
|
||||
attr_name = 'default_price'
|
||||
_data_column = 'default_price'
|
||||
|
||||
display_name = "default_price"
|
||||
desc = "Default price of reaction roles on this message."
|
||||
|
||||
long_desc = (
|
||||
"Reaction roles on this message will have this cost if they do not have an individual price set."
|
||||
)
|
||||
|
||||
accepts = "An integer number of coins. Use `0` or `None` to make roles free by default."
|
||||
|
||||
_default = 0
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, id, data, **kwargs):
|
||||
if not data:
|
||||
return "Free"
|
||||
else:
|
||||
return "`{}` coins".format(data)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Reaction roles on this message will cost `{}` coins by default.".format(self.value)
|
||||
else:
|
||||
return "Reaction roles on this message will be free by default."
|
||||
|
||||
|
||||
@RoleMessageSettings.attach_setting
|
||||
class log(setting_types.Boolean, RoleMessageSetting):
|
||||
attr_name = 'log'
|
||||
_data_column = 'event_log'
|
||||
|
||||
display_name = "log"
|
||||
desc = "Whether to log reaction role usage in the event log."
|
||||
|
||||
long_desc = (
|
||||
"When enabled, roles added or removed with reactions will be logged in the configured event log."
|
||||
)
|
||||
|
||||
_default = True
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Role updates will now be logged."
|
||||
else:
|
||||
return "Role updates will not be logged."
|
||||
|
||||
|
||||
class ReactionSettings(ObjectSettings):
|
||||
settings = DotDict()
|
||||
|
||||
|
||||
class ReactionSetting(ColumnData, Setting):
|
||||
_table_interface = reaction_role_reactions
|
||||
_id_column = 'reactionid'
|
||||
_create_row = False
|
||||
|
||||
write_ward = guild_admin
|
||||
|
||||
|
||||
@ReactionSettings.attach_setting
|
||||
class price(setting_types.Integer, ReactionSetting):
|
||||
attr_name = 'price'
|
||||
_data_column = 'price'
|
||||
|
||||
display_name = "price"
|
||||
desc = "Price of this reaction role (may be negative)."
|
||||
|
||||
long_desc = (
|
||||
"The number of coins that will be deducted from the user when this reaction is used.\n"
|
||||
"The number may be negative, in order to give a reward when the member choses the reaction."
|
||||
)
|
||||
|
||||
accepts = "An integer number of coins. Use `0` to make the role free, or `None` to use the message default."
|
||||
_max = 2 ** 20
|
||||
|
||||
@property
|
||||
def default(self):
|
||||
"""
|
||||
The default price is given by the ReactionMessage price setting.
|
||||
"""
|
||||
return default_price.get(self._table_interface.fetch(self.id).messageid).value
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, id, data, **kwargs):
|
||||
if not data:
|
||||
return "Free"
|
||||
else:
|
||||
return "`{}` coins".format(data)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value is not None:
|
||||
return "{{reaction.emoji}} {{reaction.role.mention}} now costs `{}` coins.".format(self.value)
|
||||
else:
|
||||
return "{reaction.emoji} {reaction.role.mention} is now free."
|
||||
|
||||
|
||||
@ReactionSettings.attach_setting
|
||||
class duration(setting_types.Duration, ReactionSetting):
|
||||
attr_name = 'duration'
|
||||
_data_column = 'timeout'
|
||||
|
||||
display_name = "duration"
|
||||
desc = "How long this reaction role will last."
|
||||
|
||||
long_desc = (
|
||||
"If set, the reaction role will be removed after the configured duration. "
|
||||
"Note that this does not affect existing members with the role, or existing expiries."
|
||||
)
|
||||
|
||||
_default_multiplier = 3600
|
||||
_show_days = True
|
||||
_min = 600
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, id, data, **kwargs):
|
||||
if data is None:
|
||||
return "Permanent"
|
||||
else:
|
||||
return super()._format_data(id, data, **kwargs)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value is not None:
|
||||
return "{{reaction.emoji}} {{reaction.role.mention}} will expire `{}` after selection.".format(
|
||||
self.formatted
|
||||
)
|
||||
else:
|
||||
return "{reaction.emoji} {reaction.role.mention} will not expire."
|
||||
@@ -0,0 +1,590 @@
|
||||
import asyncio
|
||||
from codecs import ignore_errors
|
||||
import logging
|
||||
import traceback
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
from typing import List, Mapping, Optional
|
||||
from cachetools import LFUCache
|
||||
|
||||
import discord
|
||||
from discord import PartialEmoji
|
||||
|
||||
from meta import client
|
||||
from core import Lion
|
||||
from data import Row
|
||||
from data.conditions import THIS_SHARD
|
||||
from utils.lib import utc_now
|
||||
from settings import GuildSettings
|
||||
|
||||
from ..module import module
|
||||
from .data import reaction_role_messages, reaction_role_reactions
|
||||
from .settings import RoleMessageSettings, ReactionSettings
|
||||
from .expiry import schedule_expiry, cancel_expiry
|
||||
|
||||
|
||||
class ReactionRoleReaction:
|
||||
"""
|
||||
Light data class representing a reaction role reaction.
|
||||
"""
|
||||
__slots__ = ('reactionid', '_emoji', '_message', '_role')
|
||||
|
||||
def __init__(self, reactionid, message=None, **kwargs):
|
||||
self.reactionid = reactionid
|
||||
self._message: ReactionRoleMessage = None
|
||||
self._role = None
|
||||
self._emoji = None
|
||||
|
||||
@classmethod
|
||||
def create(cls, messageid, roleid, emoji: PartialEmoji, message=None, **kwargs) -> 'ReactionRoleReaction':
|
||||
"""
|
||||
Create a new ReactionRoleReaction with the provided attributes.
|
||||
`emoji` sould be provided as a PartialEmoji.
|
||||
`kwargs` are passed transparently to the `insert` method.
|
||||
"""
|
||||
row = reaction_role_reactions.create_row(
|
||||
messageid=messageid,
|
||||
roleid=roleid,
|
||||
emoji_name=emoji.name,
|
||||
emoji_id=emoji.id,
|
||||
emoji_animated=emoji.animated,
|
||||
**kwargs
|
||||
)
|
||||
return cls(row.reactionid, message=message)
|
||||
|
||||
@property
|
||||
def emoji(self) -> PartialEmoji:
|
||||
if self._emoji is None:
|
||||
data = self.data
|
||||
self._emoji = PartialEmoji(
|
||||
name=data.emoji_name,
|
||||
animated=data.emoji_animated,
|
||||
id=data.emoji_id,
|
||||
)
|
||||
return self._emoji
|
||||
|
||||
@property
|
||||
def data(self) -> Row:
|
||||
return reaction_role_reactions.fetch(self.reactionid)
|
||||
|
||||
@property
|
||||
def settings(self) -> ReactionSettings:
|
||||
return ReactionSettings(self.reactionid)
|
||||
|
||||
@property
|
||||
def reaction_message(self):
|
||||
if self._message is None:
|
||||
self._message = ReactionRoleMessage.fetch(self.data.messageid)
|
||||
return self._message
|
||||
|
||||
@property
|
||||
def role(self):
|
||||
if self._role is None:
|
||||
guild = self.reaction_message.guild
|
||||
if guild:
|
||||
self._role = guild.get_role(self.data.roleid)
|
||||
return self._role
|
||||
|
||||
|
||||
class ReactionRoleMessage:
|
||||
"""
|
||||
Light data class representing a reaction role message.
|
||||
Primarily acts as an interface to the corresponding Settings.
|
||||
"""
|
||||
__slots__ = ('messageid', '_message')
|
||||
|
||||
# Full live messageid cache for this client. Should always be up to date.
|
||||
_messages: Mapping[int, 'ReactionRoleMessage'] = {} # messageid -> associated Reaction message
|
||||
|
||||
# Reaction cache for the live messages. Least frequently used, will be fetched on demand.
|
||||
_reactions: Mapping[int, List[ReactionRoleReaction]] = LFUCache(1000) # messageid -> List of Reactions
|
||||
|
||||
# User-keyed locks so we only handle one reaction per user at a time
|
||||
_locks: Mapping[int, asyncio.Lock] = defaultdict(asyncio.Lock) # userid -> Lock
|
||||
|
||||
def __init__(self, messageid):
|
||||
self.messageid = messageid
|
||||
self._message = None
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, messageid) -> 'ReactionRoleMessage':
|
||||
"""
|
||||
Fetch the ReactionRoleMessage for the provided messageid.
|
||||
Returns None if the messageid is not registered.
|
||||
"""
|
||||
# Since the cache is assumed to be always up to date, just pass to fetch-from-cache.
|
||||
return cls._messages.get(messageid, None)
|
||||
|
||||
@classmethod
|
||||
def create(cls, messageid, guildid, channelid, **kwargs) -> 'ReactionRoleMessage':
|
||||
"""
|
||||
Create a ReactionRoleMessage with the given `messageid`.
|
||||
Other `kwargs` are passed transparently to `insert`.
|
||||
"""
|
||||
# Insert the data
|
||||
reaction_role_messages.create_row(
|
||||
messageid=messageid,
|
||||
guildid=guildid,
|
||||
channelid=channelid,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Create the ReactionRoleMessage
|
||||
rmsg = cls(messageid)
|
||||
|
||||
# Add to the global cache
|
||||
cls._messages[messageid] = rmsg
|
||||
|
||||
# Return the constructed ReactionRoleMessage
|
||||
return rmsg
|
||||
|
||||
def delete(self):
|
||||
"""
|
||||
Delete this ReactionRoleMessage.
|
||||
"""
|
||||
# Remove message from cache
|
||||
self._messages.pop(self.messageid, None)
|
||||
|
||||
# Remove reactions from cache
|
||||
reactionids = [reaction.reactionid for reaction in self.reactions]
|
||||
[self._reactions.pop(reactionid, None) for reactionid in reactionids]
|
||||
|
||||
# Remove message from data
|
||||
reaction_role_messages.delete_where(messageid=self.messageid)
|
||||
|
||||
@property
|
||||
def data(self) -> Row:
|
||||
"""
|
||||
Data row associated with this Message.
|
||||
Passes directly to the RowTable cache.
|
||||
Should not generally be used directly, use the settings interface instead.
|
||||
"""
|
||||
return reaction_role_messages.fetch(self.messageid)
|
||||
|
||||
@property
|
||||
def settings(self):
|
||||
"""
|
||||
RoleMessageSettings associated to this Message.
|
||||
"""
|
||||
return RoleMessageSettings(self.messageid)
|
||||
|
||||
def refresh(self):
|
||||
"""
|
||||
Refresh the reaction cache for this message.
|
||||
Returns the generated `ReactionRoleReaction`s for convenience.
|
||||
"""
|
||||
# Fetch reactions and pre-populate reaction cache
|
||||
rows = reaction_role_reactions.fetch_rows_where(messageid=self.messageid, _extra="ORDER BY reactionid ASC")
|
||||
reactions = [ReactionRoleReaction(row.reactionid) for row in rows]
|
||||
self._reactions[self.messageid] = reactions
|
||||
return reactions
|
||||
|
||||
@property
|
||||
def reactions(self) -> List[ReactionRoleReaction]:
|
||||
"""
|
||||
Returns the list of active reactions for this message, as `ReactionRoleReaction`s.
|
||||
Lazily fetches the reactions from data if they have not been loaded.
|
||||
"""
|
||||
reactions = self._reactions.get(self.messageid, None)
|
||||
if reactions is None:
|
||||
reactions = self.refresh()
|
||||
return reactions
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""
|
||||
Whether this Message is enabled.
|
||||
Passes directly to data for efficiency.
|
||||
"""
|
||||
return self.data.enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool):
|
||||
self.data.enabled = value
|
||||
|
||||
# Discord properties
|
||||
@property
|
||||
def guild(self) -> discord.Guild:
|
||||
return client.get_guild(self.data.guildid)
|
||||
|
||||
@property
|
||||
def channel(self) -> discord.TextChannel:
|
||||
return client.get_channel(self.data.channelid)
|
||||
|
||||
async def fetch_message(self) -> discord.Message:
|
||||
if self._message:
|
||||
return self._message
|
||||
|
||||
channel = self.channel
|
||||
if channel:
|
||||
try:
|
||||
self._message = await channel.fetch_message(self.messageid)
|
||||
return self._message
|
||||
except discord.NotFound:
|
||||
# The message no longer exists
|
||||
# TODO: Cache and data cleanup? Or allow moving after death?
|
||||
pass
|
||||
|
||||
@property
|
||||
def message(self) -> Optional[discord.Message]:
|
||||
return self._message
|
||||
|
||||
@property
|
||||
def message_link(self) -> str:
|
||||
"""
|
||||
Jump link tho the reaction message.
|
||||
"""
|
||||
return 'https://discord.com/channels/{}/{}/{}'.format(
|
||||
self.data.guildid,
|
||||
self.data.channelid,
|
||||
self.messageid
|
||||
)
|
||||
|
||||
# Event handlers
|
||||
async def process_raw_reaction_add(self, payload):
|
||||
"""
|
||||
Process a general reaction add payload.
|
||||
"""
|
||||
event_log = GuildSettings(self.guild.id).event_log
|
||||
async with self._locks[payload.user_id]:
|
||||
reaction = next((reaction for reaction in self.reactions if reaction.emoji == payload.emoji), None)
|
||||
if reaction:
|
||||
# User pressed a live reaction. Process!
|
||||
member = payload.member
|
||||
lion = Lion.fetch(member.guild.id, member.id)
|
||||
role = reaction.role
|
||||
if reaction.role and (role not in member.roles):
|
||||
# Required role check, make sure the user has the required role, if set.
|
||||
required_role = self.settings.required_role.value
|
||||
if required_role and required_role not in member.roles:
|
||||
# Silently remove their reaction
|
||||
try:
|
||||
message = await self.fetch_message()
|
||||
await message.remove_reaction(
|
||||
payload.emoji,
|
||||
member
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return
|
||||
|
||||
# Maximum check, check whether the user already has too many roles from this message.
|
||||
maximum = self.settings.maximum.value
|
||||
if maximum is not None:
|
||||
# Fetch the number of applicable roles the user has
|
||||
roleids = set(reaction.data.roleid for reaction in self.reactions)
|
||||
member_roleids = set(role.id for role in member.roles)
|
||||
if len(roleids.intersection(member_roleids)) >= maximum:
|
||||
# Notify the user
|
||||
embed = discord.Embed(
|
||||
title="Maximum group roles reached!",
|
||||
description=(
|
||||
"Couldn't give you **{}**, "
|
||||
"because you already have `{}` roles from this group!".format(
|
||||
role.name,
|
||||
maximum
|
||||
)
|
||||
)
|
||||
)
|
||||
# Silently try to notify the user
|
||||
try:
|
||||
await member.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
# Silently remove the reaction
|
||||
try:
|
||||
message = await self.fetch_message()
|
||||
await message.remove_reaction(
|
||||
payload.emoji,
|
||||
member
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
return
|
||||
|
||||
# Economy hook, check whether the user can pay for the role.
|
||||
price = reaction.settings.price.value
|
||||
if price and price > lion.coins:
|
||||
# They can't pay!
|
||||
# Build the can't pay embed
|
||||
embed = discord.Embed(
|
||||
title="Insufficient funds!",
|
||||
description="Sorry, **{}** costs `{}` coins, but you only have `{}`.".format(
|
||||
role.name,
|
||||
price,
|
||||
lion.coins
|
||||
),
|
||||
colour=discord.Colour.red()
|
||||
).set_footer(
|
||||
icon_url=self.guild.icon_url,
|
||||
text=self.guild.name
|
||||
).add_field(
|
||||
name="Jump Back",
|
||||
value="[Click here]({})".format(self.message_link)
|
||||
)
|
||||
# Try to send them the embed, ignore errors
|
||||
try:
|
||||
await member.send(
|
||||
embed=embed
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Remove their reaction, ignore errors
|
||||
try:
|
||||
message = await self.fetch_message()
|
||||
await message.remove_reaction(
|
||||
payload.emoji,
|
||||
member
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
return
|
||||
|
||||
# Add the role
|
||||
try:
|
||||
await member.add_roles(
|
||||
role,
|
||||
atomic=True,
|
||||
reason="Adding reaction role."
|
||||
)
|
||||
except discord.Forbidden:
|
||||
event_log.log(
|
||||
"Insufficient permissions to give {} the [reaction role]({}) {}".format(
|
||||
member.mention,
|
||||
self.message_link,
|
||||
role.mention,
|
||||
),
|
||||
title="Failed to add reaction role",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
except discord.HTTPException:
|
||||
event_log.log(
|
||||
"Something went wrong while adding the [reaction role]({}) "
|
||||
"{} to {}.".format(
|
||||
self.message_link,
|
||||
role.mention,
|
||||
member.mention
|
||||
),
|
||||
title="Failed to add reaction role",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
client.log(
|
||||
"Unexpected HTTPException encountered while adding '{}' (rid:{}) to "
|
||||
"user '{}' (uid:{}) in guild '{}' (gid:{}).\n{}".format(
|
||||
role.name,
|
||||
role.id,
|
||||
member,
|
||||
member.id,
|
||||
member.guild.name,
|
||||
member.guild.id,
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="REACTION_ROLE_ADD",
|
||||
level=logging.WARNING
|
||||
)
|
||||
else:
|
||||
# Charge the user and notify them, if the price is set
|
||||
if price:
|
||||
lion.addCoins(-price)
|
||||
# Notify the user of their purchase
|
||||
embed = discord.Embed(
|
||||
title="Purchase successful!",
|
||||
description="You have purchased **{}** for `{}` coins!".format(
|
||||
role.name,
|
||||
price
|
||||
),
|
||||
colour=discord.Colour.green()
|
||||
).set_footer(
|
||||
icon_url=self.guild.icon_url,
|
||||
text=self.guild.name
|
||||
).add_field(
|
||||
name="Jump Back",
|
||||
value="[Click Here]({})".format(self.message_link)
|
||||
)
|
||||
try:
|
||||
await member.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Schedule the expiry, if required
|
||||
duration = reaction.settings.duration.value
|
||||
if duration:
|
||||
expiry = utc_now() + datetime.timedelta(seconds=duration)
|
||||
schedule_expiry(self.guild.id, member.id, role.id, expiry, reaction.reactionid)
|
||||
else:
|
||||
expiry = None
|
||||
|
||||
# Log the role modification if required
|
||||
if self.settings.log.value:
|
||||
event_log.log(
|
||||
"Added [reaction role]({}) {} "
|
||||
"to {}{}.{}".format(
|
||||
self.message_link,
|
||||
role.mention,
|
||||
member.mention,
|
||||
" for `{}` coins".format(price) if price else '',
|
||||
"\nThis role will expire at <t:{:.0f}>.".format(
|
||||
expiry.timestamp()
|
||||
) if expiry else ''
|
||||
),
|
||||
title="Reaction Role Added"
|
||||
)
|
||||
|
||||
async def process_raw_reaction_remove(self, payload):
|
||||
"""
|
||||
Process a general reaction remove payload.
|
||||
"""
|
||||
if self.settings.removable.value:
|
||||
event_log = GuildSettings(self.guild.id).event_log
|
||||
async with self._locks[payload.user_id]:
|
||||
reaction = next((reaction for reaction in self.reactions if reaction.emoji == payload.emoji), None)
|
||||
if reaction:
|
||||
# User removed a live reaction. Process!
|
||||
member = self.guild.get_member(payload.user_id)
|
||||
role = reaction.role
|
||||
if member and not member.bot and role and (role in member.roles):
|
||||
# Check whether they have the required role, if set
|
||||
required_role = self.settings.required_role.value
|
||||
if required_role and required_role not in member.roles:
|
||||
# Ignore the reaction removal
|
||||
return
|
||||
|
||||
try:
|
||||
await member.remove_roles(
|
||||
role,
|
||||
atomic=True,
|
||||
reason="Removing reaction role."
|
||||
)
|
||||
except discord.Forbidden:
|
||||
event_log.log(
|
||||
"Insufficient permissions to remove "
|
||||
"the [reaction role]({}) {} from {}".format(
|
||||
self.message_link,
|
||||
role.mention,
|
||||
member.mention,
|
||||
),
|
||||
title="Failed to remove reaction role",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
except discord.HTTPException:
|
||||
event_log.log(
|
||||
"Something went wrong while removing the [reaction role]({}) "
|
||||
"{} from {}.".format(
|
||||
self.message_link,
|
||||
role.mention,
|
||||
member.mention
|
||||
),
|
||||
title="Failed to remove reaction role",
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
client.log(
|
||||
"Unexpected HTTPException encountered while removing '{}' (rid:{}) from "
|
||||
"user '{}' (uid:{}) in guild '{}' (gid:{}).\n{}".format(
|
||||
role.name,
|
||||
role.id,
|
||||
member,
|
||||
member.id,
|
||||
member.guild.name,
|
||||
member.guild.id,
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="REACTION_ROLE_RM",
|
||||
level=logging.WARNING
|
||||
)
|
||||
else:
|
||||
# Economy hook, handle refund if required
|
||||
price = reaction.settings.price.value
|
||||
refund = self.settings.refunds.value
|
||||
if price and refund:
|
||||
# Give the user the refund
|
||||
lion = Lion.fetch(self.guild.id, member.id)
|
||||
lion.addCoins(price)
|
||||
|
||||
# Notify the user
|
||||
embed = discord.Embed(
|
||||
title="Role sold",
|
||||
description=(
|
||||
"You sold the role **{}** for `{}` coins.".format(
|
||||
role.name,
|
||||
price
|
||||
)
|
||||
),
|
||||
colour=discord.Colour.green()
|
||||
).set_footer(
|
||||
icon_url=self.guild.icon_url,
|
||||
text=self.guild.name
|
||||
).add_field(
|
||||
name="Jump Back",
|
||||
value="[Click Here]({})".format(self.message_link)
|
||||
)
|
||||
try:
|
||||
await member.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Log role removal if required
|
||||
if self.settings.log.value:
|
||||
event_log.log(
|
||||
"Removed [reaction role]({}) {} "
|
||||
"from {}.".format(
|
||||
self.message_link,
|
||||
role.mention,
|
||||
member.mention
|
||||
),
|
||||
title="Reaction Role Removed"
|
||||
)
|
||||
|
||||
# Cancel any existing expiry
|
||||
cancel_expiry(self.guild.id, member.id, role.id)
|
||||
|
||||
|
||||
# TODO: Make all the embeds a bit nicer, and maybe make a consistent interface for them
|
||||
# TODO: Handle RawMessageDelete event
|
||||
# TODO: Handle permission errors when fetching message in config
|
||||
|
||||
@client.add_after_event('raw_reaction_add')
|
||||
async def reaction_role_add(client, payload):
|
||||
reaction_message = ReactionRoleMessage.fetch(payload.message_id)
|
||||
if payload.guild_id and payload.user_id != client.user.id and reaction_message and reaction_message.enabled:
|
||||
try:
|
||||
await reaction_message.process_raw_reaction_add(payload)
|
||||
except Exception:
|
||||
# Unknown exception, catch and log it.
|
||||
client.log(
|
||||
"Unhandled exception while handling reaction message payload: {}\n{}".format(
|
||||
payload,
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="REACTION_ROLE_ADD",
|
||||
level=logging.ERROR
|
||||
)
|
||||
|
||||
|
||||
@client.add_after_event('raw_reaction_remove')
|
||||
async def reaction_role_remove(client, payload):
|
||||
reaction_message = ReactionRoleMessage.fetch(payload.message_id)
|
||||
if payload.guild_id and reaction_message and reaction_message.enabled:
|
||||
try:
|
||||
await reaction_message.process_raw_reaction_remove(payload)
|
||||
except Exception:
|
||||
# Unknown exception, catch and log it.
|
||||
client.log(
|
||||
"Unhandled exception while handling reaction message payload: {}\n{}".format(
|
||||
payload,
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="REACTION_ROLE_RM",
|
||||
level=logging.ERROR
|
||||
)
|
||||
|
||||
|
||||
@module.init_task
|
||||
def load_reaction_roles(client):
|
||||
"""
|
||||
Load the ReactionRoleMessages.
|
||||
"""
|
||||
rows = reaction_role_messages.fetch_rows_where(guildid=THIS_SHARD)
|
||||
ReactionRoleMessage._messages = {row.messageid: ReactionRoleMessage(row.messageid) for row in rows}
|
||||
65
bot/modules/pending-rewrite/guild_admin/statreset.py
Normal file
65
bot/modules/pending-rewrite/guild_admin/statreset.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from io import StringIO
|
||||
|
||||
import discord
|
||||
from wards import guild_admin
|
||||
from data import tables
|
||||
from core import Lion
|
||||
|
||||
from .module import module
|
||||
|
||||
|
||||
@module.cmd("studyreset",
|
||||
desc="Perform a reset of the server's study statistics.",
|
||||
group="Guild Admin")
|
||||
@guild_admin()
|
||||
async def cmd_statreset(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}studyreset
|
||||
Description:
|
||||
Perform a complete reset of the server's study statistics.
|
||||
That is, deletes the tracked time of all members and removes their study badges.
|
||||
|
||||
This may be used to set "seasons" of study.
|
||||
|
||||
Before the reset, I will send a csv file with the current member statistics.
|
||||
|
||||
**This is not reversible.**
|
||||
"""
|
||||
if not await ctx.ask("Are you sure you want to reset the study time and badges for all members? "
|
||||
"**THIS IS NOT REVERSIBLE!**"):
|
||||
return
|
||||
# Build the data csv
|
||||
rows = tables.lions.select_where(
|
||||
select_columns=('userid', 'tracked_time', 'coins', 'workout_count', 'b.roleid AS badge_roleid'),
|
||||
_extra=(
|
||||
"LEFT JOIN study_badges b ON last_study_badgeid = b.badgeid "
|
||||
"WHERE members.guildid={}"
|
||||
).format(ctx.guild.id)
|
||||
)
|
||||
header = "userid, tracked_time, coins, workouts, rank_roleid\n"
|
||||
csv_rows = [
|
||||
','.join(str(data) for data in row)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
with StringIO() as stats_file:
|
||||
stats_file.write(header)
|
||||
stats_file.write('\n'.join(csv_rows))
|
||||
stats_file.seek(0)
|
||||
|
||||
out_file = discord.File(stats_file, filename="guild_{}_member_statistics.csv".format(ctx.guild.id))
|
||||
await ctx.reply(file=out_file)
|
||||
|
||||
# Reset the statistics
|
||||
tables.lions.update_where(
|
||||
{'tracked_time': 0},
|
||||
guildid=ctx.guild.id
|
||||
)
|
||||
|
||||
Lion.sync()
|
||||
|
||||
await ctx.embed_reply(
|
||||
"The member study times have been reset!\n"
|
||||
"(It may take a while for the studybadges to update.)"
|
||||
)
|
||||
7
bot/modules/pending-rewrite/meta/__init__.py
Normal file
7
bot/modules/pending-rewrite/meta/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# flake8: noqa
|
||||
from .module import module
|
||||
|
||||
from . import help
|
||||
from . import links
|
||||
from . import nerd
|
||||
from . import join_message
|
||||
237
bot/modules/pending-rewrite/meta/help.py
Normal file
237
bot/modules/pending-rewrite/meta/help.py
Normal file
@@ -0,0 +1,237 @@
|
||||
import discord
|
||||
from cmdClient.checks import is_owner
|
||||
|
||||
from utils.lib import prop_tabulate
|
||||
from utils import interactive, ctx_addons # noqa
|
||||
from wards import is_guild_admin
|
||||
|
||||
from .module import module
|
||||
from .lib import guide_link
|
||||
|
||||
|
||||
new_emoji = " 🆕"
|
||||
new_commands = {'botconfig', 'sponsors'}
|
||||
|
||||
# Set the command groups to appear in the help
|
||||
group_hints = {
|
||||
'Pomodoro': "*Stay in sync with your friends using our timers!*",
|
||||
'Productivity': "*Use these to help you stay focused and productive!*",
|
||||
'Statistics': "*StudyLion leaderboards and study statistics.*",
|
||||
'Economy': "*Buy, sell, and trade with your hard-earned coins!*",
|
||||
'Personal Settings': "*Tell me about yourself!*",
|
||||
'Guild Admin': "*Dangerous administration commands!*",
|
||||
'Guild Configuration': "*Control how I behave in your server.*",
|
||||
'Meta': "*Information about me!*",
|
||||
'Support Us': "*Support the team and keep the project alive by using LionGems!*"
|
||||
}
|
||||
|
||||
standard_group_order = (
|
||||
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings', 'Meta'),
|
||||
)
|
||||
|
||||
mod_group_order = (
|
||||
('Moderation', 'Meta'),
|
||||
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
|
||||
)
|
||||
|
||||
admin_group_order = (
|
||||
('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
|
||||
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
|
||||
)
|
||||
|
||||
bot_admin_group_order = (
|
||||
('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
|
||||
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
|
||||
)
|
||||
|
||||
# Help embed format
|
||||
# TODO: Add config fields for this
|
||||
title = "StudyLion Command List"
|
||||
header = """
|
||||
[StudyLion](https://bot.studylions.com/) is a fully featured study assistant \
|
||||
that tracks your study time and offers productivity tools \
|
||||
such as to-do lists, task reminders, private study rooms, group accountability sessions, and much much more.\n
|
||||
Use `{{ctx.best_prefix}}help <command>` (e.g. `{{ctx.best_prefix}}help send`) to learn how to use each command, \
|
||||
or [click here]({guide_link}) for a comprehensive tutorial.
|
||||
""".format(guide_link=guide_link)
|
||||
|
||||
|
||||
@module.cmd("help",
|
||||
group="Meta",
|
||||
desc="StudyLion command list.",
|
||||
aliases=('man', 'ls', 'list'))
|
||||
async def cmd_help(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}help [cmdname]
|
||||
Description:
|
||||
When used with no arguments, displays a list of commands with brief descriptions.
|
||||
Otherwise, shows documentation for the provided command.
|
||||
Examples:
|
||||
{prefix}help
|
||||
{prefix}help top
|
||||
{prefix}help timezone
|
||||
"""
|
||||
if ctx.arg_str:
|
||||
# Attempt to fetch the command
|
||||
command = ctx.client.cmd_names.get(ctx.arg_str.strip(), None)
|
||||
if command is None:
|
||||
return await ctx.error_reply(
|
||||
("Command `{}` not found!\n"
|
||||
"Write `{}help` to see a list of commands.").format(ctx.args, ctx.best_prefix)
|
||||
)
|
||||
|
||||
smart_help = getattr(command, 'smart_help', None)
|
||||
if smart_help is not None:
|
||||
return await smart_help(ctx)
|
||||
|
||||
help_fields = command.long_help.copy()
|
||||
help_map = {field_name: i for i, (field_name, _) in enumerate(help_fields)}
|
||||
|
||||
if not help_map:
|
||||
return await ctx.reply("No documentation has been written for this command yet!")
|
||||
|
||||
field_pages = [[]]
|
||||
page_fields = field_pages[0]
|
||||
for name, pos in help_map.items():
|
||||
if name.endswith("``"):
|
||||
# Handle codeline help fields
|
||||
page_fields.append((
|
||||
name.strip("`"),
|
||||
"`{}`".format('`\n`'.join(help_fields[pos][1].splitlines()))
|
||||
))
|
||||
elif name.endswith(":"):
|
||||
# Handle property/value help fields
|
||||
lines = help_fields[pos][1].splitlines()
|
||||
|
||||
names = []
|
||||
values = []
|
||||
for line in lines:
|
||||
split = line.split(":", 1)
|
||||
names.append(split[0] if len(split) > 1 else "")
|
||||
values.append(split[-1])
|
||||
|
||||
page_fields.append((
|
||||
name.strip(':'),
|
||||
prop_tabulate(names, values)
|
||||
))
|
||||
elif name == "Related":
|
||||
# Handle the related field
|
||||
names = [cmd_name.strip() for cmd_name in help_fields[pos][1].split(',')]
|
||||
names.sort(key=len)
|
||||
values = [
|
||||
(getattr(ctx.client.cmd_names.get(cmd_name, None), 'desc', '') or '').format(ctx=ctx)
|
||||
for cmd_name in names
|
||||
]
|
||||
page_fields.append((
|
||||
name,
|
||||
prop_tabulate(names, values)
|
||||
))
|
||||
elif name == "PAGEBREAK":
|
||||
page_fields = []
|
||||
field_pages.append(page_fields)
|
||||
else:
|
||||
page_fields.append((name, help_fields[pos][1]))
|
||||
|
||||
# Build the aliases
|
||||
aliases = getattr(command, 'aliases', [])
|
||||
alias_str = "(Aliases `{}`.)".format("`, `".join(aliases)) if aliases else ""
|
||||
|
||||
# Build the embeds
|
||||
pages = []
|
||||
for i, page_fields in enumerate(field_pages):
|
||||
embed = discord.Embed(
|
||||
title="`{}` command documentation. {}".format(
|
||||
command.name,
|
||||
alias_str
|
||||
),
|
||||
colour=discord.Colour(0x9b59b6)
|
||||
)
|
||||
for fieldname, fieldvalue in page_fields:
|
||||
embed.add_field(
|
||||
name=fieldname,
|
||||
value=fieldvalue.format(ctx=ctx, prefix=ctx.best_prefix),
|
||||
inline=False
|
||||
)
|
||||
|
||||
embed.set_footer(
|
||||
text="{}\n[optional] and <required> denote optional and required arguments, respectively.".format(
|
||||
"Page {} of {}".format(i + 1, len(field_pages)) if len(field_pages) > 1 else '',
|
||||
)
|
||||
)
|
||||
pages.append(embed)
|
||||
|
||||
# Post the embed
|
||||
await ctx.pager(pages)
|
||||
else:
|
||||
# Build the command groups
|
||||
cmd_groups = {}
|
||||
for command in ctx.client.cmds:
|
||||
# Get the command group
|
||||
group = getattr(command, 'group', "Misc")
|
||||
cmd_group = cmd_groups.get(group, [])
|
||||
if not cmd_group:
|
||||
cmd_groups[group] = cmd_group
|
||||
|
||||
# Add the command name and description to the group
|
||||
cmd_group.append(
|
||||
(command.name, (getattr(command, 'desc', '') + (new_emoji if command.name in new_commands else '')))
|
||||
)
|
||||
|
||||
# Add any required aliases
|
||||
for alias, desc in getattr(command, 'help_aliases', {}).items():
|
||||
cmd_group.append((alias, desc))
|
||||
|
||||
# Turn the command groups into strings
|
||||
stringy_cmd_groups = {}
|
||||
for group_name, cmd_group in cmd_groups.items():
|
||||
cmd_group.sort(key=lambda tup: len(tup[0]))
|
||||
if ctx.alias == 'ls':
|
||||
stringy_cmd_groups[group_name] = ', '.join(
|
||||
f"`{name}`" for name, _ in cmd_group
|
||||
)
|
||||
else:
|
||||
stringy_cmd_groups[group_name] = prop_tabulate(*zip(*cmd_group))
|
||||
|
||||
# Now put everything into a bunch of embeds
|
||||
if await is_owner.run(ctx):
|
||||
group_order = bot_admin_group_order
|
||||
elif ctx.guild:
|
||||
if is_guild_admin(ctx.author):
|
||||
group_order = admin_group_order
|
||||
elif ctx.guild_settings.mod_role.value in ctx.author.roles:
|
||||
group_order = mod_group_order
|
||||
else:
|
||||
group_order = standard_group_order
|
||||
else:
|
||||
group_order = admin_group_order
|
||||
|
||||
help_embeds = []
|
||||
for page_groups in group_order:
|
||||
embed = discord.Embed(
|
||||
description=header.format(ctx=ctx),
|
||||
colour=discord.Colour(0x9b59b6),
|
||||
title=title
|
||||
)
|
||||
for group in page_groups:
|
||||
group_hint = group_hints.get(group, '').format(ctx=ctx)
|
||||
group_str = stringy_cmd_groups.get(group, None)
|
||||
if group_str:
|
||||
embed.add_field(
|
||||
name=group,
|
||||
value="{}\n{}".format(group_hint, group_str).format(ctx=ctx),
|
||||
inline=False
|
||||
)
|
||||
help_embeds.append(embed)
|
||||
|
||||
# Add the page numbers
|
||||
for i, embed in enumerate(help_embeds):
|
||||
embed.set_footer(text="Page {}/{}".format(i+1, len(help_embeds)))
|
||||
|
||||
# Send the embeds
|
||||
if help_embeds:
|
||||
await ctx.pager(help_embeds)
|
||||
else:
|
||||
await ctx.reply(
|
||||
embed=discord.Embed(description=header, colour=discord.Colour(0x9b59b6))
|
||||
)
|
||||
50
bot/modules/pending-rewrite/meta/join_message.py
Normal file
50
bot/modules/pending-rewrite/meta/join_message.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import discord
|
||||
|
||||
from cmdClient import cmdClient
|
||||
|
||||
from meta import client, conf
|
||||
from .lib import guide_link, animation_link
|
||||
|
||||
|
||||
message = """
|
||||
Thank you for inviting me to your community.
|
||||
Get started by typing `{prefix}help` to see my commands, and `{prefix}config info` \
|
||||
to read about my configuration options!
|
||||
|
||||
To learn how to configure me and use all of my features, \
|
||||
make sure to [click here]({guide_link}) to read our full setup guide.
|
||||
|
||||
Remember, if you need any help configuring me, \
|
||||
want to suggest a feature, report a bug and stay updated, \
|
||||
make sure to join our main support and study server by [clicking here]({support_link}).
|
||||
|
||||
Best of luck with your studies!
|
||||
|
||||
""".format(
|
||||
guide_link=guide_link,
|
||||
support_link=conf.bot.get('support_link'),
|
||||
prefix=client.prefix
|
||||
)
|
||||
|
||||
|
||||
@client.add_after_event('guild_join', priority=0)
|
||||
async def post_join_message(client: cmdClient, guild: discord.Guild):
|
||||
try:
|
||||
await guild.me.edit(nick="Leo")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
if (channel := guild.system_channel) and channel.permissions_for(guild.me).embed_links:
|
||||
embed = discord.Embed(
|
||||
description=message
|
||||
)
|
||||
embed.set_author(
|
||||
name="Hello everyone! My name is Leo, the StudyLion!",
|
||||
icon_url="https://cdn.discordapp.com/emojis/933610591459872868.webp"
|
||||
)
|
||||
embed.set_image(url=animation_link)
|
||||
try:
|
||||
await channel.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
# Something went wrong sending the hi message
|
||||
# Not much we can do about this
|
||||
pass
|
||||
5
bot/modules/pending-rewrite/meta/lib.py
Normal file
5
bot/modules/pending-rewrite/meta/lib.py
Normal file
@@ -0,0 +1,5 @@
|
||||
guide_link = "https://discord.studylions.com/tutorial"
|
||||
|
||||
animation_link = (
|
||||
"https://media.discordapp.net/attachments/879412267731542047/926837189814419486/ezgif.com-resize.gif"
|
||||
)
|
||||
57
bot/modules/pending-rewrite/meta/links.py
Normal file
57
bot/modules/pending-rewrite/meta/links.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import discord
|
||||
|
||||
from meta import conf
|
||||
|
||||
from LionContext import LionContext as Context
|
||||
|
||||
from .module import module
|
||||
from .lib import guide_link
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"support",
|
||||
group="Meta",
|
||||
desc=f"Have a question? Join my [support server]({conf.bot.get('support_link')})"
|
||||
)
|
||||
async def cmd_support(ctx: Context):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}support
|
||||
Description:
|
||||
Replies with an invite link to my support server.
|
||||
"""
|
||||
await ctx.reply(
|
||||
f"Click here to join my support server: {conf.bot.get('support_link')}"
|
||||
)
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"invite",
|
||||
group="Meta",
|
||||
desc=f"[Invite me]({conf.bot.get('invite_link')}) to your server so I can help your members stay productive!"
|
||||
)
|
||||
async def cmd_invite(ctx: Context):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}invite
|
||||
Description:
|
||||
Replies with my invite link so you can add me to your server.
|
||||
"""
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
description=f"Click here to add me to your server: {conf.bot.get('invite_link')}"
|
||||
)
|
||||
embed.add_field(
|
||||
name="Setup tips",
|
||||
value=(
|
||||
"Remember to check out `{prefix}help` for the full command list, "
|
||||
"and `{prefix}config info` for the configuration options.\n"
|
||||
"[Click here]({guide}) for our comprehensive setup tutorial, and if you still have questions you can "
|
||||
"join our support server [here]({support}) to talk to our friendly support team!"
|
||||
).format(
|
||||
prefix=ctx.best_prefix,
|
||||
support=conf.bot.get('support_link'),
|
||||
guide=guide_link
|
||||
)
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
3
bot/modules/pending-rewrite/meta/module.py
Normal file
3
bot/modules/pending-rewrite/meta/module.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
module = LionModule("Meta")
|
||||
144
bot/modules/pending-rewrite/meta/nerd.py
Normal file
144
bot/modules/pending-rewrite/meta/nerd.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import datetime
|
||||
import asyncio
|
||||
import discord
|
||||
import psutil
|
||||
import sys
|
||||
import gc
|
||||
|
||||
from data import NOTNULL
|
||||
from data.queries import select_where
|
||||
from utils.lib import prop_tabulate, utc_now
|
||||
|
||||
from LionContext import LionContext as Context
|
||||
|
||||
from .module import module
|
||||
|
||||
|
||||
process = psutil.Process()
|
||||
process.cpu_percent()
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"nerd",
|
||||
group="Meta",
|
||||
desc="Information and statistics about me!"
|
||||
)
|
||||
async def cmd_nerd(ctx: Context):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}nerd
|
||||
Description:
|
||||
View nerdy information and statistics about me!
|
||||
"""
|
||||
# Create embed
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title="Nerd Panel",
|
||||
description=(
|
||||
"Hi! I'm [StudyLion]({studylion}), a study management bot owned by "
|
||||
"[Ari Horesh]({ari}) and developed by [Conatum#5317]({cona}), with [contributors]({github})."
|
||||
).format(
|
||||
studylion="http://studylions.com/",
|
||||
ari="https://arihoresh.com/",
|
||||
cona="https://github.com/Intery",
|
||||
github="https://github.com/StudyLions/StudyLion"
|
||||
)
|
||||
)
|
||||
|
||||
# ----- Study stats -----
|
||||
# Current studying statistics
|
||||
current_students, current_channels, current_guilds= (
|
||||
ctx.client.data.current_sessions.select_one_where(
|
||||
select_columns=(
|
||||
"COUNT(*) AS studying_count",
|
||||
"COUNT(DISTINCT(channelid)) AS channel_count",
|
||||
"COUNT(DISTINCT(guildid)) AS guild_count"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Past studying statistics
|
||||
past_sessions, past_students, past_duration, past_guilds = ctx.client.data.session_history.select_one_where(
|
||||
select_columns=(
|
||||
"COUNT(*) AS session_count",
|
||||
"COUNT(DISTINCT(userid)) AS user_count",
|
||||
"SUM(duration) / 3600 AS total_hours",
|
||||
"COUNT(DISTINCT(guildid)) AS guild_count"
|
||||
)
|
||||
)
|
||||
|
||||
# Tasklist statistics
|
||||
tasks = ctx.client.data.tasklist.select_one_where(
|
||||
select_columns=(
|
||||
'COUNT(*)'
|
||||
)
|
||||
)[0]
|
||||
|
||||
tasks_completed = ctx.client.data.tasklist.select_one_where(
|
||||
completed_at=NOTNULL,
|
||||
select_columns=(
|
||||
'COUNT(*)'
|
||||
)
|
||||
)[0]
|
||||
|
||||
# Timers
|
||||
timer_count, timer_guilds = ctx.client.data.timers.select_one_where(
|
||||
select_columns=("COUNT(*)", "COUNT(DISTINCT(guildid))")
|
||||
)
|
||||
|
||||
study_fields = {
|
||||
"Currently": f"`{current_students}` people working in `{current_channels}` rooms of `{current_guilds}` guilds",
|
||||
"Recorded": f"`{past_duration}` hours from `{past_students}` people across `{past_sessions}` sessions",
|
||||
"Tasks": f"`{tasks_completed}` out of `{tasks}` tasks completed",
|
||||
"Timers": f"`{timer_count}` timers running in `{timer_guilds}` communities"
|
||||
}
|
||||
study_table = prop_tabulate(*zip(*study_fields.items()))
|
||||
|
||||
# ----- Shard statistics -----
|
||||
shard_number = ctx.client.shard_id
|
||||
shard_count = ctx.client.shard_count
|
||||
guilds = len(ctx.client.guilds)
|
||||
member_count = sum(guild.member_count for guild in ctx.client.guilds)
|
||||
commands = len(ctx.client.cmds)
|
||||
aliases = len(ctx.client.cmd_names)
|
||||
dpy_version = discord.__version__
|
||||
py_version = sys.version.split()[0]
|
||||
data_version, data_time, _ = select_where(
|
||||
"VersionHistory",
|
||||
_extra="ORDER BY time DESC LIMIT 1"
|
||||
)[0]
|
||||
data_timestamp = int(data_time.replace(tzinfo=datetime.timezone.utc).timestamp())
|
||||
|
||||
shard_fields = {
|
||||
"Shard": f"`{shard_number}` of `{shard_count}`",
|
||||
"Guilds": f"`{guilds}` servers with `{member_count}` members (on this shard)",
|
||||
"Commands": f"`{commands}` commands with `{aliases}` keywords",
|
||||
"Version": f"`v{data_version}`, last updated <t:{data_timestamp}:F>",
|
||||
"Py version": f"`{py_version}` running discord.py `{dpy_version}`"
|
||||
}
|
||||
shard_table = prop_tabulate(*zip(*shard_fields.items()))
|
||||
|
||||
|
||||
# ----- Execution statistics -----
|
||||
running_commands = len(ctx.client.active_contexts)
|
||||
tasks = len(asyncio.all_tasks())
|
||||
objects = len(gc.get_objects())
|
||||
cpu_percent = process.cpu_percent()
|
||||
mem_percent = int(process.memory_percent())
|
||||
uptime = int(utc_now().timestamp() - process.create_time())
|
||||
|
||||
execution_fields = {
|
||||
"Running": f"`{running_commands}` commands",
|
||||
"Waiting for": f"`{tasks}` tasks to complete",
|
||||
"Objects": f"`{objects}` loaded in memory",
|
||||
"Usage": f"`{cpu_percent}%` CPU, `{mem_percent}%` MEM",
|
||||
"Uptime": f"`{uptime // (24 * 3600)}` days, `{uptime // 3600 % 24:02}:{uptime // 60 % 60:02}:{uptime % 60:02}`"
|
||||
}
|
||||
execution_table = prop_tabulate(*zip(*execution_fields.items()))
|
||||
|
||||
# ----- Combine and output -----
|
||||
embed.add_field(name="Study Stats", value=study_table, inline=False)
|
||||
embed.add_field(name=f"Shard Info", value=shard_table, inline=False)
|
||||
embed.add_field(name=f"Process Stats", value=execution_table, inline=False)
|
||||
|
||||
await ctx.reply(embed=embed)
|
||||
9
bot/modules/pending-rewrite/moderation/__init__.py
Normal file
9
bot/modules/pending-rewrite/moderation/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .module import module
|
||||
|
||||
from . import data
|
||||
from . import admin
|
||||
|
||||
from . import tickets
|
||||
from . import video
|
||||
|
||||
from . import commands
|
||||
109
bot/modules/pending-rewrite/moderation/admin.py
Normal file
109
bot/modules/pending-rewrite/moderation/admin.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import discord
|
||||
|
||||
from settings import GuildSettings, GuildSetting
|
||||
from wards import guild_admin
|
||||
|
||||
import settings
|
||||
|
||||
from .data import studyban_durations
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class mod_log(settings.Channel, GuildSetting):
|
||||
category = "Moderation"
|
||||
|
||||
attr_name = 'mod_log'
|
||||
_data_column = 'mod_log_channel'
|
||||
|
||||
display_name = "mod_log"
|
||||
desc = "Moderation event logging channel."
|
||||
|
||||
long_desc = (
|
||||
"Channel to post moderation tickets.\n"
|
||||
"These are produced when a manual or automatic moderation action is performed on a member. "
|
||||
"This channel acts as a more context rich moderation history source than the audit log."
|
||||
)
|
||||
|
||||
_chan_type = discord.ChannelType.text
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Moderation tickets will be posted to {}.".format(self.formatted)
|
||||
else:
|
||||
return "The moderation log has been unset."
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class studyban_role(settings.Role, GuildSetting):
|
||||
category = "Moderation"
|
||||
|
||||
attr_name = 'studyban_role'
|
||||
_data_column = 'studyban_role'
|
||||
|
||||
display_name = "studyban_role"
|
||||
desc = "The role given to members to prevent them from using server study features."
|
||||
|
||||
long_desc = (
|
||||
"This role is to be given to members to prevent them from using the server's study features.\n"
|
||||
"Typically, this role should act as a 'partial mute', and prevent the user from joining study voice channels, "
|
||||
"or participating in study text channels.\n"
|
||||
"It will be given automatically after study related offences, "
|
||||
"such as not enabling video in the video-only channels."
|
||||
)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "The study ban role is now {}.".format(self.formatted)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class studyban_durations(settings.SettingList, settings.ListData, settings.Setting):
|
||||
category = "Moderation"
|
||||
|
||||
attr_name = 'studyban_durations'
|
||||
|
||||
_table_interface = studyban_durations
|
||||
_id_column = 'guildid'
|
||||
_data_column = 'duration'
|
||||
_order_column = "rowid"
|
||||
|
||||
_default = [
|
||||
5 * 60,
|
||||
60 * 60,
|
||||
6 * 60 * 60,
|
||||
24 * 60 * 60,
|
||||
168 * 60 * 60,
|
||||
720 * 60 * 60
|
||||
]
|
||||
|
||||
_setting = settings.Duration
|
||||
|
||||
write_ward = guild_admin
|
||||
display_name = "studyban_durations"
|
||||
desc = "Sequence of durations for automatic study bans."
|
||||
|
||||
long_desc = (
|
||||
"This sequence describes how long a member will be automatically study-banned for "
|
||||
"after committing a study-related offence (such as not enabling their video in video only channels).\n"
|
||||
"If the sequence is `1d, 7d, 30d`, for example, the member will be study-banned "
|
||||
"for `1d` on their first offence, `7d` on their second offence, and `30d` on their third. "
|
||||
"On their fourth offence, they will not be unbanned.\n"
|
||||
"This does not count pardoned offences."
|
||||
)
|
||||
accepts = (
|
||||
"Comma separated list of durations in days/hours/minutes/seconds, for example `12h, 1d, 7d, 30d`."
|
||||
)
|
||||
|
||||
# Flat cache, no need to expire objects
|
||||
_cache = {}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "The automatic study ban durations are now {}.".format(self.formatted)
|
||||
else:
|
||||
return "Automatic study bans will never be reverted."
|
||||
|
||||
|
||||
448
bot/modules/pending-rewrite/moderation/commands.py
Normal file
448
bot/modules/pending-rewrite/moderation/commands.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""
|
||||
Shared commands for the moderation module.
|
||||
"""
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
import discord
|
||||
|
||||
from cmdClient.lib import ResponseTimedOut
|
||||
from wards import guild_moderator
|
||||
|
||||
from .module import module
|
||||
from .tickets import Ticket, TicketType, TicketState
|
||||
|
||||
|
||||
type_accepts = {
|
||||
'note': TicketType.NOTE,
|
||||
'notes': TicketType.NOTE,
|
||||
'studyban': TicketType.STUDY_BAN,
|
||||
'studybans': TicketType.STUDY_BAN,
|
||||
'warn': TicketType.WARNING,
|
||||
'warns': TicketType.WARNING,
|
||||
'warning': TicketType.WARNING,
|
||||
'warnings': TicketType.WARNING,
|
||||
}
|
||||
|
||||
type_formatted = {
|
||||
TicketType.NOTE: 'NOTE',
|
||||
TicketType.STUDY_BAN: 'STUDYBAN',
|
||||
TicketType.WARNING: 'WARNING',
|
||||
}
|
||||
|
||||
type_summary_formatted = {
|
||||
TicketType.NOTE: 'note',
|
||||
TicketType.STUDY_BAN: 'studyban',
|
||||
TicketType.WARNING: 'warning',
|
||||
}
|
||||
|
||||
state_formatted = {
|
||||
TicketState.OPEN: 'ACTIVE',
|
||||
TicketState.EXPIRING: 'TEMP',
|
||||
TicketState.EXPIRED: 'EXPIRED',
|
||||
TicketState.PARDONED: 'PARDONED'
|
||||
}
|
||||
|
||||
state_summary_formatted = {
|
||||
TicketState.OPEN: 'Active',
|
||||
TicketState.EXPIRING: 'Temporary',
|
||||
TicketState.EXPIRED: 'Expired',
|
||||
TicketState.REVERTED: 'Manually Reverted',
|
||||
TicketState.PARDONED: 'Pardoned'
|
||||
}
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"tickets",
|
||||
group="Moderation",
|
||||
desc="View and filter the server moderation tickets.",
|
||||
flags=('active', 'type=')
|
||||
)
|
||||
@guild_moderator()
|
||||
async def cmd_tickets(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}tickets [@user] [--type <type>] [--active]
|
||||
Description:
|
||||
Display and optionally filter the moderation event history in this guild.
|
||||
Flags::
|
||||
type: Filter by ticket type. See **Ticket Types** below.
|
||||
active: Only show in-effect tickets (i.e. hide expired and pardoned ones).
|
||||
Ticket Types::
|
||||
note: Moderation notes.
|
||||
warn: Moderation warnings, both manual and automatic.
|
||||
studyban: Bans from using study features from abusing the study system.
|
||||
blacklist: Complete blacklisting from using my commands.
|
||||
Ticket States::
|
||||
Active: Active tickets that will not automatically expire.
|
||||
Temporary: Active tickets that will automatically expire after a set duration.
|
||||
Expired: Tickets that have automatically expired.
|
||||
Reverted: Tickets with actions that have been reverted.
|
||||
Pardoned: Tickets that have been pardoned and no longer apply to the user.
|
||||
Examples:
|
||||
{prefix}tickets {ctx.guild.owner.mention} --type warn --active
|
||||
"""
|
||||
# Parse filter fields
|
||||
# First the user
|
||||
if ctx.args:
|
||||
userstr = ctx.args.strip('<@!&> ')
|
||||
if not userstr.isdigit():
|
||||
return await ctx.error_reply(
|
||||
"**Usage:** `{prefix}tickets [@user] [--type <type>] [--active]`.\n"
|
||||
"Please provide the `user` as a mention or id!".format(prefix=ctx.best_prefix)
|
||||
)
|
||||
filter_userid = int(userstr)
|
||||
else:
|
||||
filter_userid = None
|
||||
|
||||
if flags['type']:
|
||||
typestr = flags['type'].lower()
|
||||
if typestr not in type_accepts:
|
||||
return await ctx.error_reply(
|
||||
"Please see `{prefix}help tickets` for the valid ticket types!".format(prefix=ctx.best_prefix)
|
||||
)
|
||||
filter_type = type_accepts[typestr]
|
||||
else:
|
||||
filter_type = None
|
||||
|
||||
filter_active = flags['active']
|
||||
|
||||
# Build the filter arguments
|
||||
filters = {'guildid': ctx.guild.id}
|
||||
if filter_userid:
|
||||
filters['targetid'] = filter_userid
|
||||
if filter_type:
|
||||
filters['ticket_type'] = filter_type
|
||||
if filter_active:
|
||||
filters['ticket_state'] = [TicketState.OPEN, TicketState.EXPIRING]
|
||||
|
||||
# Fetch the tickets with these filters
|
||||
tickets = Ticket.fetch_tickets(**filters)
|
||||
|
||||
if not tickets:
|
||||
if filters:
|
||||
return await ctx.embed_reply("There are no tickets with these criteria!")
|
||||
else:
|
||||
return await ctx.embed_reply("There are no moderation tickets in this server!")
|
||||
|
||||
tickets = sorted(tickets, key=lambda ticket: ticket.data.guild_ticketid, reverse=True)
|
||||
ticket_map = {ticket.data.guild_ticketid: ticket for ticket in tickets}
|
||||
|
||||
# Build the format string based on the filters
|
||||
components = []
|
||||
# Ticket id with link to message in mod log
|
||||
components.append("[#{ticket.data.guild_ticketid}]({ticket.link})")
|
||||
# Ticket creation date
|
||||
components.append("<t:{timestamp:.0f}:d>")
|
||||
# Ticket type, with current state
|
||||
if filter_type is None:
|
||||
if not filter_active:
|
||||
components.append("`{ticket_type}{ticket_state}`")
|
||||
else:
|
||||
components.append("`{ticket_type}`")
|
||||
elif not filter_active:
|
||||
components.append("`{ticket_real_state}`")
|
||||
if not filter_userid:
|
||||
# Ticket user
|
||||
components.append("<@{ticket.data.targetid}>")
|
||||
if filter_userid or (filter_active and filter_type):
|
||||
# Truncated ticket content
|
||||
components.append("{content}")
|
||||
|
||||
format_str = ' | '.join(components)
|
||||
|
||||
# Break tickets into blocks
|
||||
blocks = [tickets[i:i+10] for i in range(0, len(tickets), 10)]
|
||||
|
||||
# Build pages of tickets
|
||||
ticket_pages = []
|
||||
for block in blocks:
|
||||
ticket_page = []
|
||||
|
||||
type_len = max(len(type_formatted[ticket.type]) for ticket in block)
|
||||
state_len = max(len(state_formatted[ticket.state]) for ticket in block)
|
||||
for ticket in block:
|
||||
# First truncate content if required
|
||||
content = ticket.data.content
|
||||
if len(content) > 40:
|
||||
content = content[:37] + '...'
|
||||
|
||||
# Build ticket line
|
||||
line = format_str.format(
|
||||
ticket=ticket,
|
||||
timestamp=ticket.data.created_at.timestamp(),
|
||||
ticket_type=type_formatted[ticket.type],
|
||||
type_len=type_len,
|
||||
ticket_state=" [{}]".format(state_formatted[ticket.state]) if ticket.state != TicketState.OPEN else '',
|
||||
ticket_real_state=state_formatted[ticket.state],
|
||||
state_len=state_len,
|
||||
content=content
|
||||
)
|
||||
if ticket.state == TicketState.PARDONED:
|
||||
line = "~~{}~~".format(line)
|
||||
|
||||
# Add to current page
|
||||
ticket_page.append(line)
|
||||
# Combine lines and add page to pages
|
||||
ticket_pages.append('\n'.join(ticket_page))
|
||||
|
||||
# Build active ticket type summary
|
||||
freq = defaultdict(int)
|
||||
for ticket in tickets:
|
||||
if ticket.state != TicketState.PARDONED:
|
||||
freq[ticket.type] += 1
|
||||
summary_pairs = [
|
||||
(num, type_summary_formatted[ttype] + ('s' if num > 1 else ''))
|
||||
for ttype, num in freq.items()
|
||||
]
|
||||
summary_pairs.sort(key=lambda pair: pair[0])
|
||||
# num_len = max(len(str(num)) for num in freq.values())
|
||||
# type_summary = '\n'.join(
|
||||
# "**`{:<{}}`** {}".format(pair[0], num_len, pair[1])
|
||||
# for pair in summary_pairs
|
||||
# )
|
||||
|
||||
# # Build status summary
|
||||
# freq = defaultdict(int)
|
||||
# for ticket in tickets:
|
||||
# freq[ticket.state] += 1
|
||||
# num_len = max(len(str(num)) for num in freq.values())
|
||||
# status_summary = '\n'.join(
|
||||
# "**`{:<{}}`** {}".format(freq[state], num_len, state_str)
|
||||
# for state, state_str in state_summary_formatted.items()
|
||||
# if state in freq
|
||||
# )
|
||||
|
||||
summary_strings = [
|
||||
"**`{}`** {}".format(*pair) for pair in summary_pairs
|
||||
]
|
||||
if len(summary_strings) > 2:
|
||||
summary = ', '.join(summary_strings[:-1]) + ', and ' + summary_strings[-1]
|
||||
elif len(summary_strings) == 2:
|
||||
summary = ' and '.join(summary_strings)
|
||||
else:
|
||||
summary = ''.join(summary_strings)
|
||||
if summary:
|
||||
summary += '.'
|
||||
|
||||
# Build embed info
|
||||
title = "{}{}{}".format(
|
||||
"Active " if filter_active else '',
|
||||
"{} tickets ".format(type_formatted[filter_type]) if filter_type else "Tickets ",
|
||||
(" for {}".format(ctx.guild.get_member(filter_userid) or filter_userid)
|
||||
if filter_userid else " in {}".format(ctx.guild.name))
|
||||
)
|
||||
footer = "Click a ticket id to jump to it, or type the number to show the full ticket."
|
||||
page_count = len(blocks)
|
||||
if page_count > 1:
|
||||
footer += "\nPage {{page_num}}/{}".format(page_count)
|
||||
|
||||
# Create embeds
|
||||
embeds = [
|
||||
discord.Embed(
|
||||
title=title,
|
||||
description="{}\n{}".format(summary, page),
|
||||
colour=discord.Colour.orange(),
|
||||
).set_footer(text=footer.format(page_num=i+1))
|
||||
for i, page in enumerate(ticket_pages)
|
||||
]
|
||||
|
||||
# Run output with cancellation and listener
|
||||
out_msg = await ctx.pager(embeds, add_cancel=True)
|
||||
old_task = _displays.pop((ctx.ch.id, ctx.author.id), None)
|
||||
if old_task:
|
||||
old_task.cancel()
|
||||
_displays[(ctx.ch.id, ctx.author.id)] = display_task = asyncio.create_task(_ticket_display(ctx, ticket_map))
|
||||
ctx.tasks.append(display_task)
|
||||
await ctx.cancellable(out_msg, add_reaction=False)
|
||||
|
||||
|
||||
_displays = {} # (channelid, userid) -> Task
|
||||
async def _ticket_display(ctx, ticket_map):
|
||||
"""
|
||||
Display tickets when the ticket number is entered.
|
||||
"""
|
||||
current_ticket_msg = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Wait for a number
|
||||
try:
|
||||
result = await ctx.client.wait_for(
|
||||
"message",
|
||||
check=lambda msg: (msg.author == ctx.author
|
||||
and msg.channel == ctx.ch
|
||||
and msg.content.isdigit()
|
||||
and int(msg.content) in ticket_map),
|
||||
timeout=60
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return
|
||||
|
||||
# Delete the response
|
||||
try:
|
||||
await result.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Display the ticket
|
||||
embed = ticket_map[int(result.content)].msg_args['embed']
|
||||
if current_ticket_msg:
|
||||
try:
|
||||
await current_ticket_msg.edit(embed=embed)
|
||||
except discord.HTTPException:
|
||||
current_ticket_msg = None
|
||||
|
||||
if not current_ticket_msg:
|
||||
try:
|
||||
current_ticket_msg = await ctx.reply(embed=embed)
|
||||
except discord.HTTPException:
|
||||
return
|
||||
asyncio.create_task(ctx.offer_delete(current_ticket_msg))
|
||||
except asyncio.CancelledError:
|
||||
if current_ticket_msg:
|
||||
try:
|
||||
await current_ticket_msg.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"pardon",
|
||||
group="Moderation",
|
||||
desc="Pardon a ticket, or clear a member's moderation history.",
|
||||
flags=('type=',)
|
||||
)
|
||||
@guild_moderator()
|
||||
async def cmd_pardon(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}pardon ticketid, ticketid, ticketid
|
||||
{prefix}pardon @user [--type <type>]
|
||||
Description:
|
||||
Marks the given tickets as no longer applicable.
|
||||
These tickets will not be considered when calculating automod actions such as automatic study bans.
|
||||
|
||||
This may be used to mark warns or other tickets as no longer in-effect.
|
||||
If the ticket is active when it is pardoned, it will be reverted, and any expiry cancelled.
|
||||
|
||||
Use the `{prefix}tickets` command to view the relevant tickets.
|
||||
Flags::
|
||||
type: Filter by ticket type. See **Ticket Types** in `{prefix}help tickets`.
|
||||
Examples:
|
||||
{prefix}pardon 21
|
||||
{prefix}pardon {ctx.guild.owner.mention} --type warn
|
||||
"""
|
||||
usage = "**Usage**: `{prefix}pardon ticketid` or `{prefix}pardon @user`.".format(prefix=ctx.best_prefix)
|
||||
if not ctx.args:
|
||||
return await ctx.error_reply(
|
||||
usage
|
||||
)
|
||||
|
||||
# Parse provided tickets or filters
|
||||
targetid = None
|
||||
ticketids = []
|
||||
args = {'guildid': ctx.guild.id}
|
||||
if ',' in ctx.args:
|
||||
# Assume provided numbers are ticketids.
|
||||
items = [item.strip() for item in ctx.args.split(',')]
|
||||
if not all(item.isdigit() for item in items):
|
||||
return await ctx.error_reply(usage)
|
||||
ticketids = [int(item) for item in items]
|
||||
args['guild_ticketid'] = ticketids
|
||||
else:
|
||||
# Guess whether the provided numbers were ticketids or not
|
||||
idstr = ctx.args.strip('<@!&> ')
|
||||
if not idstr.isdigit():
|
||||
return await ctx.error_reply(usage)
|
||||
|
||||
maybe_id = int(idstr)
|
||||
if maybe_id > 4194304: # Testing whether it is greater than the minimum snowflake id
|
||||
# Assume userid
|
||||
targetid = maybe_id
|
||||
args['targetid'] = maybe_id
|
||||
|
||||
# Add the type filter if provided
|
||||
if flags['type']:
|
||||
typestr = flags['type'].lower()
|
||||
if typestr not in type_accepts:
|
||||
return await ctx.error_reply(
|
||||
"Please see `{prefix}help tickets` for the valid ticket types!".format(prefix=ctx.best_prefix)
|
||||
)
|
||||
args['ticket_type'] = type_accepts[typestr]
|
||||
else:
|
||||
# Assume guild ticketid
|
||||
ticketids = [maybe_id]
|
||||
args['guild_ticketid'] = maybe_id
|
||||
|
||||
# Fetch the matching tickets
|
||||
tickets = Ticket.fetch_tickets(**args)
|
||||
|
||||
# Check whether we have the right selection of tickets
|
||||
if targetid and not tickets:
|
||||
return await ctx.error_reply(
|
||||
"<@{}> has no matching tickets to pardon!"
|
||||
)
|
||||
if ticketids and len(ticketids) != len(tickets):
|
||||
# Not all of the ticketids were valid
|
||||
difference = list(set(ticketids).difference(ticket.ticketid for ticket in tickets))
|
||||
if len(difference) == 1:
|
||||
return await ctx.error_reply(
|
||||
"Couldn't find ticket `{}`!".format(difference[0])
|
||||
)
|
||||
else:
|
||||
return await ctx.error_reply(
|
||||
"Couldn't find any of the following tickets:\n`{}`".format(
|
||||
'`, `'.join(difference)
|
||||
)
|
||||
)
|
||||
|
||||
# Check whether there are any tickets left to pardon
|
||||
to_pardon = [ticket for ticket in tickets if ticket.state != TicketState.PARDONED]
|
||||
if not to_pardon:
|
||||
if ticketids and len(tickets) == 1:
|
||||
ticket = tickets[0]
|
||||
return await ctx.error_reply(
|
||||
"[Ticket #{}]({}) is already pardoned!".format(ticket.data.guild_ticketid, ticket.link)
|
||||
)
|
||||
else:
|
||||
return await ctx.error_reply(
|
||||
"All of these tickets are already pardoned!"
|
||||
)
|
||||
|
||||
# We now know what tickets we want to pardon
|
||||
# Request the pardon reason
|
||||
try:
|
||||
reason = await ctx.input("Please provide a reason for the pardon.")
|
||||
except ResponseTimedOut:
|
||||
raise ResponseTimedOut("Prompt timed out, no tickets were pardoned.")
|
||||
|
||||
# Pardon the tickets
|
||||
for ticket in to_pardon:
|
||||
await ticket.pardon(ctx.author, reason)
|
||||
|
||||
# Finally, ack the pardon
|
||||
if targetid:
|
||||
await ctx.embed_reply(
|
||||
"The active {}s for <@{}> have been cleared.".format(
|
||||
type_summary_formatted[args['ticket_type']] if flags['type'] else 'ticket',
|
||||
targetid
|
||||
)
|
||||
)
|
||||
elif len(to_pardon) == 1:
|
||||
ticket = to_pardon[0]
|
||||
await ctx.embed_reply(
|
||||
"[Ticket #{}]({}) was pardoned.".format(
|
||||
ticket.data.guild_ticketid,
|
||||
ticket.link
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.embed_reply(
|
||||
"The following tickets were pardoned.\n{}".format(
|
||||
", ".join(
|
||||
"[#{}]({})".format(ticket.data.guild_ticketid, ticket.link)
|
||||
for ticket in to_pardon
|
||||
)
|
||||
)
|
||||
)
|
||||
19
bot/modules/pending-rewrite/moderation/data.py
Normal file
19
bot/modules/pending-rewrite/moderation/data.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from data import Table, RowTable
|
||||
|
||||
|
||||
studyban_durations = Table('studyban_durations')
|
||||
|
||||
ticket_info = RowTable(
|
||||
'ticket_info',
|
||||
('ticketid', 'guild_ticketid',
|
||||
'guildid', 'targetid', 'ticket_type', 'ticket_state', 'moderator_id', 'auto',
|
||||
'log_msg_id', 'created_at',
|
||||
'content', 'context', 'addendum', 'duration',
|
||||
'file_name', 'file_data',
|
||||
'expiry',
|
||||
'pardoned_by', 'pardoned_at', 'pardoned_reason'),
|
||||
'ticketid',
|
||||
cache_size=20000
|
||||
)
|
||||
|
||||
tickets = Table('tickets')
|
||||
4
bot/modules/pending-rewrite/moderation/module.py
Normal file
4
bot/modules/pending-rewrite/moderation/module.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from cmdClient import Module
|
||||
|
||||
|
||||
module = Module("Moderation")
|
||||
486
bot/modules/pending-rewrite/moderation/tickets/Ticket.py
Normal file
486
bot/modules/pending-rewrite/moderation/tickets/Ticket.py
Normal file
@@ -0,0 +1,486 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
import datetime
|
||||
|
||||
import discord
|
||||
|
||||
from meta import client
|
||||
from data.conditions import THIS_SHARD
|
||||
from settings import GuildSettings
|
||||
from utils.lib import FieldEnum, strfdelta, utc_now
|
||||
|
||||
from .. import data
|
||||
from ..module import module
|
||||
|
||||
|
||||
class TicketType(FieldEnum):
|
||||
"""
|
||||
The possible ticket types.
|
||||
"""
|
||||
NOTE = 'NOTE', 'Note'
|
||||
WARNING = 'WARNING', 'Warning'
|
||||
STUDY_BAN = 'STUDY_BAN', 'Study Ban'
|
||||
MESAGE_CENSOR = 'MESSAGE_CENSOR', 'Message Censor'
|
||||
INVITE_CENSOR = 'INVITE_CENSOR', 'Invite Censor'
|
||||
|
||||
|
||||
class TicketState(FieldEnum):
|
||||
"""
|
||||
The possible ticket states.
|
||||
"""
|
||||
OPEN = 'OPEN', "Active"
|
||||
EXPIRING = 'EXPIRING', "Active"
|
||||
EXPIRED = 'EXPIRED', "Expired"
|
||||
PARDONED = 'PARDONED', "Pardoned"
|
||||
REVERTED = 'REVERTED', "Reverted"
|
||||
|
||||
|
||||
class Ticket:
|
||||
"""
|
||||
Abstract base class representing a Ticketed moderation action.
|
||||
"""
|
||||
# Type of event the class represents
|
||||
_ticket_type = None # type: TicketType
|
||||
|
||||
_ticket_types = {} # Map: TicketType -> Ticket subclass
|
||||
|
||||
_expiry_tasks = {} # Map: ticketid -> expiry Task
|
||||
|
||||
def __init__(self, ticketid, *args, **kwargs):
|
||||
self.ticketid = ticketid
|
||||
|
||||
@classmethod
|
||||
async def create(cls, *args, **kwargs):
|
||||
"""
|
||||
Method used to create a new ticket of the current type.
|
||||
Should add a row to the ticket table, post the ticket, and return the Ticket.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
"""
|
||||
Ticket row.
|
||||
This will usually be a row of `ticket_info`.
|
||||
"""
|
||||
return data.ticket_info.fetch(self.ticketid)
|
||||
|
||||
@property
|
||||
def guild(self):
|
||||
return client.get_guild(self.data.guildid)
|
||||
|
||||
@property
|
||||
def target(self):
|
||||
guild = self.guild
|
||||
return guild.get_member(self.data.targetid) if guild else None
|
||||
|
||||
@property
|
||||
def msg_args(self):
|
||||
"""
|
||||
Ticket message posted in the moderation log.
|
||||
"""
|
||||
args = {}
|
||||
|
||||
# Build embed
|
||||
info = self.data
|
||||
member = self.target
|
||||
name = str(member) if member else str(info.targetid)
|
||||
|
||||
if info.auto:
|
||||
title_fmt = "Ticket #{} | {} | {}[Auto] | {}"
|
||||
else:
|
||||
title_fmt = "Ticket #{} | {} | {} | {}"
|
||||
title = title_fmt.format(
|
||||
info.guild_ticketid,
|
||||
TicketState(info.ticket_state).desc,
|
||||
TicketType(info.ticket_type).desc,
|
||||
name
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=title,
|
||||
description=info.content,
|
||||
timestamp=info.created_at
|
||||
)
|
||||
embed.add_field(
|
||||
name="Target",
|
||||
value="<@{}>".format(info.targetid)
|
||||
)
|
||||
|
||||
if not info.auto:
|
||||
embed.add_field(
|
||||
name="Moderator",
|
||||
value="<@{}>".format(info.moderator_id)
|
||||
)
|
||||
|
||||
# if info.duration:
|
||||
# value = "`{}` {}".format(
|
||||
# strfdelta(datetime.timedelta(seconds=info.duration)),
|
||||
# "(Expiry <t:{:.0f}>)".format(info.expiry.timestamp()) if info.expiry else ""
|
||||
# )
|
||||
# embed.add_field(
|
||||
# name="Duration",
|
||||
# value=value
|
||||
# )
|
||||
if info.expiry:
|
||||
if info.ticket_state == TicketState.EXPIRING:
|
||||
embed.add_field(
|
||||
name="Expires at",
|
||||
value="<t:{:.0f}>\n(Duration: `{}`)".format(
|
||||
info.expiry.timestamp(),
|
||||
strfdelta(datetime.timedelta(seconds=info.duration))
|
||||
)
|
||||
)
|
||||
elif info.ticket_state == TicketState.EXPIRED:
|
||||
embed.add_field(
|
||||
name="Expired",
|
||||
value="<t:{:.0f}>".format(
|
||||
info.expiry.timestamp(),
|
||||
)
|
||||
)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="Expiry",
|
||||
value="<t:{:.0f}>".format(
|
||||
info.expiry.timestamp()
|
||||
)
|
||||
)
|
||||
|
||||
if info.context:
|
||||
embed.add_field(
|
||||
name="Context",
|
||||
value=info.context,
|
||||
inline=False
|
||||
)
|
||||
|
||||
if info.addendum:
|
||||
embed.add_field(
|
||||
name="Notes",
|
||||
value=info.addendum,
|
||||
inline=False
|
||||
)
|
||||
|
||||
if self.state == TicketState.PARDONED:
|
||||
embed.add_field(
|
||||
name="Pardoned",
|
||||
value=(
|
||||
"Pardoned by <@{}> at <t:{:.0f}>.\n{}"
|
||||
).format(
|
||||
info.pardoned_by,
|
||||
info.pardoned_at.timestamp(),
|
||||
info.pardoned_reason or ""
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
|
||||
embed.set_footer(text="ID: {}".format(info.targetid))
|
||||
|
||||
args['embed'] = embed
|
||||
|
||||
# Add file
|
||||
if info.file_name:
|
||||
args['file'] = discord.File(info.file_data, info.file_name)
|
||||
|
||||
return args
|
||||
|
||||
@property
|
||||
def link(self):
|
||||
"""
|
||||
The link to the ticket in the moderation log.
|
||||
"""
|
||||
info = self.data
|
||||
modlog = GuildSettings(info.guildid).mod_log.data
|
||||
|
||||
return 'https://discord.com/channels/{}/{}/{}'.format(
|
||||
info.guildid,
|
||||
modlog,
|
||||
info.log_msg_id
|
||||
)
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
return TicketState(self.data.ticket_state)
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return TicketType(self.data.ticket_type)
|
||||
|
||||
async def update(self, **kwargs):
|
||||
"""
|
||||
Update ticket fields.
|
||||
"""
|
||||
fields = (
|
||||
'targetid', 'moderator_id', 'auto', 'log_msg_id',
|
||||
'content', 'expiry', 'ticket_state',
|
||||
'context', 'addendum', 'duration', 'file_name', 'file_data',
|
||||
'pardoned_by', 'pardoned_at', 'pardoned_reason',
|
||||
)
|
||||
params = {field: kwargs[field] for field in fields if field in kwargs}
|
||||
if params:
|
||||
data.ticket_info.update_where(params, ticketid=self.ticketid)
|
||||
|
||||
await self.update_expiry()
|
||||
await self.post()
|
||||
|
||||
async def post(self):
|
||||
"""
|
||||
Post or update the ticket in the moderation log.
|
||||
Also updates the saved message id.
|
||||
"""
|
||||
info = self.data
|
||||
modlog = GuildSettings(info.guildid).mod_log.value
|
||||
if not modlog:
|
||||
return
|
||||
|
||||
resend = True
|
||||
try:
|
||||
if info.log_msg_id:
|
||||
# Try to fetch the message
|
||||
message = await modlog.fetch_message(info.log_msg_id)
|
||||
if message:
|
||||
if message.author.id == client.user.id:
|
||||
# TODO: Handle file edit
|
||||
await message.edit(embed=self.msg_args['embed'])
|
||||
resend = False
|
||||
else:
|
||||
try:
|
||||
await message.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
if resend:
|
||||
message = await modlog.send(**self.msg_args)
|
||||
self.data.log_msg_id = message.id
|
||||
except discord.HTTPException:
|
||||
client.log(
|
||||
"Cannot post ticket (tid: {}) due to discord exception or issue.".format(self.ticketid)
|
||||
)
|
||||
except Exception:
|
||||
# This should never happen in normal operation
|
||||
client.log(
|
||||
"Error while posting ticket (tid:{})! "
|
||||
"Exception traceback follows.\n{}".format(
|
||||
self.ticketid,
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="TICKETS",
|
||||
level=logging.ERROR
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load_expiring(cls):
|
||||
"""
|
||||
Load and schedule all expiring tickets.
|
||||
"""
|
||||
# TODO: Consider changing this to a flat timestamp system, to avoid storing lots of coroutines.
|
||||
# TODO: Consider only scheduling the expiries in the next day, and updating this once per day.
|
||||
# TODO: Only fetch tickets from guilds we are in.
|
||||
|
||||
# Cancel existing expiry tasks
|
||||
for task in cls._expiry_tasks.values():
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
# Get all expiring tickets
|
||||
expiring_rows = data.tickets.select_where(
|
||||
ticket_state=TicketState.EXPIRING,
|
||||
guildid=THIS_SHARD
|
||||
)
|
||||
|
||||
# Create new expiry tasks
|
||||
now = utc_now()
|
||||
cls._expiry_tasks = {
|
||||
row['ticketid']: asyncio.create_task(
|
||||
cls._schedule_expiry_for(
|
||||
row['ticketid'],
|
||||
(row['expiry'] - now).total_seconds()
|
||||
)
|
||||
) for row in expiring_rows
|
||||
}
|
||||
|
||||
# Log
|
||||
client.log(
|
||||
"Loaded {} expiring tickets.".format(len(cls._expiry_tasks)),
|
||||
context="TICKET_LOADER",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _schedule_expiry_for(cls, ticketid, delay):
|
||||
"""
|
||||
Schedule expiry for a given ticketid
|
||||
"""
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
ticket = Ticket.fetch(ticketid)
|
||||
if ticket:
|
||||
await asyncio.shield(ticket._expire())
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
def update_expiry(self):
|
||||
# Cancel any existing expiry task
|
||||
task = self._expiry_tasks.pop(self.ticketid, None)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
|
||||
# Schedule a new expiry task, if applicable
|
||||
if self.data.ticket_state == TicketState.EXPIRING:
|
||||
self._expiry_tasks[self.ticketid] = asyncio.create_task(
|
||||
self._schedule_expiry_for(
|
||||
self.ticketid,
|
||||
(self.data.expiry - utc_now()).total_seconds()
|
||||
)
|
||||
)
|
||||
|
||||
async def cancel_expiry(self):
|
||||
"""
|
||||
Cancel ticket expiry.
|
||||
|
||||
In particular, may be used if another ticket overrides `self`.
|
||||
Sets the ticket state to `OPEN`, so that it no longer expires.
|
||||
"""
|
||||
if self.state == TicketState.EXPIRING:
|
||||
# Update the ticket state
|
||||
self.data.ticket_state = TicketState.OPEN
|
||||
|
||||
# Remove from expiry tsks
|
||||
self.update_expiry()
|
||||
|
||||
# Repost
|
||||
await self.post()
|
||||
|
||||
async def _revert(self, reason=None):
|
||||
"""
|
||||
Method used to revert the ticket action, e.g. unban or remove mute role.
|
||||
Generally called by `pardon` and `_expire`.
|
||||
|
||||
May be overriden by the Ticket type, if they implement any revert logic.
|
||||
Is a no-op by default.
|
||||
"""
|
||||
return
|
||||
|
||||
async def _expire(self):
|
||||
"""
|
||||
Method to automatically expire a ticket.
|
||||
|
||||
May be overriden by the Ticket type for more complex expiry logic.
|
||||
Must set `data.ticket_state` to `EXPIRED` if applicable.
|
||||
"""
|
||||
if self.state == TicketState.EXPIRING:
|
||||
client.log(
|
||||
"Automatically expiring ticket (tid:{}).".format(self.ticketid),
|
||||
context="TICKETS"
|
||||
)
|
||||
try:
|
||||
await self._revert(reason="Automatic Expiry")
|
||||
except Exception:
|
||||
# This should never happen in normal operation
|
||||
client.log(
|
||||
"Error while expiring ticket (tid:{})! "
|
||||
"Exception traceback follows.\n{}".format(
|
||||
self.ticketid,
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="TICKETS",
|
||||
level=logging.ERROR
|
||||
)
|
||||
|
||||
# Update state
|
||||
self.data.ticket_state = TicketState.EXPIRED
|
||||
|
||||
# Update log message
|
||||
await self.post()
|
||||
|
||||
# Post a note to the modlog
|
||||
modlog = GuildSettings(self.data.guildid).mod_log.value
|
||||
if modlog:
|
||||
try:
|
||||
await modlog.send(
|
||||
embed=discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
description="[Ticket #{}]({}) expired!".format(self.data.guild_ticketid, self.link)
|
||||
)
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
async def pardon(self, moderator, reason, timestamp=None):
|
||||
"""
|
||||
Pardon process for the ticket.
|
||||
|
||||
May be overidden by the Ticket type for more complex pardon logic.
|
||||
Must set `data.ticket_state` to `PARDONED` if applicable.
|
||||
"""
|
||||
if self.state != TicketState.PARDONED:
|
||||
if self.state in (TicketState.OPEN, TicketState.EXPIRING):
|
||||
try:
|
||||
await self._revert(reason="Pardoned by {}".format(moderator.id))
|
||||
except Exception:
|
||||
# This should never happen in normal operation
|
||||
client.log(
|
||||
"Error while pardoning ticket (tid:{})! "
|
||||
"Exception traceback follows.\n{}".format(
|
||||
self.ticketid,
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="TICKETS",
|
||||
level=logging.ERROR
|
||||
)
|
||||
|
||||
# Update state
|
||||
with self.data.batch_update():
|
||||
self.data.ticket_state = TicketState.PARDONED
|
||||
self.data.pardoned_at = utc_now()
|
||||
self.data.pardoned_by = moderator.id
|
||||
self.data.pardoned_reason = reason
|
||||
|
||||
# Update (i.e. remove) expiry
|
||||
self.update_expiry()
|
||||
|
||||
# Update log message
|
||||
await self.post()
|
||||
|
||||
@classmethod
|
||||
def fetch_tickets(cls, *ticketids, **kwargs):
|
||||
"""
|
||||
Fetch tickets matching the given criteria (passed transparently to `select_where`).
|
||||
Positional arguments are treated as `ticketids`, which are not supported in keyword arguments.
|
||||
"""
|
||||
if ticketids:
|
||||
kwargs['ticketid'] = ticketids
|
||||
|
||||
# Set the ticket type to the class type if not specified
|
||||
if cls._ticket_type and 'ticket_type' not in kwargs:
|
||||
kwargs['ticket_type'] = cls._ticket_type
|
||||
|
||||
# This is actually mainly for caching, since we don't pass the data to the initialiser
|
||||
rows = data.ticket_info.fetch_rows_where(
|
||||
**kwargs
|
||||
)
|
||||
|
||||
return [
|
||||
cls._ticket_types[TicketType(row.ticket_type)](row.ticketid)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, ticketid):
|
||||
"""
|
||||
Return the Ticket with the given id, if found, or `None` otherwise.
|
||||
"""
|
||||
tickets = cls.fetch_tickets(ticketid)
|
||||
return tickets[0] if tickets else None
|
||||
|
||||
@classmethod
|
||||
def register_ticket_type(cls, ticket_cls):
|
||||
"""
|
||||
Decorator to register a new Ticket subclass as a ticket type.
|
||||
"""
|
||||
cls._ticket_types[ticket_cls._ticket_type] = ticket_cls
|
||||
return ticket_cls
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def load_expiring_tickets(client):
|
||||
Ticket.load_expiring()
|
||||
@@ -0,0 +1,4 @@
|
||||
from .Ticket import Ticket, TicketType, TicketState
|
||||
from .studybans import StudyBanTicket
|
||||
from .notes import NoteTicket
|
||||
from .warns import WarnTicket
|
||||
112
bot/modules/pending-rewrite/moderation/tickets/notes.py
Normal file
112
bot/modules/pending-rewrite/moderation/tickets/notes.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Note ticket implementation.
|
||||
|
||||
Guild moderators can add a note about a user, visible in their moderation history.
|
||||
Notes appear in the moderation log and the user's ticket history, like any other ticket.
|
||||
|
||||
This module implements the Note TicketType and the `note` moderation command.
|
||||
"""
|
||||
from cmdClient.lib import ResponseTimedOut
|
||||
|
||||
from wards import guild_moderator
|
||||
|
||||
from ..module import module
|
||||
from ..data import tickets
|
||||
|
||||
from .Ticket import Ticket, TicketType, TicketState
|
||||
|
||||
|
||||
@Ticket.register_ticket_type
|
||||
class NoteTicket(Ticket):
|
||||
_ticket_type = TicketType.NOTE
|
||||
|
||||
@classmethod
|
||||
async def create(cls, guildid, targetid, moderatorid, content, **kwargs):
|
||||
"""
|
||||
Create a new Note on a target.
|
||||
|
||||
`kwargs` are passed transparently to the table insert method.
|
||||
"""
|
||||
ticket_row = tickets.insert(
|
||||
guildid=guildid,
|
||||
targetid=targetid,
|
||||
ticket_type=cls._ticket_type,
|
||||
ticket_state=TicketState.OPEN,
|
||||
moderator_id=moderatorid,
|
||||
auto=False,
|
||||
content=content,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Create the note ticket
|
||||
ticket = cls(ticket_row['ticketid'])
|
||||
|
||||
# Post the ticket and return
|
||||
await ticket.post()
|
||||
return ticket
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"note",
|
||||
group="Moderation",
|
||||
desc="Add a Note to a member's record."
|
||||
)
|
||||
@guild_moderator()
|
||||
async def cmd_note(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}note @target
|
||||
{prefix}note @target <content>
|
||||
Description:
|
||||
Add a note to the target's moderation record.
|
||||
The note will appear in the moderation log and in the `tickets` command.
|
||||
|
||||
The `target` must be specificed by mention or user id.
|
||||
If the `content` is not given, it will be prompted for.
|
||||
Example:
|
||||
{prefix}note {ctx.author.mention} Seen reading the `note` documentation.
|
||||
"""
|
||||
if not ctx.args:
|
||||
return await ctx.error_reply(
|
||||
"**Usage:** `{}note @target <content>`.".format(ctx.best_prefix)
|
||||
)
|
||||
|
||||
# Extract the target. We don't require them to be in the server
|
||||
splits = ctx.args.split(maxsplit=1)
|
||||
target_str = splits[0].strip('<@!&> ')
|
||||
if not target_str.isdigit():
|
||||
return await ctx.error_reply(
|
||||
"**Usage:** `{}note @target <content>`.\n"
|
||||
"`target` must be provided by mention or userid.".format(ctx.best_prefix)
|
||||
)
|
||||
targetid = int(target_str)
|
||||
|
||||
# Extract or prompt for the content
|
||||
if len(splits) != 2:
|
||||
try:
|
||||
content = await ctx.input("What note would you like to add?", timeout=300)
|
||||
except ResponseTimedOut:
|
||||
raise ResponseTimedOut("Prompt timed out, no note was created.")
|
||||
else:
|
||||
content = splits[1].strip()
|
||||
|
||||
# Create the note ticket
|
||||
ticket = await NoteTicket.create(
|
||||
ctx.guild.id,
|
||||
targetid,
|
||||
ctx.author.id,
|
||||
content
|
||||
)
|
||||
|
||||
if ticket.data.log_msg_id:
|
||||
await ctx.embed_reply(
|
||||
"Note on <@{}> created as [Ticket #{}]({}).".format(
|
||||
targetid,
|
||||
ticket.data.guild_ticketid,
|
||||
ticket.link
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.embed_reply(
|
||||
"Note on <@{}> created as Ticket #{}.".format(targetid, ticket.data.guild_ticketid)
|
||||
)
|
||||
126
bot/modules/pending-rewrite/moderation/tickets/studybans.py
Normal file
126
bot/modules/pending-rewrite/moderation/tickets/studybans.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import datetime
|
||||
import discord
|
||||
|
||||
from meta import client
|
||||
from utils.lib import utc_now
|
||||
from settings import GuildSettings
|
||||
from data import NOT
|
||||
|
||||
from .. import data
|
||||
from .Ticket import Ticket, TicketType, TicketState
|
||||
|
||||
|
||||
@Ticket.register_ticket_type
|
||||
class StudyBanTicket(Ticket):
|
||||
_ticket_type = TicketType.STUDY_BAN
|
||||
|
||||
@classmethod
|
||||
async def create(cls, guildid, targetid, moderatorid, reason, expiry=None, **kwargs):
|
||||
"""
|
||||
Create a new study ban ticket.
|
||||
"""
|
||||
# First create the ticket itself
|
||||
ticket_row = data.tickets.insert(
|
||||
guildid=guildid,
|
||||
targetid=targetid,
|
||||
ticket_type=cls._ticket_type,
|
||||
ticket_state=TicketState.EXPIRING if expiry else TicketState.OPEN,
|
||||
moderator_id=moderatorid,
|
||||
auto=(moderatorid == client.user.id),
|
||||
content=reason,
|
||||
expiry=expiry,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Create the Ticket
|
||||
ticket = cls(ticket_row['ticketid'])
|
||||
|
||||
# Schedule ticket expiry, if applicable
|
||||
if expiry:
|
||||
ticket.update_expiry()
|
||||
|
||||
# Cancel any existing studyban expiry for this member
|
||||
tickets = cls.fetch_tickets(
|
||||
guildid=guildid,
|
||||
ticketid=NOT(ticket_row['ticketid']),
|
||||
targetid=targetid,
|
||||
ticket_state=TicketState.EXPIRING
|
||||
)
|
||||
for ticket in tickets:
|
||||
await ticket.cancel_expiry()
|
||||
|
||||
# Post the ticket
|
||||
await ticket.post()
|
||||
|
||||
# Return the ticket
|
||||
return ticket
|
||||
|
||||
async def _revert(self, reason=None):
|
||||
"""
|
||||
Revert the studyban by removing the role.
|
||||
"""
|
||||
guild_settings = GuildSettings(self.data.guildid)
|
||||
role = guild_settings.studyban_role.value
|
||||
target = self.target
|
||||
|
||||
if target and role:
|
||||
try:
|
||||
await target.remove_roles(
|
||||
role,
|
||||
reason="Reverting StudyBan: {}".format(reason)
|
||||
)
|
||||
except discord.HTTPException:
|
||||
# TODO: Error log?
|
||||
...
|
||||
|
||||
@classmethod
|
||||
async def autoban(cls, guild, target, reason, **kwargs):
|
||||
"""
|
||||
Convenience method to automatically studyban a member, for the configured duration.
|
||||
If the role is set, this will create and return a `StudyBanTicket` regardless of whether the
|
||||
studyban was successful.
|
||||
If the role is not set, or the ticket cannot be created, this will return `None`.
|
||||
"""
|
||||
# Get the studyban role, fail if there isn't one set, or the role doesn't exist
|
||||
guild_settings = GuildSettings(guild.id)
|
||||
role = guild_settings.studyban_role.value
|
||||
if not role:
|
||||
return None
|
||||
|
||||
# Attempt to add the role, record failure
|
||||
try:
|
||||
await target.add_roles(role, reason="Applying StudyBan: {}".format(reason[:400]))
|
||||
except discord.HTTPException:
|
||||
role_failed = True
|
||||
else:
|
||||
role_failed = False
|
||||
|
||||
# Calculate the applicable automatic duration and expiry
|
||||
# First count the existing non-pardoned studybans for this target
|
||||
studyban_count = data.tickets.select_one_where(
|
||||
guildid=guild.id,
|
||||
targetid=target.id,
|
||||
ticket_type=cls._ticket_type,
|
||||
ticket_state=NOT(TicketState.PARDONED),
|
||||
select_columns=('COUNT(*)',)
|
||||
)[0]
|
||||
studyban_count = int(studyban_count)
|
||||
|
||||
# Then read the guild setting to find the applicable duration
|
||||
studyban_durations = guild_settings.studyban_durations.value
|
||||
if studyban_count < len(studyban_durations):
|
||||
duration = studyban_durations[studyban_count]
|
||||
expiry = utc_now() + datetime.timedelta(seconds=duration)
|
||||
else:
|
||||
duration = None
|
||||
expiry = None
|
||||
|
||||
# Create the ticket and return
|
||||
if role_failed:
|
||||
kwargs['addendum'] = '\n'.join((
|
||||
kwargs.get('addendum', ''),
|
||||
"Could not add the studyban role! Please add the role manually and check my permissions."
|
||||
))
|
||||
return await cls.create(
|
||||
guild.id, target.id, client.user.id, reason, duration=duration, expiry=expiry, **kwargs
|
||||
)
|
||||
153
bot/modules/pending-rewrite/moderation/tickets/warns.py
Normal file
153
bot/modules/pending-rewrite/moderation/tickets/warns.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Warn ticket implementation.
|
||||
|
||||
Guild moderators can officially warn a user via command.
|
||||
This DMs the users with the warning.
|
||||
"""
|
||||
import datetime
|
||||
import discord
|
||||
from cmdClient.lib import ResponseTimedOut
|
||||
|
||||
from wards import guild_moderator
|
||||
|
||||
from ..module import module
|
||||
from ..data import tickets
|
||||
|
||||
from .Ticket import Ticket, TicketType, TicketState
|
||||
|
||||
|
||||
@Ticket.register_ticket_type
|
||||
class WarnTicket(Ticket):
|
||||
_ticket_type = TicketType.WARNING
|
||||
|
||||
@classmethod
|
||||
async def create(cls, guildid, targetid, moderatorid, content, **kwargs):
|
||||
"""
|
||||
Create a new Warning for the target.
|
||||
|
||||
`kwargs` are passed transparently to the table insert method.
|
||||
"""
|
||||
ticket_row = tickets.insert(
|
||||
guildid=guildid,
|
||||
targetid=targetid,
|
||||
ticket_type=cls._ticket_type,
|
||||
ticket_state=TicketState.OPEN,
|
||||
moderator_id=moderatorid,
|
||||
content=content,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Create the note ticket
|
||||
ticket = cls(ticket_row['ticketid'])
|
||||
|
||||
# Post the ticket and return
|
||||
await ticket.post()
|
||||
return ticket
|
||||
|
||||
async def _revert(*args, **kwargs):
|
||||
# Warnings don't have a revert process
|
||||
pass
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"warn",
|
||||
group="Moderation",
|
||||
desc="Officially warn a user for a misbehaviour."
|
||||
)
|
||||
@guild_moderator()
|
||||
async def cmd_warn(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}warn @target
|
||||
{prefix}warn @target <reason>
|
||||
Description:
|
||||
|
||||
The `target` must be specificed by mention or user id.
|
||||
If the `reason` is not given, it will be prompted for.
|
||||
Example:
|
||||
{prefix}warn {ctx.author.mention} Don't actually read the documentation!
|
||||
"""
|
||||
if not ctx.args:
|
||||
return await ctx.error_reply(
|
||||
"**Usage:** `{}warn @target <reason>`.".format(ctx.best_prefix)
|
||||
)
|
||||
|
||||
# Extract the target. We do require them to be in the server
|
||||
splits = ctx.args.split(maxsplit=1)
|
||||
target_str = splits[0].strip('<@!&> ')
|
||||
if not target_str.isdigit():
|
||||
return await ctx.error_reply(
|
||||
"**Usage:** `{}warn @target <reason>`.\n"
|
||||
"`target` must be provided by mention or userid.".format(ctx.best_prefix)
|
||||
)
|
||||
targetid = int(target_str)
|
||||
target = ctx.guild.get_member(targetid)
|
||||
if not target:
|
||||
return await ctx.error_reply("Cannot warn a user who is not in the server!")
|
||||
|
||||
# Extract or prompt for the content
|
||||
if len(splits) != 2:
|
||||
try:
|
||||
content = await ctx.input("Please give a reason for this warning!", timeout=300)
|
||||
except ResponseTimedOut:
|
||||
raise ResponseTimedOut("Prompt timed out, the member was not warned.")
|
||||
else:
|
||||
content = splits[1].strip()
|
||||
|
||||
# Create the warn ticket
|
||||
ticket = await WarnTicket.create(
|
||||
ctx.guild.id,
|
||||
targetid,
|
||||
ctx.author.id,
|
||||
content
|
||||
)
|
||||
|
||||
# Attempt to message the member
|
||||
embed = discord.Embed(
|
||||
title="You have received a warning!",
|
||||
description=(
|
||||
content
|
||||
),
|
||||
colour=discord.Colour.red(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
)
|
||||
embed.add_field(
|
||||
name="Info",
|
||||
value=(
|
||||
"*Warnings appear in your moderation history. "
|
||||
"Failure to comply, or repeated warnings, "
|
||||
"may result in muting, studybanning, or server banning.*"
|
||||
)
|
||||
)
|
||||
embed.set_footer(
|
||||
icon_url=ctx.guild.icon_url,
|
||||
text=ctx.guild.name
|
||||
)
|
||||
dm_msg = None
|
||||
try:
|
||||
dm_msg = await target.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Get previous warnings
|
||||
count = tickets.select_one_where(
|
||||
guildid=ctx.guild.id,
|
||||
targetid=targetid,
|
||||
ticket_type=TicketType.WARNING,
|
||||
ticket_state=[TicketState.OPEN, TicketState.EXPIRING],
|
||||
select_columns=('COUNT(*)',)
|
||||
)[0]
|
||||
if count == 1:
|
||||
prev_str = "This is their first warning."
|
||||
else:
|
||||
prev_str = "They now have `{}` warnings.".format(count)
|
||||
|
||||
await ctx.embed_reply(
|
||||
"[Ticket #{}]({}): {} has been warned. {}\n{}".format(
|
||||
ticket.data.guild_ticketid,
|
||||
ticket.link,
|
||||
target.mention,
|
||||
prev_str,
|
||||
"*Could not DM the user their warning!*" if not dm_msg else ''
|
||||
)
|
||||
)
|
||||
4
bot/modules/pending-rewrite/moderation/video/__init__.py
Normal file
4
bot/modules/pending-rewrite/moderation/video/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import data
|
||||
from . import admin
|
||||
|
||||
from . import watchdog
|
||||
128
bot/modules/pending-rewrite/moderation/video/admin.py
Normal file
128
bot/modules/pending-rewrite/moderation/video/admin.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from settings import GuildSettings, GuildSetting
|
||||
from wards import guild_admin
|
||||
|
||||
import settings
|
||||
|
||||
from .data import video_channels
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class video_channels(settings.ChannelList, settings.ListData, settings.Setting):
|
||||
category = "Video Channels"
|
||||
|
||||
attr_name = 'video_channels'
|
||||
|
||||
_table_interface = video_channels
|
||||
_id_column = 'guildid'
|
||||
_data_column = 'channelid'
|
||||
_setting = settings.VoiceChannel
|
||||
|
||||
write_ward = guild_admin
|
||||
display_name = "video_channels"
|
||||
desc = "Channels where members are required to enable their video."
|
||||
|
||||
_force_unique = True
|
||||
|
||||
long_desc = (
|
||||
"Members must keep their video enabled in these channels.\n"
|
||||
"If they do not keep their video enabled, they will be asked to enable it in their DMS after `15` seconds, "
|
||||
"and then kicked from the channel with another warning after the `video_grace_period` duration has passed.\n"
|
||||
"After the first offence, if the `video_studyban` is enabled and the `studyban_role` is set, "
|
||||
"they will also be automatically studybanned."
|
||||
)
|
||||
|
||||
# Flat cache, no need to expire objects
|
||||
_cache = {}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Members must enable their video in the following channels:\n{}".format(self.formatted)
|
||||
else:
|
||||
return "There are no video-required channels set up."
|
||||
|
||||
@classmethod
|
||||
async def launch_task(cls, client):
|
||||
"""
|
||||
Launch initialisation step for the `video_channels` setting.
|
||||
|
||||
Pre-fill cache for the guilds with currently active voice channels.
|
||||
"""
|
||||
active_guildids = [
|
||||
guild.id
|
||||
for guild in client.guilds
|
||||
if any(channel.members for channel in guild.voice_channels)
|
||||
]
|
||||
if active_guildids:
|
||||
cache = {guildid: [] for guildid in active_guildids}
|
||||
rows = cls._table_interface.select_where(
|
||||
guildid=active_guildids
|
||||
)
|
||||
for row in rows:
|
||||
cache[row['guildid']].append(row['channelid'])
|
||||
cls._cache.update(cache)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class video_studyban(settings.Boolean, GuildSetting):
|
||||
category = "Video Channels"
|
||||
|
||||
attr_name = 'video_studyban'
|
||||
_data_column = 'video_studyban'
|
||||
|
||||
display_name = "video_studyban"
|
||||
desc = "Whether to studyban members if they don't enable their video."
|
||||
|
||||
long_desc = (
|
||||
"If enabled, members who do not enable their video in the configured `video_channels` will be "
|
||||
"study-banned after a single warning.\n"
|
||||
"When disabled, members will only be warned and removed from the channel."
|
||||
)
|
||||
|
||||
_default = True
|
||||
_outputs = {True: "Enabled", False: "Disabled"}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Members will now be study-banned if they don't enable their video in the configured video channels."
|
||||
else:
|
||||
return "Members will not be study-banned if they don't enable their video in video channels."
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class video_grace_period(settings.Duration, GuildSetting):
|
||||
category = "Video Channels"
|
||||
|
||||
attr_name = 'video_grace_period'
|
||||
_data_column = 'video_grace_period'
|
||||
|
||||
display_name = "video_grace_period"
|
||||
desc = "How long to wait before kicking/studybanning members who don't enable their video."
|
||||
|
||||
long_desc = (
|
||||
"The period after a member has been asked to enable their video in a video-only channel "
|
||||
"before they will be kicked from the channel, and warned or studybanned (if enabled)."
|
||||
)
|
||||
|
||||
_default = 90
|
||||
_default_multiplier = 1
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, id: int, data, **kwargs):
|
||||
"""
|
||||
Return the string version of the data.
|
||||
"""
|
||||
if data is None:
|
||||
return None
|
||||
else:
|
||||
return "`{} seconds`".format(data)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return (
|
||||
"Members who do not enable their video will "
|
||||
"be disconnected after {}.".format(self.formatted)
|
||||
)
|
||||
4
bot/modules/pending-rewrite/moderation/video/data.py
Normal file
4
bot/modules/pending-rewrite/moderation/video/data.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from data import Table, RowTable
|
||||
|
||||
|
||||
video_channels = Table('video_channels')
|
||||
381
bot/modules/pending-rewrite/moderation/video/watchdog.py
Normal file
381
bot/modules/pending-rewrite/moderation/video/watchdog.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Implements a tracker to warn, kick, and studyban members in video channels without video enabled.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import datetime
|
||||
import discord
|
||||
|
||||
from meta import client
|
||||
from core import Lion
|
||||
from utils.lib import strfdelta
|
||||
from settings import GuildSettings
|
||||
|
||||
from ..tickets import StudyBanTicket, WarnTicket
|
||||
from ..module import module
|
||||
|
||||
|
||||
_tasks = {} # (guildid, userid) -> Task
|
||||
|
||||
|
||||
async def _send_alert(member, embed, alert_channel):
|
||||
"""
|
||||
Sends an embed to the member.
|
||||
If we can't reach the member, send it via alert_channel, if it exists.
|
||||
Returns the message, if it was sent, otherwise None.
|
||||
"""
|
||||
try:
|
||||
return await member.send(embed=embed)
|
||||
except discord.Forbidden:
|
||||
if alert_channel:
|
||||
try:
|
||||
return await alert_channel.send(
|
||||
content=(
|
||||
"{} (Please enable your DMs with me to get alerts privately!)"
|
||||
).format(member.mention),
|
||||
embed=embed
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
|
||||
async def _join_video_channel(member, channel):
|
||||
# Sanity checks
|
||||
if not member.voice and member.voice.channel:
|
||||
# Not in a voice channel
|
||||
return
|
||||
if member.voice.self_video:
|
||||
# Already have video on
|
||||
return
|
||||
|
||||
# First wait for 15 seconds for them to turn their video on
|
||||
try:
|
||||
await asyncio.sleep(15)
|
||||
except asyncio.CancelledError:
|
||||
# They left the channel or turned their video on
|
||||
return
|
||||
|
||||
# Fetch the relevant settings and build embeds
|
||||
guild_settings = GuildSettings(member.guild.id)
|
||||
grace_period = guild_settings.video_grace_period.value
|
||||
studyban = guild_settings.video_studyban.value
|
||||
studyban_role = guild_settings.studyban_role.value
|
||||
alert_channel = guild_settings.alert_channel.value
|
||||
|
||||
lion = Lion.fetch(member.guild.id, member.id)
|
||||
previously_warned = lion.data.video_warned
|
||||
|
||||
request_embed = discord.Embed(
|
||||
title="Please enable your video!",
|
||||
description=(
|
||||
"**You have joined the video-only channel {}!**\n"
|
||||
"Please **enable your video** or **leave the channel** in the next `{}` seconds, "
|
||||
"otherwise you will be **disconnected** and "
|
||||
"potentially **banned** from using this server's study facilities."
|
||||
).format(
|
||||
channel.mention,
|
||||
grace_period
|
||||
),
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
).set_footer(
|
||||
text=member.guild.name,
|
||||
icon_url=member.guild.icon_url
|
||||
)
|
||||
|
||||
thanks_embed = discord.Embed(
|
||||
title="Thanks for enabling your video! Best of luck with your study.",
|
||||
colour=discord.Colour.green(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
).set_footer(
|
||||
text=member.guild.name,
|
||||
icon_url=member.guild.icon_url
|
||||
)
|
||||
|
||||
bye_embed = discord.Embed(
|
||||
title="Thanks for leaving the channel promptly!",
|
||||
colour=discord.Colour.green(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
).set_footer(
|
||||
text=member.guild.name,
|
||||
icon_url=member.guild.icon_url
|
||||
)
|
||||
|
||||
# Send the notification message and wait for the grace period
|
||||
out_msg = None
|
||||
alert_task = asyncio.create_task(_send_alert(
|
||||
member,
|
||||
request_embed,
|
||||
alert_channel
|
||||
))
|
||||
try:
|
||||
out_msg = await asyncio.shield(alert_task)
|
||||
await asyncio.sleep(grace_period)
|
||||
except asyncio.CancelledError:
|
||||
# They left the channel or turned their video on
|
||||
|
||||
# Finish the message task if it wasn't complete
|
||||
if not alert_task.done():
|
||||
out_msg = await alert_task
|
||||
|
||||
# Update the notification message
|
||||
# The out_msg may be None here, if we have no way of reaching the member
|
||||
if out_msg is not None:
|
||||
try:
|
||||
if not member.voice or not (member.voice.channel == channel):
|
||||
await out_msg.edit(embed=bye_embed)
|
||||
elif member.voice.self_video:
|
||||
await out_msg.edit(embed=thanks_embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
return
|
||||
|
||||
# Disconnect, notify, warn, and potentially study ban
|
||||
# Don't allow this to be cancelled any more
|
||||
_tasks.pop((member.guild.id, member.id), None)
|
||||
|
||||
# First disconnect
|
||||
client.log(
|
||||
("Disconnecting member {} (uid: {}) in guild {} (gid: {}) from video channel {} (cid:{}) "
|
||||
"for not enabling their video.").format(
|
||||
member.name,
|
||||
member.id,
|
||||
member.guild.name,
|
||||
member.guild.id,
|
||||
channel.name,
|
||||
channel.id
|
||||
),
|
||||
context="VIDEO_WATCHDOG"
|
||||
)
|
||||
try:
|
||||
await member.edit(
|
||||
voice_channel=None,
|
||||
reason="Member in video-only channel did not enable video."
|
||||
)
|
||||
except discord.HTTPException:
|
||||
# TODO: Add it to the moderation ticket
|
||||
# Error log?
|
||||
...
|
||||
|
||||
# Then warn or study ban, with appropriate notification
|
||||
only_warn = not previously_warned or not studyban or not studyban_role
|
||||
|
||||
if only_warn:
|
||||
# Give them an official warning
|
||||
embed = discord.Embed(
|
||||
title="You have received a warning!",
|
||||
description=(
|
||||
"You must enable your camera in camera-only rooms."
|
||||
),
|
||||
colour=discord.Colour.red(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
)
|
||||
embed.add_field(
|
||||
name="Info",
|
||||
value=(
|
||||
"*Warnings appear in your moderation history. "
|
||||
"Failure to comply, or repeated warnings, "
|
||||
"may result in muting, studybanning, or server banning.*"
|
||||
)
|
||||
)
|
||||
embed.set_footer(
|
||||
icon_url=member.guild.icon_url,
|
||||
text=member.guild.name
|
||||
)
|
||||
await _send_alert(member, embed, alert_channel)
|
||||
await WarnTicket.create(
|
||||
member.guild.id,
|
||||
member.id,
|
||||
client.user.id,
|
||||
"Failed to enable their video in time in the video channel {}.".format(channel.mention),
|
||||
auto=True
|
||||
)
|
||||
# TODO: Warning ticket and related embed.
|
||||
lion.data.video_warned = True
|
||||
else:
|
||||
# Apply an automatic studyban
|
||||
ticket = await StudyBanTicket.autoban(
|
||||
member.guild,
|
||||
member,
|
||||
"Failed to enable their video in time in the video channel {}.".format(channel.mention)
|
||||
)
|
||||
if ticket:
|
||||
tip = "TIP: When joining a video only study room, always be ready to enable your video immediately!"
|
||||
embed = discord.Embed(
|
||||
title="You have been studybanned!",
|
||||
description=(
|
||||
"You have been banned from studying in **{}**.\n"
|
||||
"Study features, including access to the server **study channels**, "
|
||||
"will ***not be available to you until this ban is lifted.***".format(
|
||||
member.guild.name,
|
||||
)
|
||||
),
|
||||
colour=discord.Colour.red(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
)
|
||||
embed.add_field(
|
||||
name="Reason",
|
||||
value="Failure to enable your video in time in a video-only channel.\n\n*{}*".format(tip)
|
||||
)
|
||||
if ticket.data.duration:
|
||||
embed.add_field(
|
||||
name="Duration",
|
||||
value="`{}` (Expires <t:{:.0f}>)".format(
|
||||
strfdelta(datetime.timedelta(seconds=ticket.data.duration)),
|
||||
ticket.data.expiry.timestamp()
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
embed.set_footer(
|
||||
text=member.guild.name,
|
||||
icon_url=member.guild.icon_url
|
||||
)
|
||||
await _send_alert(member, embed, alert_channel)
|
||||
else:
|
||||
# This should be impossible
|
||||
# TODO: Cautionary error logging
|
||||
pass
|
||||
|
||||
|
||||
@client.add_after_event("voice_state_update")
|
||||
async def video_watchdog(client, member, before, after):
|
||||
if member.bot:
|
||||
return
|
||||
|
||||
task_key = (member.guild.id, member.id)
|
||||
|
||||
if after.channel != before.channel:
|
||||
# Channel change, cancel any running tasks for the member
|
||||
task = _tasks.pop(task_key, None)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
|
||||
# Check whether they are joining a video channel, run join logic if so
|
||||
if after.channel and not after.self_video:
|
||||
video_channel_ids = GuildSettings(member.guild.id).video_channels.data
|
||||
if after.channel.id in video_channel_ids:
|
||||
client.log(
|
||||
("Launching join task for member {} (uid: {}) "
|
||||
"in guild {} (gid: {}) and video channel {} (cid:{}).").format(
|
||||
member.name,
|
||||
member.id,
|
||||
member.guild.name,
|
||||
member.guild.id,
|
||||
after.channel.name,
|
||||
after.channel.id
|
||||
),
|
||||
context="VIDEO_WATCHDOG",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
_tasks[task_key] = asyncio.create_task(_join_video_channel(member, after.channel))
|
||||
else:
|
||||
video_channel_ids = GuildSettings(member.guild.id).video_channels.data
|
||||
if after.channel and after.channel.id in video_channel_ids:
|
||||
channel = after.channel
|
||||
if after.self_video:
|
||||
# If they have their video on, cancel any running tasks
|
||||
task = _tasks.pop(task_key, None)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
else:
|
||||
# They have their video off
|
||||
# Don't do anything if there are running tasks, the tasks will handle it
|
||||
task = _tasks.get(task_key, None)
|
||||
if task and not task.done():
|
||||
return
|
||||
|
||||
# Otherwise, give them 10 seconds
|
||||
_tasks[task_key] = task = asyncio.create_task(asyncio.sleep(10))
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
# Task was cancelled, they left the channel or turned their video on
|
||||
return
|
||||
|
||||
# Then kick them out, alert them, and event log it
|
||||
client.log(
|
||||
("Disconnecting member {} (uid: {}) in guild {} (gid: {}) from video channel {} (cid:{}) "
|
||||
"for disabling their video.").format(
|
||||
member.name,
|
||||
member.id,
|
||||
member.guild.name,
|
||||
member.guild.id,
|
||||
channel.name,
|
||||
channel.id
|
||||
),
|
||||
context="VIDEO_WATCHDOG"
|
||||
)
|
||||
try:
|
||||
await member.edit(
|
||||
voice_channel=None,
|
||||
reason="Removing non-video member from video-only channel."
|
||||
)
|
||||
await _send_alert(
|
||||
member,
|
||||
discord.Embed(
|
||||
title="You have been kicked from the video channel.",
|
||||
description=(
|
||||
"You were disconnected from the video-only channel {} for disabling your video.\n"
|
||||
"Please keep your video on at all times, and leave the channel if you need "
|
||||
"to make adjustments!"
|
||||
).format(
|
||||
channel.mention,
|
||||
),
|
||||
colour=discord.Colour.red(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
).set_footer(
|
||||
text=member.guild.name,
|
||||
icon_url=member.guild.icon_url
|
||||
),
|
||||
GuildSettings(member.guild.id).alert_channel.value
|
||||
)
|
||||
except discord.Forbidden:
|
||||
GuildSettings(member.guild.id).event_log.log(
|
||||
"I attempted to disconnect {} from the video-only channel {} "
|
||||
"because they disabled their video, but I didn't have the required permissions!\n".format(
|
||||
member.mention,
|
||||
channel.mention
|
||||
)
|
||||
)
|
||||
else:
|
||||
GuildSettings(member.guild.id).event_log.log(
|
||||
"{} was disconnected from the video-only channel {} "
|
||||
"because they disabled their video.".format(
|
||||
member.mention,
|
||||
channel.mention
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def load_video_channels(client):
|
||||
"""
|
||||
Process existing video channel members.
|
||||
Pre-fills the video channel cache by running the setting launch task.
|
||||
|
||||
Treats members without video on as having just joined.
|
||||
"""
|
||||
# Run the video channel initialisation to populate the setting cache
|
||||
await GuildSettings.settings.video_channels.launch_task(client)
|
||||
|
||||
# Launch join tasks for all members in video channels without video enabled
|
||||
video_channels = (
|
||||
channel
|
||||
for guild in client.guilds
|
||||
for channel in guild.voice_channels
|
||||
if channel.members and channel.id in GuildSettings.settings.video_channels.get(guild.id).data
|
||||
)
|
||||
to_task = [
|
||||
(member, channel)
|
||||
for channel in video_channels
|
||||
for member in channel.members
|
||||
if not member.voice.self_video
|
||||
]
|
||||
for member, channel in to_task:
|
||||
_tasks[(member.guild.id, member.id)] = asyncio.create_task(_join_video_channel(member, channel))
|
||||
|
||||
if to_task:
|
||||
client.log(
|
||||
"Launched {} join tasks for members who need to enable their video.".format(len(to_task)),
|
||||
context="VIDEO_CHANNEL_LAUNCH"
|
||||
)
|
||||
0
bot/modules/pending-rewrite/plugins/.gitignore
vendored
Normal file
0
bot/modules/pending-rewrite/plugins/.gitignore
vendored
Normal file
5
bot/modules/pending-rewrite/reminders/__init__.py
Normal file
5
bot/modules/pending-rewrite/reminders/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .module import module
|
||||
|
||||
from . import commands
|
||||
from . import data
|
||||
from . import reminder
|
||||
264
bot/modules/pending-rewrite/reminders/commands.py
Normal file
264
bot/modules/pending-rewrite/reminders/commands.py
Normal file
@@ -0,0 +1,264 @@
|
||||
import re
|
||||
import asyncio
|
||||
import datetime
|
||||
import discord
|
||||
|
||||
from meta import sharding
|
||||
from utils.lib import parse_dur, parse_ranges, multiselect_regex
|
||||
|
||||
from .module import module
|
||||
from .data import reminders
|
||||
from .reminder import Reminder
|
||||
|
||||
|
||||
reminder_regex = re.compile(
|
||||
r"""
|
||||
(^)?(?P<type> (?: \b in) | (?: every))
|
||||
\s*(?P<duration> (?: day| hour| (?:\d+\s*(?:(?:d|h|m|s)[a-zA-Z]*)?(?:\s|and)*)+))
|
||||
(?:(?(1) (?:, | ; | : | \. | to)? | $))
|
||||
""",
|
||||
re.IGNORECASE | re.VERBOSE | re.DOTALL
|
||||
)
|
||||
|
||||
reminder_limit = 20
|
||||
|
||||
|
||||
@module.cmd(
|
||||
name="remindme",
|
||||
desc="Ask me to remind you about important tasks.",
|
||||
group="Productivity",
|
||||
aliases=('reminders', 'reminder'),
|
||||
flags=('remove', 'clear')
|
||||
)
|
||||
async def cmd_remindme(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}remindme in <duration> to <task>
|
||||
{prefix}remindme every <duration> to <task>
|
||||
{prefix}reminders
|
||||
{prefix}reminders --clear
|
||||
{prefix}reminders --remove
|
||||
Description:
|
||||
Ask {ctx.client.user.name} to remind you about important tasks.
|
||||
Examples``:
|
||||
{prefix}remindme in 2h 20m, Revise chapter 1
|
||||
{prefix}remindme every hour, Drink water!
|
||||
{prefix}remindme Anatomy class in 8h 20m
|
||||
"""
|
||||
# TODO: (FUTURE) every day at 9:00
|
||||
|
||||
if flags['remove']:
|
||||
# Do removal stuff
|
||||
rows = reminders.fetch_rows_where(
|
||||
userid=ctx.author.id,
|
||||
_extra="ORDER BY remind_at ASC"
|
||||
)
|
||||
if not rows:
|
||||
return await ctx.reply("You have no reminders to remove!")
|
||||
|
||||
live = [Reminder(row.reminderid) for row in rows]
|
||||
|
||||
if not ctx.args:
|
||||
lines = []
|
||||
num_field = len(str(len(live) - 1))
|
||||
for i, reminder in enumerate(live):
|
||||
lines.append(
|
||||
"`[{:{}}]` | {}".format(
|
||||
i,
|
||||
num_field,
|
||||
reminder.formatted
|
||||
)
|
||||
)
|
||||
|
||||
description = '\n'.join(lines)
|
||||
description += (
|
||||
"\n\nPlease select the reminders to remove, or type `c` to cancel.\n"
|
||||
"(For example, respond with `1, 2, 3` or `1-3`.)"
|
||||
)
|
||||
embed = discord.Embed(
|
||||
description=description,
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
).set_author(
|
||||
name="Reminders for {}".format(ctx.author.display_name),
|
||||
icon_url=ctx.author.avatar_url
|
||||
)
|
||||
|
||||
out_msg = await ctx.reply(embed=embed)
|
||||
|
||||
def check(msg):
|
||||
valid = msg.channel == ctx.ch and msg.author == ctx.author
|
||||
valid = valid and (re.search(multiselect_regex, msg.content) or msg.content.lower() == 'c')
|
||||
return valid
|
||||
|
||||
try:
|
||||
message = await ctx.client.wait_for('message', check=check, timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
await out_msg.delete()
|
||||
await ctx.error_reply("Session timed out. No reminders were deleted.")
|
||||
return
|
||||
|
||||
try:
|
||||
await out_msg.delete()
|
||||
await message.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
if message.content.lower() == 'c':
|
||||
return
|
||||
|
||||
to_delete = [
|
||||
live[index].reminderid
|
||||
for index in parse_ranges(message.content) if index < len(live)
|
||||
]
|
||||
else:
|
||||
to_delete = [
|
||||
live[index].reminderid
|
||||
for index in parse_ranges(ctx.args) if index < len(live)
|
||||
]
|
||||
|
||||
if not to_delete:
|
||||
return await ctx.error_reply("Nothing to delete!")
|
||||
|
||||
# Delete the selected reminders
|
||||
Reminder.delete(*to_delete)
|
||||
|
||||
# Ack
|
||||
await ctx.embed_reply(
|
||||
"{tick} Reminder{plural} deleted.".format(
|
||||
tick='✅',
|
||||
plural='s' if len(to_delete) > 1 else ''
|
||||
)
|
||||
)
|
||||
elif flags['clear']:
|
||||
# Do clear stuff
|
||||
rows = reminders.fetch_rows_where(
|
||||
userid=ctx.author.id,
|
||||
)
|
||||
if not rows:
|
||||
return await ctx.reply("You have no reminders to remove!")
|
||||
|
||||
Reminder.delete(*(row.reminderid for row in rows))
|
||||
await ctx.embed_reply(
|
||||
"{tick} Reminders cleared.".format(
|
||||
tick='✅',
|
||||
)
|
||||
)
|
||||
elif ctx.args:
|
||||
# Add a new reminder
|
||||
|
||||
content = None
|
||||
duration = None
|
||||
repeating = None
|
||||
|
||||
# First parse it
|
||||
match = re.search(reminder_regex, ctx.args)
|
||||
if match:
|
||||
repeating = match.group('type').lower() == 'every'
|
||||
|
||||
duration_str = match.group('duration').lower()
|
||||
if duration_str.isdigit():
|
||||
duration = int(duration_str)
|
||||
elif duration_str == 'day':
|
||||
duration = 24 * 60 * 60
|
||||
elif duration_str == 'hour':
|
||||
duration = 60 * 60
|
||||
else:
|
||||
duration = parse_dur(duration_str)
|
||||
|
||||
content = (ctx.args[:match.start()] + ctx.args[match.end():]).strip()
|
||||
if content.startswith('to '):
|
||||
content = content[3:].strip()
|
||||
else:
|
||||
# Legacy parsing, without requiring "in" at the front
|
||||
splits = ctx.args.split(maxsplit=1)
|
||||
if len(splits) == 2 and splits[0].isdigit():
|
||||
repeating = False
|
||||
duration = int(splits[0]) * 60
|
||||
content = splits[1].strip()
|
||||
|
||||
# Sanity checking
|
||||
if not duration or not content:
|
||||
return await ctx.error_reply(
|
||||
"Sorry, I didn't understand your reminder!\n"
|
||||
"See `{prefix}help remindme` for usage and examples.".format(prefix=ctx.best_prefix)
|
||||
)
|
||||
|
||||
# Don't allow rapid repeating reminders
|
||||
if repeating and duration < 10 * 60:
|
||||
return await ctx.error_reply(
|
||||
"You can't have a repeating reminder shorter than `10` minutes!"
|
||||
)
|
||||
|
||||
# Check the user doesn't have too many reminders already
|
||||
count = reminders.select_one_where(
|
||||
userid=ctx.author.id,
|
||||
select_columns=("COUNT(*)",)
|
||||
)[0]
|
||||
if count > reminder_limit:
|
||||
return await ctx.error_reply(
|
||||
"Sorry, you have reached your maximum of `{}` reminders!".format(reminder_limit)
|
||||
)
|
||||
|
||||
# Create reminder
|
||||
reminder = Reminder.create(
|
||||
userid=ctx.author.id,
|
||||
content=content,
|
||||
message_link=ctx.msg.jump_url,
|
||||
interval=duration if repeating else None,
|
||||
remind_at=datetime.datetime.utcnow() + datetime.timedelta(seconds=duration)
|
||||
)
|
||||
|
||||
# Schedule reminder
|
||||
if sharding.shard_number == 0:
|
||||
reminder.schedule()
|
||||
|
||||
# Ack
|
||||
embed = discord.Embed(
|
||||
title="Reminder Created!",
|
||||
colour=discord.Colour.orange(),
|
||||
description="Got it! I will remind you <t:{}:R>.".format(reminder.timestamp),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
elif ctx.alias.lower() == 'remindme':
|
||||
# Show hints about adding reminders
|
||||
...
|
||||
else:
|
||||
# Show formatted list of reminders
|
||||
rows = reminders.fetch_rows_where(
|
||||
userid=ctx.author.id,
|
||||
_extra="ORDER BY remind_at ASC"
|
||||
)
|
||||
if not rows:
|
||||
return await ctx.reply("You have no reminders!")
|
||||
|
||||
live = [Reminder(row.reminderid) for row in rows]
|
||||
|
||||
lines = []
|
||||
num_field = len(str(len(live) - 1))
|
||||
for i, reminder in enumerate(live):
|
||||
lines.append(
|
||||
"`[{:{}}]` | {}".format(
|
||||
i,
|
||||
num_field,
|
||||
reminder.formatted
|
||||
)
|
||||
)
|
||||
|
||||
description = '\n'.join(lines)
|
||||
embed = discord.Embed(
|
||||
description=description,
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
).set_author(
|
||||
name="{}'s reminders".format(ctx.author.display_name),
|
||||
icon_url=ctx.author.avatar_url
|
||||
).set_footer(
|
||||
text=(
|
||||
"Click a reminder twice to jump to the context!\n"
|
||||
"For more usage and examples see {}help reminders"
|
||||
).format(ctx.best_prefix)
|
||||
)
|
||||
|
||||
await ctx.reply(embed=embed)
|
||||
8
bot/modules/pending-rewrite/reminders/data.py
Normal file
8
bot/modules/pending-rewrite/reminders/data.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from data.interfaces import RowTable
|
||||
|
||||
|
||||
reminders = RowTable(
|
||||
'reminders',
|
||||
('reminderid', 'userid', 'remind_at', 'content', 'message_link', 'interval', 'created_at', 'title', 'footer'),
|
||||
'reminderid'
|
||||
)
|
||||
4
bot/modules/pending-rewrite/reminders/module.py
Normal file
4
bot/modules/pending-rewrite/reminders/module.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
|
||||
module = LionModule("Reminders")
|
||||
234
bot/modules/pending-rewrite/reminders/reminder.py
Normal file
234
bot/modules/pending-rewrite/reminders/reminder.py
Normal file
@@ -0,0 +1,234 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import discord
|
||||
|
||||
from meta import client, sharding
|
||||
from utils.lib import strfdur
|
||||
|
||||
from .data import reminders
|
||||
from .module import module
|
||||
|
||||
|
||||
class Reminder:
|
||||
__slots__ = ('reminderid', '_task')
|
||||
|
||||
_live_reminders = {} # map reminderid -> Reminder
|
||||
|
||||
def __init__(self, reminderid):
|
||||
self.reminderid = reminderid
|
||||
|
||||
self._task = None
|
||||
|
||||
@classmethod
|
||||
def create(cls, **kwargs):
|
||||
row = reminders.create_row(**kwargs)
|
||||
return cls(row.reminderid)
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, *reminderids):
|
||||
"""
|
||||
Fetch an live reminders associated to the given reminderids.
|
||||
"""
|
||||
return [
|
||||
cls._live_reminders[reminderid]
|
||||
for reminderid in reminderids
|
||||
if reminderid in cls._live_reminders
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def delete(cls, *reminderids):
|
||||
"""
|
||||
Cancel and delete the given reminders in an idempotent fashion.
|
||||
"""
|
||||
# Cancel the rmeinders
|
||||
for reminderid in reminderids:
|
||||
if reminderid in cls._live_reminders:
|
||||
cls._live_reminders[reminderid].cancel()
|
||||
|
||||
# Remove from data
|
||||
if reminderids:
|
||||
return reminders.delete_where(reminderid=reminderids)
|
||||
else:
|
||||
return []
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return reminders.fetch(self.reminderid)
|
||||
|
||||
@property
|
||||
def timestamp(self):
|
||||
"""
|
||||
True unix timestamp for (next) reminder time.
|
||||
"""
|
||||
return int(self.data.remind_at.replace(tzinfo=datetime.timezone.utc).timestamp())
|
||||
|
||||
@property
|
||||
def user(self):
|
||||
"""
|
||||
The discord.User that owns this reminder, if we can find them.
|
||||
"""
|
||||
return client.get_user(self.data.userid)
|
||||
|
||||
@property
|
||||
def formatted(self):
|
||||
"""
|
||||
Single-line string format for the reminder, intended for an embed.
|
||||
"""
|
||||
content = self.data.content
|
||||
trunc_content = content[:50] + '...' * (len(content) > 50)
|
||||
|
||||
if self.data.interval:
|
||||
interval = self.data.interval
|
||||
if interval == 24 * 60 * 60:
|
||||
interval_str = "day"
|
||||
elif interval == 60 * 60:
|
||||
interval_str = "hour"
|
||||
elif interval % (24 * 60 * 60) == 0:
|
||||
interval_str = "`{}` days".format(interval // (24 * 60 * 60))
|
||||
elif interval % (60 * 60) == 0:
|
||||
interval_str = "`{}` hours".format(interval // (60 * 60))
|
||||
else:
|
||||
interval_str = "`{}`".format(strfdur(interval))
|
||||
|
||||
repeat = "(Every {})".format(interval_str)
|
||||
else:
|
||||
repeat = ""
|
||||
|
||||
return "<t:{timestamp}:R>, [{content}]({jump_link}) {repeat}".format(
|
||||
jump_link=self.data.message_link,
|
||||
content=trunc_content,
|
||||
timestamp=self.timestamp,
|
||||
repeat=repeat
|
||||
)
|
||||
|
||||
def cancel(self):
|
||||
"""
|
||||
Cancel the live reminder waiting task, if it exists.
|
||||
Does not remove the reminder from data. Use `Reminder.delete` for this.
|
||||
"""
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
self._live_reminders.pop(self.reminderid, None)
|
||||
|
||||
def schedule(self):
|
||||
"""
|
||||
Schedule this reminder to be executed.
|
||||
"""
|
||||
asyncio.create_task(self._schedule())
|
||||
self._live_reminders[self.reminderid] = self
|
||||
|
||||
async def _schedule(self):
|
||||
"""
|
||||
Execute this reminder after a sleep.
|
||||
Accepts cancellation by aborting the scheduled execute.
|
||||
"""
|
||||
# Calculate time left
|
||||
remaining = (self.data.remind_at - datetime.datetime.utcnow()).total_seconds()
|
||||
|
||||
# Create the waiting task and wait for it, accepting cancellation
|
||||
self._task = asyncio.create_task(asyncio.sleep(remaining))
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
await self._execute()
|
||||
|
||||
async def _execute(self):
|
||||
"""
|
||||
Execute the reminder.
|
||||
"""
|
||||
if not self.data:
|
||||
# Reminder deleted elsewhere
|
||||
return
|
||||
|
||||
if self.data.userid in client.user_blacklist():
|
||||
self.delete(self.reminderid)
|
||||
return
|
||||
|
||||
userid = self.data.userid
|
||||
|
||||
# Build the message embed
|
||||
embed = discord.Embed(
|
||||
title="You asked me to remind you!" if self.data.title is None else self.data.title,
|
||||
colour=discord.Colour.orange(),
|
||||
description=self.data.content,
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
)
|
||||
|
||||
if self.data.message_link:
|
||||
embed.add_field(name="Context?", value="[Click here]({})".format(self.data.message_link))
|
||||
|
||||
if self.data.interval:
|
||||
embed.add_field(
|
||||
name="Next reminder",
|
||||
value="<t:{}:R>".format(
|
||||
self.timestamp + self.data.interval
|
||||
)
|
||||
)
|
||||
|
||||
if self.data.footer:
|
||||
embed.set_footer(text=self.data.footer)
|
||||
|
||||
# Update the reminder data, and reschedule if required
|
||||
if self.data.interval:
|
||||
next_time = self.data.remind_at + datetime.timedelta(seconds=self.data.interval)
|
||||
rows = reminders.update_where(
|
||||
{'remind_at': next_time},
|
||||
reminderid=self.reminderid
|
||||
)
|
||||
self.schedule()
|
||||
else:
|
||||
rows = self.delete(self.reminderid)
|
||||
if not rows:
|
||||
# Reminder deleted elsewhere
|
||||
return
|
||||
|
||||
# Send the message, if possible
|
||||
if not (user := client.get_user(userid)):
|
||||
try:
|
||||
user = await client.fetch_user(userid)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
if user:
|
||||
try:
|
||||
await user.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
# Nothing we can really do here. Maybe tell the user about their reminder next time?
|
||||
pass
|
||||
|
||||
|
||||
async def reminder_poll(client):
|
||||
"""
|
||||
One client/shard must continually poll for new or deleted reminders.
|
||||
"""
|
||||
# TODO: Clean this up with database signals or IPC
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
|
||||
client.log(
|
||||
"Running new reminder poll.",
|
||||
context="REMINDERS",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
|
||||
rids = {row.reminderid for row in reminders.fetch_rows_where()}
|
||||
|
||||
to_delete = (rid for rid in Reminder._live_reminders if rid not in rids)
|
||||
Reminder.delete(*to_delete)
|
||||
|
||||
[Reminder(rid).schedule() for rid in rids if rid not in Reminder._live_reminders]
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def schedule_reminders(client):
|
||||
if sharding.shard_number == 0:
|
||||
rows = reminders.fetch_rows_where()
|
||||
for row in rows:
|
||||
Reminder(row.reminderid).schedule()
|
||||
client.log(
|
||||
"Scheduled {} reminders.".format(len(rows)),
|
||||
context="LAUNCH_REMINDERS"
|
||||
)
|
||||
if sharding.sharded:
|
||||
asyncio.create_task(reminder_poll(client))
|
||||
5
bot/modules/pending-rewrite/renting/__init__.py
Normal file
5
bot/modules/pending-rewrite/renting/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .module import module
|
||||
|
||||
from . import commands
|
||||
from . import rooms
|
||||
from . import admin
|
||||
76
bot/modules/pending-rewrite/renting/admin.py
Normal file
76
bot/modules/pending-rewrite/renting/admin.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import discord
|
||||
|
||||
from settings import GuildSettings, GuildSetting
|
||||
import settings
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class rent_category(settings.Channel, GuildSetting):
|
||||
category = "Rented Rooms"
|
||||
|
||||
attr_name = "rent_category"
|
||||
_data_column = "renting_category"
|
||||
|
||||
display_name = "rent_category"
|
||||
desc = "Category in which members can rent their own study rooms."
|
||||
|
||||
_default = None
|
||||
|
||||
long_desc = (
|
||||
"Members can use the `rent` command to "
|
||||
"buy the use of a new private voice channel in this category for `24h`."
|
||||
)
|
||||
_accepts = "A category channel."
|
||||
|
||||
_chan_type = discord.ChannelType.category
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "Members may now rent private voice channels under **{}**.".format(self.value.name)
|
||||
else:
|
||||
return "Members may no longer rent private voice channels."
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class rent_member_limit(settings.Integer, GuildSetting):
|
||||
category = "Rented Rooms"
|
||||
|
||||
attr_name = "rent_member_limit"
|
||||
_data_column = "renting_cap"
|
||||
|
||||
display_name = "rent_member_limit"
|
||||
desc = "Maximum number of people that can be added to a rented room."
|
||||
|
||||
_default = 24
|
||||
|
||||
long_desc = (
|
||||
"Maximum number of people a member can add to a rented private voice channel."
|
||||
)
|
||||
_accepts = "An integer number of members."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "Members will now be able to add at most `{}` people to their channel.".format(self.value)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class rent_room_price(settings.Integer, GuildSetting):
|
||||
category = "Rented Rooms"
|
||||
|
||||
attr_name = "rent_room_price"
|
||||
_data_column = "renting_price"
|
||||
|
||||
display_name = "rent_price"
|
||||
desc = "Price of a privated voice channel."
|
||||
|
||||
_default = 1000
|
||||
|
||||
long_desc = (
|
||||
"How much it costs for a member to rent a private voice channel."
|
||||
)
|
||||
_accepts = "An integer number of coins."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "Private voice channels now cost `{}` coins.".format(self.value)
|
||||
215
bot/modules/pending-rewrite/renting/commands.py
Normal file
215
bot/modules/pending-rewrite/renting/commands.py
Normal file
@@ -0,0 +1,215 @@
|
||||
import discord
|
||||
from cmdClient.checks import in_guild
|
||||
|
||||
from .module import module
|
||||
from .rooms import Room
|
||||
|
||||
|
||||
@module.cmd(
|
||||
name="rent",
|
||||
desc="Rent a private study room with your friends!",
|
||||
group="Productivity",
|
||||
aliases=('add',)
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_rent(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}rent
|
||||
{prefix}rent @user1 @user2 @user3 ...
|
||||
{prefix}rent add @user1 @user2 @user3 ...
|
||||
{prefix}rent remove @user1 @user2 @user3 ...
|
||||
Description:
|
||||
Rent a private voice channel for 24 hours,\
|
||||
and invite up to `{ctx.guild_settings.rent_member_limit.value}` mentioned users.
|
||||
Use `{prefix}rent add` and `{prefix}rent remove` to give/revoke access to your room.
|
||||
|
||||
*Renting a private channel costs `{ctx.guild_settings.rent_room_price.value} LC`.*
|
||||
"""
|
||||
# TODO: More gracefully handle unexpected channel deletion
|
||||
|
||||
# Check if the category is set up
|
||||
if not ctx.guild_settings.rent_category.value:
|
||||
return await ctx.error_reply(
|
||||
"The private study channel category has not been set up! Please come back later."
|
||||
)
|
||||
|
||||
# Fetch the members' room, if it exists
|
||||
room = Room.fetch(ctx.guild.id, ctx.author.id)
|
||||
|
||||
# Handle pre-deletion of the room
|
||||
if room and not room.channel:
|
||||
ctx.guild_settings.event_log.log(
|
||||
title="Private study room not found!",
|
||||
description="{}'s study room was deleted before it expired!".format(ctx.author.mention)
|
||||
)
|
||||
room.delete()
|
||||
room = None
|
||||
|
||||
if room:
|
||||
# Show room status, or add/remove remebers
|
||||
lower = ctx.args.lower()
|
||||
if ctx.msg.mentions and lower and (lower.startswith('-') or lower.startswith('remove')):
|
||||
# Remove the mentioned members
|
||||
|
||||
# Extract members to remove
|
||||
current_memberids = set(room.memberids)
|
||||
if ctx.author in ctx.msg.mentions:
|
||||
return await ctx.error_reply(
|
||||
"You can't remove yourself from your own room!"
|
||||
)
|
||||
to_remove = (
|
||||
member for member in ctx.msg.mentions
|
||||
if member.id in current_memberids and member.id != ctx.author.id
|
||||
)
|
||||
to_remove = list(set(to_remove)) # Remove duplicates
|
||||
|
||||
# Check if there are no members to remove
|
||||
if not to_remove:
|
||||
return await ctx.error_reply(
|
||||
"None of these members have access to your study room!"
|
||||
)
|
||||
|
||||
# Finally, remove the members from the room and ack
|
||||
await room.remove_members(*to_remove)
|
||||
|
||||
await ctx.embed_reply(
|
||||
"The following members have been removed from your room:\n{}".format(
|
||||
', '.join(member.mention for member in to_remove)
|
||||
)
|
||||
)
|
||||
elif lower == 'delete':
|
||||
if await ctx.ask("Are you sure you want to delete your study room? No refunds given!"):
|
||||
# TODO: Better deletion log
|
||||
await room._execute()
|
||||
await ctx.embed_reply("Private study room deleted.")
|
||||
elif ctx.msg.mentions:
|
||||
# Add the mentioned members
|
||||
|
||||
# Extract members to add
|
||||
current_memberids = set(room.memberids)
|
||||
to_add = (
|
||||
member for member in ctx.msg.mentions
|
||||
if member.id not in current_memberids and member.id != ctx.author.id
|
||||
)
|
||||
to_add = list(set(to_add)) # Remove duplicates
|
||||
|
||||
# Check if there are no members to add
|
||||
if not to_add:
|
||||
return await ctx.error_reply(
|
||||
"All of these members already have access to your room!"
|
||||
)
|
||||
|
||||
# Check that they didn't provide too many members
|
||||
limit = ctx.guild_settings.rent_member_limit.value
|
||||
if len(to_add) + len(current_memberids) > limit:
|
||||
return await ctx.error_reply(
|
||||
"Too many members! You can invite at most `{}` members to your room.".format(
|
||||
limit
|
||||
)
|
||||
)
|
||||
|
||||
# Finally, add the members to the room and ack
|
||||
await room.add_members(*to_add)
|
||||
|
||||
await ctx.embed_reply(
|
||||
"The following members have been given access to your room:\n{}".format(
|
||||
', '.join(member.mention for member in to_add)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Show room status with hints for adding and removing members
|
||||
# Ack command
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange()
|
||||
).set_author(
|
||||
name="{}'s private room".format(ctx.author.display_name),
|
||||
icon_url=ctx.author.avatar_url
|
||||
).add_field(
|
||||
name="Channel",
|
||||
value=room.channel.mention
|
||||
).add_field(
|
||||
name="Expires",
|
||||
value="<t:{}:R>".format(room.timestamp)
|
||||
).add_field(
|
||||
name="Members",
|
||||
value=', '.join('<@{}>'.format(memberid) for memberid in room.memberids) or "None",
|
||||
inline=False
|
||||
).set_footer(
|
||||
text=(
|
||||
"Use '{prefix}rent add @mention' and '{prefix}rent remove @mention'\n"
|
||||
"to add and remove members.".format(prefix=ctx.best_prefix)
|
||||
),
|
||||
icon_url="https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png"
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
else:
|
||||
if ctx.args:
|
||||
# Rent a new room
|
||||
|
||||
to_add = (
|
||||
member for member in ctx.msg.mentions if member != ctx.author
|
||||
)
|
||||
to_add = list(set(to_add))
|
||||
|
||||
# Check that they provided at least one member
|
||||
if not to_add:
|
||||
return await ctx.error_reply(
|
||||
"Please mention at least one user to add to your new room."
|
||||
)
|
||||
|
||||
# Check that they didn't provide too many members
|
||||
limit = ctx.guild_settings.rent_member_limit.value
|
||||
if len(ctx.msg.mentions) > limit:
|
||||
return await ctx.error_reply(
|
||||
"Too many members! You can invite at most `{}` members to your room.".format(
|
||||
limit
|
||||
)
|
||||
)
|
||||
|
||||
# Check that they have enough money for this
|
||||
cost = ctx.guild_settings.rent_room_price.value
|
||||
if ctx.alion.coins < cost:
|
||||
return await ctx.error_reply(
|
||||
"Sorry, a private room costs `{}` coins, but you only have `{}`.".format(
|
||||
cost,
|
||||
ctx.alion.coins
|
||||
)
|
||||
)
|
||||
|
||||
# Create the room
|
||||
room = await Room.create(ctx.author, to_add)
|
||||
|
||||
# Deduct cost
|
||||
ctx.alion.addCoins(-cost)
|
||||
|
||||
# Ack command
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title="Private study room rented!",
|
||||
).add_field(
|
||||
name="Channel",
|
||||
value=room.channel.mention
|
||||
).add_field(
|
||||
name="Expires",
|
||||
value="<t:{}:R>".format(room.timestamp)
|
||||
).add_field(
|
||||
name="Members",
|
||||
value=', '.join(member.mention for member in to_add),
|
||||
inline=False
|
||||
).set_footer(
|
||||
text="See your room status at any time with {prefix}rent".format(prefix=ctx.best_prefix),
|
||||
icon_url="https://projects.iamcal.com/emoji-data/img-apple-64/1f4a1.png"
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
else:
|
||||
# Suggest they get a room
|
||||
await ctx.embed_reply(
|
||||
"Rent a private study room for 24 hours with up to `{}` "
|
||||
"friends by mentioning them with this command! (Rooms cost `{}` LionCoins.)\n"
|
||||
"`{}rent @user1 @user2 ...`".format(
|
||||
ctx.guild_settings.rent_member_limit.value,
|
||||
ctx.guild_settings.rent_room_price.value,
|
||||
ctx.best_prefix,
|
||||
)
|
||||
)
|
||||
11
bot/modules/pending-rewrite/renting/data.py
Normal file
11
bot/modules/pending-rewrite/renting/data.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from data import RowTable, Table
|
||||
|
||||
|
||||
rented = RowTable(
|
||||
'rented',
|
||||
('channelid', 'guildid', 'ownerid', 'expires_at', 'created_at'),
|
||||
'channelid'
|
||||
)
|
||||
|
||||
|
||||
rented_members = Table('rented_members')
|
||||
4
bot/modules/pending-rewrite/renting/module.py
Normal file
4
bot/modules/pending-rewrite/renting/module.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
|
||||
module = LionModule("Rented Rooms")
|
||||
321
bot/modules/pending-rewrite/renting/rooms.py
Normal file
321
bot/modules/pending-rewrite/renting/rooms.py
Normal file
@@ -0,0 +1,321 @@
|
||||
import discord
|
||||
import asyncio
|
||||
import datetime
|
||||
|
||||
from cmdClient.lib import SafeCancellation
|
||||
|
||||
from meta import client
|
||||
from data.conditions import THIS_SHARD
|
||||
from settings import GuildSettings
|
||||
|
||||
from .data import rented, rented_members
|
||||
from .module import module
|
||||
|
||||
|
||||
class Room:
|
||||
__slots__ = ('key', 'map_key', '_task')
|
||||
|
||||
everyone_overwrite = discord.PermissionOverwrite(
|
||||
view_channel=False
|
||||
)
|
||||
owner_overwrite = discord.PermissionOverwrite(
|
||||
view_channel=True,
|
||||
connect=True,
|
||||
priority_speaker=True
|
||||
)
|
||||
member_overwrite = discord.PermissionOverwrite(
|
||||
view_channel=True,
|
||||
connect=True,
|
||||
)
|
||||
|
||||
_table = rented
|
||||
|
||||
_rooms = {} # map (guildid, userid) -> Room
|
||||
|
||||
def __init__(self, channelid):
|
||||
self.key = channelid
|
||||
self.map_key = (self.data.guildid, self.data.ownerid)
|
||||
|
||||
self._task = None
|
||||
|
||||
@classmethod
|
||||
async def create(cls, owner: discord.Member, initial_members):
|
||||
ownerid = owner.id
|
||||
guild = owner.guild
|
||||
guildid = guild.id
|
||||
guild_settings = GuildSettings(guildid)
|
||||
|
||||
category = guild_settings.rent_category.value
|
||||
if not category:
|
||||
# This should never happen
|
||||
return SafeCancellation("Rent category not set up!")
|
||||
|
||||
# First create the channel, with the needed overrides
|
||||
overwrites = {
|
||||
guild.default_role: cls.everyone_overwrite,
|
||||
owner: cls.owner_overwrite
|
||||
}
|
||||
overwrites.update(
|
||||
{member: cls.member_overwrite for member in initial_members}
|
||||
)
|
||||
try:
|
||||
channel = await guild.create_voice_channel(
|
||||
name="{}'s private channel".format(owner.name),
|
||||
overwrites=overwrites,
|
||||
category=category
|
||||
)
|
||||
channelid = channel.id
|
||||
except discord.HTTPException:
|
||||
guild_settings.event_log.log(
|
||||
description="Failed to create a private room for {}!".format(owner.mention),
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
raise SafeCancellation("Couldn't create the private channel! Please try again later.")
|
||||
|
||||
# Add the new room to data
|
||||
cls._table.create_row(
|
||||
channelid=channelid,
|
||||
guildid=guildid,
|
||||
ownerid=ownerid
|
||||
)
|
||||
|
||||
# Add the members to data, if any
|
||||
if initial_members:
|
||||
rented_members.insert_many(
|
||||
*((channelid, member.id) for member in initial_members)
|
||||
)
|
||||
|
||||
# Log the creation
|
||||
guild_settings.event_log.log(
|
||||
title="New private study room!",
|
||||
description="Created a private study room for {} with:\n{}".format(
|
||||
owner.mention,
|
||||
', '.join(member.mention for member in initial_members)
|
||||
)
|
||||
)
|
||||
|
||||
# Create the room, schedule its expiry, and return
|
||||
room = cls(channelid)
|
||||
room.schedule()
|
||||
return room
|
||||
|
||||
@classmethod
|
||||
def fetch(cls, guildid, userid):
|
||||
"""
|
||||
Fetch a Room owned by a given member.
|
||||
"""
|
||||
return cls._rooms.get((guildid, userid), None)
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self._table.fetch(self.key)
|
||||
|
||||
@property
|
||||
def owner(self):
|
||||
"""
|
||||
The Member owning the room.
|
||||
May be `None` if the member is no longer in the guild, or is otherwise not visible.
|
||||
"""
|
||||
guild = client.get_guild(self.data.guildid)
|
||||
if guild:
|
||||
return guild.get_member(self.data.ownerid)
|
||||
|
||||
@property
|
||||
def channel(self):
|
||||
"""
|
||||
The Channel corresponding to this rented room.
|
||||
May be `None` if the channel was already deleted.
|
||||
"""
|
||||
guild = client.get_guild(self.data.guildid)
|
||||
if guild:
|
||||
return guild.get_channel(self.key)
|
||||
|
||||
@property
|
||||
def memberids(self):
|
||||
"""
|
||||
The list of memberids in the channel.
|
||||
"""
|
||||
return [row['userid'] for row in rented_members.select_where(channelid=self.key)]
|
||||
|
||||
@property
|
||||
def timestamp(self):
|
||||
"""
|
||||
True unix timestamp for the room expiry time.
|
||||
"""
|
||||
return int(self.data.expires_at.replace(tzinfo=datetime.timezone.utc).timestamp())
|
||||
|
||||
def delete(self):
|
||||
"""
|
||||
Delete the room in an idempotent way.
|
||||
"""
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
self._rooms.pop(self.map_key, None)
|
||||
self._table.delete_where(channelid=self.key)
|
||||
|
||||
def schedule(self):
|
||||
"""
|
||||
Schedule this room to be expired.
|
||||
"""
|
||||
asyncio.create_task(self._schedule())
|
||||
self._rooms[self.map_key] = self
|
||||
|
||||
async def _schedule(self):
|
||||
"""
|
||||
Expire the room after a sleep period.
|
||||
"""
|
||||
# Calculate time left
|
||||
remaining = (self.data.expires_at - datetime.datetime.utcnow()).total_seconds()
|
||||
|
||||
# Create the waiting task and wait for it, accepting cancellation
|
||||
self._task = asyncio.create_task(asyncio.sleep(remaining))
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
await self._execute()
|
||||
|
||||
async def _execute(self):
|
||||
"""
|
||||
Expire the room.
|
||||
"""
|
||||
guild_settings = GuildSettings(self.data.guildid)
|
||||
|
||||
if self.channel:
|
||||
# Delete the discord channel
|
||||
try:
|
||||
await self.channel.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
guild_settings.event_log.log(
|
||||
title="Private study room expired!",
|
||||
description="<@{}>'s private study room expired.".format(self.data.ownerid)
|
||||
)
|
||||
|
||||
# Delete the room from data (cascades to member deletion)
|
||||
self.delete()
|
||||
|
||||
async def add_members(self, *members):
|
||||
guild_settings = GuildSettings(self.data.guildid)
|
||||
|
||||
# Update overwrites
|
||||
overwrites = self.channel.overwrites
|
||||
overwrites.update({member: self.member_overwrite for member in members})
|
||||
try:
|
||||
await self.channel.edit(overwrites=overwrites)
|
||||
except discord.HTTPException:
|
||||
guild_settings.event_log.log(
|
||||
title="Failed to update study room permissions!",
|
||||
description="An error occurred while adding the following users to the private room {}.\n{}".format(
|
||||
self.channel.mention,
|
||||
', '.join(member.mention for member in members)
|
||||
),
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
raise SafeCancellation("Sorry, something went wrong while adding the members!")
|
||||
|
||||
# Update data
|
||||
rented_members.insert_many(
|
||||
*((self.key, member.id) for member in members)
|
||||
)
|
||||
|
||||
# Log
|
||||
guild_settings.event_log.log(
|
||||
title="New members added to private study room",
|
||||
description="The following were added to {}.\n{}".format(
|
||||
self.channel.mention,
|
||||
', '.join(member.mention for member in members)
|
||||
)
|
||||
)
|
||||
|
||||
async def remove_members(self, *members):
|
||||
guild_settings = GuildSettings(self.data.guildid)
|
||||
|
||||
if self.channel:
|
||||
# Update overwrites
|
||||
try:
|
||||
await asyncio.gather(
|
||||
*(self.channel.set_permissions(
|
||||
member,
|
||||
overwrite=None,
|
||||
reason="Removing members from private channel.") for member in members)
|
||||
)
|
||||
except discord.HTTPException:
|
||||
guild_settings.event_log.log(
|
||||
title="Failed to update study room permissions!",
|
||||
description=("An error occured while removing the "
|
||||
"following members from the private room {}.\n{}").format(
|
||||
self.channel.mention,
|
||||
', '.join(member.mention for member in members)
|
||||
),
|
||||
colour=discord.Colour.red()
|
||||
)
|
||||
raise SafeCancellation("Sorry, something went wrong while removing those members!")
|
||||
|
||||
# Disconnect members if possible:
|
||||
to_disconnect = set(self.channel.members).intersection(members)
|
||||
try:
|
||||
await asyncio.gather(
|
||||
*(member.edit(voice_channel=None) for member in to_disconnect)
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Update data
|
||||
rented_members.delete_where(channelid=self.key, userid=[member.id for member in members])
|
||||
|
||||
# Log
|
||||
guild_settings.event_log.log(
|
||||
title="Members removed from a private study room",
|
||||
description="The following were removed from {}.\n{}".format(
|
||||
self.channel.mention if self.channel else "`{}`".format(self.key),
|
||||
', '.join(member.mention for member in members)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def load_rented_rooms(client):
|
||||
rows = rented.fetch_rows_where(guildid=THIS_SHARD)
|
||||
for row in rows:
|
||||
Room(row.channelid).schedule()
|
||||
client.log(
|
||||
"Loaded {} private study channels.".format(len(rows)),
|
||||
context="LOAD_RENTED_ROOMS"
|
||||
)
|
||||
|
||||
|
||||
@client.add_after_event('member_join')
|
||||
async def restore_room_permission(client, member):
|
||||
"""
|
||||
If a member has, or is part of, a private room when they rejoin, restore their permissions.
|
||||
"""
|
||||
# First check whether they own a room
|
||||
owned = Room.fetch(member.guild.id, member.id)
|
||||
if owned and owned.channel:
|
||||
# Restore their room permissions
|
||||
try:
|
||||
await owned.channel.set_permissions(
|
||||
member,
|
||||
overwrite=Room.owner_overwrite
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Then check if they are in any other rooms
|
||||
in_room_rows = rented_members.select_where(
|
||||
_extra="LEFT JOIN rented USING (channelid) WHERE userid={} AND guildid={}".format(
|
||||
member.id, member.guild.id
|
||||
)
|
||||
)
|
||||
for row in in_room_rows:
|
||||
room = Room.fetch(member.guild.id, row['ownerid'])
|
||||
if room and row['ownerid'] != member.id and room.channel:
|
||||
try:
|
||||
await room.channel.set_permissions(
|
||||
member,
|
||||
overwrite=Room.member_overwrite
|
||||
)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
5
bot/modules/pending-rewrite/sponsors/__init__.py
Normal file
5
bot/modules/pending-rewrite/sponsors/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from . import module
|
||||
|
||||
from . import data
|
||||
from . import config
|
||||
from . import commands
|
||||
14
bot/modules/pending-rewrite/sponsors/commands.py
Normal file
14
bot/modules/pending-rewrite/sponsors/commands.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from .module import module
|
||||
|
||||
|
||||
@module.cmd(
|
||||
name="sponsors",
|
||||
group="Meta",
|
||||
desc="Check out our wonderful partners!",
|
||||
)
|
||||
async def cmd_sponsors(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}sponsors
|
||||
"""
|
||||
await ctx.reply(**ctx.client.settings.sponsor_message.args(ctx))
|
||||
92
bot/modules/pending-rewrite/sponsors/config.py
Normal file
92
bot/modules/pending-rewrite/sponsors/config.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from cmdClient.checks import is_owner
|
||||
|
||||
from settings import AppSettings, Setting, KeyValueData, ListData
|
||||
from settings.setting_types import Message, String, GuildIDList
|
||||
|
||||
from meta import client
|
||||
from core.data import app_config
|
||||
|
||||
from .data import guild_whitelist
|
||||
|
||||
@AppSettings.attach_setting
|
||||
class sponsor_prompt(String, KeyValueData, Setting):
|
||||
attr_name = 'sponsor_prompt'
|
||||
_default = None
|
||||
|
||||
write_ward = is_owner
|
||||
|
||||
display_name = 'sponsor_prompt'
|
||||
category = 'Sponsors'
|
||||
desc = "Text to send after core commands to encourage checking `sponsors`."
|
||||
long_desc = (
|
||||
"Text posted after several commands to encourage users to check the `sponsors` command. "
|
||||
"Occurences of `{{prefix}}` will be replaced by the bot prefix."
|
||||
)
|
||||
|
||||
_quote = False
|
||||
|
||||
_table_interface = app_config
|
||||
_id_column = 'appid'
|
||||
_key_column = 'key'
|
||||
_value_column = 'value'
|
||||
_key = 'sponsor_prompt'
|
||||
|
||||
@classmethod
|
||||
def _data_to_value(cls, id, data, **kwargs):
|
||||
if data:
|
||||
return data.replace("{prefix}", client.prefix)
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "The sponsor prompt has been update."
|
||||
else:
|
||||
return "The sponsor prompt has been cleared."
|
||||
|
||||
|
||||
@AppSettings.attach_setting
|
||||
class sponsor_message(Message, KeyValueData, Setting):
|
||||
attr_name = 'sponsor_message'
|
||||
_default = '{"content": "Coming Soon!"}'
|
||||
|
||||
write_ward = is_owner
|
||||
|
||||
display_name = 'sponsor_message'
|
||||
category = 'Sponsors'
|
||||
desc = "`sponsors` command response."
|
||||
|
||||
long_desc = (
|
||||
"Message to reply with when a user runs the `sponsors` command."
|
||||
)
|
||||
|
||||
_table_interface = app_config
|
||||
_id_column = 'appid'
|
||||
_key_column = 'key'
|
||||
_value_column = 'value'
|
||||
_key = 'sponsor_message'
|
||||
|
||||
_cmd_str = "{prefix}sponsors --edit"
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "The `sponsors` command message has been updated."
|
||||
|
||||
|
||||
@AppSettings.attach_setting
|
||||
class sponsor_guild_whitelist(GuildIDList, ListData, Setting):
|
||||
attr_name = 'sponsor_guild_whitelist'
|
||||
write_ward = is_owner
|
||||
|
||||
category = 'Sponsors'
|
||||
display_name = 'sponsor_hidden_in'
|
||||
desc = "Guilds where the sponsor prompt is not displayed."
|
||||
long_desc = (
|
||||
"A list of guilds where the sponsor prompt hint will be hidden (see the `sponsor_prompt` setting)."
|
||||
)
|
||||
|
||||
_table_interface = guild_whitelist
|
||||
_id_column = 'appid'
|
||||
_data_column = 'guildid'
|
||||
_force_unique = True
|
||||
4
bot/modules/pending-rewrite/sponsors/data.py
Normal file
4
bot/modules/pending-rewrite/sponsors/data.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from data import Table
|
||||
|
||||
|
||||
guild_whitelist = Table("sponsor_guild_whitelist")
|
||||
33
bot/modules/pending-rewrite/sponsors/module.py
Normal file
33
bot/modules/pending-rewrite/sponsors/module.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import discord
|
||||
|
||||
from LionModule import LionModule
|
||||
from LionContext import LionContext
|
||||
|
||||
from meta import client
|
||||
|
||||
|
||||
module = LionModule("Sponsor")
|
||||
|
||||
|
||||
sponsored_commands = {'profile', 'stats', 'weekly', 'monthly'}
|
||||
|
||||
|
||||
@LionContext.reply.add_wrapper
|
||||
async def sponsor_reply_wrapper(func, ctx: LionContext, *args, **kwargs):
|
||||
if ctx.cmd and ctx.cmd.name in sponsored_commands:
|
||||
if (prompt := ctx.client.settings.sponsor_prompt.value):
|
||||
if ctx.guild:
|
||||
show = ctx.guild.id not in ctx.client.settings.sponsor_guild_whitelist.value
|
||||
show = show and not ctx.client.data.premium_guilds.queries.fetch_guild(ctx.guild.id)
|
||||
else:
|
||||
show = True
|
||||
|
||||
if show:
|
||||
sponsor_hint = discord.Embed(
|
||||
description=prompt,
|
||||
colour=discord.Colour.dark_theme()
|
||||
)
|
||||
if 'embed' not in kwargs:
|
||||
kwargs['embed'] = sponsor_hint
|
||||
|
||||
return await func(ctx, *args, **kwargs)
|
||||
9
bot/modules/pending-rewrite/stats/__init__.py
Normal file
9
bot/modules/pending-rewrite/stats/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# flake8: noqa
|
||||
from .module import module
|
||||
|
||||
from . import data
|
||||
from . import profile
|
||||
from . import setprofile
|
||||
from . import top_cmd
|
||||
from . import goals
|
||||
from . import achievements
|
||||
485
bot/modules/pending-rewrite/stats/achievements.py
Normal file
485
bot/modules/pending-rewrite/stats/achievements.py
Normal file
@@ -0,0 +1,485 @@
|
||||
from typing import NamedTuple, Optional, Union
|
||||
from datetime import timedelta
|
||||
|
||||
import pytz
|
||||
import discord
|
||||
|
||||
from cmdClient.checks import in_guild
|
||||
from LionContext import LionContext
|
||||
|
||||
from meta import client, conf
|
||||
from core import Lion
|
||||
from data.conditions import NOTNULL, LEQ
|
||||
from utils.lib import utc_now
|
||||
|
||||
from modules.topgg.utils import topgg_upvote_link
|
||||
|
||||
from .module import module
|
||||
|
||||
|
||||
class AchievementLevel(NamedTuple):
|
||||
name: str
|
||||
threshold: Union[int, float]
|
||||
emoji: discord.PartialEmoji
|
||||
|
||||
|
||||
class Achievement:
|
||||
"""
|
||||
ABC for a member or user achievement.
|
||||
"""
|
||||
# Name of the achievement
|
||||
name: str = None
|
||||
|
||||
subtext: str = None
|
||||
congrats_text: str = "Congratulations, you completed this challenge!"
|
||||
|
||||
# List of levels for the achievement. Must always contain a 0 level!
|
||||
levels: list[AchievementLevel] = None
|
||||
|
||||
def __init__(self, guildid: int, userid: int):
|
||||
self.guildid = guildid
|
||||
self.userid = userid
|
||||
|
||||
# Current status of the achievement. None until calculated by `update`.
|
||||
self.value: int = None
|
||||
|
||||
# Current level index in levels. None until calculated by `update`.
|
||||
self.level_id: int = None
|
||||
|
||||
@staticmethod
|
||||
def progress_bar(value, minimum, maximum, width=10) -> str:
|
||||
"""
|
||||
Build a text progress bar representing `value` between `minimum` and `maximum`.
|
||||
"""
|
||||
emojis = conf.emojis
|
||||
|
||||
proportion = (value - minimum) / (maximum - minimum)
|
||||
sections = min(max(int(proportion * width), 0), width)
|
||||
|
||||
bar = []
|
||||
# Starting segment
|
||||
bar.append(str(emojis.progress_left_empty) if sections == 0 else str(emojis.progress_left_full))
|
||||
|
||||
# Full segments up to transition or end
|
||||
if sections >= 2:
|
||||
bar.append(str(emojis.progress_middle_full) * (sections - 2))
|
||||
|
||||
# Transition, if required
|
||||
if 1 < sections < width:
|
||||
bar.append(str(emojis.progress_middle_transition))
|
||||
|
||||
# Empty sections up to end
|
||||
if sections < width:
|
||||
bar.append(str(emojis.progress_middle_empty) * (width - max(sections, 1) - 1))
|
||||
|
||||
# End section
|
||||
bar.append(str(emojis.progress_right_empty) if sections < width else str(emojis.progress_right_full))
|
||||
|
||||
# Join all the sections together and return
|
||||
return ''.join(bar)
|
||||
|
||||
@property
|
||||
def progress_text(self) -> str:
|
||||
"""
|
||||
A brief textual description of the current progress.
|
||||
Intended to be overridden by achievement implementations.
|
||||
"""
|
||||
return f"{int(self.value)}/{self.next_level.threshold if self.next_level else self.level.threshold}"
|
||||
|
||||
def progress_field(self) -> tuple[str, str]:
|
||||
"""
|
||||
Builds the progress field for the achievement display.
|
||||
"""
|
||||
# TODO: Not adjusted for levels
|
||||
# TODO: Add hint if progress is empty?
|
||||
name = f"{self.levels[1].emoji} {self.name} ({self.progress_text})"
|
||||
value = "**0** {progress_bar} **{threshold}**\n*{subtext}*".format(
|
||||
subtext=(self.subtext if self.next_level else self.congrats_text) or '',
|
||||
progress_bar=self.progress_bar(self.value, self.levels[0].threshold, self.levels[1].threshold),
|
||||
threshold=self.levels[1].threshold
|
||||
)
|
||||
return (name, value)
|
||||
|
||||
@classmethod
|
||||
async def fetch(cls, guildid: int, userid: int) -> 'Achievement':
|
||||
"""
|
||||
Fetch an Achievement status for the given member.
|
||||
"""
|
||||
return await cls(guildid, userid).update()
|
||||
|
||||
@property
|
||||
def level(self) -> AchievementLevel:
|
||||
"""
|
||||
The current `AchievementLevel` for this member achievement.
|
||||
"""
|
||||
if self.level_id is None:
|
||||
raise ValueError("Cannot obtain level before first update!")
|
||||
return self.levels[self.level_id]
|
||||
|
||||
@property
|
||||
def next_level(self) -> Optional[AchievementLevel]:
|
||||
"""
|
||||
The next `AchievementLevel` for this member achievement,
|
||||
or `None` if it is at the maximum level.
|
||||
"""
|
||||
if self.level_id is None:
|
||||
raise ValueError("Cannot obtain level before first update!")
|
||||
|
||||
if self.level_id == len(self.levels) - 1:
|
||||
return None
|
||||
else:
|
||||
return self.levels[self.level_id + 1]
|
||||
|
||||
async def update(self) -> 'Achievement':
|
||||
"""
|
||||
Calculate and store the current member achievement status.
|
||||
Returns `self` for easy chaining.
|
||||
"""
|
||||
# First fetch the value
|
||||
self.value = await self._calculate_value()
|
||||
|
||||
# Then determine the current level
|
||||
# Using 0 as a fallback in case the value is negative
|
||||
self.level_id = next(
|
||||
(i for i, level in reversed(list(enumerate(self.levels))) if level.threshold <= self.value),
|
||||
0
|
||||
)
|
||||
|
||||
# And return `self` for chaining
|
||||
return self
|
||||
|
||||
async def _calculate_value(self) -> Union[int, float]:
|
||||
"""
|
||||
Calculate the current `value` of the member achievement.
|
||||
Must be overridden by Achievement implementations.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Workout(Achievement):
|
||||
sorting_index = 8
|
||||
emoji_index = 4
|
||||
name = "It's about Power"
|
||||
subtext = "Workout 50 times"
|
||||
|
||||
levels = [
|
||||
AchievementLevel("Level 0", 0, None),
|
||||
AchievementLevel("Level 1", 50, conf.emojis.active_achievement_4),
|
||||
]
|
||||
|
||||
async def _calculate_value(self) -> int:
|
||||
"""
|
||||
Returns the total number of workouts from this user.
|
||||
"""
|
||||
return client.data.workout_sessions.select_one_where(
|
||||
userid=self.userid,
|
||||
select_columns="COUNT(*)"
|
||||
)[0]
|
||||
|
||||
|
||||
class StudyHours(Achievement):
|
||||
sorting_index = 1
|
||||
emoji_index = 1
|
||||
name = "Dream Big"
|
||||
subtext = "Study a total of 1000 hours"
|
||||
|
||||
levels = [
|
||||
AchievementLevel("Level 0", 0, None),
|
||||
AchievementLevel("Level 1", 1000, conf.emojis.active_achievement_1),
|
||||
]
|
||||
|
||||
async def _calculate_value(self) -> float:
|
||||
"""
|
||||
Returns the total number of hours this user has studied.
|
||||
"""
|
||||
past_session_total = client.data.session_history.select_one_where(
|
||||
userid=self.userid,
|
||||
select_columns="SUM(duration)"
|
||||
)[0] or 0
|
||||
current_session_total = client.data.current_sessions.select_one_where(
|
||||
userid=self.userid,
|
||||
select_columns="SUM(EXTRACT(EPOCH FROM (NOW() - start_time)))"
|
||||
)[0] or 0
|
||||
|
||||
session_total = past_session_total + current_session_total
|
||||
hours = session_total / 3600
|
||||
return hours
|
||||
|
||||
|
||||
class StudyStreak(Achievement):
|
||||
sorting_index = 2
|
||||
emoji_index = 2
|
||||
name = "Consistency is Key"
|
||||
subtext = "Reach a 100-day study streak"
|
||||
|
||||
levels = [
|
||||
AchievementLevel("Level 0", 0, None),
|
||||
AchievementLevel("Level 1", 100, conf.emojis.active_achievement_2)
|
||||
]
|
||||
|
||||
async def _calculate_value(self) -> int:
|
||||
"""
|
||||
Return the user's maximum global study streak.
|
||||
"""
|
||||
lion = Lion.fetch(self.guildid, self.userid)
|
||||
history = client.data.session_history.select_where(
|
||||
userid=self.userid,
|
||||
select_columns=(
|
||||
"start_time",
|
||||
"(start_time + duration * interval '1 second') AS end_time"
|
||||
),
|
||||
_extra="ORDER BY start_time DESC"
|
||||
)
|
||||
|
||||
# Streak statistics
|
||||
streak = 0
|
||||
max_streak = 0
|
||||
|
||||
day_attended = True if 'sessions' in client.objects and lion.session else None
|
||||
date = lion.day_start
|
||||
daydiff = timedelta(days=1)
|
||||
|
||||
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
|
||||
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)
|
||||
streak = 0
|
||||
|
||||
# Handle loop exit state, i.e. the last streak
|
||||
if day_attended:
|
||||
streak += 1
|
||||
max_streak = max(max_streak, streak)
|
||||
|
||||
return max_streak
|
||||
|
||||
|
||||
class Voting(Achievement):
|
||||
sorting_index = 7
|
||||
emoji_index = 7
|
||||
name = "We're a Team"
|
||||
subtext = "[Vote]({}) 100 times on top.gg".format(topgg_upvote_link)
|
||||
|
||||
levels = [
|
||||
AchievementLevel("Level 0", 0, None),
|
||||
AchievementLevel("Level 1", 100, conf.emojis.active_achievement_7)
|
||||
]
|
||||
|
||||
async def _calculate_value(self) -> int:
|
||||
"""
|
||||
Returns the number of times the user has voted for the bot.
|
||||
"""
|
||||
return client.data.topgg.select_one_where(
|
||||
userid=self.userid,
|
||||
select_columns="COUNT(*)"
|
||||
)[0]
|
||||
|
||||
|
||||
class DaysStudying(Achievement):
|
||||
sorting_index = 3
|
||||
emoji_index = 3
|
||||
name = "Aim For The Moon"
|
||||
subtext = "Study on 90 different days"
|
||||
|
||||
levels = [
|
||||
AchievementLevel("Level 0", 0, None),
|
||||
AchievementLevel("Level 1", 90, conf.emojis.active_achievement_3)
|
||||
]
|
||||
|
||||
async def _calculate_value(self) -> int:
|
||||
"""
|
||||
Returns the number of days the user has studied in total.
|
||||
"""
|
||||
lion = Lion.fetch(self.guildid, self.userid)
|
||||
offset = int(lion.day_start.utcoffset().total_seconds())
|
||||
with client.data.session_history.conn as conn:
|
||||
cursor = conn.cursor()
|
||||
# TODO: Consider DST offset.
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(DISTINCT(date_trunc('day', (time AT TIME ZONE 'utc') + interval '{} seconds')))
|
||||
FROM (
|
||||
(SELECT start_time AS time FROM session_history WHERE userid=%s)
|
||||
UNION
|
||||
(SELECT (start_time + duration * interval '1 second') AS time FROM session_history WHERE userid=%s)
|
||||
) AS times;
|
||||
""".format(offset),
|
||||
(self.userid, self.userid)
|
||||
)
|
||||
data = cursor.fetchone()
|
||||
return data[0]
|
||||
|
||||
|
||||
class TasksComplete(Achievement):
|
||||
sorting_index = 4
|
||||
emoji_index = 8
|
||||
name = "One Step at a Time"
|
||||
subtext = "Complete 1000 tasks"
|
||||
|
||||
levels = [
|
||||
AchievementLevel("Level 0", 0, None),
|
||||
AchievementLevel("Level 1", 1000, conf.emojis.active_achievement_8)
|
||||
]
|
||||
|
||||
async def _calculate_value(self) -> int:
|
||||
"""
|
||||
Returns the number of tasks the user has completed.
|
||||
"""
|
||||
return client.data.tasklist.select_one_where(
|
||||
userid=self.userid,
|
||||
completed_at=NOTNULL,
|
||||
select_columns="COUNT(*)"
|
||||
)[0]
|
||||
|
||||
|
||||
class ScheduledSessions(Achievement):
|
||||
sorting_index = 5
|
||||
emoji_index = 5
|
||||
name = "Be Accountable"
|
||||
subtext = "Attend 500 scheduled sessions"
|
||||
|
||||
levels = [
|
||||
AchievementLevel("Level 0", 0, None),
|
||||
AchievementLevel("Level 1", 500, conf.emojis.active_achievement_5)
|
||||
]
|
||||
|
||||
async def _calculate_value(self) -> int:
|
||||
"""
|
||||
Returns the number of scheduled sesions the user has attended.
|
||||
"""
|
||||
return client.data.accountability_member_info.select_one_where(
|
||||
userid=self.userid,
|
||||
start_at=LEQ(utc_now()),
|
||||
select_columns="COUNT(*)",
|
||||
_extra="AND (duration > 0 OR last_joined_at IS NOT NULL)"
|
||||
)[0]
|
||||
|
||||
|
||||
class MonthlyHours(Achievement):
|
||||
sorting_index = 6
|
||||
emoji_index = 6
|
||||
name = "The 30 Days Challenge"
|
||||
subtext = "Study 100 hours in 30 days"
|
||||
|
||||
levels = [
|
||||
AchievementLevel("Level 0", 0, None),
|
||||
AchievementLevel("Level 1", 100, conf.emojis.active_achievement_6)
|
||||
]
|
||||
|
||||
async def _calculate_value(self) -> float:
|
||||
"""
|
||||
Returns the maximum number of hours the user has studied in a month.
|
||||
"""
|
||||
# Get the first session so we know how far back to look
|
||||
first_session = client.data.session_history.select_one_where(
|
||||
userid=self.userid,
|
||||
select_columns="MIN(start_time)"
|
||||
)[0]
|
||||
|
||||
# Get the user's timezone
|
||||
lion = Lion.fetch(self.guildid, self.userid)
|
||||
|
||||
# If the first session doesn't exist, simulate an existing session (to avoid an extra lookup)
|
||||
first_session = first_session or lion.day_start - timedelta(days=1)
|
||||
|
||||
# Build the list of month start timestamps
|
||||
month_start = lion.day_start.replace(day=1)
|
||||
months = [month_start.astimezone(pytz.utc)]
|
||||
|
||||
while month_start >= first_session:
|
||||
month_start -= timedelta(days=1)
|
||||
month_start = month_start.replace(day=1)
|
||||
months.append(month_start.astimezone(pytz.utc))
|
||||
|
||||
# Query the study times
|
||||
data = client.data.session_history.queries.study_times_since(
|
||||
self.guildid, self.userid, *months
|
||||
)
|
||||
cumulative_times = [row[0] or 0 for row in data]
|
||||
times = [nxt - crt for nxt, crt in zip(cumulative_times[1:], cumulative_times[0:])]
|
||||
max_time = max(cumulative_times[0], *times) if len(months) > 1 else cumulative_times[0]
|
||||
|
||||
return max_time / 3600
|
||||
|
||||
|
||||
# Define the displayed achivement order
|
||||
achievements = [
|
||||
Workout,
|
||||
StudyHours,
|
||||
StudyStreak,
|
||||
Voting,
|
||||
DaysStudying,
|
||||
TasksComplete,
|
||||
ScheduledSessions,
|
||||
MonthlyHours
|
||||
]
|
||||
|
||||
|
||||
async def get_achievements_for(member, panel_sort=False):
|
||||
status = [
|
||||
await ach.fetch(member.guild.id, member.id)
|
||||
for ach in sorted(achievements, key=lambda cls: (cls.sorting_index if panel_sort else cls.emoji_index))
|
||||
]
|
||||
return status
|
||||
|
||||
|
||||
@module.cmd(
|
||||
name="achievements",
|
||||
desc="View your progress towards the achievements!",
|
||||
group="Statistics",
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_achievements(ctx: LionContext):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}achievements
|
||||
Description:
|
||||
View your progress towards attaining the achievement badges shown on your `profile`.
|
||||
"""
|
||||
status = await get_achievements_for(ctx.author, panel_sort=True)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Achievements",
|
||||
colour=discord.Colour.orange()
|
||||
)
|
||||
for achievement in status:
|
||||
name, value = achievement.progress_field()
|
||||
embed.add_field(
|
||||
name=name, value=value, inline=False
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
39
bot/modules/pending-rewrite/stats/data.py
Normal file
39
bot/modules/pending-rewrite/stats/data.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from cachetools import TTLCache
|
||||
|
||||
from data import Table, RowTable
|
||||
|
||||
|
||||
profile_tags = Table('member_profile_tags', attach_as='profile_tags')
|
||||
|
||||
|
||||
@profile_tags.save_query
|
||||
def get_tags_for(guildid, userid):
|
||||
rows = profile_tags.select_where(
|
||||
guildid=guildid, userid=userid,
|
||||
_extra="ORDER BY tagid ASC"
|
||||
)
|
||||
return [row['tag'] for row in rows]
|
||||
|
||||
|
||||
weekly_goals = RowTable(
|
||||
'member_weekly_goals',
|
||||
('guildid', 'userid', 'weekid', 'study_goal', 'task_goal'),
|
||||
('guildid', 'userid', 'weekid'),
|
||||
cache=TTLCache(5000, 60 * 60 * 24),
|
||||
attach_as='weekly_goals'
|
||||
)
|
||||
|
||||
|
||||
# NOTE: Not using a RowTable here since these will almost always be mass-selected
|
||||
weekly_tasks = Table('member_weekly_goal_tasks')
|
||||
|
||||
|
||||
monthly_goals = RowTable(
|
||||
'member_monthly_goals',
|
||||
('guildid', 'userid', 'monthid', 'study_goal', 'task_goal'),
|
||||
('guildid', 'userid', 'monthid'),
|
||||
cache=TTLCache(5000, 60 * 60 * 24),
|
||||
attach_as='monthly_goals'
|
||||
)
|
||||
|
||||
monthly_tasks = Table('member_monthly_goal_tasks')
|
||||
332
bot/modules/pending-rewrite/stats/goals.py
Normal file
332
bot/modules/pending-rewrite/stats/goals.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
Weekly and Monthly goal display and edit interface.
|
||||
"""
|
||||
from enum import Enum
|
||||
import discord
|
||||
|
||||
from cmdClient.checks import in_guild
|
||||
from cmdClient.lib import SafeCancellation
|
||||
|
||||
from utils.lib import parse_ranges
|
||||
|
||||
from .module import module
|
||||
from .data import weekly_goals, weekly_tasks, monthly_goals, monthly_tasks
|
||||
|
||||
|
||||
MAX_LENGTH = 200
|
||||
MAX_TASKS = 10
|
||||
|
||||
|
||||
class GoalType(Enum):
|
||||
WEEKLY = 0
|
||||
MONTHLY = 1
|
||||
|
||||
|
||||
def index_range_parser(userstr, max):
|
||||
try:
|
||||
indexes = parse_ranges(userstr)
|
||||
except SafeCancellation:
|
||||
raise SafeCancellation(
|
||||
"Couldn't parse the provided task ids! "
|
||||
"Please list the task numbers or ranges separated by a comma, e.g. `0, 2-4`."
|
||||
) from None
|
||||
|
||||
return [index for index in indexes if index <= max]
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"weeklygoals",
|
||||
group="Statistics",
|
||||
desc="Set your weekly goals and view your progress!",
|
||||
aliases=('weeklygoal',),
|
||||
flags=('study=', 'tasks=')
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_weeklygoals(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}weeklygoals [--study <hours>] [--tasks <number>]
|
||||
{prefix}weeklygoals add <task>
|
||||
{prefix}weeklygoals edit <taskid> <new task>
|
||||
{prefix}weeklygoals check <taskids>
|
||||
{prefix}weeklygoals remove <taskids>
|
||||
Description:
|
||||
Set yourself up to `10` goals for this week and keep yourself accountable!
|
||||
Use `add/edit/check/remove` to edit your goals, similarly to `{prefix}todo`.
|
||||
You can also add multiple tasks at once by writing them on multiple lines.
|
||||
|
||||
You can also track your progress towards a number of hours studied with `--study`, \
|
||||
and aim for a number of tasks completed with `--tasks`.
|
||||
|
||||
Run the command with no arguments or check your profile to see your progress!
|
||||
Examples``:
|
||||
{prefix}weeklygoals add Read chapters 1 to 10.
|
||||
{prefix}weeklygoals check 1
|
||||
{prefix}weeklygoals --study 48h --tasks 60
|
||||
"""
|
||||
await goals_command(ctx, flags, GoalType.WEEKLY)
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"monthlygoals",
|
||||
group="Statistics",
|
||||
desc="Set your monthly goals and view your progress!",
|
||||
aliases=('monthlygoal',),
|
||||
flags=('study=', 'tasks=')
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_monthlygoals(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}monthlygoals [--study <hours>] [--tasks <number>]
|
||||
{prefix}monthlygoals add <task>
|
||||
{prefix}monthlygoals edit <taskid> <new task>
|
||||
{prefix}monthlygoals check <taskids>
|
||||
{prefix}monthlygoals uncheck <taskids>
|
||||
{prefix}monthlygoals remove <taskids>
|
||||
Description:
|
||||
Set yourself up to `10` goals for this month and keep yourself accountable!
|
||||
Use `add/edit/check/remove` to edit your goals, similarly to `{prefix}todo`.
|
||||
You can also add multiple tasks at once by writing them on multiple lines.
|
||||
|
||||
You can also track your progress towards a number of hours studied with `--study`, \
|
||||
and aim for a number of tasks completed with `--tasks`.
|
||||
|
||||
Run the command with no arguments or check your profile to see your progress!
|
||||
Examples``:
|
||||
{prefix}monthlygoals add Read chapters 1 to 10.
|
||||
{prefix}monthlygoals check 1
|
||||
{prefix}monthlygoals --study 180h --tasks 60
|
||||
"""
|
||||
await goals_command(ctx, flags, GoalType.MONTHLY)
|
||||
|
||||
|
||||
async def goals_command(ctx, flags, goal_type):
|
||||
prefix = ctx.best_prefix
|
||||
if goal_type == GoalType.WEEKLY:
|
||||
name = 'week'
|
||||
goal_table = weekly_goals
|
||||
task_table = weekly_tasks
|
||||
rowkey = 'weekid'
|
||||
rowid = ctx.alion.week_timestamp
|
||||
|
||||
tasklist = task_table.select_where(
|
||||
guildid=ctx.guild.id,
|
||||
userid=ctx.author.id,
|
||||
weekid=rowid,
|
||||
_extra="ORDER BY taskid ASC"
|
||||
)
|
||||
|
||||
max_time = 7 * 16
|
||||
else:
|
||||
name = 'month'
|
||||
goal_table = monthly_goals
|
||||
task_table = monthly_tasks
|
||||
rowid = ctx.alion.month_timestamp
|
||||
rowkey = 'monthid'
|
||||
|
||||
tasklist = task_table.select_where(
|
||||
guildid=ctx.guild.id,
|
||||
userid=ctx.author.id,
|
||||
monthid=rowid,
|
||||
_extra="ORDER BY taskid ASC"
|
||||
)
|
||||
|
||||
max_time = 31 * 16
|
||||
|
||||
# We ensured the `lion` existed with `ctx.alion` above
|
||||
# This also ensures a new tasklist can reference the period member goal key
|
||||
# TODO: Should creation copy the previous existing week?
|
||||
goal_row = goal_table.fetch_or_create((ctx.guild.id, ctx.author.id, rowid))
|
||||
|
||||
if flags['study']:
|
||||
# Set study hour goal
|
||||
time = flags['study'].lower().strip('h ')
|
||||
if not time or not time.isdigit():
|
||||
return await ctx.error_reply(
|
||||
f"Please provide your {name}ly study goal in hours!\n"
|
||||
f"For example, `{prefix}{ctx.alias} --study 48h`"
|
||||
)
|
||||
hours = int(time)
|
||||
if hours > max_time:
|
||||
return await ctx.error_reply(
|
||||
"You can't set your goal this high! Please rest and keep a healthy lifestyle."
|
||||
)
|
||||
|
||||
goal_row.study_goal = hours
|
||||
|
||||
if flags['tasks']:
|
||||
# Set tasks completed goal
|
||||
count = flags['tasks']
|
||||
if not count or not count.isdigit():
|
||||
return await ctx.error_reply(
|
||||
f"Please provide the number of tasks you want to complete this {name}!\n"
|
||||
f"For example, `{prefix}{ctx.alias} --tasks 300`"
|
||||
)
|
||||
if int(count) > 2048:
|
||||
return await ctx.error_reply(
|
||||
"Your task goal is too high!"
|
||||
)
|
||||
goal_row.task_goal = int(count)
|
||||
|
||||
if ctx.args:
|
||||
# If there are arguments, assume task/goal management
|
||||
# Extract the command if it exists, assume add operation if it doesn't
|
||||
splits = ctx.args.split(maxsplit=1)
|
||||
cmd = splits[0].lower().strip()
|
||||
args = splits[1].strip() if len(splits) > 1 else ''
|
||||
|
||||
if cmd in ('check', 'done', 'complete'):
|
||||
if not args:
|
||||
# Show subcommand usage
|
||||
return await ctx.error_reply(
|
||||
f"**Usage:**`{prefix}{ctx.alias} check <taskids>`\n"
|
||||
f"**Example:**`{prefix}{ctx.alias} check 0, 2-4`"
|
||||
)
|
||||
if (indexes := index_range_parser(args, len(tasklist) - 1)):
|
||||
# Check the given indexes
|
||||
# If there are no valid indexes given, just do nothing and fall out to showing the goals
|
||||
task_table.update_where(
|
||||
{'completed': True},
|
||||
taskid=[tasklist[index]['taskid'] for index in indexes]
|
||||
)
|
||||
elif cmd in ('uncheck', 'undone', 'uncomplete'):
|
||||
if not args:
|
||||
# Show subcommand usage
|
||||
return await ctx.error_reply(
|
||||
f"**Usage:**`{prefix}{ctx.alias} uncheck <taskids>`\n"
|
||||
f"**Example:**`{prefix}{ctx.alias} uncheck 0, 2-4`"
|
||||
)
|
||||
if (indexes := index_range_parser(args, len(tasklist) - 1)):
|
||||
# Check the given indexes
|
||||
# If there are no valid indexes given, just do nothing and fall out to showing the goals
|
||||
task_table.update_where(
|
||||
{'completed': False},
|
||||
taskid=[tasklist[index]['taskid'] for index in indexes]
|
||||
)
|
||||
elif cmd in ('remove', 'delete', '-', 'rm'):
|
||||
if not args:
|
||||
# Show subcommand usage
|
||||
return await ctx.error_reply(
|
||||
f"**Usage:**`{prefix}{ctx.alias} remove <taskids>`\n"
|
||||
f"**Example:**`{prefix}{ctx.alias} remove 0, 2-4`"
|
||||
)
|
||||
if (indexes := index_range_parser(args, len(tasklist) - 1)):
|
||||
# Delete the given indexes
|
||||
# If there are no valid indexes given, just do nothing and fall out to showing the goals
|
||||
task_table.delete_where(
|
||||
taskid=[tasklist[index]['taskid'] for index in indexes]
|
||||
)
|
||||
elif cmd == 'edit':
|
||||
if not args or len(splits := args.split(maxsplit=1)) < 2 or not splits[0].isdigit():
|
||||
# Show subcommand usage
|
||||
return await ctx.error_reply(
|
||||
f"**Usage:**`{prefix}{ctx.alias} edit <taskid> <edited task>`\n"
|
||||
f"**Example:**`{prefix}{ctx.alias} edit 2 Fix the scond task`"
|
||||
)
|
||||
index = int(splits[0])
|
||||
new_content = splits[1].strip()
|
||||
|
||||
if index >= len(tasklist):
|
||||
return await ctx.error_reply(
|
||||
f"Task `{index}` doesn't exist to edit!"
|
||||
)
|
||||
|
||||
if len(new_content) > MAX_LENGTH:
|
||||
return await ctx.error_reply(
|
||||
f"Please keep your goals under `{MAX_LENGTH}` characters long."
|
||||
)
|
||||
|
||||
# Passed all checks, edit task
|
||||
task_table.update_where(
|
||||
{'content': new_content},
|
||||
taskid=tasklist[index]['taskid']
|
||||
)
|
||||
else:
|
||||
# Extract the tasks to add
|
||||
if cmd in ('add', '+'):
|
||||
if not args:
|
||||
# Show subcommand usage
|
||||
return await ctx.error_reply(
|
||||
f"**Usage:**`{prefix}{ctx.alias} [add] <new task>`\n"
|
||||
f"**Example:**`{prefix}{ctx.alias} add Read the Studylion help pages.`"
|
||||
)
|
||||
else:
|
||||
args = ctx.args
|
||||
tasks = args.splitlines()
|
||||
|
||||
# Check count
|
||||
if len(tasklist) + len(tasks) > MAX_TASKS:
|
||||
return await ctx.error_reply(
|
||||
f"You can have at most **{MAX_TASKS}** {name}ly goals!"
|
||||
)
|
||||
|
||||
# Check length
|
||||
if any(len(task) > MAX_LENGTH for task in tasks):
|
||||
return await ctx.error_reply(
|
||||
f"Please keep your goals under `{MAX_LENGTH}` characters long."
|
||||
)
|
||||
|
||||
# We passed the checks, add the tasks
|
||||
to_insert = [
|
||||
(ctx.guild.id, ctx.author.id, rowid, task)
|
||||
for task in tasks
|
||||
]
|
||||
task_table.insert_many(
|
||||
*to_insert,
|
||||
insert_keys=('guildid', 'userid', rowkey, 'content')
|
||||
)
|
||||
elif not any((goal_row.study_goal, goal_row.task_goal, tasklist)):
|
||||
# The user hasn't set any goals for this time period
|
||||
# Prompt them with information about how to set a goal
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title=f"**You haven't set any goals for this {name} yet! Try the following:**\n"
|
||||
)
|
||||
embed.add_field(
|
||||
name="Aim for a number of study hours with",
|
||||
value=f"`{prefix}{ctx.alias} --study 48h`"
|
||||
)
|
||||
embed.add_field(
|
||||
name="Aim for a number of tasks completed with",
|
||||
value=f"`{prefix}{ctx.alias} --tasks 300`",
|
||||
inline=False
|
||||
)
|
||||
embed.add_field(
|
||||
name=f"Set up to 10 custom goals for the {name}!",
|
||||
value=(
|
||||
f"`{prefix}{ctx.alias} add Write a 200 page thesis.`\n"
|
||||
f"`{prefix}{ctx.alias} edit 1 Write 2 pages of the 200 page thesis.`\n"
|
||||
f"`{prefix}{ctx.alias} done 0, 1, 3-4`\n"
|
||||
f"`{prefix}{ctx.alias} delete 2-4`"
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
return await ctx.reply(embed=embed)
|
||||
|
||||
# Show the goals
|
||||
if goal_type == GoalType.WEEKLY:
|
||||
await display_weekly_goals_for(ctx)
|
||||
else:
|
||||
await display_monthly_goals_for(ctx)
|
||||
|
||||
|
||||
async def display_weekly_goals_for(ctx):
|
||||
"""
|
||||
Display the user's weekly goal summary and progress towards them
|
||||
TODO: Currently a stub, since the system is overidden by the GUI plugin
|
||||
"""
|
||||
# Collect data
|
||||
lion = ctx.alion
|
||||
rowid = lion.week_timestamp
|
||||
goals = weekly_goals.fetch_or_create((ctx.guild.id, ctx.author.id, rowid))
|
||||
tasklist = weekly_tasks.select_where(
|
||||
guildid=ctx.guild.id,
|
||||
userid=ctx.author.id,
|
||||
weekid=rowid
|
||||
)
|
||||
...
|
||||
|
||||
|
||||
async def display_monthly_goals_for(ctx):
|
||||
...
|
||||
4
bot/modules/pending-rewrite/stats/module.py
Normal file
4
bot/modules/pending-rewrite/stats/module.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
|
||||
module = LionModule("Statistics")
|
||||
266
bot/modules/pending-rewrite/stats/profile.py
Normal file
266
bot/modules/pending-rewrite/stats/profile.py
Normal file
@@ -0,0 +1,266 @@
|
||||
from datetime import datetime, timedelta
|
||||
import discord
|
||||
from cmdClient.checks import in_guild
|
||||
|
||||
from utils.lib import prop_tabulate, utc_now
|
||||
from data import tables
|
||||
from data.conditions import LEQ
|
||||
from core import Lion
|
||||
|
||||
from modules.study.tracking.data import session_history
|
||||
|
||||
from .module import module
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"stats",
|
||||
group="Statistics",
|
||||
desc="View your personal server study statistics!",
|
||||
aliases=('profile',),
|
||||
allow_before_ready=True
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_stats(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}stats
|
||||
{prefix}stats <user mention>
|
||||
Description:
|
||||
View the study statistics for yourself or 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 statistics!")
|
||||
target = ctx.msg.mentions[0]
|
||||
else:
|
||||
target = ctx.author
|
||||
|
||||
# System sync
|
||||
Lion.sync()
|
||||
|
||||
# Fetch the required data
|
||||
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)
|
||||
coins = lion.coins
|
||||
season_time = lion.time
|
||||
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.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
|
||||
period_timestamps = (
|
||||
datetime(1970, 1, 1),
|
||||
day_start.replace(day=1),
|
||||
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 statistics
|
||||
streak = 0
|
||||
current_streak = None
|
||||
max_streak = 0
|
||||
|
||||
day_attended = True if 'sessions' in ctx.client.objects and lion.session else None
|
||||
date = day_start
|
||||
daydiff = timedelta(days=1)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
# Accountability stats
|
||||
accountability = tables.accountability_member_info.select_where(
|
||||
userid=target.id,
|
||||
start_at=LEQ(utc_now()),
|
||||
select_columns=("*", "(duration > 0 OR last_joined_at IS NOT NULL) AS attended"),
|
||||
_extra="ORDER BY start_at DESC"
|
||||
)
|
||||
if len(accountability):
|
||||
acc_duration = sum(row['duration'] for row in accountability)
|
||||
|
||||
acc_attended = sum(row['attended'] for row in accountability)
|
||||
acc_total = len(accountability)
|
||||
acc_rate = (acc_attended * 100) / acc_total
|
||||
else:
|
||||
acc_duration = 0
|
||||
acc_rate = 0
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# We have all the data
|
||||
# Now start building the embed
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title="Study Profile for {}".format(str(target))
|
||||
)
|
||||
embed.set_thumbnail(url=target.avatar_url)
|
||||
|
||||
# Add studying since if they have studied
|
||||
if history:
|
||||
embed.set_footer(text="Studying Since")
|
||||
embed.timestamp = history[-1]['start_time']
|
||||
|
||||
# Set the description based on season time and server rank
|
||||
if season_time:
|
||||
time_str = "**{}:{:02}**".format(
|
||||
season_time // 3600,
|
||||
(season_time // 60) % 60
|
||||
)
|
||||
if time_rank is None:
|
||||
rank_str = None
|
||||
elif time_rank == 1:
|
||||
rank_str = "1st"
|
||||
elif time_rank == 2:
|
||||
rank_str = "2nd"
|
||||
elif time_rank == 3:
|
||||
rank_str = "3rd"
|
||||
else:
|
||||
rank_str = "{}th".format(time_rank)
|
||||
|
||||
embed.description = "{} has studied for **{}**{}{}".format(
|
||||
target.mention,
|
||||
time_str,
|
||||
" this season" if study_times[0] - season_time > 60 else "",
|
||||
", and is ranked **{}** in the server!".format(rank_str) if rank_str else "."
|
||||
)
|
||||
else:
|
||||
embed.description = "{} hasn't studied in this server yet!".format(target.mention)
|
||||
|
||||
# Build the stats table
|
||||
stats = {}
|
||||
|
||||
stats['Coins Earned'] = "**{}** LC".format(
|
||||
coins,
|
||||
# "Rank `{}`".format(coin_rank) if coins and coin_rank else "Unranked"
|
||||
)
|
||||
if workout_total:
|
||||
stats['Workouts'] = "**{}** sessions".format(workout_total)
|
||||
if acc_duration:
|
||||
stats['Accountability'] = "**{}** hours (`{:.0f}%` attended)".format(
|
||||
acc_duration // 3600,
|
||||
acc_rate
|
||||
)
|
||||
stats['Study Streak'] = "**{}** days{}".format(
|
||||
current_streak,
|
||||
" (longest **{}** days)".format(max_streak) if max_streak else ''
|
||||
)
|
||||
|
||||
stats_table = prop_tabulate(*zip(*stats.items()))
|
||||
|
||||
# Build the time table
|
||||
time_table = prop_tabulate(
|
||||
('Daily', 'Weekly', 'Monthly', 'All Time'),
|
||||
["{:02}:{:02}".format(t // 3600, (t // 60) % 60) for t in reversed(study_times)]
|
||||
)
|
||||
|
||||
# Populate the embed
|
||||
embed.add_field(name="Study Time", value=time_table)
|
||||
embed.add_field(name="Statistics", value=stats_table)
|
||||
|
||||
# Add the study league field
|
||||
if current_badge or next_badge:
|
||||
current_str = (
|
||||
"You are currently in <@&{}>!".format(current_badge.roleid) if current_badge else "No league yet!"
|
||||
)
|
||||
if next_badge:
|
||||
needed = max(next_badge.required_time - season_time, 0)
|
||||
next_str = "Study for **{:02}:{:02}** more to achieve <@&{}>.".format(
|
||||
needed // 3600,
|
||||
(needed // 60) % 60,
|
||||
next_badge.roleid
|
||||
)
|
||||
else:
|
||||
next_str = "You have reached the highest league! Congratulations!"
|
||||
embed.add_field(
|
||||
name="Study League",
|
||||
value="{}\n{}".format(current_str, next_str),
|
||||
inline=False
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
227
bot/modules/pending-rewrite/stats/setprofile.py
Normal file
227
bot/modules/pending-rewrite/stats/setprofile.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Provides a command to update a member's profile badges.
|
||||
"""
|
||||
import string
|
||||
import discord
|
||||
|
||||
from cmdClient.lib import SafeCancellation
|
||||
from cmdClient.checks import in_guild
|
||||
from wards import guild_moderator
|
||||
|
||||
from .data import profile_tags
|
||||
from .module import module
|
||||
|
||||
|
||||
MAX_TAGS = 10
|
||||
MAX_LENGTH = 30
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"setprofile",
|
||||
group="Personal Settings",
|
||||
desc="Set or update your study profile tags.",
|
||||
aliases=('editprofile', 'mytags'),
|
||||
flags=('clear', 'for')
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_setprofile(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}setprofile <tag>, <tag>, <tag>, ...
|
||||
{prefix}setprofile <id> <new tag>
|
||||
{prefix}setprofile --clear [--for @user]
|
||||
Description:
|
||||
Set or update the tags appearing in your study server profile.
|
||||
|
||||
Moderators can clear a user's tags with `--clear --for @user`.
|
||||
Examples``:
|
||||
{prefix}setprofile Mathematics, Bioloyg, Medicine, Undergraduate, Europe
|
||||
{prefix}setprofile 2 Biology
|
||||
{prefix}setprofile --clear
|
||||
"""
|
||||
if flags['clear']:
|
||||
if flags['for']:
|
||||
# Moderator-clearing a user's tags
|
||||
# First check moderator permissions
|
||||
if not await guild_moderator.run(ctx):
|
||||
return await ctx.error_reply(
|
||||
"You need to be a server moderator to use this!"
|
||||
)
|
||||
|
||||
# Check input and extract users to clear for
|
||||
if not (users := ctx.msg.mentions):
|
||||
# Show moderator usage
|
||||
return await ctx.error_reply(
|
||||
f"**Usage:** `{ctx.best_prefix}setprofile --clear --for @user`\n"
|
||||
f"**Example:** {ctx.best_prefix}setprofile --clear --for {ctx.author.mention}"
|
||||
)
|
||||
|
||||
# Clear the tags
|
||||
profile_tags.delete_where(
|
||||
guildid=ctx.guild.id,
|
||||
userid=[user.id for user in users]
|
||||
)
|
||||
|
||||
# Ack the moderator
|
||||
await ctx.embed_reply(
|
||||
"Profile tags cleared!"
|
||||
)
|
||||
else:
|
||||
# The author wants to clear their own tags
|
||||
|
||||
# First delete the tags, save the rows for reporting
|
||||
rows = profile_tags.delete_where(
|
||||
guildid=ctx.guild.id,
|
||||
userid=ctx.author.id
|
||||
)
|
||||
|
||||
# Ack the user
|
||||
if not rows:
|
||||
await ctx.embed_reply(
|
||||
"You don't have any profile tags to clear!"
|
||||
)
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.green(),
|
||||
description="Successfully cleared your profile!"
|
||||
)
|
||||
embed.add_field(
|
||||
name="Removed tags",
|
||||
value='\n'.join(row['tag'].upper() for row in rows)
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
elif ctx.args:
|
||||
if len(splits := ctx.args.split(maxsplit=1)) > 1 and splits[0].isdigit():
|
||||
# Assume we are editing the provided id
|
||||
tagid = int(splits[0])
|
||||
if tagid > MAX_TAGS:
|
||||
return await ctx.error_reply(
|
||||
f"Sorry, you can have a maximum of `{MAX_TAGS}` tags!"
|
||||
)
|
||||
if tagid == 0:
|
||||
return await ctx.error_reply("Tags start at `1`!")
|
||||
|
||||
# Retrieve the user's current taglist
|
||||
rows = profile_tags.select_where(
|
||||
guildid=ctx.guild.id,
|
||||
userid=ctx.author.id,
|
||||
_extra="ORDER BY tagid ASC"
|
||||
)
|
||||
|
||||
# Parse and validate provided new content
|
||||
content = splits[1].strip().upper()
|
||||
validate_tag(content)
|
||||
|
||||
if tagid > len(rows):
|
||||
# Trying to edit a tag that doesn't exist yet
|
||||
# Just create it instead
|
||||
profile_tags.insert(
|
||||
guildid=ctx.guild.id,
|
||||
userid=ctx.author.id,
|
||||
tag=content
|
||||
)
|
||||
|
||||
# Ack user
|
||||
await ctx.reply(
|
||||
embed=discord.Embed(title="Tag created!", colour=discord.Colour.green())
|
||||
)
|
||||
else:
|
||||
# Get the row id to update
|
||||
to_edit = rows[tagid - 1]['tagid']
|
||||
|
||||
# Update the tag
|
||||
profile_tags.update_where(
|
||||
{'tag': content},
|
||||
tagid=to_edit
|
||||
)
|
||||
|
||||
# Ack user
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.green(),
|
||||
title="Tag updated!"
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
else:
|
||||
# Assume the arguments are a comma separated list of badges
|
||||
# Parse and validate
|
||||
to_add = [split.strip().upper() for line in ctx.args.splitlines() for split in line.split(',')]
|
||||
to_add = [split.replace('<3', '❤️') for split in to_add if split]
|
||||
if not to_add:
|
||||
return await ctx.error_reply("No valid tags given, nothing to do!")
|
||||
|
||||
validate_tag(*to_add)
|
||||
|
||||
if len(to_add) > MAX_TAGS:
|
||||
return await ctx.error_reply(f"You can have a maximum of {MAX_TAGS} tags!")
|
||||
|
||||
# Remove the existing badges
|
||||
deleted_rows = profile_tags.delete_where(
|
||||
guildid=ctx.guild.id,
|
||||
userid=ctx.author.id
|
||||
)
|
||||
|
||||
# Insert the new tags
|
||||
profile_tags.insert_many(
|
||||
*((ctx.guild.id, ctx.author.id, tag) for tag in to_add),
|
||||
insert_keys=('guildid', 'userid', 'tag')
|
||||
)
|
||||
|
||||
# Ack with user
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.green(),
|
||||
title="Profile tags updated!"
|
||||
)
|
||||
embed.add_field(
|
||||
name="New tags",
|
||||
value='\n'.join(to_add)
|
||||
)
|
||||
if deleted_rows:
|
||||
embed.add_field(
|
||||
name="Replaced tags",
|
||||
value='\n'.join(row['tag'].upper() for row in deleted_rows),
|
||||
inline=False
|
||||
)
|
||||
if len(to_add) == 1:
|
||||
embed.set_footer(
|
||||
text=f"TIP: Add multiple tags with {ctx.best_prefix}setprofile tag1, tag2, ..."
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
else:
|
||||
# No input was provided
|
||||
# Show usage and exit
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.red(),
|
||||
description=(
|
||||
"Edit your study profile "
|
||||
"tags so other people can see what you do!"
|
||||
)
|
||||
)
|
||||
embed.add_field(
|
||||
name="Usage",
|
||||
value=(
|
||||
f"`{ctx.best_prefix}setprofile <tag>, <tag>, <tag>, ...`\n"
|
||||
f"`{ctx.best_prefix}setprofile <id> <new tag>`"
|
||||
)
|
||||
)
|
||||
embed.add_field(
|
||||
name="Examples",
|
||||
value=(
|
||||
f"`{ctx.best_prefix}setprofile Mathematics, Bioloyg, Medicine, Undergraduate, Europe`\n"
|
||||
f"`{ctx.best_prefix}setprofile 2 Biology`"
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
|
||||
def validate_tag(*content):
|
||||
for content in content:
|
||||
if not set(content.replace('❤️', '')).issubset(string.printable):
|
||||
raise SafeCancellation(
|
||||
f"Invalid tag `{content}`!\n"
|
||||
"Tags may only contain alphanumeric and punctuation characters."
|
||||
)
|
||||
if len(content) > MAX_LENGTH:
|
||||
raise SafeCancellation(
|
||||
f"Provided tag is too long! Please keep your tags shorter than {MAX_LENGTH} characters."
|
||||
)
|
||||
119
bot/modules/pending-rewrite/stats/top_cmd.py
Normal file
119
bot/modules/pending-rewrite/stats/top_cmd.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from cmdClient.checks import in_guild
|
||||
|
||||
import data
|
||||
from core import Lion
|
||||
from data import tables
|
||||
from utils import interactive # noqa
|
||||
|
||||
from .module import module
|
||||
|
||||
|
||||
first_emoji = "🥇"
|
||||
second_emoji = "🥈"
|
||||
third_emoji = "🥉"
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"top",
|
||||
desc="View the Study Time leaderboard.",
|
||||
group="Statistics",
|
||||
aliases=('ttop', 'toptime', 'top100'),
|
||||
help_aliases={'top100': "View the Study Time top 100."}
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_top(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}top
|
||||
{prefix}top100
|
||||
Description:
|
||||
Display the study time leaderboard, or the top 100.
|
||||
|
||||
Use the paging reactions or send `p<n>` to switch pages (e.g. `p11` to switch to page 11).
|
||||
"""
|
||||
# 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'),
|
||||
'_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_index = None
|
||||
entries = []
|
||||
for i, (userid, time) in enumerate(user_data):
|
||||
member = ctx.guild.get_member(userid)
|
||||
name = member.display_name if member else str(userid)
|
||||
name = name.replace('*', ' ').replace('_', ' ')
|
||||
|
||||
num_str = "{}.".format(i+1)
|
||||
|
||||
hours = time // 3600
|
||||
minutes = time // 60 % 60
|
||||
seconds = time % 60
|
||||
|
||||
time_str = "{}:{:02}:{:02}".format(
|
||||
hours,
|
||||
minutes,
|
||||
seconds
|
||||
)
|
||||
|
||||
if ctx.author.id == userid:
|
||||
author_index = i
|
||||
|
||||
entries.append((num_str, name, time_str))
|
||||
|
||||
# Extract blocks
|
||||
blocks = [entries[i:i+20] for i in range(0, len(entries), 20)]
|
||||
block_count = len(blocks)
|
||||
|
||||
# Build strings
|
||||
header = "Study Time Top 100" if top100 else "Study Time Leaderboard"
|
||||
if block_count > 1:
|
||||
header += " (Page {{page}}/{})".format(block_count)
|
||||
|
||||
# Build pages
|
||||
pages = []
|
||||
for i, block in enumerate(blocks):
|
||||
max_num_l, max_name_l, max_time_l = [max(len(e[i]) for e in block) for i in (0, 1, 2)]
|
||||
body = '\n'.join(
|
||||
"{:>{}} {:<{}} \t {:>{}} {} {}".format(
|
||||
entry[0], max_num_l,
|
||||
entry[1], max_name_l + 2,
|
||||
entry[2], max_time_l + 1,
|
||||
first_emoji if i == 0 and j == 0 else (
|
||||
second_emoji if i == 0 and j == 1 else (
|
||||
third_emoji if i == 0 and j == 2 else ''
|
||||
)
|
||||
),
|
||||
"⮜" if author_index is not None and author_index == i * 20 + j else ""
|
||||
)
|
||||
for j, entry in enumerate(block)
|
||||
)
|
||||
title = header.format(page=i+1)
|
||||
line = '='*len(title)
|
||||
pages.append(
|
||||
"```md\n{}\n{}\n{}```".format(title, line, body)
|
||||
)
|
||||
|
||||
# Finally, page the results
|
||||
await ctx.pager(pages, start_at=(author_index or 0)//20 if not top100 else 0)
|
||||
5
bot/modules/pending-rewrite/study/__init__.py
Normal file
5
bot/modules/pending-rewrite/study/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .module import module
|
||||
|
||||
from . import badges
|
||||
from . import timers
|
||||
from . import tracking
|
||||
2
bot/modules/pending-rewrite/study/badges/__init__.py
Normal file
2
bot/modules/pending-rewrite/study/badges/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import badge_tracker
|
||||
from . import studybadge_cmd
|
||||
349
bot/modules/pending-rewrite/study/badges/badge_tracker.py
Normal file
349
bot/modules/pending-rewrite/study/badges/badge_tracker.py
Normal file
@@ -0,0 +1,349 @@
|
||||
import datetime
|
||||
import traceback
|
||||
import logging
|
||||
import asyncio
|
||||
import contextlib
|
||||
|
||||
import discord
|
||||
|
||||
from meta import client, sharding
|
||||
from data.conditions import GEQ, THIS_SHARD
|
||||
from core.data import lions
|
||||
from utils.lib import strfdur
|
||||
from settings import GuildSettings
|
||||
|
||||
from ..module import module
|
||||
from .data import new_study_badges, study_badges
|
||||
|
||||
|
||||
guild_locks = {} # guildid -> Lock
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def guild_lock(guildid):
|
||||
"""
|
||||
Per-guild lock held while the study badges are being updated.
|
||||
This should not be used to lock the data modifications, as those are synchronous.
|
||||
Primarily for reporting and so that the member information (e.g. roles) stays consistent
|
||||
through reading and manipulation.
|
||||
"""
|
||||
# Create the lock if it hasn't been registered already
|
||||
if guildid in guild_locks:
|
||||
lock = guild_locks[guildid]
|
||||
else:
|
||||
lock = guild_locks[guildid] = asyncio.Lock()
|
||||
|
||||
await lock.acquire()
|
||||
try:
|
||||
yield lock
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
async def update_study_badges(full=False):
|
||||
while not client.is_ready():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
client.log(
|
||||
"Running global study badge update.".format(
|
||||
),
|
||||
context="STUDY_BADGE_UPDATE",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
# TODO: Consider db procedure for doing the update and returning rows
|
||||
|
||||
# Retrieve member rows with out of date study badges
|
||||
if not full and client.appdata.last_study_badge_scan is not None:
|
||||
# TODO: _extra here is a hack to cover for inflexible conditionals
|
||||
update_rows = new_study_badges.select_where(
|
||||
guildid=THIS_SHARD,
|
||||
_timestamp=GEQ(client.appdata.last_study_badge_scan or 0),
|
||||
_extra="OR session_start IS NOT NULL AND (guildid >> 22) %% {} = {}".format(
|
||||
sharding.shard_count, sharding.shard_number
|
||||
)
|
||||
)
|
||||
else:
|
||||
update_rows = new_study_badges.select_where(guildid=THIS_SHARD)
|
||||
|
||||
if not update_rows:
|
||||
client.appdata.last_study_badge_scan = datetime.datetime.utcnow()
|
||||
return
|
||||
|
||||
# Batch and fire guild updates
|
||||
current_guildid = None
|
||||
current_guild = None
|
||||
guild_buffer = []
|
||||
updated_guilds = set()
|
||||
for row in update_rows:
|
||||
if row['guildid'] != current_guildid:
|
||||
if current_guild:
|
||||
# Fire guild updater
|
||||
asyncio.create_task(_update_guild_badges(current_guild, guild_buffer))
|
||||
updated_guilds.add(current_guild.id)
|
||||
|
||||
guild_buffer = []
|
||||
current_guildid = row['guildid']
|
||||
current_guild = client.get_guild(row['guildid'])
|
||||
|
||||
if current_guild:
|
||||
guild_buffer.append(row)
|
||||
|
||||
if current_guild:
|
||||
# Fire guild updater
|
||||
asyncio.create_task(_update_guild_badges(current_guild, guild_buffer))
|
||||
updated_guilds.add(current_guild.id)
|
||||
|
||||
# Update the member study badges in data
|
||||
lions.update_many(
|
||||
*((row['current_study_badgeid'], row['guildid'], row['userid'])
|
||||
for row in update_rows if row['guildid'] in updated_guilds),
|
||||
set_keys=('last_study_badgeid',),
|
||||
where_keys=('guildid', 'userid'),
|
||||
cast_row='(NULL::int, NULL::int, NULL::int)'
|
||||
)
|
||||
|
||||
# Update the app scan time
|
||||
client.appdata.last_study_badge_scan = datetime.datetime.utcnow()
|
||||
|
||||
|
||||
async def _update_guild_badges(guild, member_rows, notify=True, log=True):
|
||||
"""
|
||||
Notify, update, and log role changes for a single guild.
|
||||
Expects a valid `guild` and a list of Rows of `new_study_badges`.
|
||||
"""
|
||||
async with guild_lock(guild.id):
|
||||
client.log(
|
||||
"Running guild badge update for guild '{guild.name}' (gid:{guild.id}) "
|
||||
"with `{count}` rows to update.".format(
|
||||
guild=guild,
|
||||
count=len(member_rows)
|
||||
),
|
||||
context="STUDY_BADGE_UPDATE",
|
||||
level=logging.DEBUG,
|
||||
post=False
|
||||
)
|
||||
|
||||
# Set of study role ids in this guild, usually from cache
|
||||
guild_roles = {
|
||||
roleid: guild.get_role(roleid)
|
||||
for roleid in study_badges.queries.for_guild(guild.id)
|
||||
}
|
||||
|
||||
log_lines = []
|
||||
flags_used = set()
|
||||
tasks = []
|
||||
for row in member_rows:
|
||||
# Fetch member
|
||||
# TODO: Potential verification issue
|
||||
member = guild.get_member(row['userid'])
|
||||
|
||||
if member:
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
_update_member_roles(row, member, guild_roles, log_lines, flags_used, notify)
|
||||
)
|
||||
)
|
||||
|
||||
# Post to the event log, in multiple pages if required
|
||||
event_log = GuildSettings(guild.id).event_log.value
|
||||
if tasks:
|
||||
task_blocks = (tasks[i:i+20] for i in range(0, len(tasks), 20))
|
||||
for task_block in task_blocks:
|
||||
# Execute the tasks
|
||||
await asyncio.gather(*task_block)
|
||||
|
||||
# Post to the log if needed
|
||||
if log and event_log:
|
||||
desc = "\n".join(log_lines)
|
||||
embed = discord.Embed(
|
||||
title="Study badge{} earned!".format('s' if len(log_lines) > 1 else ''),
|
||||
description=desc,
|
||||
colour=discord.Colour.orange(),
|
||||
timestamp=datetime.datetime.utcnow()
|
||||
)
|
||||
if flags_used:
|
||||
flag_desc = {
|
||||
'!': "`!` Could not add/remove badge role. **Check permissions!**",
|
||||
'*': "`*` Could not message member.",
|
||||
'x': "`x` Couldn't find role to add/remove!"
|
||||
}
|
||||
flag_lines = '\n'.join(desc for flag, desc in flag_desc.items() if flag in flags_used)
|
||||
embed.add_field(
|
||||
name="Legend",
|
||||
value=flag_lines
|
||||
)
|
||||
try:
|
||||
await event_log.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
# Nothing we can really do
|
||||
pass
|
||||
|
||||
# Flush the log collection pointers
|
||||
log_lines.clear()
|
||||
flags_used.clear()
|
||||
|
||||
# Wait so we don't get ratelimited
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Debug log completion
|
||||
client.log(
|
||||
"Completed guild badge update for guild '{guild.name}' (gid:{guild.id})".format(
|
||||
guild=guild,
|
||||
),
|
||||
context="STUDY_BADGE_UPDATE",
|
||||
level=logging.DEBUG,
|
||||
post=False
|
||||
)
|
||||
|
||||
|
||||
async def _update_member_roles(row, member, guild_roles, log_lines, flags_used, notify):
|
||||
guild = member.guild
|
||||
|
||||
# Logging flag chars
|
||||
flags = []
|
||||
|
||||
# Add new study role
|
||||
# First fetch the roleid using the current_study_badgeid
|
||||
new_row = study_badges.fetch(row['current_study_badgeid']) if row['current_study_badgeid'] else None
|
||||
|
||||
# Fetch actual role from the precomputed guild roles
|
||||
to_add = guild_roles.get(new_row.roleid, None) if new_row else None
|
||||
if to_add:
|
||||
# Actually add the role
|
||||
try:
|
||||
await member.add_roles(
|
||||
to_add,
|
||||
atomic=True,
|
||||
reason="Updating study badge."
|
||||
)
|
||||
except discord.HTTPException:
|
||||
flags.append('!')
|
||||
elif new_row:
|
||||
flags.append('x')
|
||||
|
||||
# Remove other roles, start by trying the last badge role
|
||||
old_row = study_badges.fetch(row['last_study_badgeid']) if row['last_study_badgeid'] else None
|
||||
|
||||
member_roleids = set(role.id for role in member.roles)
|
||||
if old_row and old_row.roleid in member_roleids:
|
||||
# The last level role exists, try to remove it
|
||||
try:
|
||||
await member.remove_roles(
|
||||
guild_roles.get(old_row.roleid),
|
||||
atomic=True
|
||||
)
|
||||
except discord.HTTPException:
|
||||
# Couldn't remove the role
|
||||
flags.append('!')
|
||||
else:
|
||||
# The last level role doesn't exist or the member doesn't have it
|
||||
# Remove all leveled roles they have
|
||||
current_roles = (
|
||||
role for roleid, role in guild_roles.items()
|
||||
if roleid in member_roleids and (not to_add or roleid != to_add.id)
|
||||
)
|
||||
if current_roles:
|
||||
try:
|
||||
await member.remove_roles(
|
||||
*current_roles,
|
||||
atomic=True,
|
||||
reason="Updating study badge."
|
||||
)
|
||||
except discord.HTTPException:
|
||||
# Couldn't remove one or more of the leveled roles
|
||||
flags.append('!')
|
||||
|
||||
# Send notification to member
|
||||
# TODO: Config customisation
|
||||
if notify and new_row and (old_row is None or new_row.required_time > old_row.required_time):
|
||||
req = new_row.required_time
|
||||
if req < 3600:
|
||||
timestr = "{} minutes".format(int(req // 60))
|
||||
elif req == 3600:
|
||||
timestr = "1 hour"
|
||||
elif req % 3600:
|
||||
timestr = "{:.1f} hours".format(req / 3600)
|
||||
else:
|
||||
timestr = "{} hours".format(int(req // 3600))
|
||||
embed = discord.Embed(
|
||||
title="New Study Badge!",
|
||||
description="Congratulations! You have earned {} for studying **{}**!".format(
|
||||
"**{}**".format(to_add.name) if to_add else "a new study badge!",
|
||||
timestr
|
||||
),
|
||||
timestamp=datetime.datetime.utcnow(),
|
||||
colour=discord.Colour.orange()
|
||||
).set_footer(text=guild.name, icon_url=guild.icon_url)
|
||||
try:
|
||||
await member.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
flags.append('*')
|
||||
|
||||
# Add to event log message
|
||||
if new_row:
|
||||
new_role_str = "earned <@&{}> **({})**".format(new_row.roleid, strfdur(new_row.required_time))
|
||||
else:
|
||||
new_role_str = "lost their study badge!"
|
||||
log_lines.append(
|
||||
"<@{}> {} {}".format(
|
||||
row['userid'],
|
||||
new_role_str,
|
||||
"`[{}]`".format(''.join(flags)) if flags else "",
|
||||
)
|
||||
)
|
||||
if flags:
|
||||
flags_used.update(flags)
|
||||
|
||||
|
||||
async def study_badge_tracker():
|
||||
"""
|
||||
Runloop for the study badge updater.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
await update_study_badges()
|
||||
except Exception:
|
||||
# Unknown exception. Catch it so the loop doesn't die.
|
||||
client.log(
|
||||
"Error while updating study badges! "
|
||||
"Exception traceback follows.\n{}".format(
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="STUDY_BADGE_TRACKER",
|
||||
level=logging.ERROR
|
||||
)
|
||||
# Long delay since this is primarily needed for external modifications
|
||||
# or badge updates while studying
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
async def update_member_studybadge(member):
|
||||
"""
|
||||
Checks and (if required) updates the study badge for a single member.
|
||||
"""
|
||||
update_rows = new_study_badges.select_where(
|
||||
guildid=member.guild.id,
|
||||
userid=member.id
|
||||
)
|
||||
if update_rows:
|
||||
# Debug log the update
|
||||
client.log(
|
||||
"Updating study badge for user '{member.name}' (uid:{member.id}) "
|
||||
"in guild '{member.guild.name}' (gid:{member.guild.id}).".format(
|
||||
member=member
|
||||
),
|
||||
context="STUDY_BADGE_UPDATE",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
|
||||
# Update the data first
|
||||
lions.update_where({'last_study_badgeid': update_rows[0]['current_study_badgeid']},
|
||||
guildid=member.guild.id, userid=member.id)
|
||||
|
||||
# Run the update task
|
||||
await _update_guild_badges(member.guild, update_rows)
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def launch_study_badge_tracker(client):
|
||||
asyncio.create_task(study_badge_tracker())
|
||||
24
bot/modules/pending-rewrite/study/badges/data.py
Normal file
24
bot/modules/pending-rewrite/study/badges/data.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from cachetools import cached
|
||||
|
||||
from data import Table, RowTable
|
||||
|
||||
study_badges = RowTable(
|
||||
'study_badges',
|
||||
('badgeid', 'guildid', 'roleid', 'required_time'),
|
||||
'badgeid'
|
||||
)
|
||||
|
||||
current_study_badges = Table('current_study_badges')
|
||||
|
||||
new_study_badges = Table('new_study_badges')
|
||||
|
||||
|
||||
# Cache of study role ids attached to each guild. Not automatically updated.
|
||||
guild_role_cache = {} # guildid -> set(roleids)
|
||||
|
||||
|
||||
@study_badges.save_query
|
||||
@cached(guild_role_cache)
|
||||
def for_guild(guildid):
|
||||
rows = study_badges.fetch_rows_where(guildid=guildid)
|
||||
return set(row.roleid for row in rows)
|
||||
462
bot/modules/pending-rewrite/study/badges/studybadge_cmd.py
Normal file
462
bot/modules/pending-rewrite/study/badges/studybadge_cmd.py
Normal file
@@ -0,0 +1,462 @@
|
||||
import re
|
||||
import asyncio
|
||||
import discord
|
||||
import datetime
|
||||
|
||||
from cmdClient.checks import in_guild
|
||||
from cmdClient.lib import SafeCancellation
|
||||
|
||||
from data import NULL
|
||||
from utils.lib import parse_dur, strfdur, parse_ranges
|
||||
from wards import is_guild_admin
|
||||
from core.data import lions
|
||||
from settings import GuildSettings
|
||||
|
||||
from ..module import module
|
||||
from .data import study_badges, guild_role_cache, new_study_badges
|
||||
from .badge_tracker import _update_guild_badges
|
||||
|
||||
|
||||
_multiselect_regex = re.compile(
|
||||
r"^([0-9, -]+)$",
|
||||
re.DOTALL | re.IGNORECASE | re.VERBOSE
|
||||
)
|
||||
|
||||
|
||||
@module.cmd(
|
||||
"studybadges",
|
||||
group="Guild Configuration",
|
||||
desc="View or configure the server study badges.",
|
||||
aliases=('studyroles', 'studylevels'),
|
||||
flags=('add', 'remove', 'clear', 'refresh')
|
||||
)
|
||||
@in_guild()
|
||||
async def cmd_studybadges(ctx, flags):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}studybadges
|
||||
{prefix}studybadges [--add] <role>, <duration>
|
||||
{prefix}studybadges --remove
|
||||
{prefix}studybadges --remove <role>
|
||||
{prefix}studybadges --remove <badge index>
|
||||
{prefix}studybadges --clear
|
||||
{prefix}studybadges --refresh
|
||||
Description:
|
||||
View or modify the study badges in this guild.
|
||||
|
||||
*Modification requires administrator permissions.*
|
||||
Flags::
|
||||
add: Add new studybadges (each line is added as a separate badge).
|
||||
remove: Remove badges. With no arguments, opens a selection menu.
|
||||
clear: Remove all study badges.
|
||||
refresh: Make sure everyone's study badges are up to date.
|
||||
Examples``:
|
||||
{prefix}studybadges Lion Cub, 100h
|
||||
{prefix}studybadges --remove Lion Cub
|
||||
"""
|
||||
if flags['refresh']:
|
||||
await ensure_admin(ctx)
|
||||
|
||||
# Count members who need updating.
|
||||
# Note that we don't get the rows here in order to avoid clashing with the auto-updater
|
||||
update_count = new_study_badges.select_one_where(
|
||||
guildid=ctx.guild.id,
|
||||
select_columns=('COUNT(*)',)
|
||||
)[0]
|
||||
|
||||
if not update_count:
|
||||
# No-one needs updating
|
||||
await ctx.reply("All study badges are up to date!")
|
||||
return
|
||||
else:
|
||||
out_msg = await ctx.reply("Updating `{}` members (this may take a while)...".format(update_count))
|
||||
|
||||
# Fetch actual update rows
|
||||
update_rows = new_study_badges.select_where(
|
||||
guildid=ctx.guild.id
|
||||
)
|
||||
|
||||
# Update data first
|
||||
lions.update_many(
|
||||
*((row['current_study_badgeid'], ctx.guild.id, row['userid']) for row in update_rows),
|
||||
set_keys=('last_study_badgeid',),
|
||||
where_keys=('guildid', 'userid')
|
||||
)
|
||||
|
||||
# Then apply the role updates and send notifications as usual
|
||||
await _update_guild_badges(ctx.guild, update_rows)
|
||||
|
||||
await out_msg.edit("Refresh complete! All study badges are up to date.")
|
||||
elif flags['clear'] or flags['remove']:
|
||||
# Make sure that the author is an admin before modifying the roles
|
||||
await ensure_admin(ctx)
|
||||
|
||||
# Pre-fetch the list of roles
|
||||
guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC")
|
||||
|
||||
if not guild_roles:
|
||||
return await ctx.error_reply("There are no studybadges to remove!")
|
||||
|
||||
# Input handling, parse or get the list of rows to delete
|
||||
to_delete = []
|
||||
if flags['remove']:
|
||||
if ctx.args:
|
||||
if ctx.args.isdigit() and 0 < int(ctx.args) <= len(guild_roles):
|
||||
# Assume it is a badge index
|
||||
row = guild_roles[int(ctx.args) - 1]
|
||||
else:
|
||||
# Assume the input is a role string
|
||||
# Get the collection of roles to search
|
||||
roleids = (row.roleid for row in guild_roles)
|
||||
roles = (ctx.guild.get_role(roleid) for roleid in roleids)
|
||||
roles = [role for role in roles if role is not None]
|
||||
role = await ctx.find_role(ctx.args, interactive=True, collection=roles, allow_notfound=False)
|
||||
index = roles.index(role)
|
||||
row = guild_roles[index]
|
||||
|
||||
# We now have a row to delete
|
||||
to_delete = [row]
|
||||
else:
|
||||
# Multi-select the badges to remove
|
||||
out_msg = await show_badge_list(
|
||||
ctx,
|
||||
desc="Please select the badge(s) to delete, or type `c` to cancel.",
|
||||
guild_roles=guild_roles
|
||||
)
|
||||
|
||||
def check(msg):
|
||||
valid = msg.channel == ctx.ch and msg.author == ctx.author
|
||||
valid = valid and (re.search(_multiselect_regex, msg.content) or msg.content.lower() == 'c')
|
||||
return valid
|
||||
|
||||
try:
|
||||
message = await ctx.client.wait_for('message', check=check, timeout=60)
|
||||
except asyncio.TimeoutError:
|
||||
await out_msg.delete()
|
||||
await ctx.error_reply("Session timed out. No study badges were deleted.")
|
||||
return
|
||||
|
||||
try:
|
||||
await out_msg.delete()
|
||||
await message.delete()
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
if message.content.lower() == 'c':
|
||||
return
|
||||
|
||||
to_delete = [
|
||||
guild_roles[index-1]
|
||||
for index in parse_ranges(message.content) if index <= len(guild_roles)
|
||||
]
|
||||
elif flags['clear']:
|
||||
if not await ctx.ask("Are you sure you want to delete **all** study badges in this server?"):
|
||||
return
|
||||
to_delete = guild_roles
|
||||
|
||||
# In some cases we may come out with no valid rows, in this case cancel.
|
||||
if not to_delete:
|
||||
return await ctx.error_reply("No matching badges, nothing to do!")
|
||||
|
||||
# Count the affected users
|
||||
affected_count = lions.select_one_where(
|
||||
guildid=ctx.guild.id,
|
||||
last_study_badgeid=[row.badgeid for row in to_delete],
|
||||
select_columns=('COUNT(*)',)
|
||||
)[0]
|
||||
|
||||
# Delete the rows
|
||||
study_badges.delete_where(badgeid=[row.badgeid for row in to_delete])
|
||||
|
||||
# Also update the cached guild roles
|
||||
guild_role_cache.pop((ctx.guild.id, ), None)
|
||||
study_badges.queries.for_guild(ctx.guild.id)
|
||||
|
||||
# Immediately refresh the member data, only for members with NULL badgeid
|
||||
update_rows = new_study_badges.select_where(
|
||||
guildid=ctx.guild.id,
|
||||
last_study_badgeid=NULL
|
||||
)
|
||||
|
||||
if update_rows:
|
||||
lions.update_many(
|
||||
*((row['current_study_badgeid'], ctx.guild.id, row['userid']) for row in update_rows),
|
||||
set_keys=('last_study_badgeid',),
|
||||
where_keys=('guildid', 'userid')
|
||||
)
|
||||
|
||||
# Launch the update task for these members, so that they get the correct new roles
|
||||
asyncio.create_task(_update_guild_badges(ctx.guild, update_rows, notify=False, log=False))
|
||||
|
||||
# Ack the deletion
|
||||
count = len(to_delete)
|
||||
roles = [ctx.guild.get_role(row.roleid) for row in to_delete]
|
||||
if count == len(guild_roles):
|
||||
await ctx.embed_reply("All study badges deleted.")
|
||||
log_embed = discord.Embed(
|
||||
title="Study badges cleared!",
|
||||
description="{} cleared the guild study badges. `{}` members affected.".format(
|
||||
ctx.author.mention,
|
||||
affected_count
|
||||
)
|
||||
)
|
||||
elif count == 1:
|
||||
badge_name = roles[0].name if roles[0] else strfdur(to_delete[0].required_time)
|
||||
await show_badge_list(
|
||||
ctx,
|
||||
desc="✅ Removed the **{}** badge.".format(badge_name)
|
||||
)
|
||||
log_embed = discord.Embed(
|
||||
title="Study badge removed!",
|
||||
description="{} removed the badge **{}**. `{}` members affected.".format(
|
||||
ctx.author.mention,
|
||||
badge_name,
|
||||
affected_count
|
||||
)
|
||||
)
|
||||
else:
|
||||
await show_badge_list(
|
||||
ctx,
|
||||
desc="✅ `{}` badges removed.".format(count)
|
||||
)
|
||||
log_embed = discord.Embed(
|
||||
title="Study badges removed!",
|
||||
description="{} removed `{}` badges. `{}` members affected.".format(
|
||||
ctx.author.mention,
|
||||
count,
|
||||
affected_count
|
||||
)
|
||||
)
|
||||
|
||||
# Post to the event log
|
||||
event_log = GuildSettings(ctx.guild.id).event_log.value
|
||||
if event_log:
|
||||
# TODO Error handling? Or improve the post method?
|
||||
log_embed.timestamp = datetime.datetime.utcnow()
|
||||
log_embed.colour = discord.Colour.orange()
|
||||
await event_log.send(embed=log_embed)
|
||||
|
||||
# Delete the roles (after asking first)
|
||||
roles = [role for role in roles if role is not None]
|
||||
if roles:
|
||||
if await ctx.ask("Do you also want to remove the associated guild roles?"):
|
||||
tasks = [
|
||||
asyncio.create_task(role.delete()) for role in roles
|
||||
]
|
||||
results = await asyncio.gather(
|
||||
*tasks,
|
||||
return_exceptions=True
|
||||
)
|
||||
bad_roles = [role for role, task in zip(roles, tasks) if task.exception()]
|
||||
if bad_roles:
|
||||
await ctx.embed_reply(
|
||||
"Couldn't delete the following roles:\n{}".format(
|
||||
'\n'.join(bad_role.mention for bad_role in bad_roles)
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.embed_reply("Deleted `{}` roles.".format(len(roles)))
|
||||
elif ctx.args:
|
||||
# Ensure admin perms for modification
|
||||
await ensure_admin(ctx)
|
||||
|
||||
guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC")
|
||||
|
||||
# Parse the input
|
||||
lines = ctx.args.splitlines()
|
||||
results = [await parse_level(ctx, line) for line in lines]
|
||||
# Check for duplicates
|
||||
_set = set()
|
||||
duplicate = next((time for time, _ in results if time in _set or _set.add(time)), None)
|
||||
if duplicate:
|
||||
return await ctx.error_reply(
|
||||
"Level `{}` provided twice!".format(strfdur(duplicate, short=False))
|
||||
)
|
||||
current_times = set(row.required_time for row in guild_roles)
|
||||
|
||||
# Split up the provided lines into levels to add and levels to edit
|
||||
to_add = [result for result in results if result[0] not in current_times]
|
||||
to_edit = [result for result in results if result[0] in current_times]
|
||||
|
||||
# Apply changes to database
|
||||
if to_add:
|
||||
study_badges.insert_many(
|
||||
*((ctx.guild.id, time, role.id) for time, role in to_add),
|
||||
insert_keys=('guildid', 'required_time', 'roleid')
|
||||
)
|
||||
if to_edit:
|
||||
study_badges.update_many(
|
||||
*((role.id, ctx.guild.id, time) for time, role in to_edit),
|
||||
set_keys=('roleid',),
|
||||
where_keys=('guildid', 'required_time')
|
||||
)
|
||||
|
||||
# Also update the cached guild roles
|
||||
guild_role_cache.pop((ctx.guild.id, ), None)
|
||||
study_badges.queries.for_guild(ctx.guild.id)
|
||||
|
||||
# Ack changes
|
||||
if to_add and to_edit:
|
||||
desc = "{tick} `{num_add}` badges added and `{num_edit}` updated."
|
||||
elif to_add:
|
||||
desc = "{tick} `{num_add}` badges added."
|
||||
elif to_edit:
|
||||
desc = "{tick} `{num_edit}` badges updated."
|
||||
|
||||
desc = desc.format(
|
||||
tick='✅',
|
||||
num_add=len(to_add),
|
||||
num_edit=len(to_edit)
|
||||
)
|
||||
|
||||
await show_badge_list(ctx, desc)
|
||||
|
||||
# Count members who need new study badges
|
||||
# Note that we don't get the rows here in order to avoid clashing with the auto-updater
|
||||
update_count = new_study_badges.select_one_where(
|
||||
guildid=ctx.guild.id,
|
||||
select_columns=('COUNT(*)',)
|
||||
)[0]
|
||||
|
||||
if not update_count:
|
||||
# No-one needs updating
|
||||
return
|
||||
|
||||
if update_count > 20:
|
||||
# Confirm whether we want to update now
|
||||
resp = await ctx.ask(
|
||||
"`{}` members need their study badge roles updated, "
|
||||
"which will occur automatically for each member when they next study.\n"
|
||||
"Do you want to refresh the roles immediately instead? This may take a while!"
|
||||
)
|
||||
if not resp:
|
||||
return
|
||||
|
||||
# Fetch actual update rows
|
||||
update_rows = new_study_badges.select_where(
|
||||
guildid=ctx.guild.id
|
||||
)
|
||||
|
||||
# Update data first
|
||||
lions.update_many(
|
||||
*((row['current_study_badgeid'], ctx.guild.id, row['userid']) for row in update_rows),
|
||||
set_keys=('last_study_badgeid',),
|
||||
where_keys=('guildid', 'userid')
|
||||
)
|
||||
|
||||
# Then apply the role updates and send notifications as usual
|
||||
await _update_guild_badges(ctx.guild, update_rows)
|
||||
|
||||
# TODO: Progress bar? Probably not needed since we have the event log
|
||||
# TODO: Ask about notifications?
|
||||
else:
|
||||
guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC")
|
||||
|
||||
# Just view the current study levels
|
||||
if not guild_roles:
|
||||
return await ctx.reply("There are no study badges set up!")
|
||||
|
||||
# TODO: You are at... this much to next level..
|
||||
await show_badge_list(ctx, guild_roles=guild_roles)
|
||||
|
||||
|
||||
async def parse_level(ctx, line):
|
||||
line = line.strip()
|
||||
|
||||
if ',' in line:
|
||||
splits = [split.strip() for split in line.split(',', maxsplit=1)]
|
||||
elif line.startswith('"') and '"' in line[1:]:
|
||||
splits = [split.strip() for split in line[1:].split('"', maxsplit=1)]
|
||||
else:
|
||||
splits = [split.strip() for split in line.split(maxsplit=1)]
|
||||
|
||||
if not line or len(splits) != 2 or not splits[1][0].isdigit():
|
||||
raise SafeCancellation(
|
||||
"**Level Syntax:** `<role>, <required_time>`, for example `Lion Cub, 200h`."
|
||||
)
|
||||
|
||||
if splits[1].isdigit():
|
||||
# No units! Assume hours
|
||||
time = int(splits[1]) * 3600
|
||||
else:
|
||||
time = parse_dur(splits[1])
|
||||
|
||||
role_str = splits[0]
|
||||
# TODO maybe add Y.. yes to all
|
||||
role = await ctx.find_role(role_str, create=True, interactive=True, allow_notfound=False)
|
||||
return time, role
|
||||
|
||||
|
||||
async def ensure_admin(ctx):
|
||||
if not is_guild_admin(ctx.author):
|
||||
raise SafeCancellation("Only guild admins can modify the server study badges!")
|
||||
|
||||
|
||||
async def show_badge_list(ctx, desc=None, guild_roles=None):
|
||||
if guild_roles is None:
|
||||
guild_roles = study_badges.fetch_rows_where(guildid=ctx.guild.id, _extra="ORDER BY required_time ASC")
|
||||
|
||||
# Generate the time range strings
|
||||
time_strings = []
|
||||
first_time = guild_roles[0].required_time
|
||||
if first_time == 0:
|
||||
prev_time_str = '0'
|
||||
prev_time_hour = False
|
||||
else:
|
||||
prev_time_str = strfdur(guild_roles[0].required_time)
|
||||
prev_time_hour = not (guild_roles[0].required_time % 3600)
|
||||
for row in guild_roles[1:]:
|
||||
time = row.required_time
|
||||
time_str = strfdur(time)
|
||||
time_hour = not (time % 3600)
|
||||
if time_hour and prev_time_hour:
|
||||
time_strings.append(
|
||||
"{} - {}".format(prev_time_str[:-1], time_str)
|
||||
)
|
||||
else:
|
||||
time_strings.append(
|
||||
"{} - {}".format(prev_time_str, time_str)
|
||||
)
|
||||
prev_time_str = time_str
|
||||
prev_time_hour = time_hour
|
||||
time_strings.append(
|
||||
"≥ {}".format(prev_time_str)
|
||||
)
|
||||
|
||||
# Pair the time strings with their roles
|
||||
pairs = [
|
||||
(time_string, row.roleid)
|
||||
for time_string, row in zip(time_strings, guild_roles)
|
||||
]
|
||||
|
||||
# pairs = [
|
||||
# (strfdur(row.required_time), row.study_role)
|
||||
# for row in guild_roles
|
||||
# ]
|
||||
|
||||
# Split the pairs into blocks
|
||||
pair_blocks = [pairs[i:i+10] for i in range(0, len(pairs), 10)]
|
||||
|
||||
# Format the blocks into strings
|
||||
blocks = []
|
||||
for i, pair_block in enumerate(pair_blocks):
|
||||
dig_len = (i * 10 + len(pair_block)) // 10 + 1
|
||||
blocks.append('\n'.join(
|
||||
"`[{:<{}}]` | <@&{}> **({})**".format(
|
||||
i * 10 + j + 1,
|
||||
dig_len,
|
||||
role,
|
||||
time_string,
|
||||
) for j, (time_string, role) in enumerate(pair_block)
|
||||
))
|
||||
|
||||
# Compile the strings into pages
|
||||
pages = [
|
||||
discord.Embed(
|
||||
title="Study Badges in {}! \nStudy more to rank up!".format(ctx.guild.name),
|
||||
description="{}\n\n{}".format(desc, block) if desc else block
|
||||
) for block in blocks
|
||||
]
|
||||
|
||||
# Output and page the pages
|
||||
return await ctx.pager(pages)
|
||||
4
bot/modules/pending-rewrite/study/module.py
Normal file
4
bot/modules/pending-rewrite/study/module.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
|
||||
module = LionModule("Study_Tracking")
|
||||
448
bot/modules/pending-rewrite/study/timers/Timer.py
Normal file
448
bot/modules/pending-rewrite/study/timers/Timer.py
Normal file
@@ -0,0 +1,448 @@
|
||||
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}"
|
||||
name = 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)
|
||||
)
|
||||
)
|
||||
return name[:100]
|
||||
|
||||
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
|
||||
args = await self.status()
|
||||
if status_content := args.pop('content', None):
|
||||
content.append(status_content)
|
||||
self.reaction_message = await self.text_channel.send(
|
||||
content='\n'.join(content),
|
||||
**args
|
||||
)
|
||||
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 not self.channel:
|
||||
return
|
||||
|
||||
if self.channel.name == self.channel_name:
|
||||
return
|
||||
|
||||
if not self.channel.permissions_for(self.channel.guild.me).manage_channels:
|
||||
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, None)
|
||||
|
||||
# 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
|
||||
|
||||
# Destroy the timer if our voice channel no longer exists
|
||||
if not self.channel:
|
||||
await self.destroy()
|
||||
break
|
||||
|
||||
if self._state.end < utc_now():
|
||||
asyncio.create_task(self.notify_change_stage(self._state, self.current_stage))
|
||||
elif self.members:
|
||||
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()
|
||||
3
bot/modules/pending-rewrite/study/timers/__init__.py
Normal file
3
bot/modules/pending-rewrite/study/timers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .Timer import Timer
|
||||
from . import commands
|
||||
from . import settings
|
||||
460
bot/modules/pending-rewrite/study/timers/commands.py
Normal file
460
bot/modules/pending-rewrite/study/timers/commands.py
Normal file
@@ -0,0 +1,460 @@
|
||||
import asyncio
|
||||
import discord
|
||||
from cmdClient.checks import in_guild
|
||||
from cmdClient.lib import SafeCancellation
|
||||
|
||||
from LionContext import LionContext as Context
|
||||
|
||||
from wards import guild_admin
|
||||
from utils.lib import utc_now, tick, prop_tabulate
|
||||
|
||||
from ..module import module
|
||||
|
||||
from .Timer import Timer
|
||||
|
||||
|
||||
config_flags = ('name==', 'threshold=', 'channelname==', 'text==')
|
||||
MAX_TIMERS_PER_GUILD = 10
|
||||
|
||||
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`)."
|
||||
}
|
||||
options_str = prop_tabulate(*zip(*options.items()))
|
||||
|
||||
|
||||
@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()
|
||||
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()
|
||||
|
||||
# Ack timer creation
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title="Timer Started!",
|
||||
description=(
|
||||
f"Started a timer in {channel.mention} with **{focus_length}** "
|
||||
f"minutes focus and **{break_length}** minutes break."
|
||||
)
|
||||
)
|
||||
embed.add_field(
|
||||
name="Further configuration",
|
||||
value=(
|
||||
"Use `{prefix}{ctx.alias} --setting value` to configure your new timer.\n"
|
||||
"*Replace `--setting` with one of the below settings, "
|
||||
"please see `{prefix}help pomodoro` for examples.*\n"
|
||||
f"{options_str.format(prefix=ctx.best_prefix)}"
|
||||
).format(prefix=ctx.best_prefix, ctx=ctx, channel=channel)
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
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/pending-rewrite/study/timers/data.py
Normal file
15
bot/modules/pending-rewrite/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/pending-rewrite/study/timers/settings.py
Normal file
47
bot/modules/pending-rewrite/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
|
||||
4
bot/modules/pending-rewrite/study/tracking/__init__.py
Normal file
4
bot/modules/pending-rewrite/study/tracking/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import data
|
||||
from . import settings
|
||||
from . import session_tracker
|
||||
from . import commands
|
||||
167
bot/modules/pending-rewrite/study/tracking/commands.py
Normal file
167
bot/modules/pending-rewrite/study/tracking/commands.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from cmdClient.checks import in_guild
|
||||
from LionContext import LionContext as Context
|
||||
|
||||
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
|
||||
...
|
||||
86
bot/modules/pending-rewrite/study/tracking/data.py
Normal file
86
bot/modules/pending-rewrite/study/tracking/data.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from psycopg2.extras import execute_values
|
||||
|
||||
from data import Table, RowTable, tables
|
||||
from utils.lib import FieldEnum
|
||||
|
||||
|
||||
untracked_channels = Table('untracked_channels')
|
||||
|
||||
|
||||
class SessionChannelType(FieldEnum):
|
||||
"""
|
||||
The possible session channel types.
|
||||
"""
|
||||
# NOTE: "None" stands for Unknown, and the STANDARD description should be replaced with the channel name
|
||||
STANDARD = 'STANDARD', "Standard"
|
||||
ACCOUNTABILITY = 'ACCOUNTABILITY', "Accountability Room"
|
||||
RENTED = 'RENTED', "Private Room"
|
||||
EXTERNAL = 'EXTERNAL', "Unknown"
|
||||
|
||||
|
||||
session_history = Table('session_history')
|
||||
current_sessions = RowTable(
|
||||
'current_sessions',
|
||||
('guildid', 'userid', 'channelid', 'channel_type',
|
||||
'rating', 'tag',
|
||||
'start_time',
|
||||
'live_duration', 'live_start',
|
||||
'stream_duration', 'stream_start',
|
||||
'video_duration', 'video_start',
|
||||
'hourly_coins', 'hourly_live_coins'),
|
||||
('guildid', 'userid'),
|
||||
cache={} # Keep all current sessions in cache
|
||||
)
|
||||
|
||||
|
||||
@current_sessions.save_query
|
||||
def close_study_session(guildid, userid):
|
||||
"""
|
||||
Close a member's current session if it exists and update the member cache.
|
||||
"""
|
||||
# Execute the `close_study_session` database function
|
||||
with current_sessions.conn as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.callproc('close_study_session', (guildid, userid))
|
||||
rows = cursor.fetchall()
|
||||
# The row has been deleted, remove the from current sessions cache
|
||||
current_sessions.row_cache.pop((guildid, userid), None)
|
||||
# Use the function output to update the member cache
|
||||
tables.lions._make_rows(*rows)
|
||||
|
||||
|
||||
@session_history.save_query
|
||||
def study_time_since(guildid, userid, timestamp):
|
||||
"""
|
||||
Retrieve the total member study time (in seconds) since the given timestamp.
|
||||
Includes the current session, if it exists.
|
||||
"""
|
||||
with session_history.conn as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.callproc('study_time_since', (guildid, userid, timestamp))
|
||||
rows = cursor.fetchall()
|
||||
return (rows[0][0] if rows else None) or 0
|
||||
|
||||
|
||||
@session_history.save_query
|
||||
def study_times_since(guildid, userid, *timestamps):
|
||||
"""
|
||||
Retrieve the total member study time (in seconds) since the given timestamps.
|
||||
Includes the current session, if it exists.
|
||||
"""
|
||||
with session_history.conn as conn:
|
||||
cursor = conn.cursor()
|
||||
data = execute_values(
|
||||
cursor,
|
||||
"""
|
||||
SELECT study_time_since(t.guildid, t.userid, t.timestamp)
|
||||
FROM (VALUES %s)
|
||||
AS t (guildid, userid, timestamp)
|
||||
""",
|
||||
[(guildid, userid, timestamp) for timestamp in timestamps],
|
||||
fetch=True
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
members_totals = Table('members_totals')
|
||||
504
bot/modules/pending-rewrite/study/tracking/session_tracker.py
Normal file
504
bot/modules/pending-rewrite/study/tracking/session_tracker.py
Normal file
@@ -0,0 +1,504 @@
|
||||
import asyncio
|
||||
import discord
|
||||
import logging
|
||||
import traceback
|
||||
from typing import Dict
|
||||
from collections import defaultdict
|
||||
|
||||
from utils.lib import utc_now
|
||||
from data import tables
|
||||
from data.conditions import THIS_SHARD
|
||||
from core import Lion
|
||||
from meta import client
|
||||
|
||||
from ..module import module
|
||||
from .data import current_sessions, SessionChannelType
|
||||
from .settings import untracked_channels, hourly_reward, hourly_live_bonus
|
||||
|
||||
|
||||
class Session:
|
||||
"""
|
||||
A `Session` describes an ongoing study session by a single guild member.
|
||||
A member is counted as studying when they are in a tracked voice channel.
|
||||
|
||||
This class acts as an opaque interface to the corresponding `sessions` data row.
|
||||
"""
|
||||
__slots__ = (
|
||||
'guildid',
|
||||
'userid',
|
||||
'_expiry_task'
|
||||
)
|
||||
# Global cache of ongoing sessions
|
||||
sessions: Dict[int, Dict[int, 'Session']] = defaultdict(dict)
|
||||
|
||||
# Global cache of members pending session start (waiting for daily cap reset)
|
||||
members_pending: Dict[int, Dict[int, asyncio.Task]] = defaultdict(dict)
|
||||
|
||||
def __init__(self, guildid, userid):
|
||||
self.guildid = guildid
|
||||
self.userid = userid
|
||||
|
||||
self._expiry_task: asyncio.Task = None
|
||||
|
||||
@classmethod
|
||||
def get(cls, guildid, userid):
|
||||
"""
|
||||
Fetch the current session for the provided member.
|
||||
If there is no current session, returns `None`.
|
||||
"""
|
||||
return cls.sessions[guildid].get(userid, None)
|
||||
|
||||
@classmethod
|
||||
def start(cls, member: discord.Member, state: discord.VoiceState):
|
||||
"""
|
||||
Start a new study session for the provided member.
|
||||
"""
|
||||
guildid = member.guild.id
|
||||
userid = member.id
|
||||
now = utc_now()
|
||||
|
||||
if userid in cls.sessions[guildid]:
|
||||
raise ValueError("A session for this member already exists!")
|
||||
|
||||
# If the user is study capped, schedule the session start for the next day
|
||||
if (lion := Lion.fetch(guildid, userid)).remaining_study_today <= 10:
|
||||
if pending := cls.members_pending[guildid].pop(userid, None):
|
||||
pending.cancel()
|
||||
task = asyncio.create_task(cls._delayed_start(guildid, userid, member, state))
|
||||
cls.members_pending[guildid][userid] = task
|
||||
client.log(
|
||||
"Member (uid:{}) in (gid:{}) is study capped, "
|
||||
"delaying session start for {} seconds until start of next day.".format(
|
||||
userid, guildid, lion.remaining_in_day
|
||||
),
|
||||
context="SESSION_TRACKER",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
return
|
||||
|
||||
# TODO: More reliable channel type determination
|
||||
if state.channel.id in tables.rented.row_cache:
|
||||
channel_type = SessionChannelType.RENTED
|
||||
elif state.channel.category and state.channel.category.id == lion.guild_settings.accountability_category.data:
|
||||
channel_type = SessionChannelType.ACCOUNTABILITY
|
||||
else:
|
||||
channel_type = SessionChannelType.STANDARD
|
||||
|
||||
current_sessions.create_row(
|
||||
guildid=guildid,
|
||||
userid=userid,
|
||||
channelid=state.channel.id,
|
||||
channel_type=channel_type,
|
||||
start_time=now,
|
||||
live_start=now if (state.self_video or state.self_stream) else None,
|
||||
stream_start=now if state.self_stream else None,
|
||||
video_start=now if state.self_video else None,
|
||||
hourly_coins=hourly_reward.get(guildid).value,
|
||||
hourly_live_coins=hourly_live_bonus.get(guildid).value
|
||||
)
|
||||
session = cls(guildid, userid).activate()
|
||||
client.log(
|
||||
"Started session: {}".format(session.data),
|
||||
context="SESSION_TRACKER",
|
||||
level=logging.DEBUG,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _delayed_start(cls, guildid, userid, *args):
|
||||
delay = Lion.fetch(guildid, userid).remaining_in_day
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
else:
|
||||
cls.start(*args)
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
"""
|
||||
RowTable Session identification key.
|
||||
"""
|
||||
return (self.guildid, self.userid)
|
||||
|
||||
@property
|
||||
def lion(self):
|
||||
"""
|
||||
The Lion member object associated with this member.
|
||||
"""
|
||||
return Lion.fetch(self.guildid, self.userid)
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
"""
|
||||
Row of the `current_sessions` table corresponding to this session.
|
||||
"""
|
||||
return current_sessions.fetch(self.key)
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
"""
|
||||
Current duration of the session.
|
||||
"""
|
||||
return (utc_now() - self.data.start_time).total_seconds()
|
||||
|
||||
@property
|
||||
def coins_earned(self):
|
||||
"""
|
||||
Number of coins earned so far.
|
||||
"""
|
||||
data = self.data
|
||||
|
||||
coins = self.duration * data.hourly_coins
|
||||
coins += data.live_duration * data.hourly_live_coins
|
||||
if data.live_start:
|
||||
coins += (utc_now() - data.live_start).total_seconds() * data.hourly_live_coins
|
||||
return coins // 3600
|
||||
|
||||
def activate(self):
|
||||
"""
|
||||
Activate the study session.
|
||||
This adds the session to the studying members cache,
|
||||
and schedules the session expiry, based on the daily study cap.
|
||||
"""
|
||||
# Add to the active cache
|
||||
self.sessions[self.guildid][self.userid] = self
|
||||
|
||||
# Schedule the session expiry
|
||||
self.schedule_expiry()
|
||||
|
||||
# Return self for easy chaining
|
||||
return self
|
||||
|
||||
def schedule_expiry(self):
|
||||
"""
|
||||
Schedule session termination when the user reaches the maximum daily study time.
|
||||
"""
|
||||
asyncio.create_task(self._schedule_expiry())
|
||||
|
||||
async def _schedule_expiry(self):
|
||||
# Cancel any existing expiry
|
||||
if self._expiry_task and not self._expiry_task.done():
|
||||
self._expiry_task.cancel()
|
||||
|
||||
# Wait for the maximum session length
|
||||
self._expiry_task = asyncio.create_task(asyncio.sleep(self.lion.remaining_study_today))
|
||||
try:
|
||||
await self._expiry_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
else:
|
||||
if self.lion.remaining_study_today <= 10:
|
||||
# End the session
|
||||
# Note that the user will not automatically start a new session when the day starts
|
||||
# TODO: Notify user? Disconnect them?
|
||||
client.log(
|
||||
"Session for (uid:{}) in (gid:{}) reached daily guild study cap.\n{}".format(
|
||||
self.userid, self.guildid, self.data
|
||||
),
|
||||
context="SESSION_TRACKER"
|
||||
)
|
||||
self.finish()
|
||||
else:
|
||||
# It's possible the expiry time was pushed forwards while waiting
|
||||
# If so, reschedule
|
||||
self.schedule_expiry()
|
||||
|
||||
def finish(self):
|
||||
"""
|
||||
Close the study session.
|
||||
"""
|
||||
# Note that save_live_status doesn't need to be called here
|
||||
# The database saving procedure will account for the values.
|
||||
current_sessions.queries.close_study_session(*self.key)
|
||||
|
||||
# Remove session from active cache
|
||||
self.sessions[self.guildid].pop(self.userid, None)
|
||||
|
||||
# Cancel any existing expiry task
|
||||
if self._expiry_task and not self._expiry_task.done():
|
||||
self._expiry_task.cancel()
|
||||
|
||||
def save_live_status(self, state: discord.VoiceState):
|
||||
"""
|
||||
Update the saved live status of the member.
|
||||
"""
|
||||
has_video = state.self_video
|
||||
has_stream = state.self_stream
|
||||
is_live = has_video or has_stream
|
||||
|
||||
now = utc_now()
|
||||
data = self.data
|
||||
|
||||
with data.batch_update():
|
||||
# Update video session stats
|
||||
if data.video_start:
|
||||
data.video_duration += (now - data.video_start).total_seconds()
|
||||
data.video_start = now if has_video else None
|
||||
|
||||
# Update stream session stats
|
||||
if data.stream_start:
|
||||
data.stream_duration += (now - data.stream_start).total_seconds()
|
||||
data.stream_start = now if has_stream else None
|
||||
|
||||
# Update overall live session stats
|
||||
if data.live_start:
|
||||
data.live_duration += (now - data.live_start).total_seconds()
|
||||
data.live_start = now if is_live else None
|
||||
|
||||
|
||||
async def session_voice_tracker(client, member, before, after):
|
||||
"""
|
||||
Voice update event dispatcher for study session tracking.
|
||||
"""
|
||||
if member.bot:
|
||||
return
|
||||
|
||||
guild = member.guild
|
||||
Lion.fetch(guild.id, member.id).update_saved_data(member)
|
||||
session = Session.get(guild.id, member.id)
|
||||
|
||||
if before.channel == after.channel:
|
||||
# Voice state change without moving channel
|
||||
if session and ((before.self_video != after.self_video) or (before.self_stream != after.self_stream)):
|
||||
# Live status has changed!
|
||||
session.save_live_status(after)
|
||||
else:
|
||||
# Member changed channel
|
||||
# End the current session and start a new one, if applicable
|
||||
if session:
|
||||
if (scid := session.data.channelid) and (not before.channel or scid != before.channel.id):
|
||||
client.log(
|
||||
"The previous voice state for "
|
||||
"member {member.name} (uid:{member.id}) in {guild.name} (gid:{guild.id}) "
|
||||
"does not match their current study session!\n"
|
||||
"Session channel is (cid:{scid}), but the previous channel is {previous}.".format(
|
||||
member=member,
|
||||
guild=member.guild,
|
||||
scid=scid,
|
||||
previous="{0.name} (cid:{0.id})".format(before.channel) if before.channel else "None"
|
||||
),
|
||||
context="SESSION_TRACKER",
|
||||
level=logging.ERROR
|
||||
)
|
||||
client.log(
|
||||
"Ending study session for {member.name} (uid:{member.id}) "
|
||||
"in {member.guild.id} (gid:{member.guild.id}) since they left the voice channel.\n{session}".format(
|
||||
member=member,
|
||||
session=session.data
|
||||
),
|
||||
context="SESSION_TRACKER",
|
||||
post=False
|
||||
)
|
||||
# End the current session
|
||||
session.finish()
|
||||
elif pending := Session.members_pending[guild.id].pop(member.id, None):
|
||||
client.log(
|
||||
"Cancelling pending study session for {member.name} (uid:{member.id}) "
|
||||
"in {member.guild.name} (gid:{member.guild.id}) since they left the voice channel.".format(
|
||||
member=member
|
||||
),
|
||||
context="SESSION_TRACKER",
|
||||
post=False
|
||||
)
|
||||
pending.cancel()
|
||||
|
||||
if after.channel:
|
||||
blacklist = client.user_blacklist()
|
||||
guild_blacklist = client.objects['ignored_members'][guild.id]
|
||||
untracked = untracked_channels.get(guild.id).data
|
||||
start_session = (
|
||||
(after.channel.id not in untracked)
|
||||
and (member.id not in blacklist)
|
||||
and (member.id not in guild_blacklist)
|
||||
)
|
||||
if start_session:
|
||||
# Start a new session for the member
|
||||
client.log(
|
||||
"Starting a new voice channel study session for {member.name} (uid:{member.id}) "
|
||||
"in {member.guild.name} (gid:{member.guild.id}).".format(
|
||||
member=member,
|
||||
),
|
||||
context="SESSION_TRACKER",
|
||||
post=False
|
||||
)
|
||||
session = Session.start(member, after)
|
||||
|
||||
|
||||
async def leave_guild_sessions(client, guild):
|
||||
"""
|
||||
`guild_leave` hook.
|
||||
Close all sessions in the guild when we leave.
|
||||
"""
|
||||
sessions = list(Session.sessions[guild.id].values())
|
||||
for session in sessions:
|
||||
session.finish()
|
||||
client.log(
|
||||
"Left {} (gid:{}) and closed {} ongoing study sessions.".format(guild.name, guild.id, len(sessions)),
|
||||
context="SESSION_TRACKER"
|
||||
)
|
||||
|
||||
|
||||
async def join_guild_sessions(client, guild):
|
||||
"""
|
||||
`guild_join` hook.
|
||||
Refresh all sessions for the guild when we rejoin.
|
||||
"""
|
||||
# Delete existing current sessions, which should have been closed when we left
|
||||
# It is possible we were removed from the guild during an outage
|
||||
current_sessions.delete_where(guildid=guild.id)
|
||||
|
||||
untracked = untracked_channels.get(guild.id).data
|
||||
members = [
|
||||
member
|
||||
for channel in guild.voice_channels
|
||||
for member in channel.members
|
||||
if channel.members and channel.id not in untracked and not member.bot
|
||||
]
|
||||
for member in members:
|
||||
client.log(
|
||||
"Starting new session for '{}' (uid: {}) in '{}' (cid: {}) of '{}' (gid: {})".format(
|
||||
member.name,
|
||||
member.id,
|
||||
member.voice.channel.name,
|
||||
member.voice.channel.id,
|
||||
member.guild.name,
|
||||
member.guild.id
|
||||
),
|
||||
context="SESSION_TRACKER",
|
||||
level=logging.INFO,
|
||||
post=False
|
||||
)
|
||||
Session.start(member, member.voice)
|
||||
|
||||
# Log newly started sessions
|
||||
client.log(
|
||||
"Joined {} (gid:{}) and started {} new study sessions from current voice channel members.".format(
|
||||
guild.name,
|
||||
guild.id,
|
||||
len(members)
|
||||
),
|
||||
context="SESSION_TRACKER",
|
||||
)
|
||||
|
||||
|
||||
async def _init_session_tracker(client):
|
||||
"""
|
||||
Load ongoing saved study sessions into the session cache,
|
||||
update them depending on the current voice states,
|
||||
and attach the voice event handler.
|
||||
"""
|
||||
# Ensure the client caches are ready and guilds are chunked
|
||||
await client.wait_until_ready()
|
||||
|
||||
# Pre-cache the untracked channels
|
||||
await untracked_channels.launch_task(client)
|
||||
|
||||
# Log init start and define logging counters
|
||||
client.log(
|
||||
"Loading ongoing study sessions.",
|
||||
context="SESSION_INIT",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
resumed = 0
|
||||
ended = 0
|
||||
|
||||
# Grab all ongoing sessions from data
|
||||
rows = current_sessions.fetch_rows_where(guildid=THIS_SHARD)
|
||||
|
||||
# Iterate through, resume or end as needed
|
||||
for row in rows:
|
||||
if (guild := client.get_guild(row.guildid)) is not None and row.channelid is not None:
|
||||
try:
|
||||
# Load the Session
|
||||
session = Session(row.guildid, row.userid)
|
||||
|
||||
# Find the channel and member voice state
|
||||
voice = None
|
||||
if channel := guild.get_channel(row.channelid):
|
||||
voice = next((member.voice for member in channel.members if member.id == row.userid), None)
|
||||
|
||||
# Resume or end as required
|
||||
if voice and voice.channel:
|
||||
client.log(
|
||||
"Resuming ongoing session: {}".format(row),
|
||||
context="SESSION_INIT",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
session.activate()
|
||||
session.save_live_status(voice)
|
||||
resumed += 1
|
||||
else:
|
||||
client.log(
|
||||
"Ending already completed session: {}".format(row),
|
||||
context="SESSION_INIT",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
session.finish()
|
||||
ended += 1
|
||||
except Exception:
|
||||
# Fatal error
|
||||
client.log(
|
||||
"Fatal error occurred initialising session: {}\n{}".format(row, traceback.format_exc()),
|
||||
context="SESSION_INIT",
|
||||
level=logging.CRITICAL
|
||||
)
|
||||
module.ready = False
|
||||
return
|
||||
|
||||
# Log resumed sessions
|
||||
client.log(
|
||||
"Resumed {} ongoing study sessions, and ended {}.".format(resumed, ended),
|
||||
context="SESSION_INIT",
|
||||
level=logging.INFO
|
||||
)
|
||||
|
||||
# Now iterate through members of all tracked voice channels
|
||||
# Start sessions if they don't already exist
|
||||
tracked_channels = [
|
||||
channel
|
||||
for guild in client.guilds
|
||||
for channel in guild.voice_channels
|
||||
if channel.members and channel.id not in untracked_channels.get(guild.id).data
|
||||
]
|
||||
new_members = [
|
||||
member
|
||||
for channel in tracked_channels
|
||||
for member in channel.members
|
||||
if not member.bot and not Session.get(member.guild.id, member.id)
|
||||
]
|
||||
for member in new_members:
|
||||
client.log(
|
||||
"Starting new session for '{}' (uid: {}) in '{}' (cid: {}) of '{}' (gid: {})".format(
|
||||
member.name,
|
||||
member.id,
|
||||
member.voice.channel.name,
|
||||
member.voice.channel.id,
|
||||
member.guild.name,
|
||||
member.guild.id
|
||||
),
|
||||
context="SESSION_INIT",
|
||||
level=logging.DEBUG
|
||||
)
|
||||
Session.start(member, member.voice)
|
||||
|
||||
# Log newly started sessions
|
||||
client.log(
|
||||
"Started {} new study sessions from current voice channel members.".format(len(new_members)),
|
||||
context="SESSION_INIT",
|
||||
level=logging.INFO
|
||||
)
|
||||
|
||||
# Now that we are in a valid initial state, attach the session event handler
|
||||
client.add_after_event("voice_state_update", session_voice_tracker)
|
||||
client.add_after_event("guild_remove", leave_guild_sessions)
|
||||
client.add_after_event("guild_join", join_guild_sessions)
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def launch_session_tracker(client):
|
||||
"""
|
||||
Launch the study session initialiser.
|
||||
Doesn't block on the client being ready.
|
||||
"""
|
||||
client.objects['sessions'] = Session.sessions
|
||||
asyncio.create_task(_init_session_tracker(client))
|
||||
143
bot/modules/pending-rewrite/study/tracking/settings.py
Normal file
143
bot/modules/pending-rewrite/study/tracking/settings.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import settings
|
||||
from settings import GuildSettings
|
||||
from wards import guild_admin
|
||||
|
||||
from .data import untracked_channels
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class untracked_channels(settings.ChannelList, settings.ListData, settings.Setting):
|
||||
category = "Study Tracking"
|
||||
|
||||
attr_name = 'untracked_channels'
|
||||
|
||||
_table_interface = untracked_channels
|
||||
_setting = settings.VoiceChannel
|
||||
|
||||
_id_column = 'guildid'
|
||||
_data_column = 'channelid'
|
||||
|
||||
write_ward = guild_admin
|
||||
display_name = "untracked_channels"
|
||||
desc = "Channels to ignore for study time tracking."
|
||||
|
||||
_force_unique = True
|
||||
|
||||
long_desc = (
|
||||
"Time spent in these voice channels won't add study time or lioncoins to the member."
|
||||
)
|
||||
|
||||
# Flat cache, no need to expire objects
|
||||
_cache = {}
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "The untracked channels have been updated:\n{}".format(self.formatted)
|
||||
else:
|
||||
return "Study time will now be counted in all channels."
|
||||
|
||||
@classmethod
|
||||
async def launch_task(cls, client):
|
||||
"""
|
||||
Launch initialisation step for the `untracked_channels` setting.
|
||||
|
||||
Pre-fill cache for the guilds with currently active voice channels.
|
||||
"""
|
||||
active_guildids = [
|
||||
guild.id
|
||||
for guild in client.guilds
|
||||
if any(channel.members for channel in guild.voice_channels)
|
||||
]
|
||||
if active_guildids:
|
||||
cache = {guildid: [] for guildid in active_guildids}
|
||||
rows = cls._table_interface.select_where(
|
||||
guildid=active_guildids
|
||||
)
|
||||
for row in rows:
|
||||
cache[row['guildid']].append(row['channelid'])
|
||||
cls._cache.update(cache)
|
||||
client.log(
|
||||
"Cached {} untracked channels for {} active guilds.".format(
|
||||
len(rows),
|
||||
len(cache)
|
||||
),
|
||||
context="UNTRACKED_CHANNELS"
|
||||
)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class hourly_reward(settings.Integer, settings.GuildSetting):
|
||||
category = "Study Tracking"
|
||||
|
||||
attr_name = "hourly_reward"
|
||||
_data_column = "study_hourly_reward"
|
||||
|
||||
display_name = "hourly_reward"
|
||||
desc = "Number of LionCoins given per hour of study."
|
||||
|
||||
_default = 50
|
||||
_max = 32767
|
||||
|
||||
long_desc = (
|
||||
"Each spent in a voice channel will reward this number of LionCoins."
|
||||
)
|
||||
_accepts = "An integer number of LionCoins to reward."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "Members will be rewarded `{}` LionCoins per hour of study.".format(self.formatted)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class hourly_live_bonus(settings.Integer, settings.GuildSetting):
|
||||
category = "Study Tracking"
|
||||
|
||||
attr_name = "hourly_live_bonus"
|
||||
_data_column = "study_hourly_live_bonus"
|
||||
|
||||
display_name = "hourly_live_bonus"
|
||||
desc = "Number of extra LionCoins given for a full hour of streaming (via go live or video)."
|
||||
|
||||
_default = 150
|
||||
_max = 32767
|
||||
|
||||
long_desc = (
|
||||
"LionCoin bonus earnt for every hour a member streams in a voice channel, including video. "
|
||||
"This is in addition to the standard `hourly_reward`."
|
||||
)
|
||||
_accepts = "An integer number of LionCoins to reward."
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "Members will be rewarded an extra `{}` LionCoins per hour if they stream.".format(self.formatted)
|
||||
|
||||
|
||||
@GuildSettings.attach_setting
|
||||
class daily_study_cap(settings.Duration, settings.GuildSetting):
|
||||
category = "Study Tracking"
|
||||
|
||||
attr_name = "daily_study_cap"
|
||||
_data_column = "daily_study_cap"
|
||||
|
||||
display_name = "daily_study_cap"
|
||||
desc = "Maximum amount of recorded study time per member per day."
|
||||
|
||||
_default = 16 * 60 * 60
|
||||
_default_multiplier = 60 * 60
|
||||
|
||||
_max = 25 * 60 * 60
|
||||
|
||||
long_desc = (
|
||||
"The maximum amount of study time that can be recorded for a member per day, "
|
||||
"intended to remove system encouragement for unhealthy or obsessive behaviour.\n"
|
||||
"The member may study for longer, but their sessions will not be tracked. "
|
||||
"The start and end of the day are determined by the member's configured timezone."
|
||||
)
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
# Refresh expiry for all sessions in the guild
|
||||
[session.schedule_expiry() for session in self.client.objects['sessions'][self.id].values()]
|
||||
|
||||
return "The maximum tracked daily study time is now {}.".format(self.formatted)
|
||||
109
bot/modules/pending-rewrite/study/tracking/time_tracker.py
Normal file
109
bot/modules/pending-rewrite/study/tracking/time_tracker.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import itertools
|
||||
import traceback
|
||||
import logging
|
||||
import asyncio
|
||||
from time import time
|
||||
|
||||
from meta import client
|
||||
from core import Lion
|
||||
|
||||
from ..module import module
|
||||
from .settings import untracked_channels, hourly_reward, hourly_live_bonus
|
||||
|
||||
|
||||
last_scan = {} # guildid -> timestamp
|
||||
|
||||
|
||||
def _scan(guild):
|
||||
"""
|
||||
Scan the tracked voice channels and add time and coins to each user.
|
||||
"""
|
||||
# Current timestamp
|
||||
now = time()
|
||||
|
||||
# Get last scan timestamp
|
||||
try:
|
||||
last = last_scan[guild.id]
|
||||
except KeyError:
|
||||
return
|
||||
finally:
|
||||
last_scan[guild.id] = now
|
||||
|
||||
# Calculate time since last scan
|
||||
interval = now - last
|
||||
|
||||
# Discard if it has been more than 20 minutes (discord outage?)
|
||||
if interval > 60 * 20:
|
||||
return
|
||||
|
||||
untracked = untracked_channels.get(guild.id).data
|
||||
guild_hourly_reward = hourly_reward.get(guild.id).data
|
||||
guild_hourly_live_bonus = hourly_live_bonus.get(guild.id).data
|
||||
|
||||
channel_members = (
|
||||
channel.members for channel in guild.voice_channels if channel.id not in untracked
|
||||
)
|
||||
|
||||
members = itertools.chain(*channel_members)
|
||||
# TODO filter out blacklisted users
|
||||
|
||||
blacklist = client.user_blacklist()
|
||||
guild_blacklist = client.objects['ignored_members'][guild.id]
|
||||
|
||||
for member in members:
|
||||
if member.bot:
|
||||
continue
|
||||
if member.id in blacklist or member.id in guild_blacklist:
|
||||
continue
|
||||
lion = Lion.fetch(guild.id, member.id)
|
||||
|
||||
# Add time
|
||||
lion.addTime(interval, flush=False)
|
||||
|
||||
# Add coins
|
||||
hour_reward = guild_hourly_reward
|
||||
if member.voice.self_stream or member.voice.self_video:
|
||||
hour_reward += guild_hourly_live_bonus
|
||||
|
||||
lion.addCoins(hour_reward * interval / (3600), flush=False, bonus=True)
|
||||
|
||||
|
||||
async def _study_tracker():
|
||||
"""
|
||||
Scanner launch loop.
|
||||
"""
|
||||
while True:
|
||||
while not client.is_ready():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# Launch scanners on each guild
|
||||
for guild in client.guilds:
|
||||
# Short wait to pass control to other asyncio tasks if they need it
|
||||
await asyncio.sleep(0)
|
||||
try:
|
||||
# Scan the guild
|
||||
_scan(guild)
|
||||
except Exception:
|
||||
# Unknown exception. Catch it so the loop doesn't die.
|
||||
client.log(
|
||||
"Error while scanning guild '{}'(gid:{})! "
|
||||
"Exception traceback follows.\n{}".format(
|
||||
guild.name,
|
||||
guild.id,
|
||||
traceback.format_exc()
|
||||
),
|
||||
context="VOICE_ACTIVITY_SCANNER",
|
||||
level=logging.ERROR
|
||||
)
|
||||
|
||||
|
||||
@module.launch_task
|
||||
async def launch_study_tracker(client):
|
||||
# First pre-load the untracked channels
|
||||
await untracked_channels.launch_task(client)
|
||||
asyncio.create_task(_study_tracker())
|
||||
|
||||
|
||||
# TODO: Logout handler, sync
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user