(guild admin): Add greeting messages.

New `SettingType` `Message` for general message settings.
New setting `greeting_message`.
New setting `greeting_channel`.
New setting `starting_funds`.
New setting `returning_message`.
Add a greeting message hook.
Add initial funds on lion creation.
Data migration v3 -> v4.
This commit is contained in:
2021-10-04 18:13:53 +03:00
parent a3b339d1cf
commit b31a34e725
13 changed files with 499 additions and 14 deletions

View File

@@ -1,2 +1,2 @@
CONFIG_FILE = "config/bot.conf"
DATA_VERSION = 3
DATA_VERSION = 4

View File

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

View File

@@ -2,7 +2,7 @@ import pytz
from meta import client
from data import tables as tb
from settings import UserSettings
from settings import UserSettings, GuildSettings
class Lion:
@@ -41,7 +41,13 @@ class Lion:
if key in cls._lions:
return cls._lions[key]
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)
@property

View File

@@ -2,3 +2,4 @@ from .module import module
from . import guild_config
from . import statreset
from . import utils

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', 'Greetings', 'Farewells'),
'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,2 @@
from . import settings
from . import greetings

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,206 @@
import datetime
import discord
from settings import GuildSettings, GuildSetting
import settings.setting_types as stypes
@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 = "Greetings"
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 = "Greetings"
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 = "Greetings"
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 = "Greetings"
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)

View File

@@ -51,6 +51,14 @@ class Setting:
embed.description = "{}\n{}".format(self.long_desc.format(self=self, client=self.client), table)
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
def summary(self):
"""

View File

@@ -1,5 +1,8 @@
import json
import asyncio
import datetime
import itertools
from io import StringIO
from enum import IntEnum
from typing import Any, Optional
@@ -9,11 +12,14 @@ from cmdClient.Context import Context
from cmdClient.lib import SafeCancellation
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
preview_emoji = '🔍'
class SettingType:
"""
Abstract class representing a setting type.
@@ -672,6 +678,209 @@ class Duration(SettingType):
return "`{}`".format(strfdelta(datetime.timedelta(seconds=data)))
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):
"""
List of a particular type of setting.

View File

@@ -17,7 +17,7 @@ tick = '✅'
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
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)
return "".join(["`{}{}{}`\t{}{}".format(" " * (max_len - len(prop)) if indent else "",
prop,
":" if len(prop) else " " * 2,
(":" if len(prop) else " " * 2) if colon else '',
value_list[i],
'' if str(value_list[i]).endswith("```") else '\n')
for i, prop in enumerate(prop_list)])
@@ -528,3 +528,14 @@ def utc_now():
Return the current timezone-aware utc timestamp.
"""
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