added live channel commands which will make the bot update channel name with a template every 10 minutes

This commit is contained in:
Toastie 2025-03-19 14:15:05 +13:00
parent c179ea6c07
commit 134e5a8c92
Signed by: toastie_t0ast
GPG key ID: 0861BE54AD481DC7
17 changed files with 759 additions and 14 deletions

View file

@ -0,0 +1,18 @@
START TRANSACTION;
CREATE TABLE livechannelconfig (
id integer GENERATED BY DEFAULT AS IDENTITY,
guildid numeric(20,0) NOT NULL,
channelid numeric(20,0) NOT NULL,
template text NOT NULL,
CONSTRAINT pk_livechannelconfig PRIMARY KEY (id)
);
CREATE INDEX ix_livechannelconfig_guildid ON livechannelconfig (guildid);
CREATE UNIQUE INDEX ix_livechannelconfig_guildid_channelid ON livechannelconfig (guildid, channelid);
INSERT INTO "__EFMigrationsHistory" (migrationid, productversion)
VALUES ('20250319010757_livechannels', '9.0.1');
COMMIT;

View file

@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace EllieBot.Migrations.PostgreSql
{
[DbContext(typeof(PostgreSqlContext))]
[Migration("20250318222207_init")]
[Migration("20250319010930_init")]
partial class init
{
/// <inheritdoc />
@ -1541,6 +1541,41 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("imageonlychannels", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.LiveChannelConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("ChannelId")
.HasColumnType("numeric(20,0)")
.HasColumnName("channelid");
b.Property<decimal>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<string>("Template")
.IsRequired()
.HasColumnType("text")
.HasColumnName("template");
b.HasKey("Id")
.HasName("pk_livechannelconfig");
b.HasIndex("GuildId")
.HasDatabaseName("ix_livechannelconfig_guildid");
b.HasIndex("GuildId", "ChannelId")
.IsUnique()
.HasDatabaseName("ix_livechannelconfig_guildid_channelid");
b.ToTable("livechannelconfig", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.LogSetting", b =>
{
b.Property<int>("Id")

View file

@ -550,6 +550,21 @@ namespace EllieBot.Migrations.PostgreSql
table.PrimaryKey("pk_imageonlychannels", x => x.id);
});
migrationBuilder.CreateTable(
name: "livechannelconfig",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
guildid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
channelid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
template = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_livechannelconfig", x => x.id);
});
migrationBuilder.CreateTable(
name: "logsettings",
columns: table => new
@ -1984,6 +1999,17 @@ namespace EllieBot.Migrations.PostgreSql
column: "channelid",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_livechannelconfig_guildid",
table: "livechannelconfig",
column: "guildid");
migrationBuilder.CreateIndex(
name: "ix_livechannelconfig_guildid_channelid",
table: "livechannelconfig",
columns: new[] { "guildid", "channelid" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_logsettings_guildid",
table: "logsettings",
@ -2475,6 +2501,9 @@ namespace EllieBot.Migrations.PostgreSql
migrationBuilder.DropTable(
name: "imageonlychannels");
migrationBuilder.DropTable(
name: "livechannelconfig");
migrationBuilder.DropTable(
name: "musicplayersettings");

View file

@ -1538,6 +1538,41 @@ namespace EllieBot.Migrations.PostgreSql
b.ToTable("imageonlychannels", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.LiveChannelConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("ChannelId")
.HasColumnType("numeric(20,0)")
.HasColumnName("channelid");
b.Property<decimal>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<string>("Template")
.IsRequired()
.HasColumnType("text")
.HasColumnName("template");
b.HasKey("Id")
.HasName("pk_livechannelconfig");
b.HasIndex("GuildId")
.HasDatabaseName("ix_livechannelconfig_guildid");
b.HasIndex("GuildId", "ChannelId")
.IsUnique()
.HasDatabaseName("ix_livechannelconfig_guildid_channelid");
b.ToTable("livechannelconfig", (string)null);
});
modelBuilder.Entity("EllieBot.Db.Models.LogSetting", b =>
{
b.Property<int>("Id")

View file

@ -0,0 +1,17 @@
BEGIN TRANSACTION;
CREATE TABLE "LiveChannelConfig" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_LiveChannelConfig" PRIMARY KEY AUTOINCREMENT,
"GuildId" INTEGER NOT NULL,
"ChannelId" INTEGER NOT NULL,
"Template" TEXT NOT NULL
);
CREATE INDEX "IX_LiveChannelConfig_GuildId" ON "LiveChannelConfig" ("GuildId");
CREATE UNIQUE INDEX "IX_LiveChannelConfig_GuildId_ChannelId" ON "LiveChannelConfig" ("GuildId", "ChannelId");
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20250319010745_livechannels', '9.0.1');
COMMIT;

View file

@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace EllieBot.Migrations.Sqlite
{
[DbContext(typeof(SqliteContext))]
[Migration("20250318222152_init")]
[Migration("20250319010920_init")]
partial class init
{
/// <inheritdoc />
@ -1151,6 +1151,32 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("ImageOnlyChannels");
});
modelBuilder.Entity("EllieBot.Db.Models.LiveChannelConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ulong>("ChannelId")
.HasColumnType("INTEGER");
b.Property<ulong>("GuildId")
.HasColumnType("INTEGER");
b.Property<string>("Template")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GuildId");
b.HasIndex("GuildId", "ChannelId")
.IsUnique();
b.ToTable("LiveChannelConfig");
});
modelBuilder.Entity("EllieBot.Db.Models.LogSetting", b =>
{
b.Property<int>("Id")

View file

@ -550,6 +550,21 @@ namespace EllieBot.Migrations.Sqlite
table.PrimaryKey("PK_ImageOnlyChannels", x => x.Id);
});
migrationBuilder.CreateTable(
name: "LiveChannelConfig",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
GuildId = table.Column<ulong>(type: "INTEGER", nullable: false),
ChannelId = table.Column<ulong>(type: "INTEGER", nullable: false),
Template = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LiveChannelConfig", x => x.Id);
});
migrationBuilder.CreateTable(
name: "LogSettings",
columns: table => new
@ -1986,6 +2001,17 @@ namespace EllieBot.Migrations.Sqlite
column: "ChannelId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_LiveChannelConfig_GuildId",
table: "LiveChannelConfig",
column: "GuildId");
migrationBuilder.CreateIndex(
name: "IX_LiveChannelConfig_GuildId_ChannelId",
table: "LiveChannelConfig",
columns: new[] { "GuildId", "ChannelId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_LogSettings_GuildId",
table: "LogSettings",
@ -2477,6 +2503,9 @@ namespace EllieBot.Migrations.Sqlite
migrationBuilder.DropTable(
name: "ImageOnlyChannels");
migrationBuilder.DropTable(
name: "LiveChannelConfig");
migrationBuilder.DropTable(
name: "MusicPlayerSettings");

View file

@ -1148,6 +1148,32 @@ namespace EllieBot.Migrations.Sqlite
b.ToTable("ImageOnlyChannels");
});
modelBuilder.Entity("EllieBot.Db.Models.LiveChannelConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<ulong>("ChannelId")
.HasColumnType("INTEGER");
b.Property<ulong>("GuildId")
.HasColumnType("INTEGER");
b.Property<string>("Template")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GuildId");
b.HasIndex("GuildId", "ChannelId")
.IsUnique();
b.ToTable("LiveChannelConfig");
});
modelBuilder.Entity("EllieBot.Db.Models.LogSetting", b =>
{
b.Property<int>("Id")

View file

@ -85,8 +85,7 @@ public sealed class GuildTimezoneService : ITimezoneService, IReadyExecutor, IES
to = GetTimeZoneOrDefault(g.Id) ?? TimeZoneInfo.Local;
}
return TimeZoneInfo.ConvertTime(DateTime.UtcNow, TimeZoneInfo.Utc, to).ToString("HH:mm ")
+ to.StandardName.GetInitials();
return TimeZoneInfo.ConvertTime(DateTime.UtcNow, TimeZoneInfo.Utc, to).ToShortTimeString();
});
}
}

View file

@ -0,0 +1,91 @@
namespace EllieBot.Modules.Utility.LiveChannel;
public partial class Utility
{
[Group]
public class LiveChannelCommands(LiveChannelService svc) : EllieModule
{
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageChannels)]
[BotPerm(GuildPerm.ManageChannels)]
public async Task LiveChAdd(IChannel channel, [Leftover] string template)
{
if (!await svc.AddLiveChannelAsync(ctx.Guild.Id, channel, template))
{
await Response()
.Error(strs.livechannel_limit(LiveChannelService.MAX_LIVECHANNELS))
.SendAsync();
return;
}
var eb = CreateEmbed()
.WithOkColor()
.WithDescription(GetText(strs.livechannel_added(channel.Name)))
.AddField(GetText(strs.template), template)
.AddField(GetText(strs.preview),
await repSvc.ReplaceAsync(template,
new(
client: ctx.Client as DiscordSocketClient,
guild: ctx.Guild
)));
await Response()
.Confirm(strs.livechannel_added(channel.Name))
.SendAsync();
return;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageChannels)]
[BotPerm(GuildPerm.ManageChannels)]
public async Task LiveChList()
{
var liveChannels = await svc.GetLiveChannelsAsync(ctx.Guild.Id);
if (liveChannels.Count == 0)
{
await Response().Pending(strs.livechannel_list_empty).SendAsync();
return;
}
var embed = CreateEmbed()
.WithTitle(GetText(strs.livechannel_list_title(ctx.Guild.Name)));
foreach (var config in liveChannels)
{
var channelName = await ctx.Guild.GetChannelAsync(config.ChannelId)
.Fmap(x => x?.Name ?? config.ChannelId.ToString());
embed.AddField(channelName, config.Template);
}
await Response().Embed(embed).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageChannels)]
[BotPerm(GuildPerm.ManageChannels)]
public Task LiveChRemove(IChannel channel)
=> LiveChRemove(channel.Id);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageChannels)]
[BotPerm(GuildPerm.ManageChannels)]
public async Task LiveChRemove(ulong channelId)
{
if (await svc.RemoveLiveChannelAsync(ctx.Guild.Id, channelId))
{
await Response()
.Confirm(strs.livechannel_removed(((SocketGuild)ctx.Guild).GetChannel(channelId)?.Name ??
channelId.ToString())).SendAsync();
}
else
{
await Response().Error(strs.livechannel_not_found).SendAsync();
}
}
}
}

View file

@ -0,0 +1,195 @@
using System.Net;
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
using Newtonsoft.Json.Linq;
namespace EllieBot.Modules.Utility.LiveChannel;
/// <summary>
/// Service for managing live channels.
/// </summary>
public class LiveChannelService(
DbService db,
DiscordSocketClient client,
IReplacementService repSvc,
ShardData shardData) : IReadyExecutor, IEService
{
public const int MAX_LIVECHANNELS = 5;
private readonly ConcurrentDictionary<ulong, ConcurrentDictionary<ulong, LiveChannelConfig>> _liveChannels = new();
/// <summary>
/// Initializes data when bot is ready
/// </summary>
public async Task OnReadyAsync()
{
// Load all existing live channels into memory
await using var uow = db.GetDbContext();
var configs = await uow.GetTable<LiveChannelConfig>()
.AsNoTracking()
.Where(x => Queries.GuildOnShard(x.GuildId, shardData.TotalShards, shardData.ShardId))
.ToListAsyncLinqToDB();
foreach (var config in configs)
{
var guildDict = _liveChannels.GetOrAdd(
config.GuildId,
_ => new());
guildDict[config.ChannelId] = config;
}
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(10));
while (await timer.WaitForNextTickAsync())
{
try
{
// get all live channels from cache
var channels = new List<LiveChannelConfig>(_liveChannels.Count * 2);
foreach (var (_, vals) in _liveChannels)
{
foreach (var (_, config) in vals)
{
channels.Add(config);
}
}
foreach (var config in channels)
{
var guild = client.GetGuild(config.GuildId);
var channel = guild?.GetChannel(config.ChannelId);
if (channel is null)
{
await RemoveLiveChannelAsync(config.GuildId, config.ChannelId);
continue;
}
var repCtx = new ReplacementContext(
user: null,
guild: guild,
client: client
);
try
{
var text = await repSvc.ReplaceAsync(config.Template, repCtx);
// only update if needed
if (channel.Name != text)
await channel.ModifyAsync(x => x.Name = text);
}
catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden)
{
await RemoveLiveChannelAsync(config.GuildId, config.ChannelId);
Log.Warning(
"Channel {ChannelId} in guild {GuildId} is not accessible. Live channel will be removed",
config.ChannelId,
config.GuildId);
}
catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.TooManyRequests ||
ex.DiscordCode == DiscordErrorCode.ChannelWriteRatelimit)
{
Log.Warning(ex, "LiveChannel hit a ratelimit. Sleeping for 2 minutes: {Message}", ex.Message);
await Task.Delay(2.Minutes());
}
catch (Exception e)
{
Log.Error(e, "Error in live channel service: {ErrorMessage}", e.Message);
}
// wait for half a second to reduce the chance of global ratelimits
await Task.Delay(500);
}
}
catch (Exception ex)
{
Log.Error(ex, "Error in live channel service: {ErrorMessage}", ex.Message);
}
}
}
/// <summary>
/// Adds a new live channel configuration to the specified guild.
/// </summary>
/// <param name="guildId">ID of the guild</param>
/// <param name="channel">Channel to set as live</param>
/// <param name="template">Template text to use for the channel</param>
/// <returns>True if successfully added, false otherwise</returns>
public async Task<bool> AddLiveChannelAsync(ulong guildId, IChannel channel, string template)
{
var guildDict = _liveChannels.GetOrAdd(
guildId,
_ => new());
if (guildDict.Count >= MAX_LIVECHANNELS)
return false;
await using var uow = db.GetDbContext();
await uow.GetTable<LiveChannelConfig>()
.InsertOrUpdateAsync(() => new()
{
GuildId = guildId,
ChannelId = channel.Id,
Template = template
},
(_) => new()
{
Template = template
},
() => new()
{
GuildId = guildId,
ChannelId = channel.Id
});
// Add to in-memory cache
var newConfig = new LiveChannelConfig
{
GuildId = guildId,
ChannelId = channel.Id,
Template = template
};
guildDict[channel.Id] = newConfig;
return true;
}
/// <summary>
/// Removes a live channel configuration from the specified guild.
/// </summary>
/// <param name="guildId">ID of the guild</param>
/// <param name="channelId">ID of the channel to remove as live</param>
/// <returns>True if successfully removed, false otherwise</returns>
public async Task<bool> RemoveLiveChannelAsync(ulong guildId, ulong channelId)
{
if (!_liveChannels.TryGetValue(guildId, out var guildDict) ||
!guildDict.TryRemove(channelId, out _))
return false;
await using var uow = db.GetDbContext();
await uow.GetTable<LiveChannelConfig>()
.Where(x => x.GuildId == guildId && x.ChannelId == channelId)
.DeleteAsync();
return true;
}
/// <summary>
/// Gets all live channels for a guild.
/// </summary>
/// <param name="guildId">ID of the guild</param>
/// <returns>List of live channel configurations</returns>
public async Task<List<LiveChannelConfig>> GetLiveChannelsAsync(ulong guildId)
{
await using var uow = db.GetDbContext();
return await uow.GetTable<LiveChannelConfig>()
.AsNoTracking()
.Where(x => x.GuildId == guildId)
.ToListAsyncLinqToDB();
}
}

View file

@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using EllieBot.Common.TypeReaders.Models;
using EllieBot.Db;
using EllieBot.Db.Models;
namespace EllieBot.Db.Models;
/// <summary>
/// Configuration for a live channel.
/// </summary>
public class LiveChannelConfig
{
[Key]
public int Id { get; set; }
/// <summary>
/// ID of the server this live channel belongs to.
/// </summary>
public ulong GuildId { get; set; }
/// <summary>
/// ID of the channel that is configured as a live channel.
/// </summary>
public ulong ChannelId { get; set; }
/// <summary>
/// Text template to be used for the live channel.
/// </summary>
public string Template { get; set; } = "";
}
public class LiveChannelConfigDbEntityTypeConfiguration : IEntityTypeConfiguration<LiveChannelConfig>
{
public void Configure(EntityTypeBuilder<LiveChannelConfig> builder)
{
builder.HasIndex(x => x.GuildId);
builder.HasIndex(x => new { x.GuildId, x.ChannelId }).IsUnique();
}
}

View file

@ -1731,7 +1731,7 @@
".sar excl",
".sar tesar"
],
"Description": "Toggles whether self-assigned roles are exclusive.\nWhile enabled, users can only have one self-assignable role per group.",
"Description": "Toggles the sar group as exclusive.\nWhile enabled, users can only have one self-assignable role from that group.",
"Usage": [
".sar exclusive 1"
],
@ -2617,6 +2617,21 @@
"ModerateMembers Server Permission"
]
},
{
"Aliases": [
".masskick"
],
"Description": "Kicks multiple users at once. Specify a space separated list of IDs of users who you wish to kick.",
"Usage": [
".masskick 123123123 3333333333 444444444"
],
"Submodule": "UserPunishCommands",
"Module": "Administration",
"Options": null,
"Requirements": [
"KickMembers Server Permission"
]
},
{
"Aliases": [
".massban"
@ -2717,7 +2732,7 @@
".exas",
".expraddserver"
],
"Description": "Add an expression with a trigger and a response in this server. Bot will post a response whenever someone types the trigger word. This command is useful if you want to lower the permission requirement for managing expressions by using `.dpo`. Guide [here](<https://docs.elliebot.net/ellie/features/expressions/>).",
"Description": "Add an expression with a trigger and a response in this server. Bot will post a response whenever someone types the trigger word. This command is useful if you want to lower the permission requirement for managing expressions by using `.dpo`.",
"Usage": [
".expradds \"hello\" Hi there %user.mention%"
],
@ -2735,7 +2750,7 @@
".exa",
".acr"
],
"Description": "Add an expression with a trigger and a response. Bot will post a response whenever someone types the trigger word. Running this command in a server requires the Administrator permission. Running this command in DM is Bot Owner only and adds a new global expression. Guide [here](<https://docs.elliebot.net/ellie/features/expressions/>)",
"Description": "Add an expression with a trigger and a response.\nBot will post a response whenever someone types the trigger word.\nRunning this command in a server requires the Administrator permission.\nRunning this command in DM is Bot Owner only and adds a new global expression.",
"Usage": [
".expradd \"hello\" Hi there %user.mention%"
],
@ -4513,6 +4528,61 @@
]
}
],
"LiveChannelCommands": [
{
"Aliases": [
".livechadd",
".lcha",
".lchadd"
],
"Description": "Adds a channel as a live channel with the specified template.\nYou can see a full list of placeholders with `.phs` command.",
"Usage": [
".livechadd #general Time: %server.time%",
".livechadd #general -- %server.members% --"
],
"Submodule": "LiveChannelCommands",
"Module": "LiveChannelCommands",
"Options": null,
"Requirements": [
"ManageChannels Server Permission"
]
},
{
"Aliases": [
".livechlist",
".lchl",
".lchli",
".lchlist"
],
"Description": "Lists all live channels in the server.",
"Usage": [
".livechlist"
],
"Submodule": "LiveChannelCommands",
"Module": "LiveChannelCommands",
"Options": null,
"Requirements": [
"ManageChannels Server Permission"
]
},
{
"Aliases": [
".livechremove",
".lchd",
".lchrm"
],
"Description": "Removes a live channel.",
"Usage": [
".livechremove #general"
],
"Submodule": "LiveChannelCommands",
"Module": "LiveChannelCommands",
"Options": null,
"Requirements": [
"ManageChannels Server Permission"
]
}
],
"Marmalade": [
{
"Aliases": [
@ -5766,6 +5836,54 @@
]
}
],
"ScheduledCommands": [
{
"Aliases": [
".schedulelist",
".schl",
".schli"
],
"Description": "Lists your scheduled commands in the current server.",
"Usage": [
".schedulelist"
],
"Submodule": "ScheduledCommands",
"Module": "ScheduledCommands",
"Options": null,
"Requirements": []
},
{
"Aliases": [
".scheduledelete",
".schd",
".schdel"
],
"Description": "Deletes one of your scheduled commands by its ID.",
"Usage": [
".scheduledelete 5"
],
"Submodule": "ScheduledCommands",
"Module": "ScheduledCommands",
"Options": null,
"Requirements": []
},
{
"Aliases": [
".scheduleadd",
".scha",
".schadd"
],
"Description": "Schedules a command to be executed after the specified amount of time.\nYou can schedule up to 5 commands at a time.",
"Usage": [
".scheduleadd 1h5m .say Hello after 1 hour and 5 minutes",
".scheduleadd 3h .br all"
],
"Submodule": "ScheduledCommands",
"Module": "ScheduledCommands",
"Options": null,
"Requirements": []
}
],
"Searches": [
{
"Aliases": [
@ -6898,7 +7016,7 @@
"Aliases": [
".savechat"
],
"Description": "Saves a number of messages to a text file and sends it to you.",
"Description": "Saves a number of messages to a text file and sends it to you.\nMax is 1000, unless you're the bot owner. ",
"Usage": [
".savechat 150"
],
@ -6906,7 +7024,7 @@
"Module": "Utility",
"Options": null,
"Requirements": [
"Bot Owner Only"
"Administrator Server Permission"
]
},
{
@ -8513,6 +8631,41 @@
"Options": null,
"Requirements": []
},
{
"Aliases": [
".xpexclusion",
".xpexl"
],
"Description": "Shows a list of all XP exclusions in the server.",
"Usage": [
".xpexclusion"
],
"Submodule": "XpExclusionCommands",
"Module": "Xp",
"Options": null,
"Requirements": [
"Administrator Server Permission"
]
},
{
"Aliases": [
".xpexclude",
".xpex"
],
"Description": "Toggles XP gain exclusion for a specified item.\nItem types can be Role or User.",
"Usage": [
".xpexclude @CoolRole",
".xpexclude @User",
".xpexclude role 123123123",
".xpexclude user 123123123"
],
"Submodule": "XpExclusionCommands",
"Module": "Xp",
"Options": null,
"Requirements": [
"Administrator Server Permission"
]
},
{
"Aliases": [
".xprate"

View file

@ -1635,3 +1635,16 @@ xpexclusion:
xpexclude:
- xpexclude
- xpex
livechadd:
- livechadd
- lcha
- lchadd
livechlist:
- livechlist
- lchl
- lchli
- lchlist
livechremove:
- livechremove
- lchd
- lchrm

View file

@ -5129,3 +5129,30 @@ xpexclude:
desc: "Type of the item to exclude: role or user"
itemId:
desc: "ID or mention of the item to exclude."
livechadd:
desc: |-
Adds a channel as a live channel with the specified template.
You can see a full list of placeholders with `{0}phs` command.
ex:
- '#general Time: %server.time%'
- '#general -- %server.members% --'
params:
- channel:
desc: "The channel to configure as a live channel."
- template:
desc: "The text template to use as a name for the live channel."
livechlist:
desc: |-
Lists all live channels in the server.
ex:
- ''
params:
- { }
livechremove:
desc: |-
Removes a live channel.
ex:
- '#general'
params:
- channel:
desc: "The channel to remove from live channels."

View file

@ -1221,5 +1221,15 @@
"xp_exclusion_none": "There are no exclusions set for this server.",
"xp_exclusion_title": "XP Exclusions",
"xp_exclude_added": "{0}: {1} has been excluded from the XP system.",
"xp_exclude_removed": "{0}: {1} is no longer excluded from the XP system."
"xp_exclude_removed": "{0}: {1} is no longer excluded from the XP system.",
"livechannel_added": "Successfully added {0} as a live channel.",
"livechannel_template": "Template: `{0}`",
"livechannel_limit": "You can have at most {0} live channels per server.",
"livechannel_exists": "This channel is already configured as a live channel.",
"livechannel_removed": "Successfully removed {0} from live channels.",
"livechannel_not_found": "Channel was not found in the live channels list.",
"livechannel_list_title": "Live Channels in {0}",
"livechannel_list_empty": "No live channels configured for this server.",
"template": "Template",
"preview": "Preview"
}