Merge pull request #62 from StudyLions/rewrite

Admin and Moderation
This commit is contained in:
Interitio
2023-10-24 14:34:52 +03:00
committed by GitHub
110 changed files with 3223 additions and 3016 deletions

View File

@@ -12,6 +12,8 @@ ALSO_READ = config/emojis.conf, config/secrets.conf, config/gui.conf
asset_path = assets
support_guild =
invite_bot =
[ENDPOINTS]
guild_log =
@@ -50,3 +52,8 @@ domains = base, wards, schedule, shop, moderation, economy, user_config, config,
[TEXT_TRACKER]
batchsize = 1
batchtime = 600
[TOPGG]
enabled = false
route = /dbl
port = 5000

View File

@@ -4,3 +4,6 @@ token =
[DATA]
args = dbname=lion_data
appid = StudyLion
[TOPGG]
auth =

View File

@@ -0,0 +1,7 @@
BEGIN;
ALTER TABLE bot_config ADD COLUMN sponsor_prompt TEXT;
ALTER TABLE bot_config ADD COLUMN sponsor_message TEXT;
INSERT INTO VersionHistory (version, author) VALUES (14, 'v13-v14 migration');
COMMIT;

View File

@@ -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 (13, 'Initial Creation');
INSERT INTO VersionHistory (version, author) VALUES (14, 'Initial Creation');
CREATE OR REPLACE FUNCTION update_timestamp_column()
@@ -17,17 +17,6 @@ $$ language 'plpgsql';
-- }}}
-- App metadata {{{
CREATE TABLE AppData(
appid TEXT PRIMARY KEY,
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,
@@ -50,6 +39,8 @@ CREATE TABLE app_config(
CREATE TABLE bot_config(
appname TEXT PRIMARY KEY REFERENCES app_config(appname) ON DELETE CASCADE,
sponsor_prompt TEXT,
sponsor_message TEXT,
default_skin TEXT
);

View File

@@ -41,7 +41,7 @@ class BabelCog(LionCog):
self.bot.core.user_config.register_model_setting(LocaleSettings.UserLocale)
configcog = self.bot.get_cog('ConfigCog')
self.crossload_group(self.configure_group, configcog.configure_group)
self.crossload_group(self.configure_group, configcog.config_group)
userconfigcog = self.bot.get_cog('UserConfigCog')
self.crossload_group(self.userconfig_group, userconfigcog.userconfig_group)
@@ -114,8 +114,6 @@ class BabelCog(LionCog):
language=LocaleSettings.GuildLocale._display_name,
force_language=LocaleSettings.ForceLocale._display_name
)
@appcmds.guild_only() # Can be removed when attached as a subcommand
@appcmds.default_permissions(manage_guild=True)
@low_management_ward
async def cmd_configure_language(self, ctx: LionContext,
language: Optional[str] = None,

View File

@@ -7,6 +7,7 @@ from settings.groups import SettingGroup
from meta.errors import UserInputError
from meta.context import ctx_bot
from core.data import CoreData
from wards import low_management_iward
from .translator import ctx_translator
from . import babel
@@ -104,9 +105,10 @@ class LocaleSettings(SettingGroup):
"""
Guild configuration for whether to force usage of the guild locale.
Exposed via `/configure language` command and standard configuration interface.
Exposed via `/config language` command and standard configuration interface.
"""
setting_id = 'force_locale'
_write_ward = low_management_iward
_display_name = _p('guildset:force_locale', 'force_language')
_desc = _p('guildset:force_locale|desc',
@@ -144,15 +146,16 @@ class LocaleSettings(SettingGroup):
def set_str(self):
bot = ctx_bot.get()
if bot:
return bot.core.mention_cmd('configure language')
return bot.core.mention_cmd('config language')
class GuildLocale(ModelData, LocaleSetting):
"""
Guild-configured locale.
Exposed via `/configure language` command, and standard configuration interface.
Exposed via `/config language` command, and standard configuration interface.
"""
setting_id = 'guild_locale'
_write_ward = low_management_iward
_display_name = _p('guildset:locale', 'language')
_desc = _p('guildset:locale|desc', "Your preferred language for interacting with me.")
@@ -180,4 +183,4 @@ class LocaleSettings(SettingGroup):
def set_str(self):
bot = ctx_bot.get()
if bot:
return bot.core.mention_cmd('configure language')
return bot.core.mention_cmd('config language')

View File

@@ -29,6 +29,7 @@ class LocaleSettingUI(ConfigUI):
async def force_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
setting = next(inst for inst in self.instances if inst.setting_id == LocaleSettings.ForceLocale.setting_id)
await setting.interaction_check(self.guildid, press)
setting.value = not setting.value
await setting.write()
@@ -80,7 +81,7 @@ class LocaleSettingUI(ConfigUI):
class LocaleDashboard(DashboardSection):
section_name = _p(
'dash:locale|title',
"Server Language Configuration ({commands[configure language]})"
"Server Language Configuration ({commands[config language]})"
)
_option_name = _p(
"dash:locale|dropdown|placeholder",

View File

@@ -1,5 +1,5 @@
CONFIG_FILE = "config/bot.conf"
DATA_VERSION = 13
DATA_VERSION = 14
MAX_COINS = 2147483647 - 1

View File

@@ -25,12 +25,35 @@ class ConfigCog(LionCog):
...
@cmds.hybrid_group(
name=_p('group:configure', "configure"),
description=_p('group:configure|desc', "View and adjust my configuration options."),
name=_p('group:config', "config"),
description=_p('group:config|desc', "View and adjust moderation-level configuration."),
)
@appcmds.guild_only
@appcmds.default_permissions(manage_guild=True)
async def configure_group(self, ctx: LionContext):
async def config_group(self, ctx: LionContext):
"""
Bare command group, has no function.
"""
return
@cmds.hybrid_group(
name=_p('group:admin', "admin"),
description=_p('group:admin|desc', "Administrative commands."),
)
@appcmds.guild_only
@appcmds.default_permissions(administrator=True)
async def admin_group(self, ctx: LionContext):
"""
Bare command group, has no function.
"""
return
@admin_group.group(
name=_p('group:admin_config', "config"),
description=_p('group:admin_config|desc', "View and adjust admin-level configuration."),
)
@appcmds.guild_only
async def admin_config_group(self, ctx: LionContext):
"""
Bare command group, has no function.
"""

View File

@@ -47,6 +47,8 @@ class CoreData(Registry, name="core"):
------
CREATE TABLE bot_config(
appname TEXT PRIMARY KEY REFERENCES app_config(appname) ON DELETE CASCADE,
sponsor_prompt TEXT,
sponsor_message TEXT,
default_skin TEXT
);
"""
@@ -54,6 +56,8 @@ class CoreData(Registry, name="core"):
appname = String(primary=True)
default_skin = String()
sponsor_prompt = String()
sponsor_message = String()
class Shard(RowModel):
"""

View File

@@ -18,6 +18,8 @@ active = [
'.moderation',
'.video_channels',
'.meta',
'.sponsors',
'.topgg',
'.test',
]

View File

@@ -29,14 +29,14 @@ class GuildConfigCog(LionCog):
configcog = self.bot.get_cog('ConfigCog')
if configcog is None:
raise ValueError("Cannot load GuildConfigCog without ConfigCog")
self.crossload_group(self.configure_group, configcog.configure_group)
self.crossload_group(self.configure_group, configcog.config_group)
@cmds.hybrid_command(
name="dashboard",
description="At-a-glance view of the server's configuration."
)
@appcmds.guild_only
@appcmds.default_permissions(manage_guild=True)
@low_management_ward
async def dashboard_cmd(self, ctx: LionContext):
if not ctx.guild or not ctx.interaction:
return
@@ -64,8 +64,6 @@ class GuildConfigCog(LionCog):
timezone=GeneralSettings.Timezone._desc,
event_log=GeneralSettings.EventLog._desc,
)
@appcmds.guild_only()
@appcmds.default_permissions(manage_guild=True)
@low_management_ward
async def cmd_configure_general(self, ctx: LionContext,
timezone: Optional[str] = None,

View File

@@ -9,6 +9,7 @@ from meta.context import ctx_bot
from meta.errors import UserInputError
from core.data import CoreData
from babel.translator import ctx_translator
from wards import low_management_iward
from . import babel
@@ -20,13 +21,14 @@ class GeneralSettings(SettingGroup):
"""
Guild timezone configuration.
Exposed via `/configure general timezone:`, and the standard interface.
Exposed via `/config general timezone:`, and the standard interface.
The `timezone` setting acts as the default timezone for all members,
and the timezone used to display guild-wide statistics.
"""
setting_id = 'timezone'
_event = 'guildset_timezone'
_set_cmd = 'configure general'
_set_cmd = 'config general'
_write_ward = low_management_iward
_display_name = _p('guildset:timezone', "timezone")
_desc = _p(
@@ -58,7 +60,8 @@ class GeneralSettings(SettingGroup):
"""
setting_id = 'eventlog'
_event = 'guildset_eventlog'
_set_cmd = 'configure general'
_set_cmd = 'config general'
_write_ward = low_management_iward
_display_name = _p('guildset:eventlog', "event_log")
_desc = _p(

View File

@@ -41,6 +41,7 @@ class GeneralSettingUI(ConfigUI):
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(GeneralSettings.EventLog)
await setting.interaction_check(setting.parent_id, selection)
value = selected.values[0].resolve() if selected.values else None
setting = await setting.from_value(self.guildid, value)
@@ -95,7 +96,7 @@ class GeneralSettingUI(ConfigUI):
class GeneralDashboard(DashboardSection):
section_name = _p(
"dash:general|title",
"General Configuration ({commands[configure general]})"
"General Configuration ({commands[config general]})"
)
_option_name = _p(
"dash:general|option|name",

View File

@@ -64,7 +64,7 @@ class Economy(LionCog):
"Attempting to load the EconomyCog before ConfigCog! Failed to crossload configuration group."
)
else:
self.crossload_group(self.configure_group, configcog.configure_group)
self.crossload_group(self.configure_group, configcog.config_group)
# ----- Economy Bonus registration -----
def register_economy_bonus(self, bonus_coro, name=None):
@@ -903,7 +903,6 @@ class Economy(LionCog):
appcmds.Choice(name=EconomySettings.AllowTransfers._outputs[False], value=0),
]
)
@appcmds.default_permissions(manage_guild=True)
@moderator_ward
async def configure_economy(self, ctx: LionContext,
allow_transfers: Optional[appcmds.Choice[int]] = None,

View File

@@ -17,6 +17,7 @@ from meta.logger import log_wrap
from core.data import CoreData
from core.setting_types import CoinSetting
from babel.translator import ctx_translator
from wards import low_management_iward
from . import babel, logger
from .data import EconomyData
@@ -32,6 +33,7 @@ class EconomySettings(SettingGroup):
"""
class CoinsPerXP(ModelData, CoinSetting):
setting_id = 'coins_per_xp'
_write_ward = low_management_iward
_display_name = _p('guildset:coins_per_xp', "coins_per_100xp")
_desc = _p(
@@ -63,10 +65,11 @@ class EconomySettings(SettingGroup):
@property
def set_str(self):
bot = ctx_bot.get()
return bot.core.mention_cmd('configure economy') if bot else None
return bot.core.mention_cmd('config economy') if bot else None
class AllowTransfers(ModelData, BoolSetting):
setting_id = 'allow_transfers'
_write_ward = low_management_iward
_display_name = _p('guildset:allow_transfers', "allow_transfers")
_desc = _p(
@@ -91,7 +94,7 @@ class EconomySettings(SettingGroup):
@property
def set_str(self):
bot = ctx_bot.get()
return bot.core.mention_cmd('configure economy') if bot else None
return bot.core.mention_cmd('config economy') if bot else None
@property
def update_message(self):
@@ -115,6 +118,7 @@ class EconomySettings(SettingGroup):
class StartingFunds(ModelData, CoinSetting):
setting_id = 'starting_funds'
_write_ward = low_management_iward
_display_name = _p('guildset:starting_funds', "starting_funds")
_desc = _p(

View File

@@ -64,7 +64,7 @@ class EconomyConfigUI(ConfigUI):
class EconomyDashboard(DashboardSection):
section_name = _p(
'dash:economy|title',
"Economy Configuration ({commands[configure economy]})"
"Economy Configuration ({commands[config economy]})"
)
_option_name = _p(
"dash:economy|dropdown|placeholder",

View File

@@ -1,15 +1,23 @@
from io import StringIO
from typing import Optional
import asyncio
import discord
from discord.ext import commands as cmds
from discord.enums import AppCommandOptionType
from discord import app_commands as appcmds
from psycopg import sql
from data.queries import NULLS, ORDER
from meta import LionCog, LionBot, LionContext
from meta.logger import log_wrap
from meta.sharding import THIS_SHARD
from meta.errors import UserInputError, SafeCancellation
from babel.translator import ctx_locale
from utils.lib import utc_now
from utils.lib import utc_now, parse_time_static, write_records
from utils.ui import ChoicedEnum, Transformed
from utils.ratelimits import Bucket, BucketFull, BucketOverFull
from data import RawExpr, NULL
from wards import low_management_ward, equippable_role, high_management_ward
@@ -21,6 +29,24 @@ from .settingui import MemberAdminUI
_p = babel._p
class DownloadableData(ChoicedEnum):
VOICE_LEADERBOARD = _p('cmd:admin_data|param:data_type|choice:voice_leaderboard', "Voice Leaderboard")
MSG_LEADERBOARD = _p('cmd:admin_data|param:data_type|choice:msg_leaderboard', "Message Leaderboard")
XP_LEADERBOARD = _p('cmd:admin_data|param:data_type|choice:xp_leaderboard', "XP Leaderboard")
ROLEMENU_EQUIP = _p('cmd:admin_data|param:data_type|choice:rolemenu_equip', "Rolemenu Roles Equipped")
TRANSACTIONS = _p('cmd:admin_data|param:data_type|choice:transactions', "Economy Transactions (Incomplete)")
BALANCES = _p('cmd:admin_data|param:data_type|choice:balances', "Economy Balances")
VOICE_SESSIONS = _p('cmd:admin_data|param:data_type|choice:voice_sessions', "Voice Sessions")
@property
def choice_name(self):
return self.value
@property
def choice_value(self):
return self.name
class MemberAdminCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
@@ -31,6 +57,9 @@ class MemberAdminCog(LionCog):
# Set of (guildid, userid) that are currently being added
self._adding_roles = set()
# Map of guildid -> Bucket
self._data_request_buckets: dict[int, Bucket] = {}
# ----- Initialisation -----
async def cog_load(self):
await self.data.init()
@@ -46,7 +75,8 @@ class MemberAdminCog(LionCog):
"Configuration command cannot be crossloaded."
)
else:
self.crossload_group(self.configure_group, configcog.configure_group)
self.crossload_group(self.configure_group, configcog.config_group)
self.crossload_group(self.admin_group, configcog.admin_group)
# ----- Cog API -----
async def absent_remove_role(self, guildid, userid, roleid):
@@ -55,6 +85,12 @@ class MemberAdminCog(LionCog):
"""
return await self.data.past_roles.delete_where(guildid=guildid, userid=userid, roleid=roleid)
def data_bucket_req(self, guildid: int):
bucket = self._data_request_buckets.get(guildid, None)
if bucket is None:
bucket = self._data_request_buckets[guildid] = Bucket(10, 10)
bucket.request()
# ----- Event Handlers -----
@LionCog.listener('on_member_join')
@log_wrap(action="Greetings")
@@ -320,7 +356,15 @@ class MemberAdminCog(LionCog):
)
# ----- Cog Commands -----
@cmds.hybrid_command(
@LionCog.placeholder_group
@cmds.hybrid_group('admin', with_app_command=False)
async def admin_group(self, ctx: LionContext):
"""
Substitute configure command group.
"""
pass
@admin_group.command(
name=_p('cmd:resetmember', "resetmember"),
description=_p(
'cmd:resetmember|desc',
@@ -342,7 +386,6 @@ class MemberAdminCog(LionCog):
),
)
@high_management_ward
@appcmds.default_permissions(administrator=True)
async def cmd_resetmember(self, ctx: LionContext,
target: discord.User,
saved_roles: Optional[bool] = False,
@@ -378,6 +421,214 @@ class MemberAdminCog(LionCog):
ephemeral=True
)
@admin_group.command(
name=_p('cmd:admin_data', "data"),
description=_p(
'cmd:admin_data|desc',
"Download various raw data for external analysis and backup."
)
)
@appcmds.rename(
data_type=_p('cmd:admin_data|param:data_type', "type"),
target=_p('cmd:admin_data|param:target', "target"),
start=_p('cmd:admin_data|param:start', "after"),
end=_p('cmd:admin_data|param:end', "before"),
limit=_p('cmd:admin_data|param:limit', "limit"),
)
@appcmds.describe(
data_type=_p(
'cmd:admin_data|param:data_type|desc',
"Select the type of data you want to download"
),
target=_p(
'cmd:admin_data|param:target|desc',
"Filter the data by selecting a user or role"
),
start=_p(
'cmd:admin_data|param:start|desc',
"Retrieve records created after this date and time in server timezone (YYYY-MM-DD HH:MM)"
),
end=_p(
'cmd:admin_data|param:end|desc',
"Retrieve records created before this date and time in server timezone (YYYY-MM-DD HH:MM)"
),
limit=_p(
'cmd:admin_data|param:limit|desc',
"Maximum number of records to retrieve."
)
)
@high_management_ward
async def cmd_data(self, ctx: LionContext,
data_type: Transformed[DownloadableData, AppCommandOptionType.string],
target: Optional[discord.User | discord.Member | discord.Role] = None,
start: Optional[str] = None,
end: Optional[str] = None,
limit: appcmds.Range[int, 1, 100000] = 1000,
):
if not ctx.guild:
return
if not ctx.interaction:
return
t = self.bot.translator.t
# Parse arguments
userids: Optional[list[int]] = None
if target is None:
# All guild members
userids = None
elif isinstance(target, discord.Role):
# Members of the given role
userids = [member.id for member in target.members]
else:
# target is a user or member
userids = [target.id]
if start:
start_time = await parse_time_static(start, ctx.lguild.timezone)
else:
start_time = ctx.guild.created_at
if end:
end_time = await parse_time_static(end, ctx.lguild.timezone)
else:
end_time = utc_now()
# Form query
if data_type is DownloadableData.VOICE_LEADERBOARD:
query = self.bot.core.data.Member.table.select_where()
query.select(
'guildid',
'userid',
total_time=RawExpr(
sql.SQL("study_time_between(guildid, userid, %s, %s)"),
(start_time, end_time)
)
)
query.order_by('total_time', ORDER.DESC, NULLS.LAST)
elif data_type is DownloadableData.MSG_LEADERBOARD:
from tracking.text.data import TextTrackerData as Data
query = Data.TextSessions.table.select_where()
query.select(
'guildid',
'userid',
total_messages="SUM(messages)"
)
query.where(
Data.TextSessions.start_time >= start_time,
Data.TextSessions.start_time < end_time,
)
query.group_by('guildid', 'userid')
query.order_by('total_messages', ORDER.DESC, NULLS.LAST)
elif data_type is DownloadableData.XP_LEADERBOARD:
from modules.statistics.data import StatsData as Data
query = Data.MemberExp.table.select_where()
query.select(
'guildid',
'userid',
total_xp="SUM(amount)"
)
query.where(
Data.MemberExp.earned_at >= start_time,
Data.MemberExp.earned_at < end_time,
)
query.group_by('guildid', 'userid')
query.order_by('total_xp', ORDER.DESC, NULLS.LAST)
elif data_type is DownloadableData.ROLEMENU_EQUIP:
from modules.rolemenus.data import RoleMenuData as Data
query = Data.RoleMenuHistory.table.select_where().leftjoin('role_menus', using=('menuid',))
query.select(
guildid=Data.RoleMenu.guildid,
userid=Data.RoleMenuHistory.userid,
menuid=Data.RoleMenu.menuid,
menu_messageid=Data.RoleMenu.messageid,
menu_name=Data.RoleMenu.name,
equipid=Data.RoleMenuHistory.equipid,
roleid=Data.RoleMenuHistory.roleid,
obtained_at=Data.RoleMenuHistory.obtained_at,
expires_at=Data.RoleMenuHistory.expires_at,
removed_at=Data.RoleMenuHistory.removed_at,
transactionid=Data.RoleMenuHistory.transactionid,
)
query.where(
Data.RoleMenuHistory.obtained_at >= start_time,
Data.RoleMenuHistory.obtained_at < end_time,
)
query.order_by(Data.RoleMenuHistory.obtained_at, ORDER.DESC)
elif data_type is DownloadableData.TRANSACTIONS:
raise SafeCancellation("Transaction data is not yet available")
elif data_type is DownloadableData.BALANCES:
raise SafeCancellation("Member balance data is not yet available")
elif data_type is DownloadableData.VOICE_SESSIONS:
raise SafeCancellation("Raw voice session data is not yet available")
else:
raise ValueError(f"Unknown data type requested {data_type}")
query.where(guildid=ctx.guild.id)
if userids:
query.where(userid=userids)
query.limit(limit)
query.with_no_adapter()
# Request bucket
try:
self.data_bucket_req(ctx.guild.id)
except BucketOverFull:
# Don't do anything, even respond to the interaction
raise SafeCancellation()
except BucketFull:
raise SafeCancellation(t(_p(
'cmd:admin_data|error:ratelimited',
"Too many requests! Please wait a few minutes before using this command again."
)))
# Run query
await ctx.interaction.response.defer(thinking=True)
results = await query
if results:
with StringIO() as stream:
write_records(results, stream)
stream.seek(0)
file = discord.File(stream, filename='data.csv')
await ctx.reply(file=file)
else:
await ctx.error_reply(
t(_p(
'cmd:admin_data|error:no_results',
"Your query had no results! Try relaxing your filters."
))
)
@cmd_data.autocomplete('start')
@cmd_data.autocomplete('end')
async def cmd_data_acmpl_time(self, interaction: discord.Interaction, partial: str):
if not interaction.guild:
return []
lguild = await self.bot.core.lions.fetch_guild(interaction.guild.id)
timezone = lguild.timezone
t = self.bot.translator.t
try:
timestamp = await parse_time_static(partial, timezone)
choice = appcmds.Choice(
name=timestamp.strftime('%Y-%m-%d %H:%M'),
value=partial
)
except UserInputError:
choice = appcmds.Choice(
name=t(_p(
'cmd:admin_data|acmpl:time|error:parse',
"Cannot parse \"{partial}\" as a time. Try the format YYYY-MM-DD HH:MM"
)).format(partial=partial)[:100],
value=partial
)
return [choice]
# ----- Config Commands -----
@LionCog.placeholder_group

View File

@@ -9,6 +9,7 @@ from settings import ListData, ModelData
from settings.groups import SettingGroup
from settings.setting_types import BoolSetting, ChannelSetting, RoleListSetting
from utils.lib import recurse_map, replace_multiple, tabulate
from wards import low_management_iward, high_management_iward
from . import babel
from .data import MemberAdminData
@@ -36,6 +37,7 @@ _greeting_subkey_desc = {
class MemberAdminSettings(SettingGroup):
class GreetingChannel(ModelData, ChannelSetting):
setting_id = 'greeting_channel'
_write_ward = low_management_iward
_display_name = _p('guildset:greeting_channel', "welcome_channel")
_desc = _p(
@@ -87,6 +89,7 @@ class MemberAdminSettings(SettingGroup):
class GreetingMessage(ModelData, MessageSetting):
setting_id = 'greeting_message'
_write_ward = low_management_iward
_display_name = _p(
'guildset:greeting_message', "welcome_message"
@@ -209,6 +212,7 @@ class MemberAdminSettings(SettingGroup):
class ReturningMessage(ModelData, MessageSetting):
setting_id = 'returning_message'
_write_ward = low_management_iward
_display_name = _p(
'guildset:returning_message', "returning_message"
@@ -335,6 +339,7 @@ class MemberAdminSettings(SettingGroup):
class Autoroles(ListData, RoleListSetting):
setting_id = 'autoroles'
_write_ward = high_management_iward
_display_name = _p(
'guildset:autoroles', "autoroles"
@@ -357,6 +362,7 @@ class MemberAdminSettings(SettingGroup):
class BotAutoroles(ListData, RoleListSetting):
setting_id = 'bot_autoroles'
_write_ward = high_management_iward
_display_name = _p(
'guildset:bot_autoroles', "bot_autoroles"
@@ -379,6 +385,7 @@ class MemberAdminSettings(SettingGroup):
class RolePersistence(ModelData, BoolSetting):
setting_id = 'role_persistence'
_event = 'guildset_role_persistence'
_write_ward = low_management_iward
_display_name = _p('guildset:role_persistence', "role_persistence")
_desc = _p(

View File

@@ -45,6 +45,7 @@ class MemberAdminUI(ConfigUI):
"""
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(Settings.GreetingChannel)
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values[0] if selected.values else None
await setting.write()
await selection.delete_original_response()
@@ -73,6 +74,7 @@ class MemberAdminUI(ConfigUI):
await equippable_role(self.bot, role, selection.user)
setting = self.get_instance(Settings.Autoroles)
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values
await setting.write()
# Instance hooks will update the menu
@@ -102,6 +104,7 @@ class MemberAdminUI(ConfigUI):
await equippable_role(self.bot, role, selection.user)
setting = self.get_instance(Settings.BotAutoroles)
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values
await setting.write()
# Instance hooks will update the menu
@@ -131,6 +134,7 @@ class MemberAdminUI(ConfigUI):
await press.response.defer(thinking=True, ephemeral=True)
t = self.bot.translator.t
setting = self.get_instance(Settings.GreetingMessage)
await setting.interaction_check(setting.parent_id, press)
value = setting.value
if value is None:
@@ -173,6 +177,7 @@ class MemberAdminUI(ConfigUI):
await press.response.defer(thinking=True, ephemeral=True)
t = self.bot.translator.t
setting = self.get_instance(Settings.ReturningMessage)
await setting.interaction_check(setting.parent_id, press)
greeting = self.get_instance(Settings.GreetingMessage)
value = setting.value
@@ -254,7 +259,7 @@ class MemberAdminUI(ConfigUI):
class MemberAdminDashboard(DashboardSection):
section_name = _p(
"dash:member_admin|title",
"Greetings and Initial Roles ({commands[configure welcome]})"
"Greetings and Initial Roles ({commands[config welcome]})"
)
_option_name = _p(
"dash:member_admin|dropdown|placeholder",
@@ -278,7 +283,7 @@ class MemberAdminDashboard(DashboardSection):
page.add_field(
name=t(_p(
'dash:member_admin|section:greeting_messages|name',
"Greeting Messages ({commands[configure welcome]})"
"Greeting Messages ({commands[admin config welcome]})"
)).format(commands=self.bot.core.mention_cache),
value=table,
inline=False
@@ -289,7 +294,7 @@ class MemberAdminDashboard(DashboardSection):
page.add_field(
name=t(_p(
'dash:member_admin|section:initial_roles|name',
"Initial Roles ({commands[configure welcome]})"
"Initial Roles ({commands[admin config welcome]})"
)).format(commands=self.bot.core.mention_cache),
value=table,
inline=False

View File

@@ -1,19 +1,36 @@
from typing import Optional
import gc
import sys
import asyncio
import logging
import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
from data.queries import ORDER
from utils.lib import tabulate
from wards import low_management
from meta import LionBot, LionCog, LionContext
from data import Table
from utils.ui import AButton, AsComponents
from utils.lib import utc_now
from . import babel
from .helpui import HelpUI
_p = babel._p
logger = logging.getLogger(__name__)
created = utc_now()
guide_link = "https://discord.studylions.com/tutorial"
animation_link = (
"https://media.discordapp.net/attachments/879412267731542047/926837189814419486/ezgif.com-resize.gif"
)
class MetaCog(LionCog):
def __init__(self, bot: LionBot):
@@ -27,6 +44,8 @@ class MetaCog(LionCog):
)
)
async def help_cmd(self, ctx: LionContext):
if not ctx.interaction:
return
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
ui = HelpUI(
ctx.bot,
@@ -35,3 +54,342 @@ class MetaCog(LionCog):
show_admin=await low_management(ctx.bot, ctx.author, ctx.guild),
)
await ui.run(ctx.interaction)
@LionCog.listener('on_guild_join')
async def post_join_message(self, guild: discord.Guild):
logger.debug(f"Sending join message to <gid: {guild.id}>")
# Send join message
t = self.bot.translator.t
message = t(_p(
'new_guild_join_message|desc',
"Thank you for inviting me to your community!\n"
"Get started by typing {help_cmd} to see my commands,"
" and {dash_cmd} to view and set up my configuration options!\n\n"
"If you need any help configuring me,"
" or would like to suggest a feature,"
" report a bug, and stay updated,"
" make sure to join our main support server by [clicking here]({support})."
)).format(
dash_cmd=self.bot.core.mention_cmd('dashboard'),
help_cmd=self.bot.core.mention_cmd('help'),
support=self.bot.config.bot.support_guild,
)
try:
await guild.me.edit(nick="Leo")
except discord.HTTPException:
pass
if (channel := guild.system_channel) and channel.permissions_for(guild.me).embed_links:
embed = discord.Embed(
description=message,
colour=discord.Colour.orange(),
)
embed.set_author(
name=t(_p(
'new_guild_join_message|name',
"Hello everyone! My name is Leo, the LionBot!"
)),
icon_url="https://cdn.discordapp.com/emojis/933610591459872868.webp"
)
embed.set_image(url=animation_link)
try:
await channel.send(embed=embed)
except discord.HTTPException:
logger.warning(
f"Could not send join message to <gid: {guild.id}>",
exc_info=True,
)
@cmds.hybrid_command(
name=_p('cmd:invite', "invite"),
description=_p(
'cmd:invite|desc',
"Invite LionBot to your own server."
)
)
async def invite_cmd(self, ctx: LionContext):
t = self.bot.translator.t
embed = discord.Embed(
colour=discord.Colour.orange(),
description=t(_p(
'cmd:invite|embed|desc',
"[Click here]({invite_link}) to add me to your server."
)).format(
invite_link=self.bot.config.bot.invite_bot,
)
)
embed.add_field(
name=t(_p(
'cmd:invite|embed|field:tips|name',
"Setup Tips"
)),
value=t(_p(
'cmd:invite|embed|field:tips|value',
"Remember to check out {help_cmd} for the important command list,"
" including the admin page which displays the hidden admin-level"
" configuration commands like {dashboard}!\n"
"Also, if you have any issues or questions,"
" you can join our [support server]({support_link}) to talk to our friendly"
" support team!"
)).format(
help_cmd=self.bot.core.mention_cmd('help'),
dashboard=self.bot.core.mention_cmd('dashboard'),
support_link=self.bot.config.bot.support_guild,
)
)
await ctx.reply(embed=embed, ephemeral=True)
@cmds.hybrid_command(
name=_p('cmd:support', "support"),
description=_p(
'cmd:support|desc',
"Have an issue or a question? Speak to my friendly support team here."
)
)
async def support_cmd(self, ctx: LionContext):
t = self.bot.translator.t
await ctx.reply(
t(_p(
'cmd:support|response',
"Speak to my friendly support team by joining this server and making a ticket"
" in the support channel!\n"
"{support_link}"
)).format(support_link=self.bot.config.bot.support_guild),
ephemeral=True,
)
@cmds.hybrid_command(
name=_p('cmd:nerd', "nerd"),
description=_p(
'cmd:nerd|desc',
"View hidden details and statistics about me ('nerd statistics')",
)
)
async def nerd_cmd(self, ctx: LionContext):
t = self.bot.translator.t
if ctx.interaction:
await ctx.interaction.response.defer(thinking=True)
embed = discord.Embed(
colour=discord.Colour.orange(),
title=t(_p(
'cmd:nerd|title',
"Nerd Statistics"
)),
)
if ctx.guild:
embed.set_footer(
text=f"Your guildid: {ctx.guild.id}"
)
else:
embed.set_footer(
text="Sent from direct message"
)
# Bot Stats
bot_stats_lines = []
# Currently {n} people active in {m} rooms of {n} guilds
query = await Table('voice_sessions_ongoing').bind(self.bot.db).select_one_where(
).select(
total_users='COUNT(userid)',
total_rooms='COUNT(channelid)',
total_guilds='COUNT(guildid)',
)
bot_stats_lines.append((
t(_p('cmd:nerd|field:currently|name', "Currently")),
t(_p(
'cmd:nerd|field:currently|value',
"`{people}` people active in `{rooms}` rooms of `{guilds}` guilds."
)).format(
people=query['total_users'],
rooms=query['total_rooms'],
guilds=query['total_guilds']
)
))
# Recorded {h} voice hours from {n} people across {n} sessions
query = await Table('voice_sessions').bind(self.bot.db).select_one_where(
).select(
total_hours='SUM(duration) / 3600',
total_users='COUNT(userid)',
total_sessions='COUNT(*)',
)
bot_stats_lines.append((
t(_p('cmd:nerd|field:recorded|name', "Recorded")),
t(_p(
'cmd:nerd|field:recorded|value',
"`{hours}` voice hours from `{users}` people across `{sessions}` sessions."
)).format(
hours=query['total_hours'],
users=query['total_users'],
sessions=query['total_sessions'],
)
))
# Registered {n} users and {m} guilds
query1 = await Table('user_config').bind(self.bot.db).select_one_where(
).select(total_users='COUNT(*)')
query2 = await Table('guild_config').bind(self.bot.db).select_one_where(
).select(total_guilds='COUNT(*)')
bot_stats_lines.append((
t(_p('cmd:nerd|field:registered|name', "Registered")),
t(_p(
'cmd:nerd|field:registered|value',
"`{users}` users and `{guilds}` guilds."
)).format(
users=query1['total_users'],
guilds=query2['total_guilds'],
)
))
# {n} tasks completed out of {m}
query = await Table('tasklist').bind(self.bot.db).select_one_where(
).select(
total_tasks='COUNT(*)',
total_completed='COUNT(*) filter (WHERE completed_at IS NOT NULL)',
)
bot_stats_lines.append((
t(_p('cmd:nerd|field:tasks|name', "Tasks")),
t(_p(
'cmd:nerd|field:tasks|value',
"`{tasks}` tasks completed out of `{total}`."
)).format(
tasks=query['total_completed'], total=query['total_tasks']
)
))
# {m} timers running across {n} guilds
query = await Table('timers').bind(self.bot.db).select_one_where(
).select(
total_timers='COUNT(*)',
guilds='COUNT(guildid)'
)
bot_stats_lines.append((
t(_p('cmd:nerd|field:timers|name', "Timers")),
t(_p(
'cmd:nerd|field:timers|value',
"`{timers}` timers running across `{guilds}` guilds."
)).format(
timers=query['total_timers'],
guilds=query['guilds'],
)
))
bot_stats_section = '\n'.join(tabulate(*bot_stats_lines))
embed.add_field(
name=t(_p('cmd:nerd|section:bot_stats|name', "Bot Stats")),
value=bot_stats_section,
inline=False,
)
# ----- Process -----
process_lines = []
# Shard {n} of {n}
process_lines.append((
t(_p('cmd:nerd|field:shard|name', "Shard")),
t(_p(
'cmd:nerd|field:shard|value',
"`{shard_number}` of `{shard_count}`"
)).format(shard_number=self.bot.shard_id, shard_count=self.bot.shard_count)
))
# Guilds
process_lines.append((
t(_p('cmd:nerd|field:guilds|name', "Guilds")),
t(_p(
'cmd:nerd|field:guilds|value',
"`{guilds}` guilds with `{count}` total members."
)).format(
guilds=len(self.bot.guilds),
count=sum(guild.member_count or 0 for guild in self.bot.guilds)
)
))
# Version
version = await self.bot.db.version()
process_lines.append((
t(_p('cmd:nerd|field:version|name', "Leo Version")),
t(_p(
'cmd:nerd|field:version|value',
"`v{version}`, last updated {timestamp} from `{reason}`."
)).format(
version=version.version,
timestamp=discord.utils.format_dt(version.time, 'D'),
reason=version.author,
)
))
# Py version
py_version = sys.version.split()[0]
dpy_version = discord.__version__
process_lines.append((
t(_p('cmd:nerd|field:py_version|name', "Py Version")),
t(_p(
'cmd:nerd|field:py_version|value',
"`{py_version}` running discord.py `{dpy_version}`"
)).format(
py_version=py_version, dpy_version=dpy_version,
)
))
process_section = '\n'.join(tabulate(*process_lines))
embed.add_field(
name=t(_p('cmd:nerd|section:process_section|name', "Process")),
value=process_section,
inline=False,
)
# ----- Shard Statistics -----
shard_lines = []
# Handling `n` events
shard_lines.append((
t(_p('cmd:nerd|field:handling|name', "Handling")),
t(_p(
'cmd:nerd|field:handling|name',
"`{events}` active commands and events."
)).format(
events=len(self.bot._running_events)
),
))
# Working on n background tasks
shard_lines.append((
t(_p('cmd:nerd|field:working|name', "Working On")),
t(_p(
'cmd:nerd|field:working|value',
"`{tasks}` background tasks."
)).format(tasks=len(asyncio.all_tasks()))
))
# Count objects in memory
shard_lines.append((
t(_p('cmd:nerd|field:objects|name', "Objects")),
t(_p(
'cmd:nerd|field:objects|value',
"`{objects}` loaded in memory."
)).format(objects=gc.get_count())
))
# Uptime
uptime = int((utc_now() - created).total_seconds())
uptimestr = (
f"`{uptime // (24 * 3600)}` days, `{uptime // 3600 % 24:02}:{uptime // 60 % 60:02}:{uptime % 60:02}`"
)
shard_lines.append((
t(_p('cmd:nerd|field:uptime|name', "Uptime")),
uptimestr,
))
shard_section = '\n'.join(tabulate(*shard_lines))
embed.add_field(
name=t(_p('cmd:nerd|section:shard_section|name', "Shard Statistics")),
value=shard_section,
inline=False,
)
await ctx.reply(embed=embed)

View File

@@ -74,8 +74,8 @@ admin_extra = _p(
Use {cmd_dashboard} to see an overview of the server configuration, \
and quickly jump to the feature configuration panels to modify settings.
Configuration panels are also accessible directly through the `/configure` commands \
and most features may be configured through these commands.
Most settings may also be directly set through the `/config` and `/admin config` commands, \
depending on whether the settings require moderator (manage server) or admin level permissions, respectively.
Other relevant commands for guild configuration below:
`/editshop`: Add/Edit/Remove colour roles from the {coin} shop.

View File

@@ -5,22 +5,27 @@ import asyncio
import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
from discord.ui.text_input import TextInput, TextStyle
from meta import LionCog, LionBot, LionContext
from meta.errors import SafeCancellation, UserInputError
from meta.logger import log_wrap
from meta.sharding import THIS_SHARD
from core.data import CoreData
from utils.lib import utc_now
from utils.lib import utc_now, parse_ranges, parse_time_static
from utils.ui import input
from wards import low_management_ward, high_management_ward, equippable_role
from wards import low_management_ward, high_management_ward, equippable_role, moderator_ward
from . import babel, logger
from .data import ModerationData, TicketType, TicketState
from .settings import ModerationSettings
from .settingui import ModerationSettingUI
from .ticket import Ticket
from .tickets import NoteTicket, WarnTicket
from .ticketui import TicketListUI, TicketFilter
_p = babel._p
_p, _np = babel._p, babel._np
class ModerationCog(LionCog):
@@ -51,7 +56,7 @@ class ModerationCog(LionCog):
"Moderation configuration will not crossload."
)
else:
self.crossload_group(self.configure_group, configcog.configure_group)
self.crossload_group(self.configure_group, configcog.admin_config_group)
if self.bot.is_ready():
await self.initialise()
@@ -125,6 +130,447 @@ class ModerationCog(LionCog):
...
# ----- Commands -----
# modnote command
@cmds.hybrid_command(
name=_p('cmd:modnote', "modnote"),
description=_p(
'cmd:modnote|desc',
"Add a note to the target member's moderation record."
)
)
@appcmds.rename(
target=_p('cmd:modnote|param:target', "target"),
note=_p('cmd:modnote|param:note', "note"),
)
@appcmds.describe(
target=_p(
'cmd:modnote|param:target|desc',
"Target member or user to add a note to."
),
note=_p(
'cmd:modnote|param:note|desc',
"Contents of the note."
),
)
@appcmds.default_permissions(manage_guild=True)
@appcmds.guild_only
@moderator_ward
async def cmd_modnote(self, ctx: LionContext,
target: discord.Member | discord.User,
note: Optional[appcmds.Range[str, 1, 1024]] = None,
):
"""
Create a NoteTicket on the given target.
If `note` is not given, prompts for the note content via modal.
"""
if not ctx.guild:
return
if not ctx.interaction:
return
t = self.bot.translator.t
if note is None:
# Prompt for note via modal
modal_title = t(_p(
'cmd:modnote|modal:enter_note|title',
"Moderation Note"
))
input_field = TextInput(
label=t(_p(
'cmd:modnote|modal:enter_note|field|label',
"Note Content",
)),
style=TextStyle.long,
min_length=1,
max_length=1024,
)
try:
interaction, note = await input(
ctx.interaction, modal_title,
field=input_field,
timeout=300
)
except asyncio.TimeoutError:
# Moderator did not fill in the modal in time
# Just leave quietly
raise SafeCancellation
else:
interaction = ctx.interaction
await interaction.response.defer(thinking=True, ephemeral=True)
# Create NoteTicket
ticket = await NoteTicket.create(
bot=self.bot,
guildid=ctx.guild.id, userid=target.id,
moderatorid=ctx.author.id, content=note, expiry=None
)
# Write confirmation with ticket number and link to ticket if relevant
embed = discord.Embed(
colour=discord.Colour.orange(),
description=t(_p(
'cmd:modnote|embed:success|desc',
"Moderation note created as [Ticket #{ticket}]({jump_link})"
)).format(
ticket=ticket.data.guild_ticketid,
jump_link=ticket.jump_url or ctx.message.jump_url
)
)
await interaction.edit_original_response(embed=embed)
# Warning Ticket Command
@cmds.hybrid_command(
name=_p('cmd:warning', "warning"),
description=_p(
'cmd:warning|desc',
"Warn a member for a misdemeanour, and add it to their moderation record."
)
)
@appcmds.rename(
target=_p('cmd:warning|param:target', "target"),
reason=_p('cmd:warning|param:reason', "reason"),
)
@appcmds.describe(
target=_p(
'cmd:warning|param:target|desc',
"Target member to warn."
),
reason=_p(
'cmd:warning|param:reason|desc',
"The reason why you are warning this member."
),
)
@appcmds.default_permissions(manage_guild=True)
@appcmds.guild_only
@moderator_ward
async def cmd_warning(self, ctx: LionContext,
target: discord.Member,
reason: Optional[appcmds.Range[str, 0, 1024]] = None,
):
if not ctx.guild:
return
if not ctx.interaction:
return
t = self.bot.translator.t
# Prompt for warning reason if not given
if reason is None:
modal_title = t(_p(
'cmd:warning|modal:reason|title',
"Moderation Warning"
))
input_field = TextInput(
label=t(_p(
'cmd:warning|modal:reason|field|label',
"Reason for the warning (visible to user)."
)),
style=TextStyle.long,
min_length=0,
max_length=1024,
)
try:
interaction, note = await input(
ctx.interaction, modal_title,
field=input_field,
timeout=300,
)
except asyncio.TimeoutError:
raise SafeCancellation
else:
interaction = ctx.interaction
await interaction.response.defer(thinking=True, ephemeral=False)
# Create WarnTicket
ticket = await WarnTicket.create(
bot=self.bot,
guildid=ctx.guild.id, userid=target.id,
moderatorid=ctx.author.id, content=reason
)
# Post to user or moderation notify channel
alert_embed = discord.Embed(
colour=discord.Colour.dark_red(),
title=t(_p(
'cmd:warning|embed:user_alert|title',
"You have received a warning!"
)),
description=reason,
)
alert_embed.add_field(
name=t(_p(
'cmd:warning|embed:user_alert|field:note|name',
"Note"
)),
value=t(_p(
'cmd:warning|embed:user_alert|field:note|value',
"*Warnings appear in your moderation history."
" Continuing failure to comply with server rules and moderator"
" directions may result in more severe action."
))
)
alert_embed.set_footer(
icon_url=ctx.guild.icon,
text=ctx.guild.name,
)
alert = await self.send_alert(target, embed=alert_embed)
# Ack the ticket creation, including alert status and warning count
warning_count = await ticket.count_warnings_for(
self.bot, ctx.guild.id, target.id
)
count_line = t(_np(
'cmd:warning|embed:success|line:count',
"This their first warning.",
"They have recieved **`{count}`** warnings.",
warning_count
)).format(count=warning_count)
embed = discord.Embed(
colour=discord.Colour.orange(),
description=t(_p(
'cmd:warning|embed:success|desc',
"[Ticket #{ticket}]({jump_link}) {user} has been warned."
)).format(
ticket=ticket.data.guild_ticketid,
jump_link=ticket.jump_url or ctx.message.jump_url,
user=target.mention,
) + '\n' + count_line
)
if alert is None:
embed.add_field(
name=t(_p(
'cmd:warning|embed:success|field:no_alert|name',
"Note"
)),
value=t(_p(
'cmd:warning|embed:success|field:no_alert|value',
"*Could not deliver warning to the target.*"
))
)
await interaction.edit_original_response(embed=embed)
# Pardon user command
@cmds.hybrid_command(
name=_p('cmd:pardon', "pardon"),
description=_p(
'cmd:pardon|desc',
"Pardon moderation tickets to mark them as no longer in effect."
)
)
@appcmds.rename(
ticketids=_p(
'cmd:pardon|param:ticketids',
"tickets"
),
reason=_p(
'cmd:pardon|param:reason',
"reason"
)
)
@appcmds.describe(
ticketids=_p(
'cmd:pardon|param:ticketids|desc',
"Comma separated list of ticket numbers to pardon."
),
reason=_p(
'cmd:pardon|param:reason',
"Why these tickets are being pardoned."
)
)
@appcmds.default_permissions(manage_guild=True)
@appcmds.guild_only
@moderator_ward
async def cmd_pardon(self, ctx: LionContext,
ticketids: str,
reason: Optional[appcmds.Range[str, 0, 1024]] = None,
):
if not ctx.guild:
return
if not ctx.interaction:
return
t = self.bot.translator.t
# Prompt for pardon reason if not given
# Note we can't parse first since we need to do first response with the modal
if reason is None:
modal_title = t(_p(
'cmd:pardon|modal:reason|title',
"Pardon Tickets"
))
input_field = TextInput(
label=t(_p(
'cmd:pardon|modal:reason|field|label',
"Why are you pardoning these tickets?"
)),
style=TextStyle.long,
min_length=0,
max_length=1024,
)
try:
interaction, reason = await input(
ctx.interaction, modal_title, field=input_field, timeout=300,
)
except asyncio.TimeoutError:
raise SafeCancellation
else:
interaction = ctx.interaction
await interaction.response.defer(thinking=True)
# Parse provided ticketids
try:
parsed_ids = parse_ranges(ticketids)
errored = False
except ValueError:
errored = True
parsed_ids = []
if errored or not parsed_ids:
raise UserInputError(t(_p(
'cmd:pardon|error:parse_ticketids',
"Could not parse provided tickets as a list of ticket ids!"
" Please enter tickets as a comma separated list of ticket numbers,"
" for example `1, 2, 3`."
)))
# Now find these tickets
tickets = await Ticket.fetch_tickets(
bot=self.bot,
guildid=ctx.guild.id,
guild_ticketid=parsed_ids,
)
if not tickets:
raise UserInputError(t(_p(
'cmd:pardon|error:no_matching',
"No matching moderation tickets found to pardon!"
)))
# Pardon each ticket
for ticket in tickets:
await ticket.pardon(
modid=ctx.author.id,
reason=reason
)
# Now ack the pardon
count = len(tickets)
ticketstr = ', '.join(
f"[#{ticket.data.guild_ticketid}]({ticket.jump_url})" for ticket in tickets
)
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=t(_np(
'cmd:pardon|embed:success|title',
"Ticket {ticketstr} has been pardoned.",
"The following tickets have been pardoned:\n{ticketstr}",
count
)).format(ticketstr=ticketstr)
)
await interaction.edit_original_response(embed=embed)
# View tickets
@cmds.hybrid_command(
name=_p('cmd:tickets', "tickets"),
description=_p(
'cmd:tickets|desc',
"View moderation tickets in this server."
)
)
@appcmds.rename(
target_user=_p('cmd:tickets|param:target', "target"),
ticket_type=_p('cmd:tickets|param:type', "type"),
ticket_state=_p('cmd:tickets|param:state', "ticket_state"),
include_pardoned=_p('cmd:tickets|param:pardoned', "include_pardoned"),
acting_moderator=_p('cmd:tickets|param:moderator', "acting_moderator"),
after=_p('cmd:tickets|param:after', "after"),
before=_p('cmd:tickets|param:before', "before"),
)
@appcmds.describe(
target_user=_p(
'cmd:tickets|param:target|desc',
"Filter by tickets acting on a given user."
),
ticket_type=_p(
'cmd:tickets|param:type|desc',
"Filter by ticket type."
),
ticket_state=_p(
'cmd:tickets|param:state|desc',
"Filter by ticket state."
),
include_pardoned=_p(
'cmd:tickets|param:pardoned|desc',
"Whether to only show active tickets, or also include pardoned."
),
acting_moderator=_p(
'cmd:tickets|param:moderator|desc',
"Filter by moderator responsible for the ticket."
),
after=_p(
'cmd:tickets|param:after|desc',
"Only show tickets after this date (YYY-MM-DD HH:MM)"
),
before=_p(
'cmd:tickets|param:before|desc',
"Only show tickets before this date (YYY-MM-DD HH:MM)"
),
)
@appcmds.choices(
ticket_type=[
appcmds.Choice(name=typ.name, value=typ.name)
for typ in (TicketType.NOTE, TicketType.WARNING, TicketType.STUDY_BAN)
],
ticket_state=[
appcmds.Choice(name=state.name, value=state.name)
for state in (
TicketState.OPEN, TicketState.EXPIRING, TicketState.EXPIRED, TicketState.PARDONED,
)
]
)
@appcmds.default_permissions(manage_guild=True)
@appcmds.guild_only
@moderator_ward
async def tickets_cmd(self, ctx: LionContext,
target_user: Optional[discord.User] = None,
ticket_type: Optional[appcmds.Choice[str]] = None,
ticket_state: Optional[appcmds.Choice[str]] = None,
include_pardoned: Optional[bool] = None,
acting_moderator: Optional[discord.User] = None,
after: Optional[str] = None,
before: Optional[str] = None,
):
if not ctx.guild:
return
if not ctx.interaction:
return
filters = TicketFilter(self.bot)
if target_user is not None:
filters.targetids = [target_user.id]
if ticket_type is not None:
filters.types = [TicketType[ticket_type.value]]
if ticket_state is not None:
filters.states = [TicketState[ticket_state.value]]
elif include_pardoned:
filters.states = None
else:
filters.states = [TicketState.OPEN, TicketState.EXPIRING]
if acting_moderator is not None:
filters.moderatorids = [acting_moderator.id]
if after is not None:
filters.after = await parse_time_static(after, ctx.lguild.timezone)
if before is not None:
filters.before = await parse_time_static(before, ctx.lguild.timezone)
ticketsui = TicketListUI(self.bot, ctx.guild, ctx.author.id, filters=filters)
await ticketsui.run(ctx.interaction)
await ticketsui.wait()
# ----- Configuration -----
@LionCog.placeholder_group
@@ -140,12 +586,13 @@ class ModerationCog(LionCog):
)
)
@appcmds.rename(
adminrole=ModerationSettings.AdminRole._display_name,
modrole=ModerationSettings.ModRole._display_name,
ticket_log=ModerationSettings.TicketLog._display_name,
alert_channel=ModerationSettings.AlertChannel._display_name,
)
@appcmds.describe(
modrole=ModerationSettings.ModRole._desc,
adminrole=ModerationSettings.AdminRole._desc,
ticket_log=ModerationSettings.TicketLog._desc,
alert_channel=ModerationSettings.AlertChannel._desc,
)
@@ -154,6 +601,7 @@ class ModerationCog(LionCog):
modrole: Optional[discord.Role] = None,
ticket_log: Optional[discord.TextChannel] = None,
alert_channel: Optional[discord.TextChannel] = None,
adminrole: Optional[discord.Role] = None,
):
if not ctx.guild:
return
@@ -169,6 +617,12 @@ class ModerationCog(LionCog):
instance = setting(ctx.guild.id, modrole.id)
modified.append(instance)
if adminrole is not None:
setting = self.settings.AdminRole
await setting._check_value(ctx.guild.id, adminrole)
instance = setting(ctx.guild.id, adminrole.id)
modified.append(instance)
if ticket_log is not None:
setting = self.settings.TicketLog
await setting._check_value(ctx.guild.id, ticket_log)

View File

@@ -105,6 +105,6 @@ class ModerationData(Registry):
file_data = String()
expiry = Timestamp()
pardoned_by = Integer()
pardoned_at = Integer()
pardoned_at = Timestamp()
pardoned_reason = String()
created_at = Timestamp()

View File

@@ -6,6 +6,7 @@ from settings.setting_types import (
from core.data import CoreData
from babel.translator import ctx_translator
from wards import low_management_iward, high_management_iward
from . import babel
@@ -16,6 +17,7 @@ class ModerationSettings(SettingGroup):
class TicketLog(ModelData, ChannelSetting):
setting_id = "ticket_log"
_event = 'guildset_ticket_log'
_write_ward = low_management_iward
_display_name = _p('guildset:ticket_log', "ticket_log")
_desc = _p(
@@ -66,6 +68,7 @@ class ModerationSettings(SettingGroup):
class AlertChannel(ModelData, ChannelSetting):
setting_id = "alert_channel"
_event = 'guildset_alert_channel'
_write_ward = low_management_iward
_display_name = _p('guildset:alert_channel', "alert_channel")
_desc = _p(
@@ -119,18 +122,23 @@ class ModerationSettings(SettingGroup):
class ModRole(ModelData, RoleSetting):
setting_id = "mod_role"
_event = 'guildset_mod_role'
_write_ward = high_management_iward
_display_name = _p('guildset:mod_role', "mod_role")
_desc = _p(
'guildset:mod_role|desc',
"Guild role permitted to view configuration and perform moderation tasks."
"Server role permitted to perform moderation and minor bot configuration."
)
_long_desc = _p(
'guildset:mod_role|long_desc',
"Members with the set role will be able to access my configuration panels, "
"and perform some moderation tasks, such as setting up pomodoro timers. "
"Moderators cannot reconfigure most bot configuration, "
"or perform operations they do not already have permission for in Discord."
"Members with the moderator role are considered moderators,"
" and are permitted to use moderator commands,"
" such as viewing and pardoning moderation tickets,"
" creating moderation notes,"
" and performing minor reconfiguration through the `/config` command.\n"
"Moderators are never permitted to perform actions (such as giving roles)"
" that they do not already have the Discord permissions for.\n"
"Members with the 'Manage Guild' permission are always considered moderators."
)
_accepts = _p(
'guildset:mod_role|accepts',
@@ -148,12 +156,14 @@ class ModerationSettings(SettingGroup):
if value:
resp = t(_p(
'guildset:mod_role|set_response:set',
"Members with the {role} will be considered moderators."
"Members with {role} will be considered moderators."
" You may need to grant them access to view moderation commands"
" via the server integration settings."
)).format(role=value.mention)
else:
resp = t(_p(
'guildset:mod_role|set_response:unset',
"No members will be given moderation privileges."
"Only members with the 'Manage Guild' permission will be considered moderators."
))
return resp
@@ -167,3 +177,47 @@ class ModerationSettings(SettingGroup):
'guildset:mod_role|formatted:unset',
"Not Set."
))
class AdminRole(ModelData, RoleSetting):
setting_id = "admin_role"
_event = 'guildset_admin_role'
_write_ward = high_management_iward
_display_name = _p('guildset:admin_role', "admin_role")
_desc = _p(
'guildset:admin_role|desc',
"Server role allowing access to all administrator level functionality in Leo."
)
_long_desc = _p(
'guildset:admin_role|long_desc',
"Members with this role are considered to be server administrators, "
"allowing them to use all of my interfaces and commands, "
"except for managing roles that are above them in the role hierachy. "
"This setting allows giving members administrator-level permissions "
"over my systems, without actually giving the members admin server permissions. "
"Note that the role will also need to be given permission to see the commands "
"through the Discord server integrations interface."
)
_accepts = _p(
'guildset:admin_role|accepts',
"Admin role name or id."
)
_model = CoreData.Guild
_column = CoreData.Guild.admin_role.name
@property
def update_message(self) -> str:
t = ctx_translator.get().t
value = self.value
if value:
resp = t(_p(
'guildset:admin_role|set_response:set',
"Members with {role} will now be considered admins, and have access to my full interface."
)).format(role=value.mention)
else:
resp = t(_p(
'guildset:admin_role|set_response:unset',
"The admin role has been unset. Only members with administrator permissions will be considered admins."
))
return resp

View File

@@ -18,9 +18,10 @@ _p = babel._p
class ModerationSettingUI(ConfigUI):
setting_classes = (
ModerationSettings.ModRole,
ModerationSettings.AdminRole,
ModerationSettings.TicketLog,
ModerationSettings.AlertChannel,
ModerationSettings.ModRole,
)
def __init__(self, bot: LionBot, guildid: int, channelid, **kwargs):
@@ -41,6 +42,7 @@ class ModerationSettingUI(ConfigUI):
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(ModerationSettings.TicketLog)
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values[0] if selected.values else None
await setting.write()
await selection.delete_original_response()
@@ -66,6 +68,7 @@ class ModerationSettingUI(ConfigUI):
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(ModerationSettings.AlertChannel)
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values[0] if selected.values else None
await setting.write()
await selection.delete_original_response()
@@ -91,6 +94,7 @@ class ModerationSettingUI(ConfigUI):
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(ModerationSettings.ModRole)
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values[0] if selected.values else None
await setting.write()
await selection.delete_original_response()
@@ -103,6 +107,32 @@ class ModerationSettingUI(ConfigUI):
"Select Moderator Role"
))
# Admin Role Selector
@select(
cls=RoleSelect,
placeholder="ADMINROLE_MENU_PLACEHOLDER",
min_values=0, max_values=1
)
async def adminrole_menu(self, selection: discord.Interaction, selected: RoleSelect):
"""
Single role selector for the `admin_role` setting.
"""
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(ModerationSettings.AdminRole)
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values[0] if selected.values else None
await setting.write()
await selection.delete_original_response()
async def adminrole_menu_refresh(self):
menu = self.adminrole_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:moderation_config|menu:adminrole|placeholder',
"Select Admin Role"
))
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
@@ -133,13 +163,15 @@ class ModerationSettingUI(ConfigUI):
self.ticket_log_menu_refresh(),
self.alert_channel_menu_refresh(),
self.modrole_menu_refresh(),
self.adminrole_menu_refresh(),
)
await asyncio.gather(*component_refresh)
self.set_layout(
(self.adminrole_menu,),
(self.modrole_menu,),
(self.ticket_log_menu,),
(self.alert_channel_menu,),
(self.modrole_menu,),
(self.edit_button, self.reset_button, self.close_button,)
)
@@ -147,7 +179,7 @@ class ModerationSettingUI(ConfigUI):
class ModerationDashboard(DashboardSection):
section_name = _p(
"dash:moderation|title",
"Moderation Settings ({commands[configure moderation]})"
"Moderation Settings ({commands[admin config moderation]})"
)
_option_name = _p(
"dash:moderation|dropdown|placeholder",

View File

@@ -99,11 +99,11 @@ class Ticket:
return tickets
@property
def guild(self):
def guild(self) -> Optional[discord.Guild]:
return self.bot.get_guild(self.data.guildid)
@property
def target(self):
def target(self) -> Optional[discord.Member]:
guild = self.guild
if guild:
return guild.get_member(self.data.targetid)
@@ -111,7 +111,7 @@ class Ticket:
return None
@property
def type(self):
def type(self) -> TicketType:
return self.data.ticket_type
@property
@@ -227,10 +227,10 @@ class Ticket:
name=t(_p('ticket|field:pardoned|name', "Pardoned")),
value=t(_p(
'ticket|field:pardoned|value',
"Pardoned by <&{moderator}> at {timestamp}.\n{reason}"
"Pardoned by <@{moderator}> at {timestamp}.\n{reason}"
)).format(
moderator=data.pardoned_by,
timestamp=discord.utils.format_dt(timestamp),
timestamp=discord.utils.format_dt(data.pardoned_at) if data.pardoned_at else 'Unknown',
reason=data.pardoned_reason or ''
),
inline=False
@@ -297,9 +297,6 @@ class Ticket:
self.expiring.cancel_tasks(self.data.ticketid)
await self.post()
async def _revert(self):
raise NotImplementedError
async def _expire(self):
"""
Actual expiry method.
@@ -321,11 +318,16 @@ class Ticket:
await self.post()
# TODO: Post an extra note to the modlog about the expiry.
async def revert(self):
async def revert(self, reason: Optional[str] = None, **kwargs):
"""
Revert this ticket.
By default this is a no-op.
Ticket types should override to implement any required revert logic.
The optional `reason` paramter is intended for any auditable actions.
"""
raise NotImplementedError
return
async def expire(self):
"""
@@ -336,5 +338,31 @@ class Ticket:
"""
await self._expire()
async def pardon(self):
raise NotImplementedError
async def pardon(self, modid: int, reason: str):
"""
Pardon a ticket.
Specifically, set the state of the ticket to `PARDONED`,
with the given moderator and reason,
and revert the ticket if applicable.
If the ticket is already pardoned, this is a no-op.
"""
if self.data.ticket_state != TicketState.PARDONED:
# Cancel expiry if it was scheduled
self.expiring.cancel_tasks(self.data.ticketid)
# Revert the ticket if it is currently active
if self.data.ticket_state in (TicketState.OPEN, TicketState.EXPIRING):
await self.revert(reason=f"Pardoned by {modid}")
# Set pardoned state
await self.data.update(
ticket_state=TicketState.PARDONED,
pardoned_at=utc_now(),
pardoned_by=modid,
pardoned_reason=reason
)
# Update ticket log message
await self.post()

View File

@@ -0,0 +1,2 @@
from .note import NoteTicket
from .warning import WarnTicket

View File

@@ -0,0 +1,48 @@
from typing import TYPE_CHECKING
import datetime as dt
import discord
from meta import LionBot
from utils.lib import utc_now
from ..ticket import Ticket, ticket_factory
from ..data import TicketType, TicketState, ModerationData
from .. import logger, babel
if TYPE_CHECKING:
from ..cog import ModerationCog
_p = babel._p
@ticket_factory(TicketType.NOTE)
class NoteTicket(Ticket):
__slots__ = ()
@classmethod
async def create(
cls, bot: LionBot, guildid: int, userid: int,
moderatorid: int, content: str, expiry=None,
**kwargs
):
modcog: 'ModerationCog' = bot.get_cog('ModerationCog')
ticket_data = await modcog.data.Ticket.create(
guildid=guildid,
targetid=userid,
ticket_type=TicketType.NOTE,
ticket_state=TicketState.OPEN,
moderator_id=moderatorid,
content=content,
expiry=expiry,
created_at=utc_now().replace(tzinfo=None),
**kwargs
)
lguild = await bot.core.lions.fetch_guild(guildid)
new_ticket = cls(lguild, ticket_data)
await new_ticket.post()
if expiry:
cls.expiring.schedule_task(ticket_data.ticketid, expiry.timestamp())
return new_ticket

View File

@@ -0,0 +1,66 @@
from typing import TYPE_CHECKING, Optional
import datetime as dt
import discord
from meta import LionBot
from utils.lib import utc_now
from ..ticket import Ticket, ticket_factory
from ..data import TicketType, TicketState, ModerationData
from .. import logger, babel
if TYPE_CHECKING:
from ..cog import ModerationCog
_p = babel._p
@ticket_factory(TicketType.WARNING)
class WarnTicket(Ticket):
__slots__ = ()
@classmethod
async def create(
cls, bot: LionBot, guildid: int, userid: int,
moderatorid: int, content: Optional[str], expiry=None,
**kwargs
):
modcog: 'ModerationCog' = bot.get_cog('ModerationCog')
ticket_data = await modcog.data.Ticket.create(
guildid=guildid,
targetid=userid,
ticket_type=TicketType.WARNING,
ticket_state=TicketState.OPEN,
moderator_id=moderatorid,
content=content,
expiry=expiry,
created_at=utc_now().replace(tzinfo=None),
**kwargs
)
lguild = await bot.core.lions.fetch_guild(guildid)
new_ticket = cls(lguild, ticket_data)
await new_ticket.post()
if expiry:
cls.expiring.schedule_task(ticket_data.ticketid, expiry.timestamp())
return new_ticket
@classmethod
async def count_warnings_for(
cls, bot: LionBot, guildid: int, userid: int, **kwargs
):
modcog: 'ModerationCog' = bot.get_cog('ModerationCog')
Ticket = modcog.data.Ticket
record = await Ticket.table.select_one_where(
(Ticket.ticket_state != TicketState.PARDONED),
guildid=guildid,
targetid=userid,
ticket_type=TicketType.WARNING,
**kwargs
).select(ticket_count='COUNT(*)').with_no_adapter()
return (record[0]['ticket_count'] or 0) if record else 0

View File

@@ -0,0 +1,656 @@
from itertools import chain
from typing import Optional
from dataclasses import dataclass
import asyncio
import datetime as dt
import discord
from discord.ui.select import select, Select, SelectOption, UserSelect
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.text_input import TextInput, TextStyle
from meta import LionBot, conf
from meta.errors import ResponseTimedOut, SafeCancellation, UserInputError
from data import ORDER, Condition
from utils.ui import MessageUI, input
from utils.lib import MessageArgs, tabulate, utc_now
from . import babel, logger
from .ticket import Ticket
from .data import ModerationData, TicketType, TicketState
_p = babel._p
@dataclass
class TicketFilter:
bot: LionBot
after: Optional[dt.datetime] = None
before: Optional[dt.datetime] = None
targetids: Optional[list[int]] = None
moderatorids: Optional[list[int]] = None
types: Optional[list[TicketType]] = None
states: Optional[list[TicketState]] = None
def conditions(self) -> list[Condition]:
conditions = []
Ticket = ModerationData.Ticket
if self.after is not None:
conditions.append(Ticket.created_at >= self.after)
if self.before is not None:
conditions.append(Ticket.created_at < self.before)
if self.targetids is not None:
conditions.append(Ticket.targetid == self.targetids)
if self.moderatorids is not None:
conditions.append(Ticket.moderator_id == self.moderatorids)
if self.types is not None:
conditions.append(Ticket.ticket_type == self.types)
if self.states is not None:
conditions.append(Ticket.ticket_state == self.states)
return conditions
def formatted(self) -> str:
t = self.bot.translator.t
lines = []
if self.after is not None:
name = t(_p(
'ticketfilter|field:after|name',
"Created After"
))
value = discord.utils.format_dt(self.after, 'd')
lines.append((name, value))
if self.before is not None:
name = t(_p(
'ticketfilter|field:before|name',
"Created Before"
))
value = discord.utils.format_dt(self.before, 'd')
lines.append((name, value))
if self.targetids is not None:
name = t(_p(
'ticketfilter|field:targetids|name',
"Targets"
))
value = ', '.join(f"<@{uid}>" for uid in self.targetids) or 'None'
lines.append((name, value))
if self.moderatorids is not None:
name = t(_p(
'ticketfilter|field:moderatorids|name',
"Moderators"
))
value = ', '.join(f"<@{uid}>" for uid in self.moderatorids) or 'None'
lines.append((name, value))
if self.types is not None:
name = t(_p(
'ticketfilter|field:types|name',
"Ticket Types"
))
value = ', '.join(typ.name for typ in self.types) or 'None'
lines.append((name, value))
if self.states is not None:
name = t(_p(
'ticketfilter|field:states|name',
"Ticket States"
))
value = ', '.join(state.name for state in self.states) or 'None'
lines.append((name, value))
if lines:
table = tabulate(*lines)
filterstr = '\n'.join(table)
else:
filterstr = ''
return filterstr
class TicketListUI(MessageUI):
block_len = 10
def _init_children(self):
# HACK to stop ViewWeights complaining that this UI has too many children
# Children will be correctly initialised after parent init.
return []
def __init__(self, bot: LionBot, guild: discord.Guild, callerid: int, filters=None, **kwargs):
super().__init__(callerid=callerid, **kwargs)
self._children = super()._init_children()
self.bot = bot
self.data: ModerationData = bot.db.registries[ModerationData.__name__]
self.guild = guild
self.filters = filters or TicketFilter(bot)
# Paging state
self._pagen = 0
self.blocks = [[]]
# UI State
self.show_filters = False
self.show_tickets = False
self.child_ticket: Optional[TicketUI] = None
@property
def page_count(self):
return len(self.blocks)
@property
def pagen(self):
self._pagen = self._pagen % self.page_count
return self._pagen
@pagen.setter
def pagen(self, value):
self._pagen = value % self.page_count
@property
def current_page(self):
return self.blocks[self.pagen]
# ----- API -----
# ----- UI Components -----
# Edit Filters
@button(
label="EDIT_FILTER_BUTTON_PLACEHOLDER",
style=ButtonStyle.blurple
)
async def edit_filter_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
self.show_filters = True
self.show_tickets = False
await self.refresh(thinking=press)
async def edit_filter_button_refresh(self):
button = self.edit_filter_button
t = self.bot.translator.t
button.label = t(_p(
'ui:tickets|button:edit_filter|label',
"Edit Filters"
))
button.style = ButtonStyle.grey if not self.show_filters else ButtonStyle.blurple
# Select Ticket
@button(
label="SELECT_TICKET_BUTTON_PLACEHOLDER",
style=ButtonStyle.blurple
)
async def select_ticket_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
self.show_tickets = True
self.show_filters = False
await self.refresh(thinking=press)
async def select_ticket_button_refresh(self):
button = self.select_ticket_button
t = self.bot.translator.t
button.label = t(_p(
'ui:tickets|button:select_ticket|label',
"Select Ticket"
))
button.style = ButtonStyle.grey if not self.show_tickets else ButtonStyle.blurple
# Pardon All
@button(
label="PARDON_BUTTON_PLACEHOLDER",
style=ButtonStyle.red
)
async def pardon_button(self, press: discord.Interaction, pressed: Button):
t = self.bot.translator.t
tickets = list(chain(*self.blocks))
if not tickets:
raise UserInputError(t(_p(
'ui:tickets|button:pardon|error:no_tickets',
"Not tickets matching the given criterial! Nothing to pardon."
)))
# Request reason via modal
modal_title = t(_p(
'ui:tickets|button:pardon|modal:reason|title',
"Pardon Tickets"
))
input_field = TextInput(
label=t(_p(
'ui:tickets|button:pardon|modal:reason|field|label',
"Why are you pardoning these tickets?"
)),
style=TextStyle.long,
min_length=0,
max_length=1024,
)
try:
interaction, reason = await input(
press, modal_title, field=input_field, timeout=300,
)
except asyncio.TimeoutError:
raise ResponseTimedOut
await interaction.response.defer(thinking=True, ephemeral=True)
# Run pardon
for ticket in tickets:
await ticket.pardon(modid=press.user.id, reason=reason)
await self.refresh(thinking=interaction)
async def pardon_button_refresh(self):
button = self.pardon_button
t = self.bot.translator.t
button.label = t(_p(
'ui:tickets|button:pardon|label',
"Pardon All"
))
button.disabled = not bool(self.current_page)
# Filter Ticket Type
@select(
cls=Select,
placeholder="FILTER_TYPE_MENU_PLACEHOLDER",
min_values=1, max_values=3,
)
async def filter_type_menu(self, selection: discord.Interaction, selected: Select):
await selection.response.defer(thinking=True, ephemeral=True)
self.filters.types = [TicketType[value] for value in selected.values] or None
self.pagen = 0
await self.refresh(thinking=selection)
async def filter_type_menu_refresh(self):
menu = self.filter_type_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:tickets|menu:filter_type|placeholder',
"Select Ticket Types"
))
options = []
descmap = {
TicketType.NOTE: ('Notes',),
TicketType.WARNING: ('Warnings',),
TicketType.STUDY_BAN: ('Video Blacklists',),
}
filtered = self.filters.types
for typ, (name,) in descmap.items():
option = SelectOption(
label=name,
value=typ.name,
default=(filtered is None or typ in filtered)
)
options.append(option)
menu.options = options
# Filter Ticket State
@select(
cls=Select,
placeholder="FILTER_STATE_MENU_PLACEHOLDER",
min_values=1, max_values=4
)
async def filter_state_menu(self, selection: discord.Interaction, selected: Select):
await selection.response.defer(thinking=True, ephemeral=True)
self.filters.states = [TicketState[value] for value in selected.values] or None
self.pagen = 0
await self.refresh(thinking=selection)
async def filter_state_menu_refresh(self):
menu = self.filter_state_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:tickets|menu:filter_state|placeholder',
"Select Ticket States"
))
options = []
descmap = {
TicketState.OPEN: ('OPEN', ),
TicketState.EXPIRING: ('EXPIRING', ),
TicketState.EXPIRED: ('EXPIRED', ),
TicketState.PARDONED: ('PARDONED', ),
}
filtered = self.filters.states
for state, (name,) in descmap.items():
option = SelectOption(
label=name,
value=state.name,
default=(filtered is None or state in filtered)
)
options.append(option)
menu.options = options
# Filter Ticket Target
@select(
cls=UserSelect,
placeholder="FILTER_TARGET_MENU_PLACEHOLDER",
min_values=0, max_values=10
)
async def filter_target_menu(self, selection: discord.Interaction, selected: UserSelect):
await selection.response.defer(thinking=True, ephemeral=True)
self.filters.targetids = [user.id for user in selected.values] or None
self.pagen = 0
await self.refresh(thinking=selection)
async def filter_target_menu_refresh(self):
menu = self.filter_target_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:tickets|menu:filter_target|placeholder',
"Select Ticket Targets"
))
# Select Ticket
@select(
cls=Select,
placeholder="TICKETS_MENU_PLACEHOLDER",
min_values=1, max_values=1
)
async def tickets_menu(self, selection: discord.Interaction, selected: Select):
await selection.response.defer(thinking=True, ephemeral=True)
if selected.values:
ticketid = int(selected.values[0])
ticket = await Ticket.fetch_ticket(self.bot, ticketid)
ticketui = TicketUI(self.bot, ticket, self._callerid)
if self.child_ticket:
await self.child_ticket.quit()
self.child_ticket = ticketui
await ticketui.run(selection)
async def tickets_menu_refresh(self):
menu = self.tickets_menu
t = self.bot.translator.t
menu.placeholder = t(_p(
'ui:tickets|menu:tickets|placeholder',
"Select Ticket"
))
options = []
for ticket in self.current_page:
option = SelectOption(
label=f"Ticket #{ticket.data.guild_ticketid}",
value=str(ticket.data.ticketid)
)
options.append(option)
menu.options = options
# Backwards
@button(emoji=conf.emojis.backward, style=ButtonStyle.grey)
async def prev_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
self.pagen -= 1
await self.refresh(thinking=press)
# Jump to page
@button(label="JUMP_PLACEHOLDER", style=ButtonStyle.blurple)
async def jump_button(self, press: discord.Interaction, pressed: Button):
"""
Jump-to-page button.
Loads a page-switch dialogue.
"""
t = self.bot.translator.t
try:
interaction, value = await input(
press,
title=t(_p(
'ui:tickets|button:jump|input:title',
"Jump to page"
)),
question=t(_p(
'ui:tickets|button:jump|input:question',
"Page number to jump to"
))
)
value = value.strip()
except asyncio.TimeoutError:
return
if not value.lstrip('- ').isdigit():
error_embed = discord.Embed(
title=t(_p(
'ui:tickets|button:jump|error:invalid_page',
"Invalid page number, please try again!"
)),
colour=discord.Colour.brand_red()
)
await interaction.response.send_message(embed=error_embed, ephemeral=True)
else:
await interaction.response.defer(thinking=True)
pagen = int(value.lstrip('- '))
if value.startswith('-'):
pagen = -1 * pagen
elif pagen > 0:
pagen = pagen - 1
self.pagen = pagen
await self.refresh(thinking=interaction)
async def jump_button_refresh(self):
component = self.jump_button
component.label = f"{self.pagen + 1}/{self.page_count}"
component.disabled = (self.page_count <= 1)
# Forward
@button(emoji=conf.emojis.forward, style=ButtonStyle.grey)
async def next_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True)
self.pagen += 1
await self.refresh(thinking=press)
# Quit
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def quit_button(self, press: discord.Interaction, pressed: Button):
"""
Quit the UI.
"""
await press.response.defer()
if self.child_ticket:
await self.child_ticket.quit()
await self.quit()
# ----- UI Flow -----
def _format_ticket(self, ticket) -> str:
"""
Format a ticket into a single embed line.
"""
components = (
"[#{ticketid}]({link})",
"{created}",
"`{type}[{state}]`",
"<@{targetid}>",
"{content}",
)
formatstr = ' | '.join(components)
data = ticket.data
if not data.content:
content = 'No Content'
elif len(data.content) > 100:
content = data.content[:97] + '...'
else:
content = data.content
ticketstr = formatstr.format(
ticketid=data.guild_ticketid,
link=ticket.jump_url or 'https://lionbot.org',
created=discord.utils.format_dt(data.created_at, 'd'),
type=data.ticket_type.name,
state=data.ticket_state.name,
targetid=data.targetid,
content=content,
)
if data.ticket_state is TicketState.PARDONED:
ticketstr = f"~~{ticketstr}~~"
return ticketstr
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
embed = discord.Embed(
title=t(_p(
'ui:tickets|embed|title',
"Moderation Tickets in {guild}"
)).format(guild=self.guild.name),
timestamp=utc_now()
)
tickets = self.current_page
if tickets:
desc = '\n'.join(self._format_ticket(ticket) for ticket in tickets)
else:
desc = t(_p(
'ui:tickets|embed|desc:no_tickets',
"No tickets matching the given criteria!"
))
embed.description = desc
filterstr = self.filters.formatted()
if filterstr:
embed.add_field(
name=t(_p(
'ui:tickets|embed|field:filters|name',
"Filters"
)),
value=filterstr,
inline=False
)
return MessageArgs(embed=embed)
async def refresh_layout(self):
to_refresh = (
self.edit_filter_button_refresh(),
self.select_ticket_button_refresh(),
self.pardon_button_refresh(),
self.tickets_menu_refresh(),
self.filter_type_menu_refresh(),
self.filter_state_menu_refresh(),
self.filter_target_menu_refresh(),
self.jump_button_refresh(),
)
await asyncio.gather(*to_refresh)
action_line = (
self.edit_filter_button,
self.select_ticket_button,
self.pardon_button,
)
if self.page_count > 1:
page_line = (
self.prev_button,
self.jump_button,
self.quit_button,
self.next_button,
)
else:
page_line = ()
action_line = (*action_line, self.quit_button)
if self.show_filters:
menus = (
(self.filter_type_menu,),
(self.filter_state_menu,),
(self.filter_target_menu,),
)
elif self.show_tickets and self.current_page:
menus = ((self.tickets_menu,),)
else:
menus = ()
self.set_layout(
action_line,
*menus,
page_line,
)
async def reload(self):
tickets = await Ticket.fetch_tickets(
self.bot,
*self.filters.conditions(),
guildid=self.guild.id,
)
blocks = [
tickets[i:i+self.block_len]
for i in range(0, len(tickets), self.block_len)
]
self.blocks = blocks or [[]]
class TicketUI(MessageUI):
def __init__(self, bot: LionBot, ticket: Ticket, callerid: int, **kwargs):
super().__init__(callerid=callerid, **kwargs)
self.bot = bot
self.ticket = ticket
# ----- API -----
# ----- UI Components -----
# Pardon Ticket
@button(
label="PARDON_BUTTON_PLACEHOLDER",
style=ButtonStyle.red
)
async def pardon_button(self, press: discord.Interaction, pressed: Button):
t = self.bot.translator.t
modal_title = t(_p(
'ui:ticket|button:pardon|modal:reason|title',
"Pardon Moderation Ticket"
))
input_field = TextInput(
label=t(_p(
'ui:ticket|button:pardon|modal:reason|field|label',
"Why are you pardoning this ticket?"
)),
style=TextStyle.long,
min_length=0,
max_length=1024,
)
try:
interaction, reason = await input(
press, modal_title, field=input_field, timeout=300,
)
except asyncio.TimeoutError:
raise ResponseTimedOut
await interaction.response.defer(thinking=True, ephemeral=True)
await self.ticket.pardon(modid=press.user.id, reason=reason)
await self.refresh(thinking=interaction)
async def pardon_button_refresh(self):
button = self.pardon_button
t = self.bot.translator.t
button.label = t(_p(
'ui:ticket|button:pardon|label',
"Pardon"
))
button.disabled = (self.ticket.data.ticket_state is TicketState.PARDONED)
# Quit
@button(emoji=conf.emojis.cancel, style=ButtonStyle.red)
async def quit_button(self, press: discord.Interaction, pressed: Button):
"""
Quit the UI.
"""
await press.response.defer()
await self.quit()
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
return await self.ticket.make_message()
async def refresh_layout(self):
await self.pardon_button_refresh()
self.set_layout(
(self.pardon_button, self.quit_button,)
)
async def reload(self):
await self.ticket.data.refresh()

View File

@@ -1,7 +0,0 @@
# flake8: noqa
from .module import module
from . import help
from . import links
from . import nerd
from . import join_message

View File

@@ -1,237 +0,0 @@
import discord
from cmdClient.checks import is_owner
from utils.lib import prop_tabulate
from utils import interactive, ctx_addons # noqa
from wards import is_guild_admin
from .module import module
from .lib import guide_link
new_emoji = " 🆕"
new_commands = {'botconfig', 'sponsors'}
# Set the command groups to appear in the help
group_hints = {
'Pomodoro': "*Stay in sync with your friends using our timers!*",
'Productivity': "*Use these to help you stay focused and productive!*",
'Statistics': "*StudyLion leaderboards and study statistics.*",
'Economy': "*Buy, sell, and trade with your hard-earned coins!*",
'Personal Settings': "*Tell me about yourself!*",
'Guild Admin': "*Dangerous administration commands!*",
'Guild Configuration': "*Control how I behave in your server.*",
'Meta': "*Information about me!*",
'Support Us': "*Support the team and keep the project alive by using LionGems!*"
}
standard_group_order = (
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings', 'Meta'),
)
mod_group_order = (
('Moderation', 'Meta'),
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
)
admin_group_order = (
('Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
)
bot_admin_group_order = (
('Bot Admin', 'Guild Admin', 'Guild Configuration', 'Moderation', 'Meta'),
('Pomodoro', 'Productivity', 'Support Us', 'Statistics', 'Economy', 'Personal Settings')
)
# Help embed format
# TODO: Add config fields for this
title = "StudyLion Command List"
header = """
[StudyLion](https://bot.studylions.com/) is a fully featured study assistant \
that tracks your study time and offers productivity tools \
such as to-do lists, task reminders, private study rooms, group accountability sessions, and much much more.\n
Use `{{ctx.best_prefix}}help <command>` (e.g. `{{ctx.best_prefix}}help send`) to learn how to use each command, \
or [click here]({guide_link}) for a comprehensive tutorial.
""".format(guide_link=guide_link)
@module.cmd("help",
group="Meta",
desc="StudyLion command list.",
aliases=('man', 'ls', 'list'))
async def cmd_help(ctx):
"""
Usage``:
{prefix}help [cmdname]
Description:
When used with no arguments, displays a list of commands with brief descriptions.
Otherwise, shows documentation for the provided command.
Examples:
{prefix}help
{prefix}help top
{prefix}help timezone
"""
if ctx.arg_str:
# Attempt to fetch the command
command = ctx.client.cmd_names.get(ctx.arg_str.strip(), None)
if command is None:
return await ctx.error_reply(
("Command `{}` not found!\n"
"Write `{}help` to see a list of commands.").format(ctx.args, ctx.best_prefix)
)
smart_help = getattr(command, 'smart_help', None)
if smart_help is not None:
return await smart_help(ctx)
help_fields = command.long_help.copy()
help_map = {field_name: i for i, (field_name, _) in enumerate(help_fields)}
if not help_map:
return await ctx.reply("No documentation has been written for this command yet!")
field_pages = [[]]
page_fields = field_pages[0]
for name, pos in help_map.items():
if name.endswith("``"):
# Handle codeline help fields
page_fields.append((
name.strip("`"),
"`{}`".format('`\n`'.join(help_fields[pos][1].splitlines()))
))
elif name.endswith(":"):
# Handle property/value help fields
lines = help_fields[pos][1].splitlines()
names = []
values = []
for line in lines:
split = line.split(":", 1)
names.append(split[0] if len(split) > 1 else "")
values.append(split[-1])
page_fields.append((
name.strip(':'),
prop_tabulate(names, values)
))
elif name == "Related":
# Handle the related field
names = [cmd_name.strip() for cmd_name in help_fields[pos][1].split(',')]
names.sort(key=len)
values = [
(getattr(ctx.client.cmd_names.get(cmd_name, None), 'desc', '') or '').format(ctx=ctx)
for cmd_name in names
]
page_fields.append((
name,
prop_tabulate(names, values)
))
elif name == "PAGEBREAK":
page_fields = []
field_pages.append(page_fields)
else:
page_fields.append((name, help_fields[pos][1]))
# Build the aliases
aliases = getattr(command, 'aliases', [])
alias_str = "(Aliases `{}`.)".format("`, `".join(aliases)) if aliases else ""
# Build the embeds
pages = []
for i, page_fields in enumerate(field_pages):
embed = discord.Embed(
title="`{}` command documentation. {}".format(
command.name,
alias_str
),
colour=discord.Colour(0x9b59b6)
)
for fieldname, fieldvalue in page_fields:
embed.add_field(
name=fieldname,
value=fieldvalue.format(ctx=ctx, prefix=ctx.best_prefix),
inline=False
)
embed.set_footer(
text="{}\n[optional] and <required> denote optional and required arguments, respectively.".format(
"Page {} of {}".format(i + 1, len(field_pages)) if len(field_pages) > 1 else '',
)
)
pages.append(embed)
# Post the embed
await ctx.pager(pages)
else:
# Build the command groups
cmd_groups = {}
for command in ctx.client.cmds:
# Get the command group
group = getattr(command, 'group', "Misc")
cmd_group = cmd_groups.get(group, [])
if not cmd_group:
cmd_groups[group] = cmd_group
# Add the command name and description to the group
cmd_group.append(
(command.name, (getattr(command, 'desc', '') + (new_emoji if command.name in new_commands else '')))
)
# Add any required aliases
for alias, desc in getattr(command, 'help_aliases', {}).items():
cmd_group.append((alias, desc))
# Turn the command groups into strings
stringy_cmd_groups = {}
for group_name, cmd_group in cmd_groups.items():
cmd_group.sort(key=lambda tup: len(tup[0]))
if ctx.alias == 'ls':
stringy_cmd_groups[group_name] = ', '.join(
f"`{name}`" for name, _ in cmd_group
)
else:
stringy_cmd_groups[group_name] = prop_tabulate(*zip(*cmd_group))
# Now put everything into a bunch of embeds
if await is_owner.run(ctx):
group_order = bot_admin_group_order
elif ctx.guild:
if is_guild_admin(ctx.author):
group_order = admin_group_order
elif ctx.guild_settings.mod_role.value in ctx.author.roles:
group_order = mod_group_order
else:
group_order = standard_group_order
else:
group_order = admin_group_order
help_embeds = []
for page_groups in group_order:
embed = discord.Embed(
description=header.format(ctx=ctx),
colour=discord.Colour(0x9b59b6),
title=title
)
for group in page_groups:
group_hint = group_hints.get(group, '').format(ctx=ctx)
group_str = stringy_cmd_groups.get(group, None)
if group_str:
embed.add_field(
name=group,
value="{}\n{}".format(group_hint, group_str).format(ctx=ctx),
inline=False
)
help_embeds.append(embed)
# Add the page numbers
for i, embed in enumerate(help_embeds):
embed.set_footer(text="Page {}/{}".format(i+1, len(help_embeds)))
# Send the embeds
if help_embeds:
await ctx.pager(help_embeds)
else:
await ctx.reply(
embed=discord.Embed(description=header, colour=discord.Colour(0x9b59b6))
)

View File

@@ -1,50 +0,0 @@
import discord
from cmdClient import cmdClient
from meta import client, conf
from .lib import guide_link, animation_link
message = """
Thank you for inviting me to your community.
Get started by typing `{prefix}help` to see my commands, and `{prefix}config info` \
to read about my configuration options!
To learn how to configure me and use all of my features, \
make sure to [click here]({guide_link}) to read our full setup guide.
Remember, if you need any help configuring me, \
want to suggest a feature, report a bug and stay updated, \
make sure to join our main support and study server by [clicking here]({support_link}).
Best of luck with your studies!
""".format(
guide_link=guide_link,
support_link=conf.bot.get('support_link'),
prefix=client.prefix
)
@client.add_after_event('guild_join', priority=0)
async def post_join_message(client: cmdClient, guild: discord.Guild):
try:
await guild.me.edit(nick="Leo")
except discord.HTTPException:
pass
if (channel := guild.system_channel) and channel.permissions_for(guild.me).embed_links:
embed = discord.Embed(
description=message
)
embed.set_author(
name="Hello everyone! My name is Leo, the StudyLion!",
icon_url="https://cdn.discordapp.com/emojis/933610591459872868.webp"
)
embed.set_image(url=animation_link)
try:
await channel.send(embed=embed)
except discord.HTTPException:
# Something went wrong sending the hi message
# Not much we can do about this
pass

View File

@@ -1,5 +0,0 @@
guide_link = "https://discord.studylions.com/tutorial"
animation_link = (
"https://media.discordapp.net/attachments/879412267731542047/926837189814419486/ezgif.com-resize.gif"
)

View File

@@ -1,57 +0,0 @@
import discord
from meta import conf
from LionContext import LionContext as Context
from .module import module
from .lib import guide_link
@module.cmd(
"support",
group="Meta",
desc=f"Have a question? Join my [support server]({conf.bot.get('support_link')})"
)
async def cmd_support(ctx: Context):
"""
Usage``:
{prefix}support
Description:
Replies with an invite link to my support server.
"""
await ctx.reply(
f"Click here to join my support server: {conf.bot.get('support_link')}"
)
@module.cmd(
"invite",
group="Meta",
desc=f"[Invite me]({conf.bot.get('invite_link')}) to your server so I can help your members stay productive!"
)
async def cmd_invite(ctx: Context):
"""
Usage``:
{prefix}invite
Description:
Replies with my invite link so you can add me to your server.
"""
embed = discord.Embed(
colour=discord.Colour.orange(),
description=f"Click here to add me to your server: {conf.bot.get('invite_link')}"
)
embed.add_field(
name="Setup tips",
value=(
"Remember to check out `{prefix}help` for the full command list, "
"and `{prefix}config info` for the configuration options.\n"
"[Click here]({guide}) for our comprehensive setup tutorial, and if you still have questions you can "
"join our support server [here]({support}) to talk to our friendly support team!"
).format(
prefix=ctx.best_prefix,
support=conf.bot.get('support_link'),
guide=guide_link
)
)
await ctx.reply(embed=embed)

View File

@@ -1,3 +0,0 @@
from LionModule import LionModule
module = LionModule("Meta")

View File

@@ -1,144 +0,0 @@
import datetime
import asyncio
import discord
import psutil
import sys
import gc
from data import NOTNULL
from data.queries import select_where
from utils.lib import prop_tabulate, utc_now
from LionContext import LionContext as Context
from .module import module
process = psutil.Process()
process.cpu_percent()
@module.cmd(
"nerd",
group="Meta",
desc="Information and statistics about me!"
)
async def cmd_nerd(ctx: Context):
"""
Usage``:
{prefix}nerd
Description:
View nerdy information and statistics about me!
"""
# Create embed
embed = discord.Embed(
colour=discord.Colour.orange(),
title="Nerd Panel",
description=(
"Hi! I'm [StudyLion]({studylion}), a study management bot owned by "
"[Ari Horesh]({ari}) and developed by [Conatum#5317]({cona}), with [contributors]({github})."
).format(
studylion="http://studylions.com/",
ari="https://arihoresh.com/",
cona="https://github.com/Intery",
github="https://github.com/StudyLions/StudyLion"
)
)
# ----- Study stats -----
# Current studying statistics
current_students, current_channels, current_guilds= (
ctx.client.data.current_sessions.select_one_where(
select_columns=(
"COUNT(*) AS studying_count",
"COUNT(DISTINCT(channelid)) AS channel_count",
"COUNT(DISTINCT(guildid)) AS guild_count"
)
)
)
# Past studying statistics
past_sessions, past_students, past_duration, past_guilds = ctx.client.data.session_history.select_one_where(
select_columns=(
"COUNT(*) AS session_count",
"COUNT(DISTINCT(userid)) AS user_count",
"SUM(duration) / 3600 AS total_hours",
"COUNT(DISTINCT(guildid)) AS guild_count"
)
)
# Tasklist statistics
tasks = ctx.client.data.tasklist.select_one_where(
select_columns=(
'COUNT(*)'
)
)[0]
tasks_completed = ctx.client.data.tasklist.select_one_where(
completed_at=NOTNULL,
select_columns=(
'COUNT(*)'
)
)[0]
# Timers
timer_count, timer_guilds = ctx.client.data.timers.select_one_where(
select_columns=("COUNT(*)", "COUNT(DISTINCT(guildid))")
)
study_fields = {
"Currently": f"`{current_students}` people working in `{current_channels}` rooms of `{current_guilds}` guilds",
"Recorded": f"`{past_duration}` hours from `{past_students}` people across `{past_sessions}` sessions",
"Tasks": f"`{tasks_completed}` out of `{tasks}` tasks completed",
"Timers": f"`{timer_count}` timers running in `{timer_guilds}` communities"
}
study_table = prop_tabulate(*zip(*study_fields.items()))
# ----- Shard statistics -----
shard_number = ctx.client.shard_id
shard_count = ctx.client.shard_count
guilds = len(ctx.client.guilds)
member_count = sum(guild.member_count for guild in ctx.client.guilds)
commands = len(ctx.client.cmds)
aliases = len(ctx.client.cmd_names)
dpy_version = discord.__version__
py_version = sys.version.split()[0]
data_version, data_time, _ = select_where(
"VersionHistory",
_extra="ORDER BY time DESC LIMIT 1"
)[0]
data_timestamp = int(data_time.replace(tzinfo=datetime.timezone.utc).timestamp())
shard_fields = {
"Shard": f"`{shard_number}` of `{shard_count}`",
"Guilds": f"`{guilds}` servers with `{member_count}` members (on this shard)",
"Commands": f"`{commands}` commands with `{aliases}` keywords",
"Version": f"`v{data_version}`, last updated <t:{data_timestamp}:F>",
"Py version": f"`{py_version}` running discord.py `{dpy_version}`"
}
shard_table = prop_tabulate(*zip(*shard_fields.items()))
# ----- Execution statistics -----
running_commands = len(ctx.client.active_contexts)
tasks = len(asyncio.all_tasks())
objects = len(gc.get_objects())
cpu_percent = process.cpu_percent()
mem_percent = int(process.memory_percent())
uptime = int(utc_now().timestamp() - process.create_time())
execution_fields = {
"Running": f"`{running_commands}` commands",
"Waiting for": f"`{tasks}` tasks to complete",
"Objects": f"`{objects}` loaded in memory",
"Usage": f"`{cpu_percent}%` CPU, `{mem_percent}%` MEM",
"Uptime": f"`{uptime // (24 * 3600)}` days, `{uptime // 3600 % 24:02}:{uptime // 60 % 60:02}:{uptime % 60:02}`"
}
execution_table = prop_tabulate(*zip(*execution_fields.items()))
# ----- Combine and output -----
embed.add_field(name="Study Stats", value=study_table, inline=False)
embed.add_field(name=f"Shard Info", value=shard_table, inline=False)
embed.add_field(name=f"Process Stats", value=execution_table, inline=False)
await ctx.reply(embed=embed)

View File

@@ -1,9 +0,0 @@
from .module import module
from . import data
from . import admin
from . import tickets
from . import video
from . import commands

View File

@@ -1,109 +0,0 @@
import discord
from settings import GuildSettings, GuildSetting
from wards import guild_admin
import settings
from .data import studyban_durations
@GuildSettings.attach_setting
class mod_log(settings.Channel, GuildSetting):
category = "Moderation"
attr_name = 'mod_log'
_data_column = 'mod_log_channel'
display_name = "mod_log"
desc = "Moderation event logging channel."
long_desc = (
"Channel to post moderation tickets.\n"
"These are produced when a manual or automatic moderation action is performed on a member. "
"This channel acts as a more context rich moderation history source than the audit log."
)
_chan_type = discord.ChannelType.text
@property
def success_response(self):
if self.value:
return "Moderation tickets will be posted to {}.".format(self.formatted)
else:
return "The moderation log has been unset."
@GuildSettings.attach_setting
class studyban_role(settings.Role, GuildSetting):
category = "Moderation"
attr_name = 'studyban_role'
_data_column = 'studyban_role'
display_name = "studyban_role"
desc = "The role given to members to prevent them from using server study features."
long_desc = (
"This role is to be given to members to prevent them from using the server's study features.\n"
"Typically, this role should act as a 'partial mute', and prevent the user from joining study voice channels, "
"or participating in study text channels.\n"
"It will be given automatically after study related offences, "
"such as not enabling video in the video-only channels."
)
@property
def success_response(self):
if self.value:
return "The study ban role is now {}.".format(self.formatted)
@GuildSettings.attach_setting
class studyban_durations(settings.SettingList, settings.ListData, settings.Setting):
category = "Moderation"
attr_name = 'studyban_durations'
_table_interface = studyban_durations
_id_column = 'guildid'
_data_column = 'duration'
_order_column = "rowid"
_default = [
5 * 60,
60 * 60,
6 * 60 * 60,
24 * 60 * 60,
168 * 60 * 60,
720 * 60 * 60
]
_setting = settings.Duration
write_ward = guild_admin
display_name = "studyban_durations"
desc = "Sequence of durations for automatic study bans."
long_desc = (
"This sequence describes how long a member will be automatically study-banned for "
"after committing a study-related offence (such as not enabling their video in video only channels).\n"
"If the sequence is `1d, 7d, 30d`, for example, the member will be study-banned "
"for `1d` on their first offence, `7d` on their second offence, and `30d` on their third. "
"On their fourth offence, they will not be unbanned.\n"
"This does not count pardoned offences."
)
accepts = (
"Comma separated list of durations in days/hours/minutes/seconds, for example `12h, 1d, 7d, 30d`."
)
# Flat cache, no need to expire objects
_cache = {}
@property
def success_response(self):
if self.value:
return "The automatic study ban durations are now {}.".format(self.formatted)
else:
return "Automatic study bans will never be reverted."

View File

@@ -1,448 +0,0 @@
"""
Shared commands for the moderation module.
"""
import asyncio
from collections import defaultdict
import discord
from cmdClient.lib import ResponseTimedOut
from wards import guild_moderator
from .module import module
from .tickets import Ticket, TicketType, TicketState
type_accepts = {
'note': TicketType.NOTE,
'notes': TicketType.NOTE,
'studyban': TicketType.STUDY_BAN,
'studybans': TicketType.STUDY_BAN,
'warn': TicketType.WARNING,
'warns': TicketType.WARNING,
'warning': TicketType.WARNING,
'warnings': TicketType.WARNING,
}
type_formatted = {
TicketType.NOTE: 'NOTE',
TicketType.STUDY_BAN: 'STUDYBAN',
TicketType.WARNING: 'WARNING',
}
type_summary_formatted = {
TicketType.NOTE: 'note',
TicketType.STUDY_BAN: 'studyban',
TicketType.WARNING: 'warning',
}
state_formatted = {
TicketState.OPEN: 'ACTIVE',
TicketState.EXPIRING: 'TEMP',
TicketState.EXPIRED: 'EXPIRED',
TicketState.PARDONED: 'PARDONED'
}
state_summary_formatted = {
TicketState.OPEN: 'Active',
TicketState.EXPIRING: 'Temporary',
TicketState.EXPIRED: 'Expired',
TicketState.REVERTED: 'Manually Reverted',
TicketState.PARDONED: 'Pardoned'
}
@module.cmd(
"tickets",
group="Moderation",
desc="View and filter the server moderation tickets.",
flags=('active', 'type=')
)
@guild_moderator()
async def cmd_tickets(ctx, flags):
"""
Usage``:
{prefix}tickets [@user] [--type <type>] [--active]
Description:
Display and optionally filter the moderation event history in this guild.
Flags::
type: Filter by ticket type. See **Ticket Types** below.
active: Only show in-effect tickets (i.e. hide expired and pardoned ones).
Ticket Types::
note: Moderation notes.
warn: Moderation warnings, both manual and automatic.
studyban: Bans from using study features from abusing the study system.
blacklist: Complete blacklisting from using my commands.
Ticket States::
Active: Active tickets that will not automatically expire.
Temporary: Active tickets that will automatically expire after a set duration.
Expired: Tickets that have automatically expired.
Reverted: Tickets with actions that have been reverted.
Pardoned: Tickets that have been pardoned and no longer apply to the user.
Examples:
{prefix}tickets {ctx.guild.owner.mention} --type warn --active
"""
# Parse filter fields
# First the user
if ctx.args:
userstr = ctx.args.strip('<@!&> ')
if not userstr.isdigit():
return await ctx.error_reply(
"**Usage:** `{prefix}tickets [@user] [--type <type>] [--active]`.\n"
"Please provide the `user` as a mention or id!".format(prefix=ctx.best_prefix)
)
filter_userid = int(userstr)
else:
filter_userid = None
if flags['type']:
typestr = flags['type'].lower()
if typestr not in type_accepts:
return await ctx.error_reply(
"Please see `{prefix}help tickets` for the valid ticket types!".format(prefix=ctx.best_prefix)
)
filter_type = type_accepts[typestr]
else:
filter_type = None
filter_active = flags['active']
# Build the filter arguments
filters = {'guildid': ctx.guild.id}
if filter_userid:
filters['targetid'] = filter_userid
if filter_type:
filters['ticket_type'] = filter_type
if filter_active:
filters['ticket_state'] = [TicketState.OPEN, TicketState.EXPIRING]
# Fetch the tickets with these filters
tickets = Ticket.fetch_tickets(**filters)
if not tickets:
if filters:
return await ctx.embed_reply("There are no tickets with these criteria!")
else:
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}
# Build the format string based on the filters
components = []
# Ticket id with link to message in mod log
components.append("[#{ticket.data.guild_ticketid}]({ticket.link})")
# Ticket creation date
components.append("<t:{timestamp:.0f}:d>")
# Ticket type, with current state
if filter_type is None:
if not filter_active:
components.append("`{ticket_type}{ticket_state}`")
else:
components.append("`{ticket_type}`")
elif not filter_active:
components.append("`{ticket_real_state}`")
if not filter_userid:
# Ticket user
components.append("<@{ticket.data.targetid}>")
if filter_userid or (filter_active and filter_type):
# Truncated ticket content
components.append("{content}")
format_str = ' | '.join(components)
# Break tickets into blocks
blocks = [tickets[i:i+10] for i in range(0, len(tickets), 10)]
# Build pages of tickets
ticket_pages = []
for block in blocks:
ticket_page = []
type_len = max(len(type_formatted[ticket.type]) for ticket in block)
state_len = max(len(state_formatted[ticket.state]) for ticket in block)
for ticket in block:
# First truncate content if required
content = ticket.data.content
if len(content) > 40:
content = content[:37] + '...'
# Build ticket line
line = format_str.format(
ticket=ticket,
timestamp=ticket.data.created_at.timestamp(),
ticket_type=type_formatted[ticket.type],
type_len=type_len,
ticket_state=" [{}]".format(state_formatted[ticket.state]) if ticket.state != TicketState.OPEN else '',
ticket_real_state=state_formatted[ticket.state],
state_len=state_len,
content=content
)
if ticket.state == TicketState.PARDONED:
line = "~~{}~~".format(line)
# Add to current page
ticket_page.append(line)
# Combine lines and add page to pages
ticket_pages.append('\n'.join(ticket_page))
# Build active ticket type summary
freq = defaultdict(int)
for ticket in tickets:
if ticket.state != TicketState.PARDONED:
freq[ticket.type] += 1
summary_pairs = [
(num, type_summary_formatted[ttype] + ('s' if num > 1 else ''))
for ttype, num in freq.items()
]
summary_pairs.sort(key=lambda pair: pair[0])
# num_len = max(len(str(num)) for num in freq.values())
# type_summary = '\n'.join(
# "**`{:<{}}`** {}".format(pair[0], num_len, pair[1])
# for pair in summary_pairs
# )
# # Build status summary
# freq = defaultdict(int)
# for ticket in tickets:
# freq[ticket.state] += 1
# num_len = max(len(str(num)) for num in freq.values())
# status_summary = '\n'.join(
# "**`{:<{}}`** {}".format(freq[state], num_len, state_str)
# for state, state_str in state_summary_formatted.items()
# if state in freq
# )
summary_strings = [
"**`{}`** {}".format(*pair) for pair in summary_pairs
]
if len(summary_strings) > 2:
summary = ', '.join(summary_strings[:-1]) + ', and ' + summary_strings[-1]
elif len(summary_strings) == 2:
summary = ' and '.join(summary_strings)
else:
summary = ''.join(summary_strings)
if summary:
summary += '.'
# Build embed info
title = "{}{}{}".format(
"Active " if filter_active else '',
"{} tickets ".format(type_formatted[filter_type]) if filter_type else "Tickets ",
(" for {}".format(ctx.guild.get_member(filter_userid) or filter_userid)
if filter_userid else " in {}".format(ctx.guild.name))
)
footer = "Click a ticket id to jump to it, or type the number to show the full ticket."
page_count = len(blocks)
if page_count > 1:
footer += "\nPage {{page_num}}/{}".format(page_count)
# Create embeds
embeds = [
discord.Embed(
title=title,
description="{}\n{}".format(summary, page),
colour=discord.Colour.orange(),
).set_footer(text=footer.format(page_num=i+1))
for i, page in enumerate(ticket_pages)
]
# Run output with cancellation and listener
out_msg = await ctx.pager(embeds, add_cancel=True)
old_task = _displays.pop((ctx.ch.id, ctx.author.id), None)
if old_task:
old_task.cancel()
_displays[(ctx.ch.id, ctx.author.id)] = display_task = asyncio.create_task(_ticket_display(ctx, ticket_map))
ctx.tasks.append(display_task)
await ctx.cancellable(out_msg, add_reaction=False)
_displays = {} # (channelid, userid) -> Task
async def _ticket_display(ctx, ticket_map):
"""
Display tickets when the ticket number is entered.
"""
current_ticket_msg = None
try:
while True:
# Wait for a number
try:
result = await ctx.client.wait_for(
"message",
check=lambda msg: (msg.author == ctx.author
and msg.channel == ctx.ch
and msg.content.isdigit()
and int(msg.content) in ticket_map),
timeout=60
)
except asyncio.TimeoutError:
return
# Delete the response
try:
await result.delete()
except discord.HTTPException:
pass
# Display the ticket
embed = ticket_map[int(result.content)].msg_args['embed']
if current_ticket_msg:
try:
await current_ticket_msg.edit(embed=embed)
except discord.HTTPException:
current_ticket_msg = None
if not current_ticket_msg:
try:
current_ticket_msg = await ctx.reply(embed=embed)
except discord.HTTPException:
return
asyncio.create_task(ctx.offer_delete(current_ticket_msg))
except asyncio.CancelledError:
if current_ticket_msg:
try:
await current_ticket_msg.delete()
except discord.HTTPException:
pass
@module.cmd(
"pardon",
group="Moderation",
desc="Pardon a ticket, or clear a member's moderation history.",
flags=('type=',)
)
@guild_moderator()
async def cmd_pardon(ctx, flags):
"""
Usage``:
{prefix}pardon ticketid, ticketid, ticketid
{prefix}pardon @user [--type <type>]
Description:
Marks the given tickets as no longer applicable.
These tickets will not be considered when calculating automod actions such as automatic study bans.
This may be used to mark warns or other tickets as no longer in-effect.
If the ticket is active when it is pardoned, it will be reverted, and any expiry cancelled.
Use the `{prefix}tickets` command to view the relevant tickets.
Flags::
type: Filter by ticket type. See **Ticket Types** in `{prefix}help tickets`.
Examples:
{prefix}pardon 21
{prefix}pardon {ctx.guild.owner.mention} --type warn
"""
usage = "**Usage**: `{prefix}pardon ticketid` or `{prefix}pardon @user`.".format(prefix=ctx.best_prefix)
if not ctx.args:
return await ctx.error_reply(
usage
)
# Parse provided tickets or filters
targetid = None
ticketids = []
args = {'guildid': ctx.guild.id}
if ',' in ctx.args:
# Assume provided numbers are ticketids.
items = [item.strip() for item in ctx.args.split(',')]
if not all(item.isdigit() for item in items):
return await ctx.error_reply(usage)
ticketids = [int(item) for item in items]
args['guild_ticketid'] = ticketids
else:
# Guess whether the provided numbers were ticketids or not
idstr = ctx.args.strip('<@!&> ')
if not idstr.isdigit():
return await ctx.error_reply(usage)
maybe_id = int(idstr)
if maybe_id > 4194304: # Testing whether it is greater than the minimum snowflake id
# Assume userid
targetid = maybe_id
args['targetid'] = maybe_id
# Add the type filter if provided
if flags['type']:
typestr = flags['type'].lower()
if typestr not in type_accepts:
return await ctx.error_reply(
"Please see `{prefix}help tickets` for the valid ticket types!".format(prefix=ctx.best_prefix)
)
args['ticket_type'] = type_accepts[typestr]
else:
# Assume guild ticketid
ticketids = [maybe_id]
args['guild_ticketid'] = maybe_id
# Fetch the matching tickets
tickets = Ticket.fetch_tickets(**args)
# Check whether we have the right selection of tickets
if targetid and not tickets:
return await ctx.error_reply(
"<@{}> has no matching tickets to pardon!"
)
if ticketids and len(ticketids) != len(tickets):
# Not all of the ticketids were valid
difference = list(set(ticketids).difference(ticket.ticketid for ticket in tickets))
if len(difference) == 1:
return await ctx.error_reply(
"Couldn't find ticket `{}`!".format(difference[0])
)
else:
return await ctx.error_reply(
"Couldn't find any of the following tickets:\n`{}`".format(
'`, `'.join(difference)
)
)
# Check whether there are any tickets left to pardon
to_pardon = [ticket for ticket in tickets if ticket.state != TicketState.PARDONED]
if not to_pardon:
if ticketids and len(tickets) == 1:
ticket = tickets[0]
return await ctx.error_reply(
"[Ticket #{}]({}) is already pardoned!".format(ticket.data.guild_ticketid, ticket.link)
)
else:
return await ctx.error_reply(
"All of these tickets are already pardoned!"
)
# We now know what tickets we want to pardon
# Request the pardon reason
try:
reason = await ctx.input("Please provide a reason for the pardon.")
except ResponseTimedOut:
raise ResponseTimedOut("Prompt timed out, no tickets were pardoned.")
# Pardon the tickets
for ticket in to_pardon:
await ticket.pardon(ctx.author, reason)
# Finally, ack the pardon
if targetid:
await ctx.embed_reply(
"The active {}s for <@{}> have been cleared.".format(
type_summary_formatted[args['ticket_type']] if flags['type'] else 'ticket',
targetid
)
)
elif len(to_pardon) == 1:
ticket = to_pardon[0]
await ctx.embed_reply(
"[Ticket #{}]({}) was pardoned.".format(
ticket.data.guild_ticketid,
ticket.link
)
)
else:
await ctx.embed_reply(
"The following tickets were pardoned.\n{}".format(
", ".join(
"[#{}]({})".format(ticket.data.guild_ticketid, ticket.link)
for ticket in to_pardon
)
)
)

View File

@@ -1,19 +0,0 @@
from data import Table, RowTable
studyban_durations = Table('studyban_durations')
ticket_info = RowTable(
'ticket_info',
('ticketid', 'guild_ticketid',
'guildid', 'targetid', 'ticket_type', 'ticket_state', 'moderator_id', 'auto',
'log_msg_id', 'created_at',
'content', 'context', 'addendum', 'duration',
'file_name', 'file_data',
'expiry',
'pardoned_by', 'pardoned_at', 'pardoned_reason'),
'ticketid',
cache_size=20000
)
tickets = Table('tickets')

View File

@@ -1,4 +0,0 @@
from cmdClient import Module
module = Module("Moderation")

View File

@@ -1,486 +0,0 @@
import asyncio
import logging
import traceback
import datetime
import discord
from meta import client
from data.conditions import THIS_SHARD
from settings import GuildSettings
from utils.lib import FieldEnum, strfdelta, utc_now
from .. import data
from ..module import module
class TicketType(FieldEnum):
"""
The possible ticket types.
"""
NOTE = 'NOTE', 'Note'
WARNING = 'WARNING', 'Warning'
STUDY_BAN = 'STUDY_BAN', 'Study Ban'
MESAGE_CENSOR = 'MESSAGE_CENSOR', 'Message Censor'
INVITE_CENSOR = 'INVITE_CENSOR', 'Invite Censor'
class TicketState(FieldEnum):
"""
The possible ticket states.
"""
OPEN = 'OPEN', "Active"
EXPIRING = 'EXPIRING', "Active"
EXPIRED = 'EXPIRED', "Expired"
PARDONED = 'PARDONED', "Pardoned"
REVERTED = 'REVERTED', "Reverted"
class Ticket:
"""
Abstract base class representing a Ticketed moderation action.
"""
# Type of event the class represents
_ticket_type = None # type: TicketType
_ticket_types = {} # Map: TicketType -> Ticket subclass
_expiry_tasks = {} # Map: ticketid -> expiry Task
def __init__(self, ticketid, *args, **kwargs):
self.ticketid = ticketid
@classmethod
async def create(cls, *args, **kwargs):
"""
Method used to create a new ticket of the current type.
Should add a row to the ticket table, post the ticket, and return the Ticket.
"""
raise NotImplementedError
@property
def data(self):
"""
Ticket row.
This will usually be a row of `ticket_info`.
"""
return data.ticket_info.fetch(self.ticketid)
@property
def guild(self):
return client.get_guild(self.data.guildid)
@property
def target(self):
guild = self.guild
return guild.get_member(self.data.targetid) if guild else None
@property
def msg_args(self):
"""
Ticket message posted in the moderation log.
"""
args = {}
# Build embed
info = self.data
member = self.target
name = str(member) if member else str(info.targetid)
if info.auto:
title_fmt = "Ticket #{} | {} | {}[Auto] | {}"
else:
title_fmt = "Ticket #{} | {} | {} | {}"
title = title_fmt.format(
info.guild_ticketid,
TicketState(info.ticket_state).desc,
TicketType(info.ticket_type).desc,
name
)
embed = discord.Embed(
title=title,
description=info.content,
timestamp=info.created_at
)
embed.add_field(
name="Target",
value="<@{}>".format(info.targetid)
)
if not info.auto:
embed.add_field(
name="Moderator",
value="<@{}>".format(info.moderator_id)
)
# if info.duration:
# value = "`{}` {}".format(
# strfdelta(datetime.timedelta(seconds=info.duration)),
# "(Expiry <t:{:.0f}>)".format(info.expiry.timestamp()) if info.expiry else ""
# )
# embed.add_field(
# name="Duration",
# value=value
# )
if info.expiry:
if info.ticket_state == TicketState.EXPIRING:
embed.add_field(
name="Expires at",
value="<t:{:.0f}>\n(Duration: `{}`)".format(
info.expiry.timestamp(),
strfdelta(datetime.timedelta(seconds=info.duration))
)
)
elif info.ticket_state == TicketState.EXPIRED:
embed.add_field(
name="Expired",
value="<t:{:.0f}>".format(
info.expiry.timestamp(),
)
)
else:
embed.add_field(
name="Expiry",
value="<t:{:.0f}>".format(
info.expiry.timestamp()
)
)
if info.context:
embed.add_field(
name="Context",
value=info.context,
inline=False
)
if info.addendum:
embed.add_field(
name="Notes",
value=info.addendum,
inline=False
)
if self.state == TicketState.PARDONED:
embed.add_field(
name="Pardoned",
value=(
"Pardoned by <@{}> at <t:{:.0f}>.\n{}"
).format(
info.pardoned_by,
info.pardoned_at.timestamp(),
info.pardoned_reason or ""
),
inline=False
)
embed.set_footer(text="ID: {}".format(info.targetid))
args['embed'] = embed
# Add file
if info.file_name:
args['file'] = discord.File(info.file_data, info.file_name)
return args
@property
def link(self):
"""
The link to the ticket in the moderation log.
"""
info = self.data
modlog = GuildSettings(info.guildid).mod_log.data
return 'https://discord.com/channels/{}/{}/{}'.format(
info.guildid,
modlog,
info.log_msg_id
)
@property
def state(self):
return TicketState(self.data.ticket_state)
@property
def type(self):
return TicketType(self.data.ticket_type)
async def update(self, **kwargs):
"""
Update ticket fields.
"""
fields = (
'targetid', 'moderator_id', 'auto', 'log_msg_id',
'content', 'expiry', 'ticket_state',
'context', 'addendum', 'duration', 'file_name', 'file_data',
'pardoned_by', 'pardoned_at', 'pardoned_reason',
)
params = {field: kwargs[field] for field in fields if field in kwargs}
if params:
data.ticket_info.update_where(params, ticketid=self.ticketid)
await self.update_expiry()
await self.post()
async def post(self):
"""
Post or update the ticket in the moderation log.
Also updates the saved message id.
"""
info = self.data
modlog = GuildSettings(info.guildid).mod_log.value
if not modlog:
return
resend = True
try:
if info.log_msg_id:
# Try to fetch the message
message = await modlog.fetch_message(info.log_msg_id)
if message:
if message.author.id == client.user.id:
# TODO: Handle file edit
await message.edit(embed=self.msg_args['embed'])
resend = False
else:
try:
await message.delete()
except discord.HTTPException:
pass
if resend:
message = await modlog.send(**self.msg_args)
self.data.log_msg_id = message.id
except discord.HTTPException:
client.log(
"Cannot post ticket (tid: {}) due to discord exception or issue.".format(self.ticketid)
)
except Exception:
# This should never happen in normal operation
client.log(
"Error while posting ticket (tid:{})! "
"Exception traceback follows.\n{}".format(
self.ticketid,
traceback.format_exc()
),
context="TICKETS",
level=logging.ERROR
)
@classmethod
def load_expiring(cls):
"""
Load and schedule all expiring tickets.
"""
# TODO: Consider changing this to a flat timestamp system, to avoid storing lots of coroutines.
# TODO: Consider only scheduling the expiries in the next day, and updating this once per day.
# TODO: Only fetch tickets from guilds we are in.
# Cancel existing expiry tasks
for task in cls._expiry_tasks.values():
if not task.done():
task.cancel()
# Get all expiring tickets
expiring_rows = data.tickets.select_where(
ticket_state=TicketState.EXPIRING,
guildid=THIS_SHARD
)
# Create new expiry tasks
now = utc_now()
cls._expiry_tasks = {
row['ticketid']: asyncio.create_task(
cls._schedule_expiry_for(
row['ticketid'],
(row['expiry'] - now).total_seconds()
)
) for row in expiring_rows
}
# Log
client.log(
"Loaded {} expiring tickets.".format(len(cls._expiry_tasks)),
context="TICKET_LOADER",
)
@classmethod
async def _schedule_expiry_for(cls, ticketid, delay):
"""
Schedule expiry for a given ticketid
"""
try:
await asyncio.sleep(delay)
ticket = Ticket.fetch(ticketid)
if ticket:
await asyncio.shield(ticket._expire())
except asyncio.CancelledError:
return
def update_expiry(self):
# Cancel any existing expiry task
task = self._expiry_tasks.pop(self.ticketid, None)
if task and not task.done():
task.cancel()
# Schedule a new expiry task, if applicable
if self.data.ticket_state == TicketState.EXPIRING:
self._expiry_tasks[self.ticketid] = asyncio.create_task(
self._schedule_expiry_for(
self.ticketid,
(self.data.expiry - utc_now()).total_seconds()
)
)
async def cancel_expiry(self):
"""
Cancel ticket expiry.
In particular, may be used if another ticket overrides `self`.
Sets the ticket state to `OPEN`, so that it no longer expires.
"""
if self.state == TicketState.EXPIRING:
# Update the ticket state
self.data.ticket_state = TicketState.OPEN
# Remove from expiry tsks
self.update_expiry()
# Repost
await self.post()
async def _revert(self, reason=None):
"""
Method used to revert the ticket action, e.g. unban or remove mute role.
Generally called by `pardon` and `_expire`.
May be overriden by the Ticket type, if they implement any revert logic.
Is a no-op by default.
"""
return
async def _expire(self):
"""
Method to automatically expire a ticket.
May be overriden by the Ticket type for more complex expiry logic.
Must set `data.ticket_state` to `EXPIRED` if applicable.
"""
if self.state == TicketState.EXPIRING:
client.log(
"Automatically expiring ticket (tid:{}).".format(self.ticketid),
context="TICKETS"
)
try:
await self._revert(reason="Automatic Expiry")
except Exception:
# This should never happen in normal operation
client.log(
"Error while expiring ticket (tid:{})! "
"Exception traceback follows.\n{}".format(
self.ticketid,
traceback.format_exc()
),
context="TICKETS",
level=logging.ERROR
)
# Update state
self.data.ticket_state = TicketState.EXPIRED
# Update log message
await self.post()
# Post a note to the modlog
modlog = GuildSettings(self.data.guildid).mod_log.value
if modlog:
try:
await modlog.send(
embed=discord.Embed(
colour=discord.Colour.orange(),
description="[Ticket #{}]({}) expired!".format(self.data.guild_ticketid, self.link)
)
)
except discord.HTTPException:
pass
async def pardon(self, moderator, reason, timestamp=None):
"""
Pardon process for the ticket.
May be overidden by the Ticket type for more complex pardon logic.
Must set `data.ticket_state` to `PARDONED` if applicable.
"""
if self.state != TicketState.PARDONED:
if self.state in (TicketState.OPEN, TicketState.EXPIRING):
try:
await self._revert(reason="Pardoned by {}".format(moderator.id))
except Exception:
# This should never happen in normal operation
client.log(
"Error while pardoning ticket (tid:{})! "
"Exception traceback follows.\n{}".format(
self.ticketid,
traceback.format_exc()
),
context="TICKETS",
level=logging.ERROR
)
# Update state
with self.data.batch_update():
self.data.ticket_state = TicketState.PARDONED
self.data.pardoned_at = utc_now()
self.data.pardoned_by = moderator.id
self.data.pardoned_reason = reason
# Update (i.e. remove) expiry
self.update_expiry()
# Update log message
await self.post()
@classmethod
def fetch_tickets(cls, *ticketids, **kwargs):
"""
Fetch tickets matching the given criteria (passed transparently to `select_where`).
Positional arguments are treated as `ticketids`, which are not supported in keyword arguments.
"""
if ticketids:
kwargs['ticketid'] = ticketids
# Set the ticket type to the class type if not specified
if cls._ticket_type and 'ticket_type' not in kwargs:
kwargs['ticket_type'] = cls._ticket_type
# This is actually mainly for caching, since we don't pass the data to the initialiser
rows = data.ticket_info.fetch_rows_where(
**kwargs
)
return [
cls._ticket_types[TicketType(row.ticket_type)](row.ticketid)
for row in rows
]
@classmethod
def fetch(cls, ticketid):
"""
Return the Ticket with the given id, if found, or `None` otherwise.
"""
tickets = cls.fetch_tickets(ticketid)
return tickets[0] if tickets else None
@classmethod
def register_ticket_type(cls, ticket_cls):
"""
Decorator to register a new Ticket subclass as a ticket type.
"""
cls._ticket_types[ticket_cls._ticket_type] = ticket_cls
return ticket_cls
@module.launch_task
async def load_expiring_tickets(client):
Ticket.load_expiring()

View File

@@ -1,4 +0,0 @@
from .Ticket import Ticket, TicketType, TicketState
from .studybans import StudyBanTicket
from .notes import NoteTicket
from .warns import WarnTicket

View File

@@ -1,112 +0,0 @@
"""
Note ticket implementation.
Guild moderators can add a note about a user, visible in their moderation history.
Notes appear in the moderation log and the user's ticket history, like any other ticket.
This module implements the Note TicketType and the `note` moderation command.
"""
from cmdClient.lib import ResponseTimedOut
from wards import guild_moderator
from ..module import module
from ..data import tickets
from .Ticket import Ticket, TicketType, TicketState
@Ticket.register_ticket_type
class NoteTicket(Ticket):
_ticket_type = TicketType.NOTE
@classmethod
async def create(cls, guildid, targetid, moderatorid, content, **kwargs):
"""
Create a new Note on a target.
`kwargs` are passed transparently to the table insert method.
"""
ticket_row = tickets.insert(
guildid=guildid,
targetid=targetid,
ticket_type=cls._ticket_type,
ticket_state=TicketState.OPEN,
moderator_id=moderatorid,
auto=False,
content=content,
**kwargs
)
# Create the note ticket
ticket = cls(ticket_row['ticketid'])
# Post the ticket and return
await ticket.post()
return ticket
@module.cmd(
"note",
group="Moderation",
desc="Add a Note to a member's record."
)
@guild_moderator()
async def cmd_note(ctx):
"""
Usage``:
{prefix}note @target
{prefix}note @target <content>
Description:
Add a note to the target's moderation record.
The note will appear in the moderation log and in the `tickets` command.
The `target` must be specificed by mention or user id.
If the `content` is not given, it will be prompted for.
Example:
{prefix}note {ctx.author.mention} Seen reading the `note` documentation.
"""
if not ctx.args:
return await ctx.error_reply(
"**Usage:** `{}note @target <content>`.".format(ctx.best_prefix)
)
# Extract the target. We don't require them to be in the server
splits = ctx.args.split(maxsplit=1)
target_str = splits[0].strip('<@!&> ')
if not target_str.isdigit():
return await ctx.error_reply(
"**Usage:** `{}note @target <content>`.\n"
"`target` must be provided by mention or userid.".format(ctx.best_prefix)
)
targetid = int(target_str)
# Extract or prompt for the content
if len(splits) != 2:
try:
content = await ctx.input("What note would you like to add?", timeout=300)
except ResponseTimedOut:
raise ResponseTimedOut("Prompt timed out, no note was created.")
else:
content = splits[1].strip()
# Create the note ticket
ticket = await NoteTicket.create(
ctx.guild.id,
targetid,
ctx.author.id,
content
)
if ticket.data.log_msg_id:
await ctx.embed_reply(
"Note on <@{}> created as [Ticket #{}]({}).".format(
targetid,
ticket.data.guild_ticketid,
ticket.link
)
)
else:
await ctx.embed_reply(
"Note on <@{}> created as Ticket #{}.".format(targetid, ticket.data.guild_ticketid)
)

View File

@@ -1,126 +0,0 @@
import datetime
import discord
from meta import client
from utils.lib import utc_now
from settings import GuildSettings
from data import NOT
from .. import data
from .Ticket import Ticket, TicketType, TicketState
@Ticket.register_ticket_type
class StudyBanTicket(Ticket):
_ticket_type = TicketType.STUDY_BAN
@classmethod
async def create(cls, guildid, targetid, moderatorid, reason, expiry=None, **kwargs):
"""
Create a new study ban ticket.
"""
# First create the ticket itself
ticket_row = data.tickets.insert(
guildid=guildid,
targetid=targetid,
ticket_type=cls._ticket_type,
ticket_state=TicketState.EXPIRING if expiry else TicketState.OPEN,
moderator_id=moderatorid,
auto=(moderatorid == client.user.id),
content=reason,
expiry=expiry,
**kwargs
)
# Create the Ticket
ticket = cls(ticket_row['ticketid'])
# Schedule ticket expiry, if applicable
if expiry:
ticket.update_expiry()
# Cancel any existing studyban expiry for this member
tickets = cls.fetch_tickets(
guildid=guildid,
ticketid=NOT(ticket_row['ticketid']),
targetid=targetid,
ticket_state=TicketState.EXPIRING
)
for ticket in tickets:
await ticket.cancel_expiry()
# Post the ticket
await ticket.post()
# Return the ticket
return ticket
async def _revert(self, reason=None):
"""
Revert the studyban by removing the role.
"""
guild_settings = GuildSettings(self.data.guildid)
role = guild_settings.studyban_role.value
target = self.target
if target and role:
try:
await target.remove_roles(
role,
reason="Reverting StudyBan: {}".format(reason)
)
except discord.HTTPException:
# TODO: Error log?
...
@classmethod
async def autoban(cls, guild, target, reason, **kwargs):
"""
Convenience method to automatically studyban a member, for the configured duration.
If the role is set, this will create and return a `StudyBanTicket` regardless of whether the
studyban was successful.
If the role is not set, or the ticket cannot be created, this will return `None`.
"""
# Get the studyban role, fail if there isn't one set, or the role doesn't exist
guild_settings = GuildSettings(guild.id)
role = guild_settings.studyban_role.value
if not role:
return None
# Attempt to add the role, record failure
try:
await target.add_roles(role, reason="Applying StudyBan: {}".format(reason[:400]))
except discord.HTTPException:
role_failed = True
else:
role_failed = False
# Calculate the applicable automatic duration and expiry
# First count the existing non-pardoned studybans for this target
studyban_count = data.tickets.select_one_where(
guildid=guild.id,
targetid=target.id,
ticket_type=cls._ticket_type,
ticket_state=NOT(TicketState.PARDONED),
select_columns=('COUNT(*)',)
)[0]
studyban_count = int(studyban_count)
# Then read the guild setting to find the applicable duration
studyban_durations = guild_settings.studyban_durations.value
if studyban_count < len(studyban_durations):
duration = studyban_durations[studyban_count]
expiry = utc_now() + datetime.timedelta(seconds=duration)
else:
duration = None
expiry = None
# Create the ticket and return
if role_failed:
kwargs['addendum'] = '\n'.join((
kwargs.get('addendum', ''),
"Could not add the studyban role! Please add the role manually and check my permissions."
))
return await cls.create(
guild.id, target.id, client.user.id, reason, duration=duration, expiry=expiry, **kwargs
)

View File

@@ -1,153 +0,0 @@
"""
Warn ticket implementation.
Guild moderators can officially warn a user via command.
This DMs the users with the warning.
"""
import datetime
import discord
from cmdClient.lib import ResponseTimedOut
from wards import guild_moderator
from ..module import module
from ..data import tickets
from .Ticket import Ticket, TicketType, TicketState
@Ticket.register_ticket_type
class WarnTicket(Ticket):
_ticket_type = TicketType.WARNING
@classmethod
async def create(cls, guildid, targetid, moderatorid, content, **kwargs):
"""
Create a new Warning for the target.
`kwargs` are passed transparently to the table insert method.
"""
ticket_row = tickets.insert(
guildid=guildid,
targetid=targetid,
ticket_type=cls._ticket_type,
ticket_state=TicketState.OPEN,
moderator_id=moderatorid,
content=content,
**kwargs
)
# Create the note ticket
ticket = cls(ticket_row['ticketid'])
# Post the ticket and return
await ticket.post()
return ticket
async def _revert(*args, **kwargs):
# Warnings don't have a revert process
pass
@module.cmd(
"warn",
group="Moderation",
desc="Officially warn a user for a misbehaviour."
)
@guild_moderator()
async def cmd_warn(ctx):
"""
Usage``:
{prefix}warn @target
{prefix}warn @target <reason>
Description:
The `target` must be specificed by mention or user id.
If the `reason` is not given, it will be prompted for.
Example:
{prefix}warn {ctx.author.mention} Don't actually read the documentation!
"""
if not ctx.args:
return await ctx.error_reply(
"**Usage:** `{}warn @target <reason>`.".format(ctx.best_prefix)
)
# Extract the target. We do require them to be in the server
splits = ctx.args.split(maxsplit=1)
target_str = splits[0].strip('<@!&> ')
if not target_str.isdigit():
return await ctx.error_reply(
"**Usage:** `{}warn @target <reason>`.\n"
"`target` must be provided by mention or userid.".format(ctx.best_prefix)
)
targetid = int(target_str)
target = ctx.guild.get_member(targetid)
if not target:
return await ctx.error_reply("Cannot warn a user who is not in the server!")
# Extract or prompt for the content
if len(splits) != 2:
try:
content = await ctx.input("Please give a reason for this warning!", timeout=300)
except ResponseTimedOut:
raise ResponseTimedOut("Prompt timed out, the member was not warned.")
else:
content = splits[1].strip()
# Create the warn ticket
ticket = await WarnTicket.create(
ctx.guild.id,
targetid,
ctx.author.id,
content
)
# Attempt to message the member
embed = discord.Embed(
title="You have received a warning!",
description=(
content
),
colour=discord.Colour.red(),
timestamp=datetime.datetime.utcnow()
)
embed.add_field(
name="Info",
value=(
"*Warnings appear in your moderation history. "
"Failure to comply, or repeated warnings, "
"may result in muting, studybanning, or server banning.*"
)
)
embed.set_footer(
icon_url=ctx.guild.icon_url,
text=ctx.guild.name
)
dm_msg = None
try:
dm_msg = await target.send(embed=embed)
except discord.HTTPException:
pass
# Get previous warnings
count = tickets.select_one_where(
guildid=ctx.guild.id,
targetid=targetid,
ticket_type=TicketType.WARNING,
ticket_state=[TicketState.OPEN, TicketState.EXPIRING],
select_columns=('COUNT(*)',)
)[0]
if count == 1:
prev_str = "This is their first warning."
else:
prev_str = "They now have `{}` warnings.".format(count)
await ctx.embed_reply(
"[Ticket #{}]({}): {} has been warned. {}\n{}".format(
ticket.data.guild_ticketid,
ticket.link,
target.mention,
prev_str,
"*Could not DM the user their warning!*" if not dm_msg else ''
)
)

View File

@@ -1,5 +0,0 @@
from . import module
from . import data
from . import config
from . import commands

View File

@@ -1,14 +0,0 @@
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))

View File

@@ -1,92 +0,0 @@
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

View File

@@ -1,4 +0,0 @@
from data import Table
guild_whitelist = Table("sponsor_guild_whitelist")

View File

@@ -1,33 +0,0 @@
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 ctx.guild:
show = ctx.guild.id not in ctx.client.settings.sponsor_guild_whitelist.value
show = show and not ctx.client.data.premium_guilds.queries.fetch_guild(ctx.guild.id)
else:
show = True
if show:
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)

View File

@@ -1,6 +0,0 @@
from .module import module
from . import webhook
from . import commands
from . import data
from . import settings

View File

@@ -1,77 +0,0 @@
import discord
from .module import module
from cmdClient.checks import is_owner
from settings.user_settings import UserSettings
from LionContext import LionContext
from .webhook import on_dbl_vote
from .utils import lion_loveemote
@module.cmd(
"forcevote",
desc="Simulate a Topgg Vote from the given user.",
group="Bot Admin",
)
@is_owner()
async def cmd_forcevote(ctx: LionContext):
"""
Usage``:
{prefix}forcevote
Description:
Simulate Top.gg vote without actually a confirmation from Topgg site.
Can be used for force a vote for testing or if topgg has an error or production time bot error.
"""
target = ctx.author
# Identify the target
if ctx.args:
if not ctx.msg.mentions:
return await ctx.error_reply("Please mention a user to simulate a vote!")
target = ctx.msg.mentions[0]
await on_dbl_vote({"user": target.id, "type": "test"})
return await ctx.reply('Topgg vote simulation successful on {}'.format(target), suggest_vote=False)
@module.cmd(
"vote",
desc="[Vote](https://top.gg/bot/889078613817831495/vote) for me to get 25% more LCs!",
group="Economy",
aliases=('topgg', 'topggvote', 'upvote')
)
async def cmd_vote(ctx: LionContext):
"""
Usage``:
{prefix}vote
Description:
Get Top.gg bot's link for +25% Economy boost.
"""
embed = discord.Embed(
title="Claim your boost!",
description=(
"Please click [here](https://top.gg/bot/889078613817831495/vote) to vote and support our bot!\n\n"
"Thank you! {}.".format(lion_loveemote)
),
colour=discord.Colour.orange()
).set_thumbnail(
url="https://cdn.discordapp.com/attachments/908283085999706153/933012309532614666/lion-love.png"
)
return await ctx.reply(embed=embed, suggest_vote=False)
@module.cmd(
"vote_reminder",
group="Personal Settings",
desc="Turn on/off boost reminders."
)
async def cmd_remind_vote(ctx: LionContext):
"""
Usage:
`{prefix}vote_reminder on`
`{prefix}vote_reminder off`
Enable or disable DM boost reminders.
"""
await UserSettings.settings.vote_remainder.command(ctx, ctx.author.id)

View File

@@ -1,9 +0,0 @@
from data.interfaces import RowTable, Table
topggvotes = RowTable(
'topgg',
('voteid', 'userid', 'boostedTimestamp'),
'voteid'
)
guild_whitelist = Table('topgg_guild_whitelist')

View File

@@ -1,80 +0,0 @@
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
module = LionModule("Topgg")
upvote_info = "You have a boost available {}, to support our project and earn **25% more LionCoins** type `{}vote` {}"
@module.launch_task
async def attach_topgg_webhook(client):
if client.shard_id == 0:
init_webhook()
client.log("Attached top.gg voiting webhook.", context="TOPGG")
@module.launch_task
async def register_hook(client):
LionContext.reply.add_wrapper(topgg_reply_wrapper)
Lion.register_economy_bonus(economy_bonus)
client.log("Loaded top.gg hooks.", context="TOPGG")
@module.unload_task
async def unregister_hook(client):
Lion.unregister_economy_bonus(economy_bonus)
LionContext.reply.remove_wrapper(topgg_reply_wrapper.__name__)
client.log("Unloaded top.gg hooks.", context="TOPGG")
boostfree_groups = {'Meta'}
boostfree_commands = {'config', 'pomodoro'}
async def topgg_reply_wrapper(func, ctx: LionContext, *args, suggest_vote=True, **kwargs):
if not suggest_vote:
pass
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 ctx.guild and ctx.client.data.premium_guilds.queries.fetch_guild(ctx.guild.id):
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 and ctx.cmd.name not in sponsored_commands:
# Add message as an extra embed field
kwargs['embed'].add_field(
name="\u200b",
value=(
upvote_info_formatted
),
inline=False
)
else:
# Add message to content
if 'content' in kwargs and kwargs['content']:
if len(kwargs['content']) + len(upvote_info_formatted) < 1998:
kwargs['content'] += '\n\n' + upvote_info_formatted
elif args:
if len(args[0]) + len(upvote_info_formatted) < 1998:
args = list(args)
args[0] += '\n\n' + upvote_info_formatted
else:
kwargs['content'] = upvote_info_formatted
return await func(ctx, *args, **kwargs)
def economy_bonus(lion):
return 1.25 if get_last_voted_timestamp(lion.userid) else 1

View File

@@ -1,72 +0,0 @@
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
class topgg_vote_remainder(Boolean, UserSetting):
attr_name = 'vote_remainder'
_data_column = 'topgg_vote_reminder'
_default = True
display_name = 'vote_reminder'
desc = r"Toggle automatic reminders to support me for a 25% LionCoin boost."
long_desc = (
"Did you know that you can [vote for me]({vote_link}) to help me help other people reach their goals? "
"And you get a **25% boost** to all LionCoin income you make across all servers!\n"
"Enable this setting if you want me to let you know when you can vote again!"
).format(vote_link=topgg_upvote_link)
@property
def success_response(self):
if self.value:
# Check if reminder is already running
create_remainder(self.id)
return (
"Thank you for supporting me! I will remind in your DMs when you can vote next! "
"(Please make sure your DMs are open, otherwise I can't reach you!)"
)
else:
# Check if reminder is already running and get its id
r = reminders.select_one_where(
userid=self.id,
select_columns='reminderid',
content=remainder_content,
_extra="ORDER BY remind_at DESC LIMIT 1"
)
# Cancel and delete Remainder if already running
if r:
Reminder.delete(r['reminderid'])
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

View File

@@ -1,97 +0,0 @@
import discord
import datetime
from meta import sharding
from meta import conf
from meta.client import client
from utils.lib import utc_now
from settings.setting_types import Integer
from modules.reminders.reminder import Reminder
from modules.reminders.data import reminders
from . import data as db
from data.conditions import GEQ
topgg_upvote_link = 'https://top.gg/bot/889078613817831495/vote'
remainder_content = (
"You can now vote again on top.gg!\n"
"Click [here]({}) to vote, thank you for the support!"
).format(topgg_upvote_link)
lion_loveemote = conf.emojis.getemoji('lionlove')
lion_yayemote = conf.emojis.getemoji('lionyay')
def get_last_voted_timestamp(userid: Integer):
"""
Will return None if user has not voted in [-12.5hrs till now]
else will return a Tuple containing timestamp of when exactly she voted
"""
return db.topggvotes.select_one_where(
userid=userid,
select_columns="boostedTimestamp",
boostedTimestamp=GEQ(utc_now() - datetime.timedelta(hours=12.5)),
_extra="ORDER BY boostedTimestamp DESC LIMIT 1"
)
def create_remainder(userid):
"""
Checks if a remainder is already running (immaterial of remind_at time)
If no remainder exists creates a new remainder and schedules it
"""
if not reminders.select_one_where(
userid=userid,
content=remainder_content,
_extra="ORDER BY remind_at DESC LIMIT 1"
):
last_vote_time = get_last_voted_timestamp(userid)
# if no, Create reminder
reminder = Reminder.create(
userid=userid,
# TODO using content as a selector is not a good method
content=remainder_content,
message_link=None,
interval=None,
title="Your boost is now available! {}".format(lion_yayemote),
footer="Use `{}vote_reminder off` to stop receiving reminders.".format(client.prefix),
remind_at=(
last_vote_time[0] + datetime.timedelta(hours=12.5)
if last_vote_time else
utc_now() + datetime.timedelta(minutes=5)
)
# remind_at=datetime.datetime.utcnow() + datetime.timedelta(minutes=2)
)
# Schedule reminder
if sharding.shard_number == 0:
reminder.schedule()
async def send_user_dm(userid):
# Send the message, if possible
if not (user := client.get_user(userid)):
try:
user = await client.fetch_user(userid)
except discord.HTTPException:
pass
if user:
try:
embed = discord.Embed(
title="Thank you for supporting our bot on Top.gg! {}".format(lion_yayemote),
description=(
"By voting every 12 hours you will allow us to reach and help "
"even more students all over the world.\n"
"Thank you for supporting us, enjoy your LionCoins boost!"
),
colour=discord.Colour.orange()
).set_image(
url="https://cdn.discordapp.com/attachments/908283085999706153/932737228440993822/lion-yay.png"
)
await user.send(embed=embed)
except discord.HTTPException:
# Nothing we can really do here. Maybe tell the user about their reminder next time?
pass

View File

@@ -1,40 +0,0 @@
from meta.client import client
from settings.user_settings import UserSettings
from utils.lib import utc_now
from meta.config import conf
import topgg
from .utils import db, send_user_dm, create_remainder
@client.event
async def on_dbl_vote(data):
"""An event that is called whenever someone votes for the bot on Top.gg."""
client.log(f"Received a vote: \n{data}", context='Topgg')
db.topggvotes.insert(
userid=data['user'],
boostedTimestamp=utc_now()
)
await send_user_dm(data['user'])
if UserSettings.settings.vote_remainder.value:
create_remainder(data['user'])
if data["type"] == "test":
return client.dispatch("dbl_test", data)
@client.event
async def on_dbl_test(data):
"""An event that is called whenever someone tests the webhook system for your bot on Top.gg."""
client.log(f"Received a test vote:\n{data}", context='Topgg')
def init_webhook():
client.topgg_webhook = topgg.WebhookManager(client).dbl_webhook(
conf.bot.get("topgg_route"),
conf.bot.get("topgg_password")
)
client.topgg_webhook.run(conf.bot.get("topgg_port"))

View File

@@ -1,5 +0,0 @@
from .module import module
from . import admin
from . import data
from . import tracker

View File

@@ -1,83 +0,0 @@
from settings import GuildSettings, GuildSetting
from wards import guild_admin
import settings
from .data import workout_channels
@GuildSettings.attach_setting
class workout_length(settings.Integer, GuildSetting):
category = "Workout"
attr_name = "min_workout_length"
_data_column = "min_workout_length"
display_name = "min_workout_length"
desc = "Minimum length of a workout."
_default = 20
long_desc = (
"Minimun time a user must spend in a workout channel for it to count as a valid workout. "
"Value must be given in minutes."
)
_accepts = "An integer number of minutes."
@property
def success_response(self):
return "The minimum workout length is now `{}` minutes.".format(self.formatted)
@GuildSettings.attach_setting
class workout_reward(settings.Integer, GuildSetting):
category = "Workout"
attr_name = "workout_reward"
_data_column = "workout_reward"
display_name = "workout_reward"
desc = "Number of daily LionCoins to reward for completing a workout."
_default = 350
long_desc = (
"Number of LionCoins given when a member completes their daily workout."
)
_accepts = "An integer number of LionCoins."
@property
def success_response(self):
return "The workout reward is now `{}` LionCoins.".format(self.formatted)
@GuildSettings.attach_setting
class workout_channels_setting(settings.ChannelList, settings.ListData, settings.Setting):
category = "Workout"
attr_name = 'workout_channels'
_table_interface = workout_channels
_id_column = 'guildid'
_data_column = 'channelid'
_setting = settings.VoiceChannel
write_ward = guild_admin
display_name = "workout_channels"
desc = "Channels in which members can do workouts."
_force_unique = True
long_desc = (
"Sessions in these channels will be treated as workouts."
)
# Flat cache, no need to expire objects
_cache = {}
@property
def success_response(self):
if self.value:
return "The workout channels have been updated:\n{}".format(self.formatted)
else:
return "The workout channels have been removed."

View File

@@ -1,10 +0,0 @@
from data import Table, RowTable
workout_channels = Table('workout_channels')
workout_sessions = RowTable(
'workout_sessions',
('sessionid', 'guildid', 'userid', 'start_time', 'duration', 'channelid'),
'sessionid'
)

View File

@@ -1,4 +0,0 @@
from LionModule import LionModule
module = LionModule("Workout")

View File

@@ -1,256 +0,0 @@
import asyncio
import logging
import datetime as dt
import discord
from core import Lion
from settings import GuildSettings
from meta import client
from data import NULL, tables
from data.conditions import THIS_SHARD
from .module import module
from .data import workout_sessions
from . import admin
leave_tasks = {}
async def on_workout_join(member):
key = (member.guild.id, member.id)
# Cancel a leave task if the member rejoined in time
if member.id in leave_tasks:
leave_tasks[key].cancel()
leave_tasks.pop(key)
return
# Create a started workout entry
workout = workout_sessions.create_row(
guildid=member.guild.id,
userid=member.id,
channelid=member.voice.channel.id
)
# Add to current workouts
client.objects['current_workouts'][key] = workout
# Log
client.log(
"User '{m.name}'(uid:{m.id}) started a workout in channel "
"'{m.voice.channel.name}' (cid:{m.voice.channel.id}) "
"of guild '{m.guild.name}' (gid:{m.guild.id}).".format(m=member),
context="WORKOUT_STARTED"
)
GuildSettings(member.guild.id).event_log.log(
"{} started a workout in {}".format(
member.mention,
member.voice.channel.mention
), title="Workout Started"
)
async def on_workout_leave(member):
key = (member.guild.id, member.id)
# Create leave task in case of temporary disconnect
task = asyncio.create_task(asyncio.sleep(3))
leave_tasks[key] = task
# Wait for the leave task, abort if it gets cancelled
try:
await task
if member.id in leave_tasks:
if leave_tasks[key] == task:
leave_tasks.pop(key)
else:
return
except asyncio.CancelledError:
# Task was cancelled by rejoining
if key in leave_tasks and leave_tasks[key] == task:
leave_tasks.pop(key)
return
# Retrieve workout row and remove from current workouts
workout = client.objects['current_workouts'].pop(key)
await workout_left(member, workout)
async def workout_left(member, workout):
time_diff = (dt.datetime.utcnow() - workout.start_time).total_seconds()
min_length = GuildSettings(member.guild.id).min_workout_length.value
if time_diff < 60 * min_length:
# Left workout before it was finished. Log and delete
client.log(
"User '{m.name}'(uid:{m.id}) left their workout in guild '{m.guild.name}' (gid:{m.guild.id}) "
"before it was complete! ({diff:.2f} minutes). Deleting workout.\n"
"{workout}".format(
m=member,
diff=time_diff / 60,
workout=workout
),
context="WORKOUT_ABORTED",
post=True
)
GuildSettings(member.guild.id).event_log.log(
"{} left their workout before it was complete! (`{:.2f}` minutes)".format(
member.mention,
time_diff / 60,
), title="Workout Left"
)
workout_sessions.delete_where(sessionid=workout.sessionid)
else:
# Completed the workout
client.log(
"User '{m.name}'(uid:{m.id}) completed their daily workout in guild '{m.guild.name}' (gid:{m.guild.id}) "
"({diff:.2f} minutes). Saving workout and notifying user.\n"
"{workout}".format(
m=member,
diff=time_diff / 60,
workout=workout
),
context="WORKOUT_COMPLETED",
post=True
)
workout.duration = time_diff
await workout_complete(member, workout)
async def workout_complete(member, workout):
key = (member.guild.id, member.id)
# update and notify
user = Lion.fetch(*key)
user_data = user.data
with user_data.batch_update():
user_data.workout_count = user_data.workout_count + 1
user_data.last_workout_start = workout.start_time
settings = GuildSettings(member.guild.id)
reward = settings.workout_reward.value
user.addCoins(reward, bonus=True)
settings.event_log.log(
"{} completed their daily workout and was rewarded `{}` coins! (`{:.2f}` minutes)".format(
member.mention,
int(reward * user.economy_bonus),
workout.duration / 60,
), title="Workout Completed"
)
embed = discord.Embed(
description=(
"Congratulations on completing your daily workout!\n"
"You have been rewarded with `{}` LionCoins. Good job!".format(int(reward * user.economy_bonus))
),
timestamp=dt.datetime.utcnow(),
colour=discord.Color.orange()
)
embed.set_footer(
text=member.guild.name,
icon_url=member.guild.icon_url
)
try:
await member.send(embed=embed)
except discord.Forbidden:
client.log(
"Couldn't notify user '{m.name}'(uid:{m.id}) about their completed workout! "
"They might have me blocked.".format(m=member),
context="WORKOUT_COMPLETED",
post=True
)
@client.add_after_event("voice_state_update")
async def workout_voice_tracker(client, member, before, after):
# Wait until launch tasks are complete
while not module.ready:
await asyncio.sleep(0.1)
if member.bot:
return
if member.id in client.user_blacklist():
return
if member.id in client.objects['ignored_members'][member.guild.id]:
return
# Check whether we are moving to/from a workout channel
settings = GuildSettings(member.guild.id)
channels = settings.workout_channels.value
from_workout = before.channel in channels
to_workout = after.channel in channels
if to_workout ^ from_workout:
# Ensure guild row exists
tables.guild_config.fetch_or_create(member.guild.id)
# Fetch workout user
user = Lion.fetch(member.guild.id, member.id)
# Ignore all workout events from users who have already completed their workout today
if user.data.last_workout_start is not None:
last_date = user.localize(user.data.last_workout_start).date()
today = user.localize(dt.datetime.utcnow()).date()
if last_date == today:
return
# TODO: Check if they have completed a workout today, if so, ignore
if to_workout and not from_workout:
await on_workout_join(member)
elif from_workout and not to_workout:
if (member.guild.id, member.id) in client.objects['current_workouts']:
await on_workout_leave(member)
else:
client.log(
"Possible missed workout!\n"
"Member '{m.name}'(uid:{m.id}) left the workout channel '{c.name}'(cid:{c.id}) "
"in guild '{m.guild.name}'(gid:{m.guild.id}), but we never saw them join!".format(
m=member,
c=before.channel
),
context="WORKOUT_TRACKER",
level=logging.ERROR,
post=True
)
settings.event_log.log(
"{} left the workout channel {}, but I never saw them join!".format(
member.mention,
before.channel.mention,
), title="Possible Missed Workout!"
)
@module.launch_task
async def load_workouts(client):
client.objects['current_workouts'] = {} # (guildid, userid) -> Row
# Process any incomplete workouts
workouts = workout_sessions.fetch_rows_where(
duration=NULL,
guildid=THIS_SHARD
)
count = 0
for workout in workouts:
channelids = admin.workout_channels_setting.get(workout.guildid).data
member = Lion.fetch(workout.guildid, workout.userid).member
if member:
if member.voice and (member.voice.channel.id in channelids):
client.objects['current_workouts'][(workout.guildid, workout.userid)] = workout
count += 1
else:
asyncio.create_task(workout_left(member, workout))
else:
client.log(
"Removing incomplete workout from "
"non-existent member (mid:{}) in guild (gid:{})".format(
workout.userid,
workout.guildid
),
context="WORKOUT_LAUNCH",
post=True
)
if count > 0:
client.log(
"Loaded {} in-progress workouts.".format(count), context="WORKOUT_LAUNCH", post=True
)

View File

@@ -90,7 +90,7 @@ class TimerCog(LionCog):
self.bot.core.guild_config.register_model_setting(self.settings.PomodoroChannel)
configcog = self.bot.get_cog('ConfigCog')
self.crossload_group(self.configure_group, configcog.configure_group)
self.crossload_group(self.configure_group, configcog.config_group)
if self.bot.is_ready():
await self.initialise()
@@ -977,7 +977,6 @@ class TimerCog(LionCog):
@appcmds.describe(
pomodoro_channel=TimerSettings.PomodoroChannel._desc
)
@appcmds.default_permissions(manage_guild=True)
@low_management_ward
async def configure_pomodoro_command(self, ctx: LionContext,
pomodoro_channel: Optional[discord.VoiceChannel | discord.TextChannel] = None):

View File

@@ -4,6 +4,7 @@ from settings.setting_types import ChannelSetting
from core.data import CoreData
from babel.translator import ctx_translator
from wards import low_management_iward
from . import babel
@@ -14,7 +15,8 @@ class TimerSettings(SettingGroup):
class PomodoroChannel(ModelData, ChannelSetting):
setting_id = 'pomodoro_channel'
_event = 'guildset_pomodoro_channel'
_set_cmd = 'configure pomodoro'
_set_cmd = 'config pomodoro'
_write_ward = low_management_iward
_display_name = _p('guildset:pomodoro_channel', "pomodoro_channel")
_desc = _p(

View File

@@ -30,6 +30,7 @@ class TimerConfigUI(ConfigUI):
async def channel_menu(self, selection: discord.Interaction, selected: ChannelSelect):
await selection.response.defer()
setting = self.instances[0]
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values[0] if selected.values else None
await setting.write()
@@ -78,7 +79,7 @@ class TimerConfigUI(ConfigUI):
class TimerDashboard(DashboardSection):
section_name = _p(
'dash:pomodoro|title',
"Pomodoro Configuration ({commands[configure pomodoro]})"
"Pomodoro Configuration ({commands[config pomodoro]})"
)
_option_name = _p(
"dash:stats|dropdown|placeholder",

View File

@@ -140,7 +140,7 @@ class RankCog(LionCog):
self.bot.core.guild_config.register_model_setting(self.settings.DMRanks)
configcog = self.bot.get_cog('ConfigCog')
self.crossload_group(self.configure_group, configcog.configure_group)
self.crossload_group(self.configure_group, configcog.admin_config_group)
def ranklock(self, guildid):
lock = self._rank_locks.get(guildid, None)
@@ -926,7 +926,6 @@ class RankCog(LionCog):
dm_ranks=RankSettings.DMRanks._desc,
rank_channel=RankSettings.RankChannel._desc,
)
@appcmds.default_permissions(administrator=True)
@high_management_ward
async def configure_ranks_cmd(self, ctx: LionContext,
rank_type: Optional[Transformed[RankTypeChoice, AppCommandOptionType.string]] = None,

View File

@@ -4,6 +4,7 @@ from settings.setting_types import BoolSetting, ChannelSetting, EnumSetting
from core.data import RankType, CoreData
from babel.translator import ctx_translator
from wards import high_management_iward
from . import babel
@@ -40,7 +41,8 @@ class RankSettings(SettingGroup):
setting_id = 'rank_type'
_event = 'guildset_rank_type'
_set_cmd = 'configure ranks'
_set_cmd = 'admin config ranks'
_write_ward = high_management_iward
_display_name = _p('guildset:rank_type', "rank_type")
_desc = _p(
@@ -98,7 +100,8 @@ class RankSettings(SettingGroup):
If DMRanks is set, this will only be used when the target user has disabled DM notifications.
"""
setting_id = 'rank_channel'
_set_cmd = 'configure ranks'
_set_cmd = 'admin config ranks'
_write_ward = high_management_iward
_display_name = _p('guildset:rank_channel', "rank_channel")
_desc = _p(
@@ -148,7 +151,8 @@ class RankSettings(SettingGroup):
Whether to DM rank notifications.
"""
setting_id = 'dm_ranks'
_set_cmd = 'configure ranks'
_set_cmd = 'admin config ranks'
_write_ward = high_management_iward
_display_name = _p('guildset:dm_ranks', "dm_ranks")
_desc = _p(

View File

@@ -69,6 +69,7 @@ class RankConfigUI(ConfigUI):
async def type_menu(self, selection: discord.Interaction, selected: Select):
await selection.response.defer(thinking=True)
setting = self.instances[0]
await setting.interaction_check(setting.parent_id, selection)
value = selected.values[0]
data = RankType((value,))
setting.data = data
@@ -117,6 +118,7 @@ class RankConfigUI(ConfigUI):
async def channel_menu(self, selection: discord.Interaction, selected: ChannelSelect):
await selection.response.defer()
setting = self.instances[2]
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values[0] if selected.values else None
await setting.write()
@@ -168,7 +170,7 @@ class RankConfigUI(ConfigUI):
class RankDashboard(DashboardSection):
section_name = _p(
'dash:rank|title',
"Rank Configuration ({commands[configure ranks]})",
"Rank Configuration ({commands[admin config ranks]})",
)
_option_name = _p(
"dash:rank|dropdown|placeholder",

View File

@@ -20,6 +20,7 @@ from ..data import AnyRankData, RankData
from ..utils import rank_model_from_type, format_stat_range, stat_data_to_value
from .editor import RankEditor
from .preview import RankPreviewUI
from .templates import get_guild_template
_p = babel._p
@@ -87,7 +88,73 @@ class RankOverviewUI(MessageUI):
Ranks are determined by rank type.
"""
await press.response.send_message("Not Implemented Yet")
t = self.bot.translator.t
# Prevent role creation spam
if await self.rank_model.table.select_where(guildid=self.guild.id):
return await press.response.send_message(content=t(_p(
'ui:rank_overview|button:auto|error:already_created',
"The rank roles have already been created!"
)), ephemeral=True)
await press.response.defer(thinking=True)
if not self.guild.me.guild_permissions.manage_roles:
raise SafeCancellation(t(_p(
'ui:rank_overview|button:auto|error:my_permissions',
"I lack the 'Manage Roles' permission required to create rank roles!"
)))
# Get rank role template based on set RankType and VoiceMode
template = get_guild_template(self.rank_type, self.lguild.guild_mode.voice)
if not template:
# Safely error if rank type or voice mode isn't an expected value
raise SafeCancellation(t(_p(
'ui:rank_overview|button:auto|error:invalid_template',
"Unable to determine rank role template!")))
roles = []
async with self.cog.ranklock(self.guild.id):
for rank in reversed(template):
try:
colour = discord.Colour.from_str(rank.colour)
role = await self.guild.create_role(name=t(rank.name), colour=colour)
roles.append(role)
await self.rank_model.create(
roleid=role.id,
guildid=self.guild.id,
required=rank.required,
reward=rank.reward,
message=t(rank.message)
)
self.cog.flush_guild_ranks(self.guild.id)
# Error if manage roles is lost during the process. This shouldn't happen
except discord.Forbidden:
self.cog.flush_guild_ranks(self.guild.id)
raise SafeCancellation(t(_p(
'ui:rank_overview|button|auto|role_creation|error:forbidden',
"An error occurred while autocreating rank roles!\n"
"I lack the 'Manage Roles' permission required to create rank roles!"
)))
except discord.HTTPException:
self.cog.flush_guild_ranks(self.guild.id)
raise SafeCancellation(t(_p(
'ui:rank_overview|button:auto|role_creation|error:unknown',
"An error occurred while autocreating rank roles!\n"
"Please check the server has enough space for new roles "
"and try again."
)))
success_msg = t(_p(
'ui:rank_overview|button:auto|role_creation|success',
"Successfully created the following rank roles:\n{roles}"
)).format(roles="\n".join(role.mention for role in roles))
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=success_msg)
await press.edit_original_response(embed=embed)
async def auto_button_refresh(self):
self.auto_button.label = self.bot.translator.t(_p(
@@ -384,11 +451,17 @@ class RankOverviewUI(MessageUI):
# No ranks, give hints about adding ranks
desc = t(_p(
'ui:rank_overview|embed:noranks|desc',
"No activity ranks have been set up!\n"
"Press 'AUTO' to automatically create a "
"standard heirachy of voice | text | xp ranks, "
"or select a role or press Create below!"
"No activity ranks have been set up!"
))
if show_note:
auto_addendum = t(_p(
'ui:rank_overview|embed:noranks|desc|admin_addendum',
"Press 'Auto Create' to automatically create a "
"standard heirachy of ranks.\n"
"To manually create ranks, press 'Create Rank' below, or select a role!"
))
desc = "\n".join((desc, auto_addendum))
if self.rank_type is RankType.VOICE:
title = t(_p(
'ui:rank_overview|embed|title|type:voice',
@@ -430,7 +503,7 @@ class RankOverviewUI(MessageUI):
"Ranks are determined by *all-time* statistics.\n"
"To reward ranks from a later time (e.g. to have monthly/quarterly/yearly ranks) "
"set the `season_start` with {stats_cmd}"
)).format(stats_cmd=self.bot.core.mention_cmd('configure statistics'))
)).format(stats_cmd=self.bot.core.mention_cmd('admin config statistics'))
if self.rank_type is RankType.VOICE:
addendum = t(_p(
'ui:rank_overview|embed|field:note|value|voice_addendum',

View File

@@ -0,0 +1,303 @@
from collections import namedtuple
from core.data import RankType
from core.lion_guild import VoiceMode
from meta import conf, LionBot
from babel.translator import ctx_translator
from .. import babel
_p = babel._p
RankBase = namedtuple("RankBase", ("name", "required", "reward", "message", "colour"))
"""
Reward message defaults
"""
voice_reward_msg = _p(
'ui:rank_editor|input:message|default|type:voice',
"Congratulations {user_mention}!\n"
"For working hard for **{requires}**, you have achieved the rank of "
"**{role_name}** in **{guild_name}**! Keep up the good work."
)
xp_reward_msg = _p(
'ui:rank_editor|input:message|default|type:xp',
"Congratulations {user_mention}!\n"
"For earning **{requires}**, you have achieved the guild rank of "
"**{role_name}** in **{guild_name}**!"
)
msg_reward_msg = _p(
'ui:rank_editor|input:message|default|type:msg',
"Congratulations {user_mention}!\n"
"For sending **{requires}**, you have achieved the guild rank of "
"**{role_name}** in **{guild_name}**!"
)
"""
Rank templates based on voice activity
"""
study_voice_template = [
RankBase(
name=_p('rank_autocreate|template|type:study_voice|level:1',
"Voice Level 1 (1h)"),
required=3600,
reward=1000,
message=voice_reward_msg,
colour="#1f28e2"
),
RankBase(
name=_p('rank_autocreate|template|type:study_voice|level:2',
"Voice Level 2 (3h)"),
required=10800,
reward=2000,
message=voice_reward_msg,
colour="#006bff"
),
RankBase(
name=_p('rank_autocreate|template|type:study_voice|level:3',
"Voice Level 3 (6h)"),
required=21600,
reward=3000,
message=voice_reward_msg,
colour="#0091ff"
),
RankBase(
name=_p('rank_autocreate|template|type:study_voice|level:4',
"Voice Level 4 (10h)"),
required=36000,
reward=4000,
message=voice_reward_msg,
colour="#00adf5"
),
RankBase(
name=_p('rank_autocreate|template|type:study_voice|level:5',
"Voice Level 5 (20h)"),
required=72000,
reward=5000,
message=voice_reward_msg,
colour="#00c6bf"
),
RankBase(
name=_p('rank_autocreate|template|type:study_voice|level:6',
"Voice Level 6 (40h)"),
required=144000,
reward=6000,
message=voice_reward_msg,
colour="#00db86"
),
RankBase(
name=_p('rank_autocreate|template|type:study_voice|level:7',
"Voice Level 7 (80h)"),
required=288000,
reward=7000,
message=voice_reward_msg,
colour="#7cea5a"
)
]
general_voice_template = [
RankBase(
name=_p('rank_autocreate|template|type:general_voice|level:1',
"Voice Level 1 (1h)"),
required=3600,
reward=1000,
message=voice_reward_msg,
colour="#1f28e2"
),
RankBase(
name=_p('rank_autocreate|template|type:general_voice|level:2',
"Voice Level 2 (2h)"),
required=7200,
reward=2000,
message=voice_reward_msg,
colour="#006bff"
),
RankBase(
name=_p('rank_autocreate|template|type:general_voice|level:3',
"Voice Level 3 (4h)"),
required=14400,
reward=3000,
message=voice_reward_msg,
colour="#0091ff"
),
RankBase(
name=_p('rank_autocreate|template|type:general_voice|level:4',
"Voice Level 4 (8h)"),
required=28800,
reward=4000,
message=voice_reward_msg,
colour="#00adf5"
),
RankBase(
name=_p('rank_autocreate|template|type:general_voice|level:5',
"Voice Level 5 (16h)"),
required=57600,
reward=5000,
message=voice_reward_msg,
colour="#00c6bf"
),
RankBase(
name=_p('rank_autocreate|template|type:general_voice|level:6',
"Voice Level 6 (32h)"),
required=115200,
reward=6000,
message=voice_reward_msg,
colour="#00db86"
),
RankBase(
name=_p('rank_autocreate|template|type:general_voice|level:7',
"Voice Level 7 (64h)"),
required=230400,
reward=7000,
message=voice_reward_msg,
colour="#7cea5a"
)
]
"""
Rank templates based on message XP earned
"""
xp_template = [
RankBase(
name=_p('rank_autocreate|template|type:xp|level:1',
"XP Level 1 (2000)"),
required=2000,
reward=1000,
message=xp_reward_msg,
colour="#1f28e2"
),
RankBase(
name=_p('rank_autocreate|template|type:xp|level:2',
"XP Level 2 (4000)"),
required=4000,
reward=2000,
message=xp_reward_msg,
colour="#006bff"
),
RankBase(
name=_p('rank_autocreate|template|type:xp|level:3',
"XP Level 3 (8000)"),
required=8000,
reward=3000,
message=xp_reward_msg,
colour="#0091ff"
),
RankBase(
name=_p('rank_autocreate|template|type:xp|level:4',
"XP Level 4 (16000)"),
required=16000,
reward=4000,
message=xp_reward_msg,
colour="#00adf5"
),
RankBase(
name=_p('rank_autocreate|template|type:xp|level:5',
"XP Level 5 (32000)"),
required=32000,
reward=5000,
message=xp_reward_msg,
colour="#00c6bf"
),
RankBase(
name=_p('rank_autocreate|template|type:xp|level:6',
"XP Level 6 (64000)"),
required=64000,
reward=6000,
message=xp_reward_msg,
colour="#00db86"
),
RankBase(
name=_p('rank_autocreate|template|type:xp|level:7',
"XP Level 7 (128000)"),
required=128000,
reward=7000,
message=xp_reward_msg,
colour="#7cea5a"
)
]
"""
Rank templates based on messages sent
"""
msg_template = [
RankBase(
name=_p('rank_autocreate|template|type:msg|level:1',
"Message Level 1 (200)"),
required=200,
reward=1000,
message=msg_reward_msg,
colour="#1f28e2"
),
RankBase(
name=_p('rank_autocreate|template|type:msg|level:2',
"Message Level 2 (400)"),
required=400,
reward=2000,
message=msg_reward_msg,
colour="#006bff"
),
RankBase(
name=_p('rank_autocreate|template|type:msg|level:3',
"Message Level 3 (800)"),
required=800,
reward=3000,
message=msg_reward_msg,
colour="#0091ff"
),
RankBase(
name=_p('rank_autocreate|template|type:msg|level:4',
"Message Level 4 (1600)"),
required=1600,
reward=4000,
message=msg_reward_msg,
colour="#00adf5"
),
RankBase(
name=_p('rank_autocreate|template|type:msg|level:5',
"Message Level 5 (3200)"),
required=3200,
reward=5000,
message=msg_reward_msg,
colour="#00c6bf"
),
RankBase(
name=_p('rank_autocreate|template|type:msg|level:6',
"Message Level 6 (6400)"),
required=6400,
reward=6000,
message=msg_reward_msg,
colour="#00db86"
),
RankBase(
name=_p('rank_autocreate|template|type:msg|level:7',
"Message Level 7 (12800)"),
required=12800,
reward=7000,
message=msg_reward_msg,
colour="#7cea5a"
)
]
def get_guild_template(rank_type: RankType, voice_mode: VoiceMode):
"""
Returns the best fit rank template
based on the guild's rank type and voice mode.
"""
if rank_type == RankType.VOICE:
if voice_mode == VoiceMode.STUDY:
return study_voice_template
if voice_mode == VoiceMode.VOICE:
return general_voice_template
if rank_type == RankType.XP:
return xp_template
if rank_type == RankType.MESSAGE:
return msg_template
return None

View File

@@ -332,6 +332,7 @@ class Reminders(LionCog):
"View and set your reminders."
)
)
@appcmds.guild_only
async def cmd_reminders(self, ctx: LionContext):
"""
Display the reminder widget for this user.
@@ -353,6 +354,7 @@ class Reminders(LionCog):
name=_p('cmd:remindme', "remindme"),
description=_p('cmd:remindme|desc', "View and set task reminders."),
)
@appcmds.guild_only
async def remindme_group(self, ctx: LionContext):
# Base command group for scheduling reminders.
pass

View File

@@ -16,7 +16,7 @@ from utils.ui import Confirm
from constants import MAX_COINS
from core.data import CoreData
from wards import low_management_ward
from wards import high_management_ward
from . import babel, logger
from .data import RoomData
@@ -47,7 +47,7 @@ class RoomCog(LionCog):
self.bot.core.guild_config.register_model_setting(setting)
configcog = self.bot.get_cog('ConfigCog')
self.crossload_group(self.configure_group, configcog.configure_group)
self.crossload_group(self.configure_group, configcog.admin_config_group)
if self.bot.is_ready():
await self.initialise()
@@ -414,7 +414,7 @@ class RoomCog(LionCog):
t(_p(
'cmd:room_rent|error:not_setup',
"The private room system has not been set up! "
"A private room category needs to be set first with `/configure rooms`."
"A private room category needs to be set first with `/admin config rooms`."
))
), ephemeral=True
)
@@ -523,12 +523,31 @@ class RoomCog(LionCog):
self._start(room)
# Send tips message
# TODO: Actual tips.
await room.channel.send(
"{mention} welcome to your private room! You may use the menu below to configure it.".format(
mention=ctx.author.mention
)
tips = (
"Welcome to your very own private room {owner}!\n"
"You may use the control panel below to quickly configure your room, including:\n"
"- Inviting (and removing) members,\n"
"- Depositing LionCoins into the room bank to pay the daily rent, and\n"
"- Adding your very own Pomodoro timer to the room.\n\n"
"You also have elevated Discord permissions over the room itself!\n"
"This includes managing messages, and changing the name, region,"
" and bitrate of the channel, or even deleting the room entirely!"
" (Beware you will not be refunded in this case.)\n\n"
"Finally, you now have access to some new commands:\n"
"{status_cmd}: This brings up the room control panel again,"
" in case the interface below times out or is deleted/hidden.\n"
"{deposit_cmd}: Room members may use this command to easily contribute LionCoins to the room bank.\n"
"{invite_cmd} and {kick_cmd}: Quickly invite (or remove) multiple members by mentioning them.\n"
"{transfer_cmd}: Transfer the room to another owner, keeping the balance (this is not reversible!)"
).format(
owner=ctx.author.mention,
status_cmd=self.bot.core.mention_cmd('room status'),
deposit_cmd=self.bot.core.mention_cmd('room deposit'),
invite_cmd=self.bot.core.mention_cmd('room invite'),
kick_cmd=self.bot.core.mention_cmd('room kick'),
transfer_cmd=self.bot.core.mention_cmd('room transfer'),
)
await room.channel.send(tips)
# Send config UI
ui = RoomUI(self.bot, room, callerid=ctx.author.id, timeout=None)
@@ -987,8 +1006,7 @@ class RoomCog(LionCog):
@appcmds.describe(
**{setting.setting_id: setting._desc for setting in RoomSettings.model_settings}
)
@appcmds.default_permissions(manage_guild=True)
@low_management_ward
@high_management_ward
async def configure_rooms_cmd(self, ctx: LionContext,
rooms_category: Optional[discord.CategoryChannel] = None,
rooms_price: Optional[Range[int, 0, MAX_COINS]] = None,

View File

@@ -5,6 +5,7 @@ from settings.setting_types import ChannelSetting, IntegerSetting, BoolSetting
from meta import conf
from core.data import CoreData
from babel.translator import ctx_translator
from wards import low_management_iward, high_management_iward
from . import babel
@@ -15,7 +16,8 @@ class RoomSettings(SettingGroup):
class Category(ModelData, ChannelSetting):
setting_id = 'rooms_category'
_event = 'guildset_rooms_category'
_set_cmd = 'configure rooms'
_set_cmd = 'admin config rooms'
_write_ward = high_management_iward
_display_name = _p(
'guildset:room_category', "rooms_category"
@@ -70,7 +72,8 @@ class RoomSettings(SettingGroup):
class Rent(ModelData, IntegerSetting):
setting_id = 'rooms_price'
_event = 'guildset_rooms_price'
_set_cmd = 'configure rooms'
_set_cmd = 'admin config rooms'
_write_ward = low_management_iward
_display_name = _p(
'guildset:rooms_price', "room_rent"
@@ -107,7 +110,8 @@ class RoomSettings(SettingGroup):
class MemberLimit(ModelData, IntegerSetting):
setting_id = 'rooms_slots'
_event = 'guildset_rooms_slots'
_set_cmd = 'configure rooms'
_set_cmd = 'admin config rooms'
_write_ward = low_management_iward
_display_name = _p('guildset:rooms_slots', "room_member_cap")
_desc = _p(
@@ -141,7 +145,8 @@ class RoomSettings(SettingGroup):
class Visible(ModelData, BoolSetting):
setting_id = 'rooms_visible'
_event = 'guildset_rooms_visible'
_set_cmd = 'configure rooms'
_set_cmd = 'admin config rooms'
_write_ward = high_management_iward
_display_name = _p('guildset:rooms_visible', "room_visibility")
_desc = _p(

View File

@@ -29,6 +29,7 @@ class RoomSettingUI(ConfigUI):
async def category_menu(self, selection: discord.Interaction, selected: ChannelSelect):
await selection.response.defer()
setting = self.instances[0]
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values[0] if selected.values else None
await setting.write()
@@ -42,6 +43,7 @@ class RoomSettingUI(ConfigUI):
async def visible_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer()
setting = next(inst for inst in self.instances if inst.setting_id == RoomSettings.Visible.setting_id)
await setting.interaction_check(setting.parent_id, press)
setting.value = not setting.value
await setting.write()
@@ -95,7 +97,7 @@ class RoomSettingUI(ConfigUI):
class RoomDashboard(DashboardSection):
section_name = _p(
'dash:rooms|title',
"Private Room Configuration ({commands[configure rooms]})"
"Private Room Configuration ({commands[admin config rooms]})"
)
_option_name = _p(
"dash:economy|dropdown|placeholder",

View File

@@ -17,7 +17,7 @@ from meta.monitor import ComponentMonitor, ComponentStatus, StatusLevel
from utils.lib import utc_now, error_embed
from utils.ui import Confirm
from utils.data import MULTIVALUE_IN, MEMBERS
from wards import low_management_ward
from wards import high_management_ward
from core.data import CoreData
from data import NULL, ORDER
from modules.economy.data import TransactionType
@@ -118,7 +118,7 @@ class ScheduleCog(LionCog):
await self.settings.SessionChannels.setup(self.bot)
configcog = self.bot.get_cog('ConfigCog')
self.crossload_group(self.configure_group, configcog.configure_group)
self.crossload_group(self.configure_group, configcog.admin_config_group)
if self.bot.is_ready():
await self.initialise()
@@ -1090,7 +1090,7 @@ class ScheduleCog(LionCog):
@appcmds.describe(
**{param: option._desc for param, option in config_params.items()}
)
@low_management_ward
@high_management_ward
async def configure_schedule_command(self, ctx: LionContext,
session_lobby: Optional[discord.TextChannel | discord.VoiceChannel] = None,
session_room: Optional[discord.VoiceChannel] = None,

View File

@@ -11,6 +11,7 @@ from meta import conf
from meta.errors import UserInputError
from meta.sharding import THIS_SHARD
from meta.logger import log_wrap
from wards import low_management_iward, high_management_iward
from babel.translator import ctx_translator
@@ -63,7 +64,8 @@ class ScheduleSettings(SettingGroup):
class SessionLobby(ModelData, ChannelSetting):
setting_id = 'session_lobby'
_event = 'guildset_session_lobby'
_set_cmd = 'configure schedule'
_set_cmd = 'admin config schedule'
_write_ward = high_management_iward
_display_name = _p('guildset:session_lobby', "session_lobby")
_desc = _p(
@@ -119,7 +121,8 @@ class ScheduleSettings(SettingGroup):
@ScheduleConfig.register_model_setting
class SessionRoom(ModelData, ChannelSetting):
setting_id = 'session_room'
_set_cmd = 'configure schedule'
_set_cmd = 'admin config schedule'
_write_ward = high_management_iward
_display_name = _p('guildset:session_room', "session_room")
_desc = _p(
@@ -163,6 +166,7 @@ class ScheduleSettings(SettingGroup):
class SessionChannels(ListData, ChannelListSetting):
setting_id = 'session_channels'
_write_ward = high_management_iward
_display_name = _p('guildset:session_channels', "session_channels")
_desc = _p(
@@ -238,7 +242,8 @@ class ScheduleSettings(SettingGroup):
@ScheduleConfig.register_model_setting
class ScheduleCost(ModelData, CoinSetting):
setting_id = 'schedule_cost'
_set_cmd = 'configure schedule'
_set_cmd = 'admin config schedule'
_write_ward = low_management_iward
_display_name = _p('guildset:schedule_cost', "schedule_cost")
_desc = _p(
@@ -283,7 +288,8 @@ class ScheduleSettings(SettingGroup):
@ScheduleConfig.register_model_setting
class AttendanceReward(ModelData, CoinSetting):
setting_id = 'attendance_reward'
_set_cmd = 'configure schedule'
_set_cmd = 'admin config schedule'
_write_ward = low_management_iward
_display_name = _p('guildset:attendance_reward', "attendance_reward")
_desc = _p(
@@ -327,7 +333,8 @@ class ScheduleSettings(SettingGroup):
@ScheduleConfig.register_model_setting
class AttendanceBonus(ModelData, CoinSetting):
setting_id = 'attendance_bonus'
_set_cmd = 'configure schedule'
_set_cmd = 'admin config schedule'
_write_ward = low_management_iward
_display_name = _p('guildset:attendance_bonus', "group_attendance_bonus")
_desc = _p(
@@ -370,7 +377,8 @@ class ScheduleSettings(SettingGroup):
@ScheduleConfig.register_model_setting
class MinAttendance(ModelData, IntegerSetting):
setting_id = 'min_attendance'
_set_cmd = 'configure schedule'
_set_cmd = 'admin config schedule'
_write_ward = low_management_iward
_display_name = _p('guildset:min_attendance', "min_attendance")
_desc = _p(
@@ -437,8 +445,9 @@ class ScheduleSettings(SettingGroup):
@ScheduleConfig.register_model_setting
class BlacklistRole(ModelData, RoleSetting):
setting_id = 'schedule_blacklist_role'
_set_cmd = 'configure schedule'
_set_cmd = 'admin config schedule'
_event = 'guildset_schedule_blacklist_role'
_write_ward = high_management_iward
_display_name = _p('guildset:schedule_blacklist_role', "schedule_blacklist_role")
_desc = _p(
@@ -495,7 +504,8 @@ class ScheduleSettings(SettingGroup):
@ScheduleConfig.register_model_setting
class BlacklistAfter(ModelData, IntegerSetting):
setting_id = 'schedule_blacklist_after'
_set_cmd = 'configure schedule'
_set_cmd = 'admin config schedule'
_write_ward = low_management_iward
_display_name = _p('guildset:schedule_blacklist_after', "schedule_blacklist_after")
_desc = _p(

View File

@@ -28,7 +28,21 @@ if TYPE_CHECKING:
guide = _p(
'ui:schedule|about',
"Guide tips here TBD"
"**Do you think you can commit to a schedule and stick to it?**\n"
"**Schedule voice sessions here and get rewarded for keeping yourself accountable!**\n\n"
"Use the menu below to book timeslots using LionCoins. "
"If you are active (in the dedicated voice channels) during these times, "
"you will be rewarded, along with a large bonus if everyone who scheduled that slot "
"made it!\n"
"Beware though, if you fail to make it, all your booked sessions will be cancelled "
"with no refund! And if you keep failing to attend your scheduled sessions, "
"you may be forbidden from booking them in future.\n\n"
"When your scheduled session starts, you will recieve a ping from the schedule channel, "
"which will have more information about how to attend your session.\n"
"If you discover you can't make your scheduled session, please be responsible "
"and use this command to cancel or clear your schedule!\n\n"
"**Note:** *Make sure your timezone is set correctly (with `/my timezone`), "
"or the times I tell might not make sense!*"
)

View File

@@ -78,6 +78,7 @@ class ScheduleSettingUI(ConfigUI):
# TODO: Setting value checks
await selection.response.defer()
setting = self.get_instance(ScheduleSettings.SessionLobby)
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values[0] if selected.values else None
await setting.write()
@@ -95,6 +96,7 @@ class ScheduleSettingUI(ConfigUI):
async def room_menu(self, selection: discord.Interaction, selected: ChannelSelect):
await selection.response.defer()
setting = self.get_instance(ScheduleSettings.SessionRoom)
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values[0] if selected.values else None
await setting.write()
@@ -113,6 +115,7 @@ class ScheduleSettingUI(ConfigUI):
# TODO: Consider XORing input
await selection.response.defer()
setting = self.get_instance(ScheduleSettings.SessionChannels)
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values
await setting.write()
@@ -158,6 +161,7 @@ class ScheduleSettingUI(ConfigUI):
async def blacklist_role_menu(self, selection: discord.Interaction, selected: RoleSelect):
await selection.response.defer()
setting = self.get_instance(ScheduleSettings.BlacklistRole)
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values[0] if selected.values else None
# TODO: Warning for insufficient permissions?
await setting.write()
@@ -227,7 +231,7 @@ class ScheduleSettingUI(ConfigUI):
class ScheduleDashboard(DashboardSection):
section_name = _p(
'dash:schedule|title',
"Scheduled Session Configuration ({commands[configure schedule]})"
"Scheduled Session Configuration ({commands[admin config schedule]})"
)
_option_name = _p(
"dash:schedule|dropdown|placeholder",
@@ -248,7 +252,7 @@ class ScheduleDashboard(DashboardSection):
page.add_field(
name=t(_p(
'dash:schedule|section:schedule_channels|name',
"Scheduled Session Channels ({commands[configure schedule]})",
"Scheduled Session Channels ({commands[admin config schedule]})",
)).format(commands=self.bot.core.mention_cache),
value=table,
inline=False
@@ -258,7 +262,7 @@ class ScheduleDashboard(DashboardSection):
page.add_field(
name=t(_p(
'dash:schedule|section:schedule_rewards|name',
"Scheduled Session Rewards ({commands[configure schedule]})",
"Scheduled Session Rewards ({commands[admin config schedule]})",
)).format(commands=self.bot.core.mention_cache),
value=table,
inline=False
@@ -268,7 +272,7 @@ class ScheduleDashboard(DashboardSection):
page.add_field(
name=t(_p(
'dash:schedule|section:schedule_blacklist|name',
"Scheduled Session Blacklist ({commands[configure schedule]})",
"Scheduled Session Blacklist ({commands[admin config schedule]})",
)).format(commands=self.bot.core.mention_cache),
value=table,
inline=False

View File

@@ -0,0 +1,10 @@
import logging
from babel.translator import LocalBabel
babel = LocalBabel('sponsors')
logger = logging.getLogger(__name__)
async def setup(bot):
from .cog import SponsorCog
await bot.add_cog(SponsorCog(bot))

126
src/modules/sponsors/cog.py Normal file
View File

@@ -0,0 +1,126 @@
from typing import Optional
import asyncio
import discord
from discord.ext import commands as cmds
import discord.app_commands as appcmds
from meta import LionCog, LionBot, LionContext
from wards import sys_admin_ward
from . import logger, babel
from .data import SponsorData
from .settings import SponsorSettings
from .settingui import SponsorUI
_p = babel._p
class SponsorCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.data: SponsorData = bot.db.load_registry(SponsorData())
self.settings = SponsorSettings
self.whitelisted = self.settings.Whitelist._cache
async def cog_load(self):
await self.data.init()
if (leo_setting_cog := self.bot.get_cog('LeoSettings')) is not None:
leo_setting_cog.bot_setting_groups.append(self.settings)
self.crossload_group(self.leo_group, leo_setting_cog.leo_group)
async def do_sponsor_prompt(self, interaction: discord.Interaction):
"""
Send the sponsor prompt as a followup to this interaction, if applicable.
"""
if not interaction.is_expired():
# TODO: caching
whitelist = (await self.settings.Whitelist.get(self.bot.appname)).value
if interaction.guild and interaction.guild.id in whitelist:
return
setting = await self.settings.SponsorPrompt.get(self.bot.appname)
value = setting.value
if value:
args = setting.value_to_args(self.bot.appname, value)
followup = interaction.followup
await followup.send(**args.send_args, ephemeral=True)
@cmds.hybrid_command(
name=_p('cmd:sponsors', "sponsors"),
description=_p(
'cmd:sponsors|desc',
"Check out our wonderful partners!"
)
)
async def sponsor_cmd(self, ctx: LionContext):
"""
Display the sponsors message, if set.
"""
if ctx.interaction:
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
sponsor = await self.settings.SponsorMessage.get(self.bot.appname)
value = sponsor.value
if value:
args = sponsor.value_to_args(self.bot.appname, value)
await ctx.reply(**args.send_args)
else:
await ctx.reply(
"Coming Soon!"
)
@LionCog.placeholder_group
@cmds.hybrid_group("leo", with_app_command=False)
async def leo_group(self, ctx: LionContext):
...
@leo_group.command(
name=_p(
'cmd:leo_sponsors', "sponsors"
),
description=_p(
'cmd:leo_sponsors|desc',
"Configure the sponsor text and whitelist."
)
)
@appcmds.rename(
sponsor_prompt=SponsorSettings.SponsorPrompt._display_name,
sponsor_message=SponsorSettings.SponsorMessage._display_name,
)
@appcmds.describe(
sponsor_prompt=SponsorSettings.SponsorPrompt._desc,
sponsor_message=SponsorSettings.SponsorMessage._desc,
)
@sys_admin_ward
async def leo_sponsors_cmd(self, ctx: LionContext,
sponsor_prompt: Optional[discord.Attachment] = None,
sponsor_message: Optional[discord.Attachment] = None,
):
"""
Open the configuration UI for sponsors, and optionally set the prompt and message.
"""
if not ctx.interaction:
return
await ctx.interaction.response.defer(thinking=True)
modified = []
if sponsor_prompt is not None:
setting = self.settings.SponsorPrompt
content = await setting.download_attachment(sponsor_prompt)
instance = await setting.from_string(self.bot.appname, content)
modified.append(instance)
if sponsor_message is not None:
setting = self.settings.SponsorMessage
content = await setting.download_attachment(sponsor_message)
instance = await setting.from_string(self.bot.appname, content)
modified.append(instance)
for instance in modified:
await instance.write()
ui = SponsorUI(self.bot, self.bot.appname, ctx.channel.id)
await ui.run(ctx.interaction)
await ui.wait()

View File

@@ -0,0 +1,5 @@
from data import Registry, Table
class SponsorData(Registry):
sponsor_whitelist = Table('sponsor_guild_whitelist')

View File

@@ -0,0 +1,87 @@
from settings.data import ListData, ModelData
from settings.groups import SettingGroup
from settings.setting_types import GuildIDListSetting
from core.setting_types import MessageSetting
from core.data import CoreData
from wards import sys_admin_iward
from . import babel
from .data import SponsorData
_p = babel._p
class SponsorSettings(SettingGroup):
class Whitelist(ListData, GuildIDListSetting):
setting_id = 'sponsor_whitelist'
_write_ward = sys_admin_iward
_display_name = _p(
'botset:sponsor_whitelist', "sponsor_whitelist"
)
_desc = _p(
'botset:sponsor_whitelist|desc',
"List of guildids where the sponsor prompt is not shown."
)
_long_desc = _p(
'botset:sponsor_whitelist|long_desc',
"The sponsor prompt will not appear in the set guilds."
)
_accepts = _p(
'botset:sponsor_whitelist|accetps',
"Comma separated list of guildids."
)
_table_interface = SponsorData.sponsor_whitelist
_id_column = 'appid'
_data_column = 'guildid'
_order_column = 'guildid'
class SponsorPrompt(ModelData, MessageSetting):
setting_id = 'sponsor_prompt'
_set_cmd = 'leo sponsors'
_write_ward = sys_admin_iward
_display_name = _p(
'botset:sponsor_prompt', "sponsor_prompt"
)
_desc = _p(
'botset:sponsor_prompt|desc',
"Message to add underneath core commands."
)
_long_desc = _p(
'botset:sponsor_prompt|long_desc',
"Content of the message to send after core commands such as stats,"
" reminding users to check the sponsors command."
)
_model = CoreData.BotConfig
_column = CoreData.BotConfig.sponsor_prompt.name
async def editor_callback(self, editor_data):
self.value = editor_data
await self.write()
class SponsorMessage(ModelData, MessageSetting):
setting_id = 'sponsor_message'
_set_cmd = 'leo sponsors'
_write_ward = sys_admin_iward
_display_name = _p(
'botset:sponsor_message', "sponsor_message"
)
_desc = _p(
'botset:sponsor_message|desc',
"Message to send in response to /sponsors command."
)
_long_desc = _p(
'botset:sponsor_message|long_desc',
"Content of the message to send when a user runs the `/sponsors` command."
)
_model = CoreData.BotConfig
_column = CoreData.BotConfig.sponsor_message.name
async def editor_callback(self, editor_data):
self.value = editor_data
await self.write()

View File

@@ -0,0 +1,122 @@
import asyncio
import discord
from discord.ui.button import button, Button, ButtonStyle
from meta import LionBot
from utils.ui import ConfigUI
from utils.lib import MessageArgs
from utils.ui.msgeditor import MsgEditor
from .settings import SponsorSettings as Settings
from . import babel, logger
_p = babel._p
class SponsorUI(ConfigUI):
setting_classes = (
Settings.SponsorPrompt,
Settings.SponsorMessage,
Settings.Whitelist,
)
def __init__(self, bot: LionBot, appname: str, channelid: int, **kwargs):
self.settings = bot.get_cog('SponsorCog').settings
super().__init__(bot, appname, channelid, **kwargs)
# ----- UI Components -----
@button(
label="SPONSOR_PROMPT_BUTTON_PLACEHOLDER",
style=ButtonStyle.blurple
)
async def sponsor_prompt_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(Settings.SponsorPrompt)
value = setting.value
if value is None:
value = {'content': "Empty"}
editor = MsgEditor(
self.bot,
value,
callback=setting.editor_callback,
callerid=press.user.id,
)
self._slaves.append(editor)
await editor.run(press)
async def sponsor_prompt_button_refresh(self):
button = self.sponsor_prompt_button
t = self.bot.translator.t
button.label = t(_p(
'ui:sponsors|button:sponsor_prompt|label',
"Sponsor Prompt"
))
@button(
label="SPONSOR_MESSAGE_BUTTON_PLACEHOLDER",
style=ButtonStyle.blurple
)
async def sponsor_message_button(self, press: discord.Interaction, pressed: Button):
await press.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(Settings.SponsorMessage)
value = setting.value
if value is None:
value = {'content': "Empty"}
editor = MsgEditor(
self.bot,
value,
callback=setting.editor_callback,
callerid=press.user.id,
)
self._slaves.append(editor)
await editor.run(press)
async def sponsor_message_button_refresh(self):
button = self.sponsor_message_button
t = self.bot.translator.t
button.label = t(_p(
'ui:sponsors|button:sponsor_message|label',
"Sponsor Message"
))
# ----- UI Flow -----
async def make_message(self) -> MessageArgs:
t = self.bot.translator.t
title = t(_p(
'ui:sponsors|embed|title',
"Leo Sponsor Panel"
))
embed = discord.Embed(
title=title,
colour=discord.Colour.orange()
)
for setting in self.instances:
embed.add_field(**setting.embed_field, inline=False)
return MessageArgs(embed=embed)
async def reload(self):
self.instances = [
await setting.get(self.bot.appname)
for setting in self.setting_classes
]
async def refresh_components(self):
to_refresh = (
self.edit_button_refresh(),
self.close_button_refresh(),
self.reset_button_refresh(),
self.sponsor_message_button_refresh(),
self.sponsor_prompt_button_refresh(),
)
await asyncio.gather(*to_refresh)
self.set_layout(
(self.sponsor_prompt_button, self.sponsor_message_button,
self.edit_button, self.reset_button, self.close_button)
)

View File

@@ -12,7 +12,7 @@ from core.lion_guild import VoiceMode
from utils.lib import error_embed
from utils.ui import LeoUI, AButton, utc_now
from gui.base import CardMode
from wards import low_management_ward
from wards import high_management_ward
from . import babel
from .data import StatsData
@@ -41,7 +41,7 @@ class StatsCog(LionCog):
self.bot.core.guild_config.register_setting(self.settings.UnrankedRoles)
configcog = self.bot.get_cog('ConfigCog')
self.crossload_group(self.configure_group, configcog.configure_group)
self.crossload_group(self.configure_group, configcog.admin_config_group)
@cmds.hybrid_command(
name=_p('cmd:me', "me"),
@@ -55,6 +55,8 @@ class StatsCog(LionCog):
await ctx.interaction.response.defer(thinking=True)
ui = ProfileUI(self.bot, ctx.author, ctx.guild)
await ui.run(ctx.interaction)
if sponsors := self.bot.get_cog('SponsorCog'):
await sponsors.do_sponsor_prompt(ctx.interaction)
await ui.wait()
@cmds.hybrid_command(
@@ -101,6 +103,9 @@ class StatsCog(LionCog):
file = discord.File(profile_data, 'profile.png')
await ctx.reply(file=file)
if sponsors := self.bot.get_cog('SponsorCog'):
await sponsors.do_sponsor_prompt(ctx.interaction)
@cmds.hybrid_command(
name=_p('cmd:stats', "stats"),
description=_p(
@@ -116,6 +121,10 @@ class StatsCog(LionCog):
await ctx.interaction.response.defer(thinking=True)
ui = WeeklyMonthlyUI(self.bot, ctx.author, ctx.guild)
await ui.run(ctx.interaction)
if sponsors := self.bot.get_cog('SponsorCog'):
await sponsors.do_sponsor_prompt(ctx.interaction)
await ui.wait()
@cmds.hybrid_command(
@@ -151,6 +160,10 @@ class StatsCog(LionCog):
await ctx.interaction.response.defer(thinking=True)
ui = LeaderboardUI(self.bot, ctx.author, ctx.guild)
await ui.run(ctx.interaction)
if sponsors := self.bot.get_cog('SponsorCog'):
await sponsors.do_sponsor_prompt(ctx.interaction)
await ui.wait()
@cmds.hybrid_command(
@@ -204,8 +217,7 @@ class StatsCog(LionCog):
"Time from which to start counting activity for rank badges and season leaderboards. (YYYY-MM-DD)"
)
)
@appcmds.default_permissions(manage_guild=True)
@low_management_ward
@high_management_ward
async def configure_statistics_cmd(self, ctx: LionContext,
season_start: Optional[str] = None):
t = self.bot.translator.t

View File

@@ -70,7 +70,7 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int, mode: CardMode
refkey = (guildid, userid)
else:
model = data.UserExp
msg_since = msgmodel.member_messages_between
msg_since = msgmodel.user_messages_since
refkey = (userid,)
ref_since = model.xp_since
ref_between = model.xp_between

View File

@@ -21,6 +21,7 @@ from utils.lib import MessageArgs
from core.data import CoreData
from core.lion_guild import VoiceMode
from babel.translator import ctx_translator
from wards import low_management_iward, high_management_iward
from . import babel
from .data import StatsData, StatisticType
@@ -83,7 +84,8 @@ class StatisticsSettings(SettingGroup):
Time is assumed to be in set guild timezone (although supports +00 syntax)
"""
setting_id = 'season_start'
_set_cmd = 'configure statistics'
_set_cmd = 'admin config statistics'
_write_ward = high_management_iward
_display_name = _p('guildset:season_start', "season_start")
_desc = _p(
@@ -155,6 +157,7 @@ class StatisticsSettings(SettingGroup):
List of roles not displayed on the leaderboard
"""
setting_id = 'unranked_roles'
_write_ward = high_management_iward
_display_name = _p('guildset:unranked_roles', "unranked_roles")
_desc = _p(
@@ -211,6 +214,7 @@ class StatisticsSettings(SettingGroup):
Default is determined by current guild mode
"""
setting_id = 'visible_stats'
_write_ward = high_management_iward
_setting = StatTypeSetting
@@ -263,6 +267,7 @@ class StatisticsSettings(SettingGroup):
Which of the three stats to display by default
"""
setting_id = 'default_stat'
_write_ward = high_management_iward
_display_name = _p('guildset:default_stat', "default_stat")
_desc = _p(
@@ -294,6 +299,7 @@ class StatisticsConfigUI(ConfigUI):
"""
await selection.response.defer(thinking=True)
setting = self.instances[1]
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values
await setting.write()
# Don't need to refresh due to instance hooks
@@ -314,6 +320,7 @@ class StatisticsConfigUI(ConfigUI):
"""
await selection.response.defer(thinking=True)
setting = self.instances[2]
await setting.interaction_check(setting.parent_id, selection)
data = [StatisticType((value,)) for value in selected.values]
setting.data = data
await setting.write()
@@ -405,7 +412,7 @@ class StatisticsConfigUI(ConfigUI):
class StatisticsDashboard(DashboardSection):
section_name = _p(
'dash:stats|title',
"Activity Statistics Configuration ({commands[configure statistics]})"
"Activity Statistics Configuration ({commands[admin config statistics]})"
)
_option_name = _p(
"dash:stats|dropdown|placeholder",

View File

@@ -538,6 +538,10 @@ class LeaderboardUI(StatsUI):
page_row
]
voting = self.bot.get_cog('TopggCog')
if voting and not await voting.check_voted_recently(self.userid):
self._layout.append((voting.vote_button(),))
async def reload(self):
"""
Reload UI data, applying cache where possible.

View File

@@ -297,6 +297,10 @@ class ProfileUI(StatsUI):
(self.stats_button, self.edit_button, self.close_button)
]
voting = self.bot.get_cog('TopggCog')
if voting and not await voting.check_voted_recently(self.userid):
self._layout.append((voting.vote_button(),))
async def _render_stats(self):
"""
Create and render the profile card.

View File

@@ -750,6 +750,11 @@ class WeeklyMonthlyUI(StatsUI):
(self.type_menu,),
(self.edit_button, self.select_button, self.global_button, self.close_button)
]
voting = self.bot.get_cog('TopggCog')
if voting and not await voting.check_voted_recently(self.userid):
self._layout.append((voting.vote_button(),))
if self._showing_selector:
await self.period_menu_refresh()
self._layout.append((self.period_menu,))

View File

@@ -139,7 +139,7 @@ class TasklistCog(LionCog):
self.bot.add_view(TasklistCaller(self.bot))
configcog = self.bot.get_cog('ConfigCog')
self.crossload_group(self.configure_group, configcog.configure_group)
self.crossload_group(self.configure_group, configcog.config_group)
@LionCog.listener('on_tasks_completed')
@log_wrap(action="reward tasks completed")
@@ -261,6 +261,7 @@ class TasklistCog(LionCog):
"Open your tasklist."
)
)
@appcmds.guild_only
async def tasklist_cmd(self, ctx: LionContext):
if not ctx.interaction:
return
@@ -270,6 +271,7 @@ class TasklistCog(LionCog):
name=_p('group:tasks', "tasks"),
description=_p('group:tasks|desc', "Base command group for tasklist commands.")
)
@appcmds.guild_only
async def tasklist_group(self, ctx: LionContext):
raise NotImplementedError
@@ -984,7 +986,6 @@ class TasklistCog(LionCog):
reward=TasklistSettings.task_reward._desc,
reward_limit=TasklistSettings.task_reward_limit._desc
)
@appcmds.default_permissions(manage_guild=True)
@low_management_ward
async def configure_tasklist_cmd(self, ctx: LionContext,
reward: Optional[int] = None,

View File

@@ -13,6 +13,7 @@ from utils.lib import tabulate
from utils.ui import LeoUI, FastModal, error_handler_for, ModalRetryUI, DashboardSection
from core.data import CoreData
from babel.translator import ctx_translator
from wards import low_management_iward, high_management_iward
from . import babel
from .data import TasklistData
@@ -28,7 +29,8 @@ class TasklistSettings(SettingGroup):
Exposed via `/configure tasklist`, and the standard configuration interface.
"""
setting_id = 'task_reward'
_set_cmd = 'configure tasklist'
_set_cmd = 'config tasklist'
_write_ward = low_management_iward
_display_name = _p('guildset:task_reward', "task_reward")
_desc = _p(
@@ -68,7 +70,8 @@ class TasklistSettings(SettingGroup):
class task_reward_limit(ModelData, IntegerSetting):
setting_id = 'task_reward_limit'
_set_cmd = 'configure tasklist'
_set_cmd = 'config tasklist'
_write_ward = low_management_iward
_display_name = _p('guildset:task_reward_limit', "task_reward_limit")
_desc = _p(
@@ -109,6 +112,7 @@ class TasklistSettings(SettingGroup):
class tasklist_channels(ListData, ChannelListSetting):
setting_id = 'tasklist_channels'
_write_ward = low_management_iward
_display_name = _p('guildset:tasklist_channels', "tasklist_channels")
_desc = _p(
@@ -317,7 +321,7 @@ class TasklistConfigUI(LeoUI):
class TasklistDashboard(DashboardSection):
section_name = _p('dash:tasklist|name', "Tasklist Configuration ({commands[configure tasklist]})")
section_name = _p('dash:tasklist|name', "Tasklist Configuration ({commands[config tasklist]})")
_option_name = _p(
"dash:tasklist|dropdown|placeholder",
"Tasklist Options Panel"

View File

@@ -0,0 +1,10 @@
import logging
from babel.translator import LocalBabel
babel = LocalBabel('topgg')
logger = logging.getLogger(__name__)
async def setup(bot):
from .cog import TopggCog
await bot.add_cog(TopggCog(bot))

135
src/modules/topgg/cog.py Normal file
View File

@@ -0,0 +1,135 @@
from typing import Optional
import asyncio
import discord
from discord.ext import commands as cmds
import discord.app_commands as appcmds
from discord.ui.button import Button, ButtonStyle
from topgg import WebhookManager
from data.queries import ORDER
from meta import LionCog, LionBot, LionContext
from meta.logger import log_wrap
from wards import sys_admin_ward
from utils.lib import utc_now
from babel.translator import ctx_locale
from . import logger, babel
from .data import TopggData
_p = babel._p
topgg_upvote_link = 'https://top.gg/bot/889078613817831495/vote'
class TopggCog(LionCog):
def __init__(self, bot: LionBot):
self.bot = bot
self.data: TopggData = bot.db.load_registry(TopggData())
self.topgg_webhook: Optional[WebhookManager] = None
async def cog_load(self):
await self.data.init()
tgg_config = self.bot.config.topgg
if tgg_config.getboolean('enabled', False):
economy = self.bot.get_cog('Economy')
economy.register_economy_bonus(self.voting_bonus, name='voting')
if self.bot.shard_id != 0:
logger.debug(
f"Not initialising topgg executor in shard {self.bot.shard_id}"
)
else:
self.topgg_webhook = WebhookManager(self.bot).dbl_webhook(
route=tgg_config.get('route'),
auth_key=tgg_config.get('auth'),
)
self.topgg_webhook.run(tgg_config.getint('port'))
logger.info(
"Topgg webhook registered."
)
else:
logger.info(
"Topgg disabled via config, not initialising module."
)
@LionCog.listener('on_dbl_vote')
@log_wrap(action="Handle DBL Vote")
async def handle_dbl_vote(self, data):
logger.info(f"Recieved TopGG vote: {data}")
userid = data['user']
await self.data.TopGG.create(
userid=userid,
boostedtimestamp=utc_now()
)
await self._send_thanks_dm(userid)
async def voting_bonus(self, userid):
# Provides 1.25 multiplicative bonus if they have voted within 12h
if await self.check_voted_recently(userid):
return 1.25
else:
return 1
async def check_voted_recently(self, userid):
records = await self.data.TopGG.fetch_where(
userid=userid
).order_by('boostedtimestamp', ORDER.DESC).limit(1)
return records and (utc_now() - records[0].boostedtimestamp).total_seconds() < 3600 * 12
def vote_button(self):
t = self.bot.translator.t
button = Button(
style=ButtonStyle.link,
label=t(_p(
'button:vote|label',
"Vote for me!"
)),
emoji=self.bot.config.emojis.coin,
url=topgg_upvote_link,
)
return button
async def _send_thanks_dm(self, userid: int):
user = self.bot.get_user(userid)
if user is None:
try:
user = await self.bot.fetch_user(userid)
except discord.HTTPException:
logger.warning(
f"Could not find voting user <uid: {userid}> to send thanks."
)
return
t = self.bot.translator.t
luser = await self.bot.core.lions.fetch_user(userid)
locale = await self.bot.get_cog('BabelCog').get_user_locale(userid)
ctx_locale.set(locale)
embed = discord.Embed(
colour=discord.Colour.brand_green(),
title=t(_p(
'embed:voting_thanks|title',
"Thank you for supporting me on Top.gg! {yay}"
)).format(yay=self.bot.config.emojis.lionyay),
description=t(_p(
'embed:voting_thanks|desc',
"Thank you for supporting us, enjoy your LionCoins boost!"
))
).set_image(
url="https://cdn.discordapp.com/attachments/908283085999706153/932737228440993822/lion-yay.png"
)
try:
await user.send(embed=embed)
except discord.HTTPException:
logger.warning(
f"Could not send voting thanks to user <uid: {userid}>."
)

13
src/modules/topgg/data.py Normal file
View File

@@ -0,0 +1,13 @@
from data import Registry, Table, RowModel
from data.columns import Integer, Timestamp
class TopggData(Registry):
class TopGG(RowModel):
_tablename_ = 'topgg'
voteid = Integer(primary=True)
userid = Integer()
boostedtimestamp = Timestamp()
guild_whitelist = Table('topgg_guild_whitelist')

View File

@@ -57,7 +57,7 @@ class VideoCog(LionCog):
"Could not load ConfigCog. VideoCog configuration will not crossload."
)
else:
self.crossload_group(self.configure_group, configcog.configure_group)
self.crossload_group(self.configure_group, configcog.admin_config_group)
if self.bot.is_ready():
await self.initialise()
@@ -522,7 +522,7 @@ class VideoCog(LionCog):
video_blacklist_durations=VideoSettings.VideoBlacklistDurations._desc,
video_grace_period=VideoSettings.VideoGracePeriod._desc,
)
@low_management_ward
@high_management_ward
async def configure_video(self, ctx: LionContext,
video_blacklist: Optional[discord.Role] = None,
video_blacklist_durations: Optional[str] = None,
@@ -572,4 +572,3 @@ class VideoCog(LionCog):
ui = VideoSettingUI(self.bot, ctx.guild.id, ctx.channel.id)
await ui.run(ctx.interaction)
await ui.wait()

View File

@@ -14,6 +14,7 @@ from meta.sharding import THIS_SHARD
from meta.logger import log_wrap
from core.data import CoreData
from babel.translator import ctx_translator
from wards import low_management_iward, high_management_iward
from . import babel, logger
from .data import VideoData
@@ -25,6 +26,7 @@ class VideoSettings(SettingGroup):
class VideoChannels(ListData, ChannelListSetting):
setting_id = "video_channels"
_event = 'guildset_video_channels'
_write_ward = high_management_iward
_display_name = _p('guildset:video_channels', "video_channels")
_desc = _p(
@@ -101,6 +103,7 @@ class VideoSettings(SettingGroup):
class VideoBlacklist(ModelData, RoleSetting):
setting_id = "video_blacklist"
_event = 'guildset_video_blacklist'
_write_ward = high_management_iward
_display_name = _p('guildset:video_blacklist', "video_blacklist")
_desc = _p(
@@ -158,6 +161,7 @@ class VideoSettings(SettingGroup):
class VideoBlacklistDurations(ListData, ListSetting, InteractiveSetting):
setting_id = 'video_durations'
_setting = DurationSetting
_write_ward = high_management_iward
_display_name = _p('guildset:video_durations', "video_blacklist_durations")
_desc = _p(
@@ -217,6 +221,7 @@ class VideoSettings(SettingGroup):
class VideoGracePeriod(ModelData, DurationSetting):
setting_id = "video_grace_period"
_event = 'guildset_video_grace_period'
_write_ward = high_management_iward
_display_name = _p('guildset:video_grace_period', "video_grace_period")
_desc = _p(
@@ -252,6 +257,7 @@ class VideoSettings(SettingGroup):
class VideoExempt(ListData, RoleListSetting):
setting_id = "video_exempt"
_event = 'guildset_video_exempt'
_write_ward = high_management_iward
_display_name = _p('guildset:video_exempt', "video_exempt")
_desc = _p(

View File

@@ -45,6 +45,7 @@ class VideoSettingUI(ConfigUI):
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(VideoSettings.VideoChannels)
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values
await setting.write()
await selection.delete_original_response()
@@ -70,6 +71,7 @@ class VideoSettingUI(ConfigUI):
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(VideoSettings.VideoExempt)
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values
await setting.write()
await selection.delete_original_response()
@@ -95,6 +97,7 @@ class VideoSettingUI(ConfigUI):
await selection.response.defer(thinking=True, ephemeral=True)
setting = self.get_instance(VideoSettings.VideoBlacklist)
await setting.interaction_check(setting.parent_id, selection)
setting.value = selected.values[0] if selected.values else None
if setting.value:
await equippable_role(self.bot, setting.value, selection.user)
@@ -153,7 +156,7 @@ class VideoSettingUI(ConfigUI):
class VideoDashboard(DashboardSection):
section_name = _p(
"dash:video|title",
"Video Channel Settings ({commands[configure video_channels]})"
"Video Channel Settings ({commands[admin config video_channels]})"
)
_option_name = _p(
"dash:video|option|name",

Some files were not shown because too many files have changed in this diff Show More