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

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

View File

@@ -8,6 +8,7 @@ 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
from settings import GuildSettings
@@ -167,7 +168,7 @@ async def turnover():
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.last_joined_at
if mem.data and mem.data.last_joined_at
]
if to_update:
accountability_members.update_many(
@@ -177,6 +178,15 @@ async def turnover():
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()]
@@ -184,7 +194,6 @@ async def turnover():
# We could break up the session starting?
# Move members of the next session over to the session channel
# This includes any members of the session just complete
current_slots = [
aguild.current_slot for aguild in AccountabilityGuild.cache.values()
if aguild.current_slot is not None
@@ -206,21 +215,12 @@ async def turnover():
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
member_session_data = [
(0, slot.start_time, mem.slotid, mem.userid)
for slot in current_slots
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:
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.
"""
...
@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 statreset
from . import new_members
from . import reaction_roles

View File

@@ -11,7 +11,7 @@ from .module import module
# Pages of configuration categories to display
cat_pages = {
'Administration': ('Meta', 'Guild Roles'),
'Administration': ('Meta', 'Guild Roles', 'New Members'),
'Moderation': ('Moderation', 'Video Channels'),
'Productivity': ('Study Tracking', 'TODO List', 'Workout'),
'Study Rooms': ('Rented Rooms', 'Accountability Rooms'),
@@ -21,6 +21,7 @@ cat_pages = {
descriptions = {
}
@module.cmd("config",
desc="View and modify the server settings.",
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>
# 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:
# config <setting> <value>
# Check the write ward
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
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'])
except UserInputError as e:
await ctx.reply(embed=discord.Embed(
description="{} {}".format('', e.msg),
Colour=discord.Colour.red()
colour=discord.Colour.red()
))
else:
await ctx.reply(embed=discord.Embed(
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
# TODO: Add config fields for this
title = "LionBot Command List"
title = "StudyLion Command List"
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",
group="Meta",
desc="LionBot command list.")
desc="StudyLion command list.")
async def cmd_help(ctx):
"""
Usage``:

View File

@@ -120,9 +120,9 @@ async def cmd_tickets(ctx, flags):
if not tickets:
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:
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)
ticket_map = {ticket.data.guild_ticketid: ticket for ticket in tickets}

View File

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

View File

@@ -243,7 +243,7 @@ class Room:
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(
"following members from the private room {}.\n{}").format(
self.channel.mention,
', '.join(member.mention for member in members)
),
@@ -282,3 +282,38 @@ async def load_rented_rooms(client):
"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

View File

@@ -1,3 +1,5 @@
from collections import defaultdict
import settings
from settings import GuildSettings
from wards import guild_admin
@@ -37,6 +39,34 @@ class untracked_channels(settings.ChannelList, settings.ListData, settings.Setti
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:
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
class hourly_reward(settings.Integer, settings.GuildSetting):

View File

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

View File

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