From b31a34e7259b6a7424df57dc0dd08a979e1d9071 Mon Sep 17 00:00:00 2001 From: Conatum Date: Mon, 4 Oct 2021 18:13:53 +0300 Subject: [PATCH] (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. --- bot/constants.py | 2 +- bot/core/data.py | 1 + bot/core/lion.py | 10 +- bot/modules/guild_admin/__init__.py | 1 + bot/modules/guild_admin/guild_config.py | 15 +- bot/modules/guild_admin/utils/__init__.py | 2 + bot/modules/guild_admin/utils/greetings.py | 29 +++ bot/modules/guild_admin/utils/settings.py | 206 ++++++++++++++++++++ bot/settings/base.py | 8 + bot/settings/setting_types.py | 211 ++++++++++++++++++++- bot/utils/lib.py | 15 +- data/migration/v3-v4/migration.sql | 7 + data/schema.sql | 6 +- 13 files changed, 499 insertions(+), 14 deletions(-) create mode 100644 bot/modules/guild_admin/utils/__init__.py create mode 100644 bot/modules/guild_admin/utils/greetings.py create mode 100644 bot/modules/guild_admin/utils/settings.py create mode 100644 data/migration/v3-v4/migration.sql diff --git a/bot/constants.py b/bot/constants.py index ed3a3b3b..35f9ec7c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,2 +1,2 @@ CONFIG_FILE = "config/bot.conf" -DATA_VERSION = 3 +DATA_VERSION = 4 diff --git a/bot/core/data.py b/bot/core/data.py index 6113ab4e..c33914d7 100644 --- a/bot/core/data.py +++ b/bot/core/data.py @@ -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), diff --git a/bot/core/lion.py b/bot/core/lion.py index d090f176..b9b10092 100644 --- a/bot/core/lion.py +++ b/bot/core/lion.py @@ -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 diff --git a/bot/modules/guild_admin/__init__.py b/bot/modules/guild_admin/__init__.py index 9f66e5c4..b44b04da 100644 --- a/bot/modules/guild_admin/__init__.py +++ b/bot/modules/guild_admin/__init__.py @@ -2,3 +2,4 @@ from .module import module from . import guild_config from . import statreset +from . import utils diff --git a/bot/modules/guild_admin/guild_config.py b/bot/modules/guild_admin/guild_config.py index b15e9c01..e148ecf8 100644 --- a/bot/modules/guild_admin/guild_config.py +++ b/bot/modules/guild_admin/guild_config.py @@ -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 # 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 # 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() )) diff --git a/bot/modules/guild_admin/utils/__init__.py b/bot/modules/guild_admin/utils/__init__.py new file mode 100644 index 00000000..e7ff2beb --- /dev/null +++ b/bot/modules/guild_admin/utils/__init__.py @@ -0,0 +1,2 @@ +from . import settings +from . import greetings diff --git a/bot/modules/guild_admin/utils/greetings.py b/bot/modules/guild_admin/utils/greetings.py new file mode 100644 index 00000000..74ebe980 --- /dev/null +++ b/bot/modules/guild_admin/utils/greetings.py @@ -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 diff --git a/bot/modules/guild_admin/utils/settings.py b/bot/modules/guild_admin/utils/settings.py new file mode 100644 index 00000000..6b7e0c6a --- /dev/null +++ b/bot/modules/guild_admin/utils/settings.py @@ -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 .\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) diff --git a/bot/settings/base.py b/bot/settings/base.py index 2793ee16..36f11044 100644 --- a/bot/settings/base.py +++ b/bot/settings/base.py @@ -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): """ diff --git a/bot/settings/setting_types.py b/bot/settings/setting_types.py index 95f04589..59c30551 100644 --- a/bot/settings/setting_types.py +++ b/bot/settings/setting_types.py @@ -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} `.\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. diff --git a/bot/utils/lib.py b/bot/utils/lib.py index b11a368e..2d9bbb17 100644 --- a/bot/utils/lib.py +++ b/bot/utils/lib.py @@ -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 diff --git a/data/migration/v3-v4/migration.sql b/data/migration/v3-v4/migration.sql new file mode 100644 index 00000000..baf79c63 --- /dev/null +++ b/data/migration/v3-v4/migration.sql @@ -0,0 +1,7 @@ +ALTER TABLE guild_config + ADD COLUMN greeting_channel BIGINT, + ADD COLUMN greeting_message TEXT, + ADD COLUMN returning_message TEXT, + ADD COLUMN starting_funds INTEGER; + +INSERT INTO VersionHistory (version, author) VALUES (4, 'v3-v4 Migration'); diff --git a/data/schema.sql b/data/schema.sql index 0c9c2f64..b62a51bd 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -72,7 +72,11 @@ CREATE TABLE guild_config( accountability_reward INTEGER, accountability_price INTEGER, video_studyban BOOLEAN, - video_grace_period INTEGER + video_grace_period INTEGER, + greeting_channel BIGINT, + greeting_message TEXT, + returning_message TEXT, + starting_funds INTEGER ); CREATE TABLE ignored_members(