@@ -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
|
||||
|
||||
@@ -4,3 +4,6 @@ token =
|
||||
[DATA]
|
||||
args = dbname=lion_data
|
||||
appid = StudyLion
|
||||
|
||||
[TOPGG]
|
||||
auth =
|
||||
|
||||
7
data/migration/v13-v14/migration.sql
Normal file
7
data/migration/v13-v14/migration.sql
Normal 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;
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
CONFIG_FILE = "config/bot.conf"
|
||||
DATA_VERSION = 13
|
||||
DATA_VERSION = 14
|
||||
|
||||
MAX_COINS = 2147483647 - 1
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -18,6 +18,8 @@ active = [
|
||||
'.moderation',
|
||||
'.video_channels',
|
||||
'.meta',
|
||||
'.sponsors',
|
||||
'.topgg',
|
||||
'.test',
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
2
src/modules/moderation/tickets/__init__.py
Normal file
2
src/modules/moderation/tickets/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .note import NoteTicket
|
||||
from .warning import WarnTicket
|
||||
48
src/modules/moderation/tickets/note.py
Normal file
48
src/modules/moderation/tickets/note.py
Normal 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
|
||||
66
src/modules/moderation/tickets/warning.py
Normal file
66
src/modules/moderation/tickets/warning.py
Normal 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
|
||||
|
||||
|
||||
|
||||
656
src/modules/moderation/ticketui.py
Normal file
656
src/modules/moderation/ticketui.py
Normal 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()
|
||||
@@ -1,7 +0,0 @@
|
||||
# flake8: noqa
|
||||
from .module import module
|
||||
|
||||
from . import help
|
||||
from . import links
|
||||
from . import nerd
|
||||
from . import join_message
|
||||
@@ -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))
|
||||
)
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
guide_link = "https://discord.studylions.com/tutorial"
|
||||
|
||||
animation_link = (
|
||||
"https://media.discordapp.net/attachments/879412267731542047/926837189814419486/ezgif.com-resize.gif"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -1,3 +0,0 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
module = LionModule("Meta")
|
||||
@@ -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)
|
||||
@@ -1,9 +0,0 @@
|
||||
from .module import module
|
||||
|
||||
from . import data
|
||||
from . import admin
|
||||
|
||||
from . import tickets
|
||||
from . import video
|
||||
|
||||
from . import commands
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -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')
|
||||
@@ -1,4 +0,0 @@
|
||||
from cmdClient import Module
|
||||
|
||||
|
||||
module = Module("Moderation")
|
||||
@@ -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()
|
||||
@@ -1,4 +0,0 @@
|
||||
from .Ticket import Ticket, TicketType, TicketState
|
||||
from .studybans import StudyBanTicket
|
||||
from .notes import NoteTicket
|
||||
from .warns import WarnTicket
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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 ''
|
||||
)
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
from . import module
|
||||
|
||||
from . import data
|
||||
from . import config
|
||||
from . import commands
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
from data import Table
|
||||
|
||||
|
||||
guild_whitelist = Table("sponsor_guild_whitelist")
|
||||
@@ -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)
|
||||
@@ -1,6 +0,0 @@
|
||||
from .module import module
|
||||
|
||||
from . import webhook
|
||||
from . import commands
|
||||
from . import data
|
||||
from . import settings
|
||||
@@ -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)
|
||||
@@ -1,9 +0,0 @@
|
||||
from data.interfaces import RowTable, Table
|
||||
|
||||
topggvotes = RowTable(
|
||||
'topgg',
|
||||
('voteid', 'userid', 'boostedTimestamp'),
|
||||
'voteid'
|
||||
)
|
||||
|
||||
guild_whitelist = Table('topgg_guild_whitelist')
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -1,5 +0,0 @@
|
||||
from .module import module
|
||||
|
||||
from . import admin
|
||||
from . import data
|
||||
from . import tracker
|
||||
@@ -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."
|
||||
@@ -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'
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
from LionModule import LionModule
|
||||
|
||||
|
||||
module = LionModule("Workout")
|
||||
@@ -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
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
303
src/modules/ranks/ui/templates.py
Normal file
303
src/modules/ranks/ui/templates.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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!*"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
10
src/modules/sponsors/__init__.py
Normal file
10
src/modules/sponsors/__init__.py
Normal 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
126
src/modules/sponsors/cog.py
Normal 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()
|
||||
5
src/modules/sponsors/data.py
Normal file
5
src/modules/sponsors/data.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from data import Registry, Table
|
||||
|
||||
|
||||
class SponsorData(Registry):
|
||||
sponsor_whitelist = Table('sponsor_guild_whitelist')
|
||||
87
src/modules/sponsors/settings.py
Normal file
87
src/modules/sponsors/settings.py
Normal 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()
|
||||
122
src/modules/sponsors/settingui.py
Normal file
122
src/modules/sponsors/settingui.py
Normal 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)
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
10
src/modules/topgg/__init__.py
Normal file
10
src/modules/topgg/__init__.py
Normal 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
135
src/modules/topgg/cog.py
Normal 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
13
src/modules/topgg/data.py
Normal 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')
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user