Merge branch 'python-rewrite' into reaction_roles

This commit is contained in:
2021-10-19 13:23:16 +03:00
24 changed files with 907 additions and 35 deletions

View File

@@ -74,6 +74,7 @@ lions = RowTable(
'workout_count', 'last_workout_start', 'workout_count', 'last_workout_start',
'last_study_badgeid', 'last_study_badgeid',
'video_warned', 'video_warned',
'_timestamp'
), ),
('guildid', 'userid'), ('guildid', 'userid'),
cache=TTLCache(5000, ttl=60*5), cache=TTLCache(5000, ttl=60*5),

View File

@@ -2,7 +2,7 @@ import pytz
from meta import client from meta import client
from data import tables as tb from data import tables as tb
from settings import UserSettings from settings import UserSettings, GuildSettings
class Lion: class Lion:
@@ -41,7 +41,13 @@ class Lion:
if key in cls._lions: if key in cls._lions:
return cls._lions[key] return cls._lions[key]
else: else:
tb.lions.fetch_or_create(key) lion = tb.lions.fetch(key)
if not lion:
tb.lions.create_row(
guildid=guildid,
userid=userid,
coins=GuildSettings(guildid).starting_funds.value
)
return cls(guildid, userid) return cls(guildid, userid)
@property @property

View File

@@ -40,6 +40,32 @@ def setting_initialisation(client):
setting.init_task(client) setting.init_task(client)
@module.launch_task
async def preload_guild_configuration(client):
"""
Loads the plain guild configuration for all guilds the client is part of into data.
"""
guildids = [guild.id for guild in client.guilds]
rows = client.data.guild_config.fetch_rows_where(guildid=guildids)
client.log(
"Preloaded guild configuration for {} guilds.".format(len(rows)),
context="CORE_LOADING"
)
@module.launch_task
async def preload_studying_members(client):
"""
Loads the member data for all members who are currently in voice channels.
"""
userids = list(set(member.id for guild in client.guilds for ch in guild.voice_channels for member in ch.members))
rows = client.data.lions.fetch_rows_where(userid=userids)
client.log(
"Preloaded member data for {} members.".format(len(rows)),
context="CORE_LOADING"
)
@module.launch_task @module.launch_task
async def launch_lion_sync_loop(client): async def launch_lion_sync_loop(client):
asyncio.create_task(_lion_sync_loop()) asyncio.create_task(_lion_sync_loop())

View File

@@ -62,7 +62,7 @@ def ensure_exclusive(ctx):
@module.cmd( @module.cmd(
name="rooms", name="rooms",
desc="Book an accountability timeslot", desc="Schedule an accountability study session.",
group="Productivity" group="Productivity"
) )
@in_guild() @in_guild()

View File

@@ -8,6 +8,7 @@ from typing import Dict
from discord.utils import sleep_until from discord.utils import sleep_until
from meta import client from meta import client
from utils.interactive import discord_shield
from data import NULL, NOTNULL, tables from data import NULL, NOTNULL, tables
from data.conditions import LEQ from data.conditions import LEQ
from settings import GuildSettings from settings import GuildSettings
@@ -167,7 +168,7 @@ async def turnover():
to_update = [ to_update = [
(mem.data.duration + int((now - mem.data.last_joined_at).total_seconds()), None, mem.slotid, mem.userid) (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() for slot in last_slots for mem in slot.members.values()
if mem.data.last_joined_at if mem.data and mem.data.last_joined_at
] ]
if to_update: if to_update:
accountability_members.update_many( accountability_members.update_many(
@@ -177,6 +178,15 @@ async def turnover():
cast_row='(NULL::int, NULL::timestamptz, NULL::int, NULL::int)' 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 # Rotate guild sessions
[aguild.advance() for aguild in AccountabilityGuild.cache.values()] [aguild.advance() for aguild in AccountabilityGuild.cache.values()]
@@ -184,7 +194,6 @@ async def turnover():
# We could break up the session starting? # We could break up the session starting?
# Move members of the next session over to the session channel # Move members of the next session over to the session channel
# This includes any members of the session just complete
current_slots = [ current_slots = [
aguild.current_slot for aguild in AccountabilityGuild.cache.values() aguild.current_slot for aguild in AccountabilityGuild.cache.values()
if aguild.current_slot is not None if aguild.current_slot is not None
@@ -206,21 +215,12 @@ async def turnover():
return_exceptions=True return_exceptions=True
) )
# Close all completed rooms, update data
await asyncio.gather(*(slot.close() for slot in last_slots))
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
)
# Update session data of all members in new channels # Update session data of all members in new channels
member_session_data = [ member_session_data = [
(0, slot.start_time, mem.slotid, mem.userid) (0, slot.start_time, mem.slotid, mem.userid)
for slot in current_slots for slot in current_slots
for mem in slot.members.values() for mem in slot.members.values()
if mem.member.voice and mem.member.voice.channel == slot.channel if mem.data and mem.member and mem.member.voice and mem.member.voice.channel == slot.channel
] ]
if member_session_data: if member_session_data:
accountability_members.update_many( accountability_members.update_many(
@@ -460,3 +460,31 @@ async def unload_accountability(client):
Save the current sessions and cancel the runloop in preparation for client shutdown. 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
)
))

View File

@@ -2,4 +2,5 @@ from .module import module
from . import guild_config from . import guild_config
from . import statreset from . import statreset
from . import new_members
from . import reaction_roles from . import reaction_roles

View File

@@ -11,7 +11,7 @@ from .module import module
# Pages of configuration categories to display # Pages of configuration categories to display
cat_pages = { cat_pages = {
'Administration': ('Meta', 'Guild Roles'), 'Administration': ('Meta', 'Guild Roles', 'New Members'),
'Moderation': ('Moderation', 'Video Channels'), 'Moderation': ('Moderation', 'Video Channels'),
'Productivity': ('Study Tracking', 'TODO List', 'Workout'), 'Productivity': ('Study Tracking', 'TODO List', 'Workout'),
'Study Rooms': ('Rented Rooms', 'Accountability Rooms'), 'Study Rooms': ('Rented Rooms', 'Accountability Rooms'),
@@ -21,6 +21,7 @@ cat_pages = {
descriptions = { descriptions = {
} }
@module.cmd("config", @module.cmd("config",
desc="View and modify the server settings.", desc="View and modify the server settings.",
flags=('add', 'remove'), flags=('add', 'remove'),
@@ -91,27 +92,27 @@ async def cmd_config(ctx, flags):
) )
) )
if len(parts) == 1: if len(parts) == 1 and not ctx.msg.attachments:
# config <setting> # config <setting>
# View config embed for provided setting # View config embed for provided setting
await ctx.reply(embed=setting.get(ctx.guild.id).embed) await setting.get(ctx.guild.id).widget(ctx, flags=flags)
else: else:
# config <setting> <value> # config <setting> <value>
# Check the write ward # Check the write ward
if not await setting.write_ward.run(ctx): if not await setting.write_ward.run(ctx):
await ctx.error_reply(setting.msg) await ctx.error_reply(setting.write_ward.msg)
# Attempt to set config setting # Attempt to set config setting
try: try:
parsed = await setting.parse(ctx.guild.id, ctx, parts[1]) 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']) parsed.write(add_only=flags['add'], remove_only=flags['remove'])
except UserInputError as e: except UserInputError as e:
await ctx.reply(embed=discord.Embed( await ctx.reply(embed=discord.Embed(
description="{} {}".format('', e.msg), description="{} {}".format('', e.msg),
Colour=discord.Colour.red() colour=discord.Colour.red()
)) ))
else: else:
await ctx.reply(embed=discord.Embed( await ctx.reply(embed=discord.Embed(
description="{} {}".format('', setting.get(ctx.guild.id).success_response), description="{} {}".format('', setting.get(ctx.guild.id).success_response),
Colour=discord.Colour.green() colour=discord.Colour.green()
)) ))

View File

@@ -0,0 +1,3 @@
from . import settings
from . import greetings
from . import roles

View File

@@ -0,0 +1,6 @@
from data import Table, RowTable
autoroles = Table('autoroles')
bot_autoroles = Table('bot_autoroles')
past_member_roles = Table('past_member_roles')

View File

@@ -0,0 +1,29 @@
import discord
from cmdClient.Context import 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

View 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')
)

View 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 = "greeting_channel"
desc = "Channel to send the greeting message in"
long_desc = (
"Channel to post the `greeting_message` in when a new user joins the server. "
"Accepts `DM` to indicate the greeting should be direct messaged to the new member."
)
_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 "Greeting messages are disabled."
elif value == self.DMCHANNEL:
return "Greeting messages will be sent via direct message."
else:
return "Greeting 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 = 'greeting_message'
desc = "Greeting message sent to welcome new members."
long_desc = (
"Message to send to the configured `greeting_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 greeting 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 = "Greeting message sent to returning members."
long_desc = (
"Message to send to the configured `greeting_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 = 0
@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."

View File

@@ -40,15 +40,15 @@ bot_admin_group_order = (
# Help embed format # Help embed format
# TODO: Add config fields for this # TODO: Add config fields for this
title = "LionBot Command List" title = "StudyLion Command List"
header = """ header = """
Use `{ctx.best_prefix}help <cmd>` (e.g. `{ctx.best_prefix}help send`) to see how to use each command. Use `{ctx.best_prefix}help <command>` (e.g. `{ctx.best_prefix}help send`) to see how to use each command.
""" """
@module.cmd("help", @module.cmd("help",
group="Meta", group="Meta",
desc="LionBot command list.") desc="StudyLion command list.")
async def cmd_help(ctx): async def cmd_help(ctx):
""" """
Usage``: Usage``:

View File

@@ -120,9 +120,9 @@ async def cmd_tickets(ctx, flags):
if not tickets: if not tickets:
if filters: if filters:
return ctx.embed_reply("There are no tickets with these criteria!") return await ctx.embed_reply("There are no tickets with these criteria!")
else: else:
return ctx.embed_reply("There are no moderation tickets in this server!") 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) tickets = sorted(tickets, key=lambda ticket: ticket.data.guild_ticketid, reverse=True)
ticket_map = {ticket.data.guild_ticketid: ticket for ticket in tickets} ticket_map = {ticket.data.guild_ticketid: ticket for ticket in tickets}

View File

@@ -7,7 +7,7 @@ from .rooms import Room
@module.cmd( @module.cmd(
name="rent", name="rent",
desc="Rent a private study room!", desc="Rent a private study room with your friends!",
group="Productivity", group="Productivity",
aliases=('add',) aliases=('add',)
) )

View File

@@ -243,7 +243,7 @@ class Room:
guild_settings.event_log.log( guild_settings.event_log.log(
title="Failed to update study room permissions!", title="Failed to update study room permissions!",
description=("An error occured while removing the " description=("An error occured while removing the "
"following members from the private room {}.\n{}").format( "following members from the private room {}.\n{}").format(
self.channel.mention, self.channel.mention,
', '.join(member.mention for member in members) ', '.join(member.mention for member in members)
), ),
@@ -282,3 +282,38 @@ async def load_rented_rooms(client):
"Loaded {} private study channels.".format(len(rows)), "Loaded {} private study channels.".format(len(rows)),
context="LOAD_RENTED_ROOMS" 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

View File

@@ -1,3 +1,5 @@
from collections import defaultdict
import settings import settings
from settings import GuildSettings from settings import GuildSettings
from wards import guild_admin from wards import guild_admin
@@ -37,6 +39,34 @@ class untracked_channels(settings.ChannelList, settings.ListData, settings.Setti
else: else:
return "Study time will now be counted in all channels." 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:
rows = cls._table_interface.select_where(
guildid=active_guildids
)
cache = defaultdict(list)
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 @GuildSettings.attach_setting
class hourly_reward(settings.Integer, settings.GuildSetting): class hourly_reward(settings.Integer, settings.GuildSetting):

View File

@@ -101,6 +101,8 @@ async def _study_tracker():
@module.launch_task @module.launch_task
async def launch_study_tracker(client): async def launch_study_tracker(client):
# First pre-load the untracked channels
await admin.untracked_channels.launch_task(client)
asyncio.create_task(_study_tracker()) asyncio.create_task(_study_tracker())

View File

@@ -9,7 +9,7 @@ from .Tasklist import Tasklist
@module.cmd( @module.cmd(
name="todo", name="todo",
desc="Display and edit your personal TODO list.", desc="Display and edit your personal To-Do list.",
group="Productivity", group="Productivity",
flags=('add==', 'delete==', 'check==', 'uncheck==', 'edit==') flags=('add==', 'delete==', 'check==', 'uncheck==', 'edit==')
) )

View File

@@ -51,6 +51,14 @@ class Setting:
embed.description = "{}\n{}".format(self.long_desc.format(self=self, client=self.client), table) embed.description = "{}\n{}".format(self.long_desc.format(self=self, client=self.client), table)
return embed return embed
async def widget(self, ctx: Context, **kwargs):
"""
Show the setting widget for this setting.
By default this displays the setting embed.
Settings may override this if they need more complex widget context or logic.
"""
return await ctx.reply(embed=self.embed)
@property @property
def summary(self): def summary(self):
""" """

View File

@@ -1,5 +1,8 @@
import json
import asyncio
import datetime import datetime
import itertools import itertools
from io import StringIO
from enum import IntEnum from enum import IntEnum
from typing import Any, Optional from typing import Any, Optional
@@ -9,11 +12,14 @@ from cmdClient.Context import Context
from cmdClient.lib import SafeCancellation from cmdClient.lib import SafeCancellation
from meta import client from meta import client
from utils.lib import parse_dur, strfdur, strfdelta from utils.lib import parse_dur, strfdur, strfdelta, prop_tabulate, multiple_replace
from .base import UserInputError from .base import UserInputError
preview_emoji = '🔍'
class SettingType: class SettingType:
""" """
Abstract class representing a setting type. Abstract class representing a setting type.
@@ -687,6 +693,209 @@ class Duration(SettingType):
return "`{}`".format(strfdur(data, short=False, show_days=cls._show_days)) return "`{}`".format(strfdur(data, short=False, show_days=cls._show_days))
class Message(SettingType):
"""
Message type storing json-encoded message arguments.
Messages without an embed are displayed differently from those with an embed.
Types:
data: str
A json dictionary with the fields `content` and `embed`.
value: dict
An argument dictionary suitable for `Message.send` or `Message.edit`.
"""
_substitution_desc = {
}
@classmethod
def _data_from_value(cls, id, value, **kwargs):
if value is None:
return None
return json.dumps(value)
@classmethod
def _data_to_value(cls, id, data, **kwargs):
if data is None:
return None
return json.loads(data)
@classmethod
async def parse(cls, id: int, ctx: Context, userstr: str, **kwargs):
"""
Return a setting instance initialised from a parsed user string.
"""
if ctx.msg.attachments:
attachment = ctx.msg.attachments[0]
if 'text' in attachment.content_type or 'json' in attachment.content_type:
userstr = (await attachment.read()).decode()
data = await cls._parse_userstr(ctx, id, userstr, as_json=True, **kwargs)
else:
raise UserInputError("Can't read the attached file!")
else:
data = await cls._parse_userstr(ctx, id, userstr, **kwargs)
return cls(id, data, **kwargs)
@classmethod
async def _parse_userstr(cls, ctx, id, userstr, as_json=False, **kwargs):
"""
Parse the provided string as either a content-only string, or json-format arguments.
Provided string is not trusted, and is parsed in a safe manner.
"""
if userstr.lower() == 'none':
return None
if as_json:
try:
args = json.loads(userstr)
except json.JSONDecodeError:
raise UserInputError(
"Couldn't parse your message! "
"You can test and fix it on the embed builder "
"[here](https://glitchii.github.io/embedbuilder/?editor=json)."
)
if 'embed' in args and 'timestamp' in args['embed']:
args['embed'].pop('timestamp')
return json.dumps(args)
else:
return json.dumps({'content': userstr})
@classmethod
def _format_data(cls, id, data, **kwargs):
if data is None:
return "Empty"
value = cls._data_to_value(id, data, **kwargs)
if 'embed' not in value and len(value['content']) < 100:
return "`{}`".format(value['content'])
else:
return "Too long to display here!"
def substitution_keys(self, ctx, **kwargs):
"""
Instances should override this to provide their own substitution implementation.
"""
return {}
def args(self, ctx, **kwargs):
"""
Applies the substitutions with the given context to generate the final message args.
"""
value = self.value
substitutions = self.substitution_keys(ctx, **kwargs)
args = {}
if 'content' in value:
args['content'] = multiple_replace(value['content'], substitutions)
if 'embed' in value:
args['embed'] = discord.Embed.from_dict(
json.loads(multiple_replace(json.dumps(value['embed']), substitutions))
)
return args
async def widget(self, ctx, **kwargs):
value = self.value
args = self.args(ctx, **kwargs)
if not value:
return await ctx.reply(embed=self.embed)
current_str = None
preview = None
file_content = None
if 'embed' in value or len(value['content']) > 1024:
current_str = "See attached file."
file_content = json.dumps(value, indent=4)
elif "`" in value['content']:
current_str = "```{}```".format(value['content'])
if len(args['content']) < 1000:
preview = args['content']
else:
current_str = "`{}`".format(value['content'])
if len(args['content']) < 1000:
preview = args['content']
description = "{}\n\n**Current Value**: {}".format(
self.long_desc.format(self=self, client=self.client),
current_str
)
embed = discord.Embed(
title="Configuration options for `{}`".format(self.display_name),
description=description
)
if preview:
embed.add_field(name="Message Preview", value=preview, inline=False)
embed.add_field(
name="Setting Guide",
value=(
"• For plain text without an embed, use `{prefix}config {setting} <text>`.\n"
"• To include an embed, build the message [here]({builder}) "
"and upload the json code as a file with the `{prefix}config {setting}` command.\n"
"• To reset the message to the default, use `{prefix}config {setting} None`."
).format(
prefix=ctx.best_prefix,
setting=self.display_name,
builder="https://glitchii.github.io/embedbuilder/?editor=gui"
),
inline=False
)
if self._substitution_desc:
embed.add_field(
name="Substitution Keys",
value=(
"*The following keys will be substituted for their current values.*\n{}"
).format(
prop_tabulate(*zip(*self._substitution_desc.items()), colon=False)
),
inline=False
)
embed.set_footer(
text="React with {} to preview the message.".format(preview_emoji)
)
if file_content:
with StringIO() as message_file:
message_file.write(file_content)
message_file.seek(0)
out_file = discord.File(message_file, filename="{}.json".format(self.display_name))
out_msg = await ctx.reply(embed=embed, file=out_file)
else:
out_msg = await ctx.reply(embed=embed)
# Add the preview reaction and send the preview when requested
try:
await out_msg.add_reaction(preview_emoji)
except discord.HTTPException:
return
try:
await ctx.client.wait_for(
'reaction_add',
check=lambda r, u: r.message.id == out_msg.id and r.emoji == preview_emoji and u == ctx.author,
timeout=180
)
except asyncio.TimeoutError:
try:
await out_msg.remove_reaction(preview_emoji, ctx.client.user)
except discord.HTTPException:
pass
else:
try:
await ctx.offer_delete(
await ctx.reply(**args, allowed_mentions=discord.AllowedMentions.none())
)
except discord.HTTPException as e:
await ctx.reply(
embed=discord.Embed(
colour=discord.Colour.red(),
title="Preview failed! Error below",
description="```{}```".format(
e
)
)
)
class SettingList(SettingType): class SettingList(SettingType):
""" """
List of a particular type of setting. List of a particular type of setting.

View File

@@ -17,7 +17,7 @@ tick = '✅'
cross = '' cross = ''
def prop_tabulate(prop_list, value_list, indent=True): def prop_tabulate(prop_list, value_list, indent=True, colon=True):
""" """
Turns a list of properties and corresponding list of values into Turns a list of properties and corresponding list of values into
a pretty string with one `prop: value` pair each line, a pretty string with one `prop: value` pair each line,
@@ -39,7 +39,7 @@ def prop_tabulate(prop_list, value_list, indent=True):
max_len = max(len(prop) for prop in prop_list) max_len = max(len(prop) for prop in prop_list)
return "".join(["`{}{}{}`\t{}{}".format(" " * (max_len - len(prop)) if indent else "", return "".join(["`{}{}{}`\t{}{}".format(" " * (max_len - len(prop)) if indent else "",
prop, prop,
":" if len(prop) else " " * 2, (":" if len(prop) else " " * 2) if colon else '',
value_list[i], value_list[i],
'' if str(value_list[i]).endswith("```") else '\n') '' if str(value_list[i]).endswith("```") else '\n')
for i, prop in enumerate(prop_list)]) for i, prop in enumerate(prop_list)])
@@ -540,3 +540,14 @@ def utc_now():
Return the current timezone-aware utc timestamp. Return the current timezone-aware utc timestamp.
""" """
return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
def multiple_replace(string, rep_dict):
if rep_dict:
pattern = re.compile(
"|".join([re.escape(k) for k in sorted(rep_dict, key=len, reverse=True)]),
flags=re.DOTALL
)
return pattern.sub(lambda x: str(rep_dict[x.group(0)]), string)
else:
return string

View File

@@ -0,0 +1,31 @@
ALTER TABLE guild_config
ADD COLUMN greeting_channel BIGINT,
ADD COLUMN greeting_message TEXT,
ADD COLUMN returning_message TEXT,
ADD COLUMN starting_funds INTEGER,
ADD COLUMN persist_roles BOOLEAN;
CREATE INDEX rented_members_users ON rented_members (userid);
CREATE TABLE autoroles(
guildid BIGINT NOT NULL ,
roleid BIGINT NOT NULL
);
CREATE INDEX autoroles_guilds ON autoroles (guildid);
CREATE TABLE bot_autoroles(
guildid BIGINT NOT NULL ,
roleid BIGINT NOT NULL
);
CREATE INDEX bot_autoroles_guilds ON bot_autoroles (guildid);
CREATE TABLE past_member_roles(
guildid BIGINT NOT NULL,
userid BIGINT NOT NULL,
roleid BIGINT NOT NULL,
_timestamp TIMESTAMPTZ DEFAULT now(),
FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid)
);
CREATE INDEX member_role_persistence_members ON past_member_roles (guildid, userid);
INSERT INTO VersionHistory (version, author) VALUES (4, 'v3-v4 Migration');

View File

@@ -4,7 +4,7 @@ CREATE TABLE VersionHistory(
time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
author TEXT author TEXT
); );
INSERT INTO VersionHistory (version, author) VALUES (3, 'Initial Creation'); INSERT INTO VersionHistory (version, author) VALUES (4, 'Initial Creation');
CREATE OR REPLACE FUNCTION update_timestamp_column() CREATE OR REPLACE FUNCTION update_timestamp_column()
@@ -72,7 +72,12 @@ CREATE TABLE guild_config(
accountability_reward INTEGER, accountability_reward INTEGER,
accountability_price INTEGER, accountability_price INTEGER,
video_studyban BOOLEAN, video_studyban BOOLEAN,
video_grace_period INTEGER video_grace_period INTEGER,
greeting_channel BIGINT,
greeting_message TEXT,
returning_message TEXT,
starting_funds INTEGER,
persist_roles BOOLEAN
); );
CREATE TABLE ignored_members( CREATE TABLE ignored_members(
@@ -92,6 +97,18 @@ CREATE TABLE donator_roles(
roleid BIGINT NOT NULL roleid BIGINT NOT NULL
); );
CREATE INDEX donator_roles_guilds ON donator_roles (guildid); CREATE INDEX donator_roles_guilds ON donator_roles (guildid);
CREATE TABLE autoroles(
guildid BIGINT NOT NULL,
roleid BIGINT NOT NULL
);
CREATE INDEX autoroles_guilds ON autoroles (guildid);
CREATE TABLE bot_autoroles(
guildid BIGINT NOT NULL ,
roleid BIGINT NOT NULL
);
CREATE INDEX bot_autoroles_guilds ON bot_autoroles (guildid);
-- }}} -- }}}
-- Workout data {{{ -- Workout data {{{
@@ -434,6 +451,7 @@ CREATE TABLE rented_members(
userid BIGINT NOT NULL userid BIGINT NOT NULL
); );
CREATE INDEX rented_members_channels ON rented_members (channelid); CREATE INDEX rented_members_channels ON rented_members (channelid);
CREATE INDEX rented_members_users ON rented_members (userid);
-- }}} -- }}}
-- Accountability Rooms {{{ -- Accountability Rooms {{{
@@ -510,5 +528,14 @@ CREATE TABLE reaction_role_expiring(
); );
CREATE UNIQUE INDEX reaction_role_expiry_members ON reaction_role_expiring (guildid, userid, roleid); CREATE UNIQUE INDEX reaction_role_expiry_members ON reaction_role_expiring (guildid, userid, roleid);
-- Member Role Data {{{
CREATE TABLE past_member_roles(
guildid BIGINT NOT NULL,
userid BIGINT NOT NULL,
roleid BIGINT NOT NULL,
_timestamp TIMESTAMPTZ DEFAULT now(),
FOREIGN KEY (guildid, userid) REFERENCES members (guildid, userid)
);
CREATE INDEX member_role_persistence_members ON past_member_roles (guildid, userid);
-- }}} -- }}}
-- vim: set fdm=marker: -- vim: set fdm=marker: