Merge branch 'staging' into feature-gems
This commit is contained in:
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -9,4 +9,3 @@ community_bridge: # Replace with a single Community Bridge project-name e.g., cl
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: https://gofundme.com/LionBot
|
||||
|
||||
10
LICENSE.md
Normal file
10
LICENSE.md
Normal file
@@ -0,0 +1,10 @@
|
||||
Copyright (c) 2022, Ari Horesh.
|
||||
All rights reserved.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
116
README.md
116
README.md
@@ -1,51 +1,115 @@
|
||||
# StudyLion - Discord Study & Productivity Bot
|
||||
StudyLion is a Discord bot that tracks members' study and work time while offering members the ability to view their statistics and use productivity tools such as: To-do lists, pomodoro timers, reminders, and much more.
|
||||
|
||||
## StudyLion - Discord Study & Productivity Bot
|
||||
|
||||
StudyLion is a Discord bot that tracks members' study and work time while offering members the ability to view their statistics and use productivity tools such as: To-do lists, pomodoro timers, reminders, and much more.
|
||||
|
||||
|
||||
|
||||
[**Invite StudyLion here**](https://discord.studylions.com/invite "here"), and get started with `!help`.
|
||||
Join the [**support server**](https://discord.gg/studylions "support server") or start a [**discussion**](https://github.com/StudyLions/StudyLion/discussions "disscussion") if you have any questions or issues.
|
||||
|
||||
### 🧠 The Idea
|
||||
------------
|
||||
Join the [**support server**](https://discord.gg/studylions "support server") to contact us if you need help configuring the bot on your server, or start a [**discussion**](https://github.com/StudyLions/StudyLion/discussions "disscussion") to report issues and bugs.
|
||||
|
||||
|
||||
|
||||
### The Idea
|
||||
|
||||
|
||||
In the past couple of years, we noticed a new trend on Discord – instead of being a platform designed only for gamers, many students joined it as well, forming communities dedicated to studying and working together.
|
||||
I have a study community myself called [The Study Lions](http://discord.gg/studylions "The Study Lions").
|
||||
|
||||
The community members decided to raise funds and hire an amazing developer that created our own unique study/productivity bot.
|
||||
|
||||
As soon as we published the bot, hundreds of new students made their first step and started using our virtual study rooms as well!
|
||||
|
||||
Over the months we got many suggestions so we kept updating and adding more and more features to the bot!
|
||||
This bot was founder by [Ari Horesh](https://www.youtube.com/arihoresh) (Ari Horesh#0001) to support these forming study communities and allow students all over the world to study better.
|
||||
|
||||
I decided to invest further and make the bot public and open-source, so more study servers will be able to enjoy it as well, this way we can connect all study servers and create a network of students.
|
||||
### Self Hosting
|
||||
|
||||
We offer private instances based on availablity (a private bot for your community) to server owners who want their own branding (logo, color scheme, private and seperate database, better response-rate, and customizability to the text itself).
|
||||
If you are intrested, contact the founder at contact@arihoresh.com .
|
||||
|
||||
You can self-host and fork the bot using the following steps, but beware that this version **does not include** our visual graphical user interface, which is only include in the custom private instances or our the public instance.
|
||||
|
||||
Follow the steps below to self-host the bot.
|
||||
- Clone the repo recursively (which makes sure to include the cmdClient submodule, otherwise you need to initialise it separately)
|
||||
- Install the requirements from `requirements.txt`
|
||||
- Install Postgresql, and setup a database with the schema given in `data/schema.sql`
|
||||
- Copy `config/example-bot.conf` to `config/bot.conf`, filling in the appropriate information, including database connection arguments.
|
||||
- Start the bot from the top level `run.py`.
|
||||
|
||||
We do not offer support for self-hosted bots, the code is provided as is without warranty of any kind.
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
- **Students Cards and Statistics**
|
||||
|
||||
Allow users to create their own private student profile cards and set customs study field tags by using `!stats` and `!setprofile`
|
||||
|
||||

|
||||
|
||||
- **Camera only study rooms**
|
||||
|
||||
Set specific channels to force users to use their webcam to study.
|
||||
|
||||

|
||||
|
||||
### 📙 Features
|
||||
------------
|
||||
StudyLion has the following primary features:
|
||||
- **Camera only study rooms**
|
||||
Set specific channels to force users to use their webcam to study.
|
||||
- **To-Do List**
|
||||
Users can create and share their own to-do lists, and get rewards when completing a task!
|
||||
- **Reminders**
|
||||
Users can set their own private reminders, to drink water, stretch, or anything else they want to remember, every X minutes, hours, days, or maybe even just once.
|
||||
- **Accountability Rooms**
|
||||
This feature allows the users to use their coins to schedule a time to study at.
|
||||
|
||||
Users can create and share their own to-do lists, and get rewards when completing a task! Use `!todo` to launch our interactive to do list!
|
||||
|
||||
- **Reminders**
|
||||
|
||||
Users can set their own private reminders, to drink water, stretch, or anything else they want to remember, every X minutes, hours, days, or maybe even just once.
|
||||
|
||||
Example: `!remindme to drink water every 3h` will send you a reminder every 3 hours to drink water.
|
||||
|
||||

|
||||
|
||||
- **Scheduled Sessions**
|
||||
|
||||
This feature allows the users to use their coins to schedule a time to study at. Book rooms using `!rooms book`
|
||||
|
||||
Not attending prevents everyone in the room from getting the bonus.
|
||||
|
||||

|
||||
|
||||
- **Study and Work Statistics**
|
||||
Users can view their daily, weekly, monthly and all-time stats, as well as their study streak.
|
||||
|
||||
In addition to the profile cards, users can view their daily, weekly, monthly and all-time stats, as well as their study streak. Use `!weekly` and `!monthly` to view your revision statistics in more detail.
|
||||
|
||||

|
||||
|
||||
- **Pomodoro Timers**
|
||||
The bot will show the timer in the title of the study room and play a sound at the start and end of each session.
|
||||
- **Private Study Rooms**
|
||||
Allows the members to create their own private study rooms and invite their friends to join!
|
||||
|
||||
The bot will show the timer in the title of the study room and play a sound at the start and end of each session.
|
||||
Commands: `!timer` , `!pomodoro`
|
||||
|
||||

|
||||
|
||||
- **Private Study Rooms**
|
||||
|
||||
Allows the members to create their own private study rooms and invite their friends to join!
|
||||
Rent a room using `!rent [usernames]`.
|
||||
|
||||
- **Workout Rooms**
|
||||
|
||||
Allows the Admins to create workout rooms with a bonus for people who workout.
|
||||
|
||||
- **Study Tiers and Achievements**
|
||||
|
||||
Reward users based on their total study time, allow them to get better ranks, and show off how long they've been working.
|
||||
|
||||
|
||||
- **Full-Scale Economy System**
|
||||
|
||||
Reward users for studying, allow them to use the coins to buy private study rooms, schedule accountability rooms, and even change their name's color.
|
||||
|
||||
- **Full-Scale Moderation System**
|
||||
|
||||
Punish cheaters, audit-log, welcome message, and so much more using our full-scale moderation system.
|
||||
|
||||
### Tutorials
|
||||
|
||||
### ❓ Tutorials
|
||||
------------
|
||||
A command list and general documentation for StudyLion may be found using the `!help` command, and documentation for a specific command, e.g. `config`, may be found with `!help config`.
|
||||
|
||||
Make sure to check the [full documentation](https://www.notion.so/izabellakis/StudyLion-Bot-Tutorials-f493268fcd12436c9674afef2e151707 "StudyLion Tutorial") to stay updated.
|
||||
|
||||
<a href="https://imgur.com/ziPdJGw"><img src="https://i.imgur.com/ziPdJGws.png" title="source: imgur.com" /></a>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
CONFIG_FILE = "config/bot.conf"
|
||||
DATA_VERSION = 10
|
||||
DATA_VERSION = 11
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from . import data # noqa
|
||||
|
||||
from . import patches
|
||||
|
||||
from .module import module
|
||||
from .lion import Lion
|
||||
from . import blacklists
|
||||
|
||||
@@ -11,6 +11,9 @@ meta = RowTable(
|
||||
attach_as='meta',
|
||||
)
|
||||
|
||||
# TODO: Consider converting to RowTable for per-shard config caching
|
||||
app_config = Table('AppConfig')
|
||||
|
||||
|
||||
user_config = RowTable(
|
||||
'user_config',
|
||||
|
||||
111
bot/core/patches.py
Normal file
111
bot/core/patches.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Temporary patches for the discord.py library to support new features of the discord API.
|
||||
"""
|
||||
from discord.http import Route, HTTPClient
|
||||
from discord.abc import Messageable
|
||||
from discord.utils import InvalidArgument
|
||||
from discord import File, AllowedMentions
|
||||
|
||||
|
||||
def send_message(self, channel_id, content, *, tts=False, embeds=None,
|
||||
nonce=None, allowed_mentions=None, message_reference=None):
|
||||
r = Route('POST', '/channels/{channel_id}/messages', channel_id=channel_id)
|
||||
payload = {}
|
||||
|
||||
if content:
|
||||
payload['content'] = content
|
||||
|
||||
if tts:
|
||||
payload['tts'] = True
|
||||
|
||||
if embeds:
|
||||
payload['embeds'] = embeds
|
||||
|
||||
if nonce:
|
||||
payload['nonce'] = nonce
|
||||
|
||||
if allowed_mentions:
|
||||
payload['allowed_mentions'] = allowed_mentions
|
||||
|
||||
if message_reference:
|
||||
payload['message_reference'] = message_reference
|
||||
|
||||
return self.request(r, json=payload)
|
||||
|
||||
|
||||
HTTPClient.send_message = send_message
|
||||
|
||||
|
||||
async def send(self, content=None, *, tts=False, embed=None, embeds=None, file=None,
|
||||
files=None, delete_after=None, nonce=None,
|
||||
allowed_mentions=None, reference=None,
|
||||
mention_author=None):
|
||||
|
||||
channel = await self._get_channel()
|
||||
state = self._state
|
||||
content = str(content) if content is not None else None
|
||||
if embed is not None:
|
||||
if embeds is not None:
|
||||
embeds.append(embed)
|
||||
else:
|
||||
embeds = [embed]
|
||||
embed = embed.to_dict()
|
||||
if embeds is not None:
|
||||
embeds = [embed.to_dict() for embed in embeds]
|
||||
|
||||
if allowed_mentions is not None:
|
||||
if state.allowed_mentions is not None:
|
||||
allowed_mentions = state.allowed_mentions.merge(allowed_mentions).to_dict()
|
||||
else:
|
||||
allowed_mentions = allowed_mentions.to_dict()
|
||||
else:
|
||||
allowed_mentions = state.allowed_mentions and state.allowed_mentions.to_dict()
|
||||
|
||||
if mention_author is not None:
|
||||
allowed_mentions = allowed_mentions or AllowedMentions().to_dict()
|
||||
allowed_mentions['replied_user'] = bool(mention_author)
|
||||
|
||||
if reference is not None:
|
||||
try:
|
||||
reference = reference.to_message_reference_dict()
|
||||
except AttributeError:
|
||||
raise InvalidArgument('reference parameter must be Message or MessageReference') from None
|
||||
|
||||
if file is not None and files is not None:
|
||||
raise InvalidArgument('cannot pass both file and files parameter to send()')
|
||||
|
||||
if file is not None:
|
||||
if not isinstance(file, File):
|
||||
raise InvalidArgument('file parameter must be File')
|
||||
|
||||
try:
|
||||
data = await state.http.send_files(channel.id, files=[file], allowed_mentions=allowed_mentions,
|
||||
content=content, tts=tts, embed=embed, nonce=nonce,
|
||||
message_reference=reference)
|
||||
finally:
|
||||
file.close()
|
||||
|
||||
elif files is not None:
|
||||
if len(files) > 10:
|
||||
raise InvalidArgument('files parameter must be a list of up to 10 elements')
|
||||
elif not all(isinstance(file, File) for file in files):
|
||||
raise InvalidArgument('files parameter must be a list of File')
|
||||
|
||||
try:
|
||||
data = await state.http.send_files(channel.id, files=files, content=content, tts=tts,
|
||||
embed=embed, nonce=nonce, allowed_mentions=allowed_mentions,
|
||||
message_reference=reference)
|
||||
finally:
|
||||
for f in files:
|
||||
f.close()
|
||||
else:
|
||||
data = await state.http.send_message(channel.id, content, tts=tts, embeds=embeds,
|
||||
nonce=nonce, allowed_mentions=allowed_mentions,
|
||||
message_reference=reference)
|
||||
|
||||
ret = state.create_message(channel=channel, data=data)
|
||||
if delete_after is not None:
|
||||
await ret.delete(delay=delete_after)
|
||||
return ret
|
||||
|
||||
Messageable.send = send
|
||||
@@ -4,6 +4,9 @@ from data import tables
|
||||
|
||||
import core # noqa
|
||||
|
||||
# Note: This MUST be imported after core, due to table definition orders
|
||||
from settings import AppSettings
|
||||
|
||||
import modules # noqa
|
||||
|
||||
# Load and attach app specific data
|
||||
@@ -15,6 +18,8 @@ client.appdata = core.data.meta.fetch_or_create(appname)
|
||||
|
||||
client.data = tables
|
||||
|
||||
client.settings = AppSettings(conf.bot['data_appid'])
|
||||
|
||||
# Initialise all modules
|
||||
client.initialise_modules()
|
||||
|
||||
|
||||
@@ -13,3 +13,4 @@ from .renting import *
|
||||
from .moderation import *
|
||||
from .accountability import *
|
||||
from .plugins import *
|
||||
from .sponsors import *
|
||||
|
||||
@@ -10,7 +10,7 @@ from .lib import guide_link
|
||||
|
||||
|
||||
new_emoji = " 🆕"
|
||||
new_commands = {'achievements', 'nerd', 'invite', 'support'}
|
||||
new_commands = {'botconfig', 'sponsors'}
|
||||
|
||||
# Set the command groups to appear in the help
|
||||
group_hints = {
|
||||
|
||||
5
bot/modules/sponsors/__init__.py
Normal file
5
bot/modules/sponsors/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from . import module
|
||||
|
||||
from . import data
|
||||
from . import config
|
||||
from . import commands
|
||||
14
bot/modules/sponsors/commands.py
Normal file
14
bot/modules/sponsors/commands.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from .module import module
|
||||
|
||||
|
||||
@module.cmd(
|
||||
name="sponsors",
|
||||
group="Meta",
|
||||
desc="Check out our wonderful partners!",
|
||||
)
|
||||
async def cmd_sponsors(ctx):
|
||||
"""
|
||||
Usage``:
|
||||
{prefix}sponsors
|
||||
"""
|
||||
await ctx.reply(**ctx.client.settings.sponsor_message.args(ctx))
|
||||
92
bot/modules/sponsors/config.py
Normal file
92
bot/modules/sponsors/config.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from cmdClient.checks import is_owner
|
||||
|
||||
from settings import AppSettings, Setting, KeyValueData, ListData
|
||||
from settings.setting_types import Message, String, GuildIDList
|
||||
|
||||
from meta import client
|
||||
from core.data import app_config
|
||||
|
||||
from .data import guild_whitelist
|
||||
|
||||
@AppSettings.attach_setting
|
||||
class sponsor_prompt(String, KeyValueData, Setting):
|
||||
attr_name = 'sponsor_prompt'
|
||||
_default = None
|
||||
|
||||
write_ward = is_owner
|
||||
|
||||
display_name = 'sponsor_prompt'
|
||||
category = 'Sponsors'
|
||||
desc = "Text to send after core commands to encourage checking `sponsors`."
|
||||
long_desc = (
|
||||
"Text posted after several commands to encourage users to check the `sponsors` command. "
|
||||
"Occurences of `{{prefix}}` will be replaced by the bot prefix."
|
||||
)
|
||||
|
||||
_quote = False
|
||||
|
||||
_table_interface = app_config
|
||||
_id_column = 'appid'
|
||||
_key_column = 'key'
|
||||
_value_column = 'value'
|
||||
_key = 'sponsor_prompt'
|
||||
|
||||
@classmethod
|
||||
def _data_to_value(cls, id, data, **kwargs):
|
||||
if data:
|
||||
return data.replace("{prefix}", client.prefix)
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
if self.value:
|
||||
return "The sponsor prompt has been update."
|
||||
else:
|
||||
return "The sponsor prompt has been cleared."
|
||||
|
||||
|
||||
@AppSettings.attach_setting
|
||||
class sponsor_message(Message, KeyValueData, Setting):
|
||||
attr_name = 'sponsor_message'
|
||||
_default = '{"content": "Coming Soon!"}'
|
||||
|
||||
write_ward = is_owner
|
||||
|
||||
display_name = 'sponsor_message'
|
||||
category = 'Sponsors'
|
||||
desc = "`sponsors` command response."
|
||||
|
||||
long_desc = (
|
||||
"Message to reply with when a user runs the `sponsors` command."
|
||||
)
|
||||
|
||||
_table_interface = app_config
|
||||
_id_column = 'appid'
|
||||
_key_column = 'key'
|
||||
_value_column = 'value'
|
||||
_key = 'sponsor_message'
|
||||
|
||||
_cmd_str = "{prefix}sponsors --edit"
|
||||
|
||||
@property
|
||||
def success_response(self):
|
||||
return "The `sponsors` command message has been updated."
|
||||
|
||||
|
||||
@AppSettings.attach_setting
|
||||
class sponsor_guild_whitelist(GuildIDList, ListData, Setting):
|
||||
attr_name = 'sponsor_guild_whitelist'
|
||||
write_ward = is_owner
|
||||
|
||||
category = 'Sponsors'
|
||||
display_name = 'sponsor_hidden_in'
|
||||
desc = "Guilds where the sponsor prompt is not displayed."
|
||||
long_desc = (
|
||||
"A list of guilds where the sponsor prompt hint will be hidden (see the `sponsor_prompt` setting)."
|
||||
)
|
||||
|
||||
_table_interface = guild_whitelist
|
||||
_id_column = 'appid'
|
||||
_data_column = 'guildid'
|
||||
_force_unique = True
|
||||
4
bot/modules/sponsors/data.py
Normal file
4
bot/modules/sponsors/data.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from data import Table
|
||||
|
||||
|
||||
guild_whitelist = Table("sponsor_guild_whitelist")
|
||||
27
bot/modules/sponsors/module.py
Normal file
27
bot/modules/sponsors/module.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import discord
|
||||
|
||||
from LionModule import LionModule
|
||||
from LionContext import LionContext
|
||||
|
||||
from meta import client
|
||||
|
||||
|
||||
module = LionModule("Sponsor")
|
||||
|
||||
|
||||
sponsored_commands = {'profile', 'stats', 'weekly', 'monthly'}
|
||||
|
||||
|
||||
@LionContext.reply.add_wrapper
|
||||
async def sponsor_reply_wrapper(func, ctx: LionContext, *args, **kwargs):
|
||||
if ctx.cmd and ctx.cmd.name in sponsored_commands:
|
||||
if (prompt := ctx.client.settings.sponsor_prompt.value):
|
||||
if not ctx.guild or ctx.guild.id not in ctx.client.settings.sponsor_guild_whitelist.value:
|
||||
sponsor_hint = discord.Embed(
|
||||
description=prompt,
|
||||
colour=discord.Colour.dark_theme()
|
||||
)
|
||||
if 'embed' not in kwargs:
|
||||
kwargs['embed'] = sponsor_hint
|
||||
|
||||
return await func(ctx, *args, **kwargs)
|
||||
@@ -4,3 +4,4 @@ from . import exec_cmds
|
||||
from . import guild_log
|
||||
from . import status
|
||||
from . import blacklist
|
||||
from . import botconfig
|
||||
|
||||
96
bot/modules/sysadmin/botconfig.py
Normal file
96
bot/modules/sysadmin/botconfig.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import difflib
|
||||
import discord
|
||||
from cmdClient.checks import is_owner
|
||||
|
||||
from settings import UserInputError
|
||||
|
||||
from utils.lib import prop_tabulate
|
||||
|
||||
from .module import module
|
||||
|
||||
|
||||
@module.cmd("botconfig",
|
||||
desc="Update global bot configuration.",
|
||||
flags=('add', 'remove'),
|
||||
group="Bot Admin")
|
||||
@is_owner()
|
||||
async def cmd_botconfig(ctx, flags):
|
||||
"""
|
||||
Usage``
|
||||
{prefix}botconfig
|
||||
{prefix}botconfig info
|
||||
{prefix}botconfig <setting>
|
||||
{prefix}botconfig <setting> <value>
|
||||
Description:
|
||||
Usage directly follows the `config` command for guild configuration.
|
||||
"""
|
||||
# Cache and map some info for faster access
|
||||
setting_displaynames = {setting.display_name.lower(): setting for setting in ctx.client.settings.settings.values()}
|
||||
appid = ctx.client.conf['data_appid']
|
||||
|
||||
if not ctx.args or ctx.args.lower() in ('info', 'help'):
|
||||
# Fill the setting cats
|
||||
cats = {}
|
||||
for setting in ctx.client.settings.settings.values():
|
||||
cat = cats.get(setting.category, [])
|
||||
cat.append(setting)
|
||||
cats[setting.category] = cat
|
||||
|
||||
# Format the cats
|
||||
sections = {}
|
||||
for catname, cat in cats.items():
|
||||
catprops = {
|
||||
setting.display_name: setting.get(appid).summary if not ctx.args else setting.desc
|
||||
for setting in cat
|
||||
}
|
||||
# TODO: Add cat description here
|
||||
sections[catname] = prop_tabulate(*zip(*catprops.items()))
|
||||
|
||||
# Build the cat page
|
||||
embed = discord.Embed(
|
||||
colour=discord.Colour.orange(),
|
||||
title="App Configuration"
|
||||
)
|
||||
for name, section in sections.items():
|
||||
embed.add_field(name=name, value=section, inline=False)
|
||||
|
||||
await ctx.reply(embed=embed)
|
||||
else:
|
||||
# Some args were given
|
||||
parts = ctx.args.split(maxsplit=1)
|
||||
|
||||
name = parts[0]
|
||||
setting = setting_displaynames.get(name.lower(), None)
|
||||
if setting is None:
|
||||
matches = difflib.get_close_matches(name, setting_displaynames.keys(), n=2)
|
||||
match = "`{}`".format('` or `'.join(matches)) if matches else None
|
||||
return await ctx.error_reply(
|
||||
"Couldn't find a setting called `{}`!\n"
|
||||
"{}"
|
||||
"Use `{}botconfig info` to see all the available settings.".format(
|
||||
name,
|
||||
"Maybe you meant {}?\n".format(match) if match else "",
|
||||
ctx.best_prefix
|
||||
)
|
||||
)
|
||||
|
||||
if len(parts) == 1 and not ctx.msg.attachments:
|
||||
# config <setting>
|
||||
# View config embed for provided setting
|
||||
await setting.get(appid).widget(ctx, flags=flags)
|
||||
else:
|
||||
# config <setting> <value>
|
||||
# Attempt to set config setting
|
||||
try:
|
||||
parsed = await setting.parse(appid, ctx, parts[1] if len(parts) > 1 else '')
|
||||
parsed.write(add_only=flags['add'], remove_only=flags['remove'])
|
||||
except UserInputError as e:
|
||||
await ctx.reply(embed=discord.Embed(
|
||||
description="{} {}".format('❌', e.msg),
|
||||
colour=discord.Colour.red()
|
||||
))
|
||||
else:
|
||||
await ctx.reply(embed=discord.Embed(
|
||||
description="{} {}".format('✅', setting.get(appid).success_response),
|
||||
colour=discord.Colour.green()
|
||||
))
|
||||
@@ -1,4 +1,4 @@
|
||||
from data.interfaces import RowTable
|
||||
from data.interfaces import RowTable, Table
|
||||
|
||||
topggvotes = RowTable(
|
||||
'topgg',
|
||||
@@ -6,3 +6,4 @@ topggvotes = RowTable(
|
||||
'voteid'
|
||||
)
|
||||
|
||||
guild_whitelist = Table('topgg_guild_whitelist')
|
||||
|
||||
@@ -2,6 +2,8 @@ from LionModule import LionModule
|
||||
from LionContext import LionContext
|
||||
from core.lion import Lion
|
||||
|
||||
from modules.sponsors.module import sponsored_commands
|
||||
|
||||
from .utils import get_last_voted_timestamp, lion_loveemote, lion_yayemote
|
||||
from .webhook import init_webhook
|
||||
|
||||
@@ -39,12 +41,16 @@ boostfree_commands = {'config', 'pomodoro'}
|
||||
async def topgg_reply_wrapper(func, ctx: LionContext, *args, suggest_vote=True, **kwargs):
|
||||
if not suggest_vote:
|
||||
pass
|
||||
elif ctx.cmd and (ctx.cmd.name in boostfree_commands or ctx.cmd.group in boostfree_groups):
|
||||
elif not ctx.cmd:
|
||||
pass
|
||||
elif ctx.cmd.name in boostfree_commands or ctx.cmd.group in boostfree_groups:
|
||||
pass
|
||||
elif ctx.guild and ctx.guild.id in ctx.client.settings.topgg_guild_whitelist.value:
|
||||
pass
|
||||
elif not get_last_voted_timestamp(ctx.author.id):
|
||||
upvote_info_formatted = upvote_info.format(lion_yayemote, ctx.best_prefix, lion_loveemote)
|
||||
|
||||
if 'embed' in kwargs:
|
||||
if 'embed' in kwargs and ctx.cmd.name not in sponsored_commands:
|
||||
# Add message as an extra embed field
|
||||
kwargs['embed'].add_field(
|
||||
name="\u200b",
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from settings.user_settings import UserSettings, UserSetting
|
||||
from settings.setting_types import Boolean
|
||||
from cmdClient.checks import is_owner
|
||||
|
||||
from settings import UserSettings, UserSetting, AppSettings
|
||||
from settings.base import ListData, Setting
|
||||
from settings.setting_types import Boolean, GuildIDList
|
||||
|
||||
from modules.reminders.reminder import Reminder
|
||||
from modules.reminders.data import reminders
|
||||
|
||||
from .utils import create_remainder, remainder_content, topgg_upvote_link
|
||||
from .data import guild_whitelist
|
||||
|
||||
|
||||
@UserSettings.attach_setting
|
||||
@@ -48,3 +52,21 @@ class topgg_vote_remainder(Boolean, UserSetting):
|
||||
return (
|
||||
"I will no longer send you voting reminders."
|
||||
)
|
||||
|
||||
|
||||
@AppSettings.attach_setting
|
||||
class topgg_guild_whitelist(GuildIDList, ListData, Setting):
|
||||
attr_name = 'topgg_guild_whitelist'
|
||||
write_ward = is_owner
|
||||
|
||||
category = 'Topgg Voting'
|
||||
display_name = 'topgg_hidden_in'
|
||||
desc = "Guilds where the topgg vote prompt is not displayed."
|
||||
long_desc = (
|
||||
"A list of guilds where the topgg vote prompt will be hidden."
|
||||
)
|
||||
|
||||
_table_interface = guild_whitelist
|
||||
_id_column = 'appid'
|
||||
_data_column = 'guildid'
|
||||
_force_unique = True
|
||||
|
||||
@@ -3,3 +3,4 @@ from .setting_types import * # noqa
|
||||
|
||||
from .user_settings import UserSettings, UserSetting # noqa
|
||||
from .guild_settings import GuildSettings, GuildSetting # noqa
|
||||
from .app_settings import AppSettings
|
||||
|
||||
5
bot/settings/app_settings.py
Normal file
5
bot/settings/app_settings.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import settings
|
||||
from utils.lib import DotDict
|
||||
|
||||
class AppSettings(settings.ObjectSettings):
|
||||
settings = DotDict()
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import discord
|
||||
from cmdClient.cmdClient import cmdClient
|
||||
from cmdClient.lib import SafeCancellation
|
||||
@@ -201,13 +202,13 @@ class Setting:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
async def command(cls, ctx, id):
|
||||
async def command(cls, ctx, id, flags=()):
|
||||
"""
|
||||
Standardised command viewing/setting interface for the setting.
|
||||
"""
|
||||
if not ctx.args:
|
||||
if not ctx.args and not ctx.msg.attachments:
|
||||
# View config embed for provided cls
|
||||
await ctx.reply(embed=cls.get(id).embed)
|
||||
await cls.get(id).widget(ctx, flags=flags)
|
||||
else:
|
||||
# Check the write ward
|
||||
if cls.write_ward and not await cls.write_ward.run(ctx):
|
||||
@@ -459,5 +460,55 @@ class ListData:
|
||||
cls._cache[id] = data
|
||||
|
||||
|
||||
class KeyValueData:
|
||||
"""
|
||||
Mixin for settings implemented in a Key-Value table.
|
||||
The underlying table should have a Unique constraint on the `(_id_column, _key_column)` pair.
|
||||
"""
|
||||
_table_interface: Table = None
|
||||
|
||||
_id_column: str = None
|
||||
|
||||
_key_column: str = None
|
||||
|
||||
_value_column: str = None
|
||||
|
||||
_key: str = None
|
||||
|
||||
@classmethod
|
||||
def _reader(cls, id: ..., **kwargs):
|
||||
params = {
|
||||
"select_columns": (cls._value_column, ),
|
||||
cls._id_column: id,
|
||||
cls._key_column: cls._key
|
||||
}
|
||||
|
||||
row = cls._table_interface.select_one_where(**params)
|
||||
data = row[cls._value_column] if row else None
|
||||
|
||||
if data is not None:
|
||||
data = json.loads(data)
|
||||
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def _writer(cls, id: ..., data: ..., **kwargs):
|
||||
params = {
|
||||
cls._id_column: id,
|
||||
cls._key_column: cls._key
|
||||
}
|
||||
if data is not None:
|
||||
values = {
|
||||
cls._value_column: json.dumps(data)
|
||||
}
|
||||
cls._table_interface.upsert(
|
||||
constraint=f"{cls._id_column}, {cls._key_column}",
|
||||
**params,
|
||||
**values
|
||||
)
|
||||
else:
|
||||
cls._table_interface.delete_where(**params)
|
||||
|
||||
|
||||
class UserInputError(SafeCancellation):
|
||||
pass
|
||||
|
||||
@@ -473,6 +473,64 @@ class Emoji(SettingType):
|
||||
return str(data)
|
||||
|
||||
|
||||
class GuildID(SettingType):
|
||||
"""
|
||||
Integer type for storing Guild IDs. Stores any snowflake.
|
||||
|
||||
Types:
|
||||
data: Optional[int]
|
||||
The stored integer value.
|
||||
value: Optional[int]
|
||||
The stored integer value.
|
||||
"""
|
||||
accepts = "Any snowflake id."
|
||||
|
||||
@classmethod
|
||||
def _data_from_value(cls, id: int, value: Optional[bool], **kwargs):
|
||||
"""
|
||||
Both data and value are of type Optional[int].
|
||||
Directly return the provided value as data.
|
||||
"""
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def _data_to_value(cls, id: int, data: Optional[bool], **kwargs):
|
||||
"""
|
||||
Both data and value are of type Optional[int].
|
||||
Directly return the internal data as the value.
|
||||
"""
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
async def _parse_userstr(cls, ctx: Context, id: int, userstr: str, **kwargs):
|
||||
"""
|
||||
Relies on integer casting to convert the user string
|
||||
"""
|
||||
if not userstr or userstr.lower() == "none":
|
||||
return None
|
||||
|
||||
try:
|
||||
num = int(userstr)
|
||||
except Exception:
|
||||
raise UserInputError("Couldn't parse provided guild id.") from None
|
||||
|
||||
return num
|
||||
|
||||
@classmethod
|
||||
def _format_data(cls, id: int, data: Optional[int], **kwargs):
|
||||
"""
|
||||
Return the string version of the data.
|
||||
"""
|
||||
if data is None:
|
||||
return None
|
||||
elif (guild := client.get_guild(data)):
|
||||
return f"`{data}` ({guild.name})"
|
||||
elif (row := client.data.guild_config.fetch(data)):
|
||||
return f"`{data}` ({row.name})"
|
||||
else:
|
||||
return f"`{data}`"
|
||||
|
||||
|
||||
class Timezone(SettingType):
|
||||
"""
|
||||
Timezone type, storing a valid timezone string.
|
||||
@@ -713,6 +771,8 @@ class Message(SettingType):
|
||||
_substitution_desc = {
|
||||
}
|
||||
|
||||
_cmd_str = '{prefix} config {setting}'
|
||||
|
||||
@classmethod
|
||||
def _data_from_value(cls, id, value, **kwargs):
|
||||
if value is None:
|
||||
@@ -755,12 +815,17 @@ class Message(SettingType):
|
||||
if as_json:
|
||||
try:
|
||||
args = json.loads(userstr)
|
||||
if not isinstance(args, dict) or (not args.get('content', None) and not args.get('embed', None)):
|
||||
raise ValueError("At least one of the 'content' or 'embed' data fields are required.")
|
||||
if not isinstance(args, dict) or (not {'content', 'embed', 'embeds'}.intersection(args.keys())):
|
||||
raise ValueError("At least one of the 'content', 'embed', or 'embeds' fields are required.")
|
||||
if 'embed' in args:
|
||||
discord.Embed.from_dict(
|
||||
args['embed']
|
||||
)
|
||||
if 'embeds' in args:
|
||||
for embed in args['embeds']:
|
||||
discord.Embed.from_dict(
|
||||
embed
|
||||
)
|
||||
except Exception as e:
|
||||
only_error = "".join(traceback.TracebackException.from_exception(e).format_exception_only())
|
||||
raise UserInputError(
|
||||
@@ -771,6 +836,8 @@ class Message(SettingType):
|
||||
)
|
||||
if 'embed' in args and 'timestamp' in args['embed']:
|
||||
args['embed'].pop('timestamp')
|
||||
if 'embeds' in args:
|
||||
[embed.pop('timestamp', None) for embed in args['embeds']]
|
||||
return json.dumps(args)
|
||||
else:
|
||||
return json.dumps({'content': userstr})
|
||||
@@ -780,9 +847,9 @@ class Message(SettingType):
|
||||
if data is None:
|
||||
return "Empty"
|
||||
value = cls._data_to_value(id, data, **kwargs)
|
||||
if 'embed' not in value and 'content' not in value:
|
||||
if not {'embed', 'content', 'embeds'}.intersection(value.keys()):
|
||||
return "Invalid"
|
||||
if 'embed' not in value and len(value['content']) < 100:
|
||||
if 'content' in value and 'embed' not in value and 'embeds' not in value and len(value['content']) < 100:
|
||||
return "`{}`".format(value['content'])
|
||||
else:
|
||||
return "Too long to display here!"
|
||||
@@ -806,6 +873,13 @@ class Message(SettingType):
|
||||
args['embed'] = discord.Embed.from_dict(
|
||||
json.loads(multiple_replace(json.dumps(value['embed']), substitutions))
|
||||
)
|
||||
if value.get('embeds', None):
|
||||
args['embeds'] = [
|
||||
discord.Embed.from_dict(
|
||||
json.loads(multiple_replace(json.dumps(embed), substitutions))
|
||||
)
|
||||
for embed in value['embeds']
|
||||
]
|
||||
return args
|
||||
|
||||
async def widget(self, ctx, **kwargs):
|
||||
@@ -818,7 +892,7 @@ class Message(SettingType):
|
||||
current_str = None
|
||||
preview = None
|
||||
file_content = None
|
||||
if 'embed' in value or len(value['content']) > 1024:
|
||||
if 'embed' in value or 'embeds' in value or len(value['content']) > 1024:
|
||||
current_str = "See attached file."
|
||||
file_content = json.dumps(value, indent=4)
|
||||
elif "`" in value['content']:
|
||||
@@ -844,14 +918,16 @@ class Message(SettingType):
|
||||
embed.add_field(
|
||||
name="Setting Guide",
|
||||
value=(
|
||||
"• For plain text without an embed, use `{prefix}config {setting} <text>`.\n"
|
||||
"• For plain text without an embed, use `{cmd_str} <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`."
|
||||
"and upload the json code as a file with the `{cmd_str}` command.\n"
|
||||
"• To reset the message to the default, use `{cmd_str} None`."
|
||||
).format(
|
||||
cmd_str=self._cmd_str,
|
||||
builder="https://glitchii.github.io/embedbuilder/?editor=gui"
|
||||
).format(
|
||||
prefix=ctx.best_prefix,
|
||||
setting=self.display_name,
|
||||
builder="https://glitchii.github.io/embedbuilder/?editor=gui"
|
||||
),
|
||||
inline=False
|
||||
)
|
||||
@@ -1028,3 +1104,16 @@ class StringList(SettingList):
|
||||
"Write `--add` or `--remove` to add or remove strings."
|
||||
)
|
||||
_setting = String
|
||||
|
||||
|
||||
class GuildIDList(SettingList):
|
||||
"""
|
||||
List of guildids.
|
||||
"""
|
||||
accepts = (
|
||||
"Comma separated list of guild ids. Use `None` to unset. "
|
||||
"Write `--add` or `--remove` to add or remove ids. "
|
||||
"The provided ids are not verified in any way."
|
||||
)
|
||||
|
||||
_setting = GuildID
|
||||
|
||||
27
data/migration/v10-v11/migration.sql
Normal file
27
data/migration/v10-v11/migration.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- App Config Data {{{
|
||||
CREATE TABLE AppConfig(
|
||||
appid TEXT,
|
||||
key TEXT,
|
||||
value TEXT,
|
||||
PRIMARY KEY(appid, key)
|
||||
);
|
||||
-- }}}
|
||||
|
||||
|
||||
-- Sponsor Data {{{
|
||||
CREATE TABLE sponsor_guild_whitelist(
|
||||
appid TEXT,
|
||||
guildid BIGINT,
|
||||
PRIMARY KEY(appid, guildid)
|
||||
);
|
||||
-- }}}
|
||||
|
||||
-- Topgg Data {{{
|
||||
CREATE TABLE topgg_guild_whitelist(
|
||||
appid TEXT,
|
||||
guildid BIGINT,
|
||||
PRIMARY KEY(appid, guildid)
|
||||
);
|
||||
-- }}}
|
||||
|
||||
INSERT INTO VersionHistory (version, author) VALUES (11, 'v10-v11 migration');
|
||||
@@ -4,7 +4,7 @@ CREATE TABLE VersionHistory(
|
||||
time TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
author TEXT
|
||||
);
|
||||
INSERT INTO VersionHistory (version, author) VALUES (10, 'Initial Creation');
|
||||
INSERT INTO VersionHistory (version, author) VALUES (11, 'Initial Creation');
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_timestamp_column()
|
||||
@@ -22,6 +22,13 @@ CREATE TABLE AppData(
|
||||
last_study_badge_scan TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE AppConfig(
|
||||
appid TEXT,
|
||||
key TEXT,
|
||||
value TEXT,
|
||||
PRIMARY KEY(appid, key)
|
||||
);
|
||||
|
||||
CREATE TABLE global_user_blacklist(
|
||||
userid BIGINT PRIMARY KEY,
|
||||
ownerid BIGINT NOT NULL,
|
||||
@@ -37,7 +44,6 @@ CREATE TABLE global_guild_blacklist(
|
||||
);
|
||||
-- }}}
|
||||
|
||||
|
||||
-- User configuration data {{{
|
||||
CREATE TABLE user_config(
|
||||
userid BIGINT PRIMARY KEY,
|
||||
@@ -800,6 +806,20 @@ create TABLE topgg(
|
||||
boostedTimestamp TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX topgg_userid_timestamp ON topgg (userid, boostedTimestamp);
|
||||
|
||||
CREATE TABLE topgg_guild_whitelist(
|
||||
appid TEXT,
|
||||
guildid BIGINT,
|
||||
PRIMARY KEY(appid, guildid)
|
||||
);
|
||||
-- }}}
|
||||
|
||||
-- Sponsor Data {{{
|
||||
CREATE TABLE sponsor_guild_whitelist(
|
||||
appid TEXT,
|
||||
guildid BIGINT,
|
||||
PRIMARY KEY(appid, guildid)
|
||||
);
|
||||
-- }}}
|
||||
|
||||
-- LionGem audit log {{{
|
||||
|
||||
Reference in New Issue
Block a user