Compare commits

..

14 commits
master ... warn

Author SHA1 Message Date
006f0888de
i think this should work 2024-06-02 09:56:12 +05:00
646c80af59
oops
Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
2024-04-08 16:46:17 +03:00
1e9e1b4bb2
Permission validation, PunishmentOption and that's it...?
Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
2024-04-08 16:38:03 +03:00
422becf6c6
and we're back to our PR
Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
2024-04-08 15:55:23 +03:00
1350c65b3d
Merge branch 'master' into warn
# Conflicts:
#	locale/Messages.tt-ru.resx
2024-04-08 15:07:34 +03:00
5ff23722ce
...and a few small touches
Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
2024-03-26 22:08:57 +03:00
5239b82806
Some workarounds...
Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
2024-03-26 21:54:31 +03:00
152682e456
(Almost) Final implementation of /warn
Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
2024-03-26 20:59:56 +03:00
2564277070
Merge remote-tracking branch 'origin/master' into warn 2024-03-26 19:42:41 +03:00
187f21f1bf
Merge remote-tracking branch 'origin/master' into warn
# Conflicts:
#	locale/Messages.resx
#	locale/Messages.ru.resx
#	locale/Messages.tt-ru.resx
#	src/Commands/BanCommandGroup.cs
2024-03-26 18:47:07 +03:00
dbd14a458a
what
Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
2024-03-24 16:09:25 +03:00
88448cdb49
Better implementation of /warn
Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
2024-03-24 15:55:26 +03:00
962580d32f
Merge remote-tracking branch 'origin/master' into warn
# Conflicts:
#	locale/Messages.resx
#	locale/Messages.ru.resx
#	locale/Messages.tt-ru.resx
#	src/Messages.Designer.cs
2024-03-24 13:19:18 +03:00
ab2158a648
Initial implementation of /warn
Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
2024-03-19 18:46:56 +03:00
51 changed files with 1412 additions and 630 deletions

View file

@ -36,6 +36,5 @@ updates:
- "Remora.Discord.*"
# For all packages, ignore all patch updates
ignore:
- dependency-name: "GitInfo"
- dependency-name: "*"
update-types: [ "version-update:semver-patch" ]

View file

@ -22,13 +22,8 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: ReSharper CLI InspectCode
uses: muno92/resharper_inspectcode@1.13.0
uses: muno92/resharper_inspectcode@1.11.10
with:
solutionPath: ./Octobot.sln
ignoreIssueType: InvertIf, ConvertIfStatementToSwitchStatement, ConvertToPrimaryConstructor

View file

@ -5,83 +5,60 @@ concurrency:
on:
push:
branches: [ "master", "deploy-test" ]
branches: [ "master" ]
jobs:
upload-image:
name: Upload Octobot Docker image
upload-solution:
name: Upload Octobot to production
runs-on: ubuntu-latest
permissions:
packages: write
actions: read
contents: read
environment: production
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Checkout repository
uses: actions/checkout@v4
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{vars.NAMESPACE}}/${{vars.IMAGE_NAME}}:latest
build-args: |
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
PUBLISH_OPTIONS=${{vars.PUBLISH_OPTIONS}}
- name: Publish solution
run: dotnet publish $PUBLISH_FLAGS
env:
PUBLISH_FLAGS: ${{vars.PUBLISH_FLAGS}}
update-production:
name: Update Octobot on production
runs-on: ubuntu-latest
environment: production
needs: upload-image
steps:
- name: Copy SSH key
- name: Setup SSH key
run: |
install -m 600 -D /dev/null ~/.ssh/id_ed25519
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
install -m 600 -D /dev/null ~/.ssh/id_rsa
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
ssh-keyscan -H $SSH_HOST > ~/.ssh/known_hosts
shell: bash
env:
SSH_PRIVATE_KEY: ${{secrets.SSH_PRIVATE_KEY}}
- name: Generate SSH known hosts file
run: |
ssh-keyscan -H -p $SSH_PORT $SSH_HOST > ~/.ssh/known_hosts
shell: bash
env:
SSH_HOST: ${{secrets.SSH_HOST}}
SSH_PORT: ${{secrets.SSH_PORT}}
- name: Stop currently running instance
run: |
ssh -p $SSH_PORT $SSH_USER@$SSH_HOST $STOP_COMMAND
ssh $SSH_USER@$SSH_HOST $STOP_COMMAND
shell: bash
env:
SSH_PORT: ${{secrets.SSH_PORT}}
SSH_USER: ${{secrets.SSH_USER}}
SSH_HOST: ${{secrets.SSH_HOST}}
STOP_COMMAND: ${{vars.STOP_COMMAND}}
- name: Update Docker image
- name: Upload published solution
run: |
ssh -p $SSH_PORT $SSH_USER@$SSH_HOST docker pull ghcr.io/$NAMESPACE/$IMAGE_NAME:latest
scp -r $UPLOAD_FROM $SSH_USER@$SSH_HOST:$UPLOAD_TO
shell: bash
env:
SSH_PORT: ${{secrets.SSH_PORT}}
SSH_USER: ${{secrets.SSH_USER}}
SSH_HOST: ${{secrets.SSH_HOST}}
NAMESPACE: ${{vars.NAMESPACE}}
IMAGE_NAME: ${{vars.IMAGE_NAME}}
UPLOAD_FROM: ${{vars.UPLOAD_FROM}}
UPLOAD_TO: ${{vars.UPLOAD_TO}}
- name: Start new instance
run: |
ssh -p $SSH_PORT $SSH_USER@$SSH_HOST $START_COMMAND
ssh $SSH_USER@$SSH_HOST $START_COMMAND
shell: bash
env:
SSH_PORT: ${{secrets.SSH_PORT}}
SSH_USER: ${{secrets.SSH_USER}}
SSH_HOST: ${{secrets.SSH_HOST}}
START_COMMAND: ${{vars.START_COMMAND}}

1
.gitignore vendored
View file

@ -8,4 +8,3 @@ riderModule.iml
/.vs/
GuildData/
Logs/
compose.yaml

View file

@ -1,15 +0,0 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0@sha256:7d24e90a392e88eb56093e4eb325ff883ad609382a55d42f17fd557b997022ca AS build-env
WORKDIR /Octobot
# Copy everything
COPY . ./
# Load build argument with publish options
ARG PUBLISH_OPTIONS="-c Release"
# Build and publish a release
RUN dotnet publish ./TeamOctolings.Octobot $PUBLISH_OPTIONS -o out
# Build runtime image
FROM mcr.microsoft.com/dotnet/runtime:9.0@sha256:1e5eb0ed94ca96a34a914456db80e48bd1bb7bc3e3c8eda5e2c3d89c153c3081
WORKDIR /Octobot
COPY --from=build-env /Octobot/out .
ENTRYPOINT ["./TeamOctolings.Octobot"]

View file

@ -2,9 +2,7 @@
public static class BuildInfo
{
public const string WebsiteUrl = "https://teamoctolings.github.io/Octobot";
private const string RepositoryUrl = "https://github.com/TeamOctolings/Octobot";
public const string RepositoryUrl = "https://github.com/TeamOctolings/Octobot";
public const string IssuesUrl = $"{RepositoryUrl}/issues";

View file

@ -106,9 +106,9 @@ public sealed class AboutCommandGroup : CommandGroup
var repositoryButton = new ButtonComponent(
ButtonComponentStyle.Link,
Messages.ButtonOpenWebsite,
Messages.ButtonOpenRepository,
new PartialEmoji(Name: "\ud83c\udf10"), // 'GLOBE WITH MERIDIANS' (U+1F310)
URL: BuildInfo.WebsiteUrl
URL: BuildInfo.RepositoryUrl
);
var wikiButton = new ButtonComponent(
@ -131,7 +131,7 @@ public sealed class AboutCommandGroup : CommandGroup
return await _feedback.SendContextualEmbedResultAsync(embed,
new FeedbackMessageOptions(MessageComponents: new[]
{
new ActionRowComponent([repositoryButton, wikiButton, issuesButton])
new ActionRowComponent(new[] { repositoryButton, wikiButton, issuesButton })
}), ct);
}
}

View file

@ -62,7 +62,7 @@ public sealed class BanCommandGroup : CommandGroup
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user
/// was banned and vice versa.
/// was banned and vice-versa.
/// </returns>
/// <seealso cref="ExecuteUnban" />
[Command("ban", "бан")]
@ -128,7 +128,7 @@ public sealed class BanCommandGroup : CommandGroup
return await BanUserAsync(executor, target, reason, timeSpan, guild, data, channelId, bot, CancellationToken);
}
private async Task<Result> BanUserAsync(
public async Task<Result> BanUserAsync(
IUser executor, IUser target, string reason, TimeSpan? duration, IGuild guild, GuildData data,
Snowflake channelId,
IUser bot, CancellationToken ct = default)
@ -219,7 +219,7 @@ public sealed class BanCommandGroup : CommandGroup
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user
/// was unbanned and vice versa.
/// was unbanned and vice-versa.
/// </returns>
/// <seealso cref="ExecuteBanAsync" />
/// <seealso cref="MemberUpdateService.TickMemberDataAsync" />

View file

@ -51,7 +51,7 @@ public sealed class ClearCommandGroup : CommandGroup
/// <param name="author">The user whose messages will be cleared.</param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that any messages
/// were cleared and vice versa.
/// were cleared and vice-versa.
/// </returns>
[Command("clear", "очистить")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]

View file

@ -81,7 +81,7 @@ public sealed class ErrorLoggingPostExecutionEvent : IPostExecutionEvent
return ResultExtensions.FromError(await _feedback.SendContextualEmbedResultAsync(embed,
new FeedbackMessageOptions(MessageComponents: new[]
{
new ActionRowComponent([issuesButton])
new ActionRowComponent(new[] { issuesButton })
}), ct)
);
}

View file

@ -288,7 +288,7 @@ public sealed class InfoCommandGroup : CommandGroup
return await ShowGuildInfoAsync(bot, guild, CancellationToken);
}
private Task<Result> ShowGuildInfoAsync(IUser bot, IGuild guild, CancellationToken ct = default)
private Task<Result> ShowGuildInfoAsync(IUser bot, IGuild guild, CancellationToken ct)
{
var description = new StringBuilder().AppendLine($"## {guild.Name}");

View file

@ -57,7 +57,7 @@ public sealed class KickCommandGroup : CommandGroup
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the member
/// was kicked and vice versa.
/// was kicked and vice-versa.
/// </returns>
[Command("kick", "кик")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
@ -111,7 +111,7 @@ public sealed class KickCommandGroup : CommandGroup
return await KickUserAsync(executor, target, reason, guild, channelId, data, bot, CancellationToken);
}
private async Task<Result> KickUserAsync(
public async Task<Result> KickUserAsync(
IUser executor, IUser target, string reason, IGuild guild, Snowflake channelId, GuildData data, IUser bot,
CancellationToken ct = default)
{

View file

@ -59,7 +59,7 @@ public sealed class MuteCommandGroup : CommandGroup
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the member
/// was muted and vice versa.
/// was muted and vice-versa.
/// </returns>
/// <seealso cref="ExecuteUnmute" />
[Command("mute", "мут")]
@ -123,7 +123,7 @@ public sealed class MuteCommandGroup : CommandGroup
CancellationToken);
}
private async Task<Result> MuteUserAsync(
public async Task<Result> MuteUserAsync(
IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data,
Snowflake channelId, IUser bot, CancellationToken ct = default)
{
@ -170,7 +170,7 @@ public sealed class MuteCommandGroup : CommandGroup
private async Task<Result> SelectMuteMethodAsync(
IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId, GuildData data,
IUser bot, DateTimeOffset until, CancellationToken ct = default)
IUser bot, DateTimeOffset until, CancellationToken ct)
{
var muteRole = GuildSettings.MuteRole.Get(data.Settings);
@ -186,7 +186,7 @@ public sealed class MuteCommandGroup : CommandGroup
private async Task<Result> RoleMuteUserAsync(
IUser executor, IUser target, string reason, Snowflake guildId, GuildData data,
DateTimeOffset until, Snowflake muteRole, CancellationToken ct = default)
DateTimeOffset until, Snowflake muteRole, CancellationToken ct)
{
var assignRoles = new List<Snowflake> { muteRole };
var memberData = data.GetOrCreateMemberData(target.ID);
@ -208,7 +208,7 @@ public sealed class MuteCommandGroup : CommandGroup
private async Task<Result> TimeoutUserAsync(
IUser executor, IUser target, string reason, TimeSpan duration, Snowflake guildId,
IUser bot, DateTimeOffset until, CancellationToken ct = default)
IUser bot, DateTimeOffset until, CancellationToken ct)
{
if (duration.TotalDays >= 28)
{
@ -235,7 +235,7 @@ public sealed class MuteCommandGroup : CommandGroup
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the member
/// was unmuted and vice versa.
/// was unmuted and vice-versa.
/// </returns>
/// <seealso cref="ExecuteMute" />
/// <seealso cref="MemberUpdateService.TickMemberDataAsync" />

View file

@ -78,7 +78,7 @@ public sealed class RemindCommandGroup : CommandGroup
return await ListRemindersAsync(data.GetOrCreateMemberData(executorId), guildId, executor, bot, CancellationToken);
}
private Task<Result> ListRemindersAsync(MemberData data, Snowflake guildId, IUser executor, IUser bot, CancellationToken ct = default)
private Task<Result> ListRemindersAsync(MemberData data, Snowflake guildId, IUser executor, IUser bot, CancellationToken ct)
{
if (data.Reminders.Count == 0)
{
@ -94,7 +94,7 @@ public sealed class RemindCommandGroup : CommandGroup
{
var reminder = data.Reminders[i];
builder.AppendBulletPointLine(string.Format(Messages.ReminderPosition, Markdown.InlineCode((i + 1).ToString())))
.AppendSubBulletPointLine(string.Format(Messages.ReminderText, reminder.Text))
.AppendSubBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(reminder.Text)))
.AppendSubBulletPointLine(string.Format(Messages.ReminderTime, Markdown.Timestamp(reminder.At)))
.AppendSubBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage, $"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}"));
}
@ -182,7 +182,7 @@ public sealed class RemindCommandGroup : CommandGroup
});
var builder = new StringBuilder()
.AppendLine(MarkdownExtensions.Quote(text))
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(text)))
.AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(remindAt)));
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderCreated, executor.GetTag()), executor)
@ -279,7 +279,7 @@ public sealed class RemindCommandGroup : CommandGroup
data.Reminders.RemoveAt(index);
var builder = new StringBuilder()
.AppendLine(MarkdownExtensions.Quote(oldReminder.Text))
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(oldReminder.Text)))
.AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(remindAt)));
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderEdited, executor.GetTag()), executor)
@ -309,7 +309,7 @@ public sealed class RemindCommandGroup : CommandGroup
data.Reminders.RemoveAt(index);
var builder = new StringBuilder()
.AppendLine(MarkdownExtensions.Quote(value))
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(value)))
.AppendBulletPoint(string.Format(Messages.ReminderTime, Markdown.Timestamp(oldReminder.At)));
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.ReminderEdited, executor.GetTag()), executor)
@ -353,7 +353,7 @@ public sealed class RemindCommandGroup : CommandGroup
}
private Task<Result> DeleteReminderAsync(MemberData data, int index, IUser bot,
CancellationToken ct = default)
CancellationToken ct)
{
if (index >= data.Reminders.Count)
{
@ -367,7 +367,7 @@ public sealed class RemindCommandGroup : CommandGroup
var reminder = data.Reminders[index];
var description = new StringBuilder()
.AppendLine(MarkdownExtensions.Quote(reminder.Text))
.AppendBulletPointLine(string.Format(Messages.ReminderText, Markdown.InlineCode(reminder.Text)))
.AppendBulletPointLine(string.Format(Messages.ReminderTime, Markdown.Timestamp(reminder.At)));
data.Reminders.RemoveAt(index);

View file

@ -38,6 +38,7 @@ public sealed class SettingsCommandGroup : CommandGroup
private static readonly IGuildOption[] AllOptions =
[
GuildSettings.Language,
GuildSettings.WarnPunishment,
GuildSettings.WelcomeMessage,
GuildSettings.LeaveMessage,
GuildSettings.ReceiveStartupMessages,
@ -45,6 +46,7 @@ public sealed class SettingsCommandGroup : CommandGroup
GuildSettings.ReturnRolesOnRejoin,
GuildSettings.AutoStartEvents,
GuildSettings.RenameHoistedUsers,
GuildSettings.WarnsThreshold,
GuildSettings.PublicFeedbackChannel,
GuildSettings.PrivateFeedbackChannel,
GuildSettings.WelcomeMessagesChannel,
@ -53,7 +55,8 @@ public sealed class SettingsCommandGroup : CommandGroup
GuildSettings.MuteRole,
GuildSettings.ModeratorRole,
GuildSettings.EventNotificationRole,
GuildSettings.EventEarlyNotificationOffset
GuildSettings.EventEarlyNotificationOffset,
GuildSettings.WarnPunishmentDuration
];
private readonly ICommandContext _context;
@ -202,27 +205,6 @@ public sealed class SettingsCommandGroup : CommandGroup
IGuildOption option, string value, GuildData data, Snowflake channelId, IUser executor, IUser bot,
CancellationToken ct = default)
{
var equalsResult = option.ValueEquals(data.Settings, value);
if (!equalsResult.IsSuccess)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, bot)
.WithDescription(equalsResult.Error.Message)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
if (equalsResult.Entity)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, bot)
.WithDescription(Messages.SettingValueEquals)
.WithColour(ColorsList.Red)
.Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var setResult = option.Set(data.Settings, value);
if (!setResult.IsSuccess)
{

View file

@ -90,7 +90,7 @@ public sealed class ToolsCommandGroup : CommandGroup
}
private Task<Result> SendRandomNumberAsync(long first, long? secondNullable,
IUser executor, CancellationToken ct = default)
IUser executor, CancellationToken ct)
{
const long secondDefault = 0;
var second = secondNullable ?? secondDefault;
@ -187,7 +187,7 @@ public sealed class ToolsCommandGroup : CommandGroup
return await SendTimestampAsync(offset, executor, CancellationToken);
}
private Task<Result> SendTimestampAsync(TimeSpan? offset, IUser executor, CancellationToken ct = default)
private Task<Result> SendTimestampAsync(TimeSpan? offset, IUser executor, CancellationToken ct)
{
var timestamp = DateTimeOffset.UtcNow.Add(offset ?? TimeSpan.Zero).ToUnixTimeSeconds();
@ -249,7 +249,7 @@ public sealed class ToolsCommandGroup : CommandGroup
return await AnswerEightBallAsync(bot, CancellationToken);
}
private Task<Result> AnswerEightBallAsync(IUser bot, CancellationToken ct = default)
private Task<Result> AnswerEightBallAsync(IUser bot, CancellationToken ct)
{
var typeNumber = Random.Shared.Next(0, 4);
var embedColor = typeNumber switch

View file

@ -0,0 +1,560 @@
using System.Collections;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Json.Nodes;
using JetBrains.Annotations;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
using TeamOctolings.Octobot.Data;
using TeamOctolings.Octobot.Extensions;
using TeamOctolings.Octobot.Services;
using static System.DateTimeOffset;
namespace TeamOctolings.Octobot.Commands;
[UsedImplicitly]
public class WarnCommandGroup : CommandGroup
{
private readonly AccessControlService _access;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context;
private readonly IFeedbackService _feedback;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly GuildDataService _guildData;
private readonly IDiscordRestUserAPI _userApi;
private readonly Utility _utility;
public WarnCommandGroup(
ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService guildData,
IFeedbackService feedback, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi,
Utility utility, AccessControlService access)
{
_context = context;
_channelApi = channelApi;
_guildData = guildData;
_feedback = feedback;
_guildApi = guildApi;
_userApi = userApi;
_utility = utility;
_access = access;
}
[Command("warn")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.KickMembers,
DiscordPermission.ModerateMembers, DiscordPermission.BanMembers)]
[Description("Warn user")]
[UsedImplicitly]
public async Task<Result> ExecuteWarnAsync(
[Description("User to warn")] IUser target,
[Description("Warn reason")] [MaxLength(256)]
string reason)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return Result.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return Result.FromError(executorResult);
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild))
{
return Result.FromError(guildResult);
}
var data = await _guildData.GetData(guild.ID, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
var interactionResult
= await _access.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Warn", CancellationToken);
if (!interactionResult.IsSuccess)
{
return ResultExtensions.FromError(interactionResult);
}
if (interactionResult.Entity is not null)
{
var errorEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: CancellationToken);
}
return await WarnPreparationAsync(executor, target, reason, guild, data, channelId, bot, CancellationToken);
}
private async Task<Result> WarnPreparationAsync(IUser executor, IUser target, string reason, IGuild guild,
GuildData data, Snowflake channelId, IUser bot, CancellationToken ct = default)
{
var memberData = data.GetOrCreateMemberData(target.ID);
var warns = memberData.Warns;
var settings = data.Settings;
var warnThreshold = GuildSettings.WarnsThreshold.Get(settings);
var warnPunishment = GuildSettings.WarnPunishment.Get(settings);
var warnDuration = GuildSettings.WarnPunishmentDuration.Get(settings);
if (warnPunishment is "off" or "disable" or "disabled"
&& warns.Count + 1 >= warnThreshold
&& warnThreshold is not 0)
{
var errorEmbed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.WarnThresholdExceeded, warnThreshold), bot)
.WithDescription(Messages.WarnThresholdExceededDescription)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: CancellationToken);
}
if (warnPunishment is "ban" or "mute" && warnDuration == TimeSpan.Zero)
{
var errorEmbed = new EmbedBuilder()
.WithSmallTitle(Messages.WarnPunishmentDurationNotSet, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: CancellationToken);
}
if (warns.Count + 1 < warnThreshold || warnThreshold is 0)
{
return await WarnUserAsync(executor, target, reason, guild, data, channelId, bot, settings,
warns, warnThreshold, warnPunishment, warnDuration, ct);
}
var interactionResult
= await _access.CheckInteractionsAsync(guild.ID, bot.ID, target.ID,
$"{char.ToUpperInvariant(warnPunishment[0])}{warnPunishment[1..]}", ct);
if (!interactionResult.IsSuccess)
{
return ResultExtensions.FromError(interactionResult);
}
if (interactionResult.Entity is not null)
{
var errorEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: ct);
}
return await WarnUserAsync(executor, target, reason, guild, data, channelId, bot, settings,
warns, warnThreshold, warnPunishment, warnDuration, ct);
}
private async Task<Result> WarnUserAsync(IUser executor, IUser target, string reason, IGuild guild,
GuildData data, Snowflake channelId, IUser bot, JsonNode settings, IList warns, int warnThreshold,
string warnPunishment, TimeSpan warnDuration, CancellationToken ct = default)
{
warns.Add(new Warn
{
WarnedBy = executor.ID.Value,
At = UtcNow,
Reason = reason
});
var builder = new StringBuilder()
.AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason))
.AppendBulletPointLine(string.Format(Messages.DescriptionWarns,
warnThreshold is 0 ? warns.Count : $"{warns.Count}/{warnThreshold}"));
var title = string.Format(Messages.UserWarned, target.GetTag());
var description = builder.ToString();
var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct);
if (dmChannelResult.IsDefined(out var dmChannel))
{
var dmEmbed = new EmbedBuilder().WithGuildTitle(guild)
.WithTitle(Messages.YouHaveBeenWarned)
.WithDescription(description)
.WithActionFooter(executor)
.WithCurrentTimestamp()
.WithColour(ColorsList.Yellow)
.Build();
await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct);
}
_utility.LogAction(settings, channelId, executor, title, description,
target, ColorsList.Yellow, false, ct);
var embed = new EmbedBuilder().WithSmallTitle(title, target)
.WithColour(ColorsList.Green).Build();
if (warns.Count >= warnThreshold && warnThreshold is not 0)
{
return await PunishUserAsync(target, guild, data, channelId, bot, warns, warnPunishment, warnDuration, CancellationToken);
}
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private async Task<Result> PunishUserAsync(IUser target, IGuild guild, GuildData data,
Snowflake channelId, IUser bot, IList warns, string punishment, TimeSpan duration, CancellationToken ct)
{
if (punishment is "ban" && duration != TimeSpan.Zero)
{
var banCommandGroup = new BanCommandGroup(
_access, _channelApi, _context, _feedback, _guildApi, _guildData, _userApi, _utility);
await banCommandGroup.BanUserAsync(bot, target, Messages.ReceivedTooManyWarnings,
duration, guild, data, channelId, bot, ct);
}
if (punishment is "kick")
{
var kickCommandGroup = new KickCommandGroup(
_access, _channelApi, _context, _feedback, _guildApi, _guildData, _userApi, _utility);
await kickCommandGroup.KickUserAsync(bot, target, Messages.ReceivedTooManyWarnings,
guild, channelId, data, bot, ct);
}
if (punishment is "mute" && duration != TimeSpan.Zero)
{
var muteCommandGroup = new MuteCommandGroup(
_access, _context, _feedback, _guildApi, _guildData, _userApi, _utility);
await muteCommandGroup.MuteUserAsync(bot, target, Messages.ReceivedTooManyWarnings,
duration, guild.ID, data, channelId, bot, ct);
}
warns.Clear();
return Result.FromSuccess();
}
[Command("unwarn")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageMessages)]
[DiscordDefaultDMPermission(false)]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[Description("Remove warns from user")]
[UsedImplicitly]
public async Task<Result> ExecuteUnwarnAsync(
[Description("User to remove warns from")]
IUser target,
[Description("Warn remove reason")] [MaxLength(256)]
string reason,
[Description("Number of the warning to be deleted")]
int? number = null)
{
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return Result.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return Result.FromError(executorResult);
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild))
{
return Result.FromError(guildResult);
}
var data = await _guildData.GetData(guild.ID, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
var interactionResult
= await _access.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "Unwarn", CancellationToken);
if (!interactionResult.IsSuccess)
{
return ResultExtensions.FromError(interactionResult);
}
if (interactionResult.Entity is not null)
{
var errorEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: CancellationToken);
}
if (number is not null)
{
return await RemoveUserWarnAsync(executor, target, reason, number.Value, guild, data, channelId, bot,
CancellationToken);
}
return await RemoveUserWarnsAsync(executor, target, reason, guild, data, channelId, bot, CancellationToken);
}
private async Task<Result> RemoveUserWarnAsync(IUser executor, IUser target, string reason, int warnNumber,
IGuild guild, GuildData data, Snowflake channelId, IUser bot, CancellationToken ct = default)
{
var memberData = data.GetOrCreateMemberData(target.ID);
var warns = memberData.Warns;
if (warns.Count is 0)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserHasNoWarnings, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var index = warnNumber - 1;
if (index >= warns.Count || index < 0)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.WrongWarningNumberSelected, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var builder = new StringBuilder()
.Append("> ").AppendLine(warns[index].Reason)
.AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason));
warns.RemoveAt(index);
var title = string.Format(Messages.UserWarnRemoved, warnNumber, target.GetTag());
var description = builder.ToString();
var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct);
if (dmChannelResult.IsDefined(out var dmChannel))
{
var dmEmbed = new EmbedBuilder().WithGuildTitle(guild)
.WithTitle(Messages.YourWarningHasBeenRevoked)
.WithDescription(description)
.WithActionFooter(executor)
.WithCurrentTimestamp()
.WithColour(ColorsList.Green)
.Build();
await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct);
}
var embed = new EmbedBuilder().WithSmallTitle(
title, target)
.WithColour(ColorsList.Green).Build();
_utility.LogAction(
data.Settings, channelId, executor, title, description, target, ColorsList.Yellow, false, ct);
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private async Task<Result> RemoveUserWarnsAsync(IUser executor, IUser target, string reason,
IGuild guild, GuildData data, Snowflake channelId, IUser bot, CancellationToken ct = default)
{
var memberData = data.GetOrCreateMemberData(target.ID);
var warns = memberData.Warns;
if (warns.Count is 0)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserHasNoWarnings, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var builder = new StringBuilder().AppendBulletPointLine(string.Format(Messages.DescriptionActionReason, reason));
warns.Clear();
var title = string.Format(Messages.UserWarnsRemoved, target.GetTag());
var description = builder.ToString();
var dmChannelResult = await _userApi.CreateDMAsync(target.ID, ct);
if (dmChannelResult.IsDefined(out var dmChannel))
{
var dmEmbed = new EmbedBuilder().WithGuildTitle(guild)
.WithTitle(Messages.YourWarningsHaveBeenRevoked)
.WithDescription(description)
.WithActionFooter(executor)
.WithCurrentTimestamp()
.WithColour(ColorsList.Green)
.Build();
await _channelApi.CreateMessageWithEmbedResultAsync(dmChannel.ID, embedResult: dmEmbed, ct: ct);
}
var embed = new EmbedBuilder().WithSmallTitle(
title, target)
.WithColour(ColorsList.Green).Build();
_utility.LogAction(
data.Settings, channelId, executor, title, description, target, ColorsList.Yellow, false, ct);
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
[Command("listwarn")]
[DiscordDefaultDMPermission(false)]
[Ephemeral]
[Description("(Ephemeral) Get current warns")]
[UsedImplicitly]
public async Task<Result> ExecuteListWarnsAsync(
[Description("(Moderator-only) Get target's current warns")]
IUser? target = null)
{
if (!_context.TryGetContextIDs(out var guildId, out _, out var executorId))
{
return new ArgumentInvalidError(nameof(_context), "Unable to retrieve necessary IDs from command context");
}
var botResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!botResult.IsDefined(out var bot))
{
return Result.FromError(botResult);
}
var executorResult = await _userApi.GetUserAsync(executorId, CancellationToken);
if (!executorResult.IsDefined(out var executor))
{
return Result.FromError(executorResult);
}
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild))
{
return Result.FromError(guildResult);
}
var data = await _guildData.GetData(guild.ID, CancellationToken);
Messages.Culture = GuildSettings.Language.Get(data.Settings);
if (target is not null)
{
return await ListTargetWarnsAsync(executor, target, guild, data, bot, CancellationToken);
}
return await ListExecutorWarnsAsync(executor, data, bot, CancellationToken);
}
private async Task<Result> ListTargetWarnsAsync(IUser executor, IUser target, IGuild guild,
GuildData data, IUser bot, CancellationToken ct = default)
{
var interactionResult
= await _access.CheckInteractionsAsync(guild.ID, executor.ID, target.ID, "GetWarns", ct);
if (!interactionResult.IsSuccess)
{
return ResultExtensions.FromError(interactionResult);
}
if (interactionResult.Entity is not null)
{
var errorEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, bot)
.WithColour(ColorsList.Red).Build();
return await _feedback.SendContextualEmbedResultAsync(errorEmbed, ct: ct);
}
var memberData = data.GetOrCreateMemberData(target.ID);
var warns = memberData.Warns;
if (warns.Count is 0)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.UserHasNoWarnings, bot)
.WithColour(ColorsList.Green).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var warnThreshold = GuildSettings.WarnsThreshold.Get(data.Settings);
var punishmentType = GuildSettings.WarnPunishment.Get(data.Settings);
var description = new StringBuilder()
.AppendLine(string.Format(Messages.DescriptionWarns,
warnThreshold is 0 ? warns.Count : $"{warns.Count}/{warnThreshold}"));
if (punishmentType is not "off" and not "disable" and not "disabled")
{
description.AppendLine(string.Format(
Messages.DescriptionPunishmentType, Markdown.InlineCode(punishmentType)));
}
var warnCount = 0;
foreach (var warn in warns)
{
warnCount++;
description.Append(warnCount).Append(". ").AppendLine(warn.Reason)
.AppendSubBulletPoint(Messages.IssuedBy).Append(' ').AppendLine(Mention.User(warn.WarnedBy.ToSnowflake()))
.AppendSubBulletPointLine(string.Format(Messages.ReceivedOn, Markdown.Timestamp(warn.At)));
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.ListTargetWarnsTitle, target.GetTag()), target)
.WithDescription(description.ToString())
.WithColour(ColorsList.Default).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
private async Task<Result> ListExecutorWarnsAsync(IUser executor, GuildData data, IUser bot,
CancellationToken ct = default)
{
var memberData = data.GetOrCreateMemberData(executor.ID);
var warns = memberData.Warns;
if (warns.Count is 0)
{
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.YouHaveNoWarnings, bot)
.WithColour(ColorsList.Green).Build();
return await _feedback.SendContextualEmbedResultAsync(failedEmbed, ct: ct);
}
var warnThreshold = GuildSettings.WarnsThreshold.Get(data.Settings);
var punishmentType = GuildSettings.WarnPunishment.Get(data.Settings);
var description = new StringBuilder()
.AppendLine(string.Format(Messages.DescriptionWarns,
warnThreshold is 0 ? warns.Count : $"{warns.Count}/{warnThreshold}"));
if (punishmentType is not "off" and not "disable" and not "disabled")
{
description.AppendLine(string.Format(
Messages.DescriptionPunishmentType, Markdown.InlineCode(punishmentType)));
}
var warnCount = 0;
foreach (var warn in warns)
{
warnCount++;
description.Append(warnCount).Append(". ").AppendLine(warn.Reason)
.AppendSubBulletPoint(Messages.IssuedBy).Append(' ').AppendLine(Mention.User(warn.WarnedBy.ToSnowflake()))
.AppendSubBulletPointLine(string.Format(Messages.ReceivedOn, Markdown.Timestamp(warn.At)));
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.ListExecutorWarnsTitle, executor.GetTag()), executor)
.WithDescription(description.ToString())
.WithColour(ColorsList.Default).Build();
return await _feedback.SendContextualEmbedResultAsync(embed, ct: ct);
}
}

View file

@ -12,6 +12,8 @@ public static class GuildSettings
{
public static readonly LanguageOption Language = new("Language", "en");
public static readonly PunishmentOption WarnPunishment = new("WarnPunishment", "disabled");
/// <summary>
/// Controls what message should be sent in <see cref="PublicFeedbackChannel" /> when a new member joins the guild.
/// </summary>
@ -58,6 +60,8 @@ public static class GuildSettings
/// </summary>
public static readonly BoolOption RenameHoistedUsers = new("RenameHoistedUsers", false);
public static readonly IntOption WarnsThreshold = new("WarnsThreshold", 0);
/// <summary>
/// Controls what channel should all public messages be sent to.
/// </summary>
@ -84,4 +88,7 @@ public static class GuildSettings
/// </summary>
public static readonly TimeSpanOption EventEarlyNotificationOffset = new(
"EventEarlyNotificationOffset", TimeSpan.Zero);
public static readonly TimeSpanOption WarnPunishmentDuration = new(
"WarnPunishmentDuration", TimeSpan.Zero);
}

View file

@ -5,13 +5,18 @@ namespace TeamOctolings.Octobot.Data;
/// </summary>
public sealed class MemberData
{
public MemberData(ulong id, List<Reminder>? reminders = null)
public MemberData(ulong id, List<Reminder>? reminders = null, List<Warn>? warns = null)
{
Id = id;
if (reminders is not null)
{
Reminders = reminders;
}
if (warns is not null)
{
Warns = warns;
}
}
public ulong Id { get; }
@ -20,4 +25,5 @@ public sealed class MemberData
public bool Kicked { get; set; }
public List<ulong> Roles { get; set; } = [];
public List<Reminder> Reminders { get; } = [];
public List<Warn> Warns { get; } = [];
}

View file

@ -13,6 +13,7 @@ namespace TeamOctolings.Octobot.Data.Options;
public enum AllOptionsEnum
{
[UsedImplicitly] Language,
[UsedImplicitly] WarnPunishment,
[UsedImplicitly] WelcomeMessage,
[UsedImplicitly] LeaveMessage,
[UsedImplicitly] ReceiveStartupMessages,
@ -20,6 +21,7 @@ public enum AllOptionsEnum
[UsedImplicitly] ReturnRolesOnRejoin,
[UsedImplicitly] AutoStartEvents,
[UsedImplicitly] RenameHoistedUsers,
[UsedImplicitly] WarnsThreshold,
[UsedImplicitly] PublicFeedbackChannel,
[UsedImplicitly] PrivateFeedbackChannel,
[UsedImplicitly] WelcomeMessagesChannel,
@ -28,5 +30,6 @@ public enum AllOptionsEnum
[UsedImplicitly] MuteRole,
[UsedImplicitly] ModeratorRole,
[UsedImplicitly] EventNotificationRole,
[UsedImplicitly] EventEarlyNotificationOffset
[UsedImplicitly] EventEarlyNotificationOffset,
[UsedImplicitly] WarnPunishmentDuration
}

View file

@ -12,16 +12,6 @@ public sealed class BoolOption : GuildOption<bool>
return Get(settings) ? Messages.Yes : Messages.No;
}
public override Result<bool> ValueEquals(JsonNode settings, string value)
{
if (!TryParseBool(value, out var boolean))
{
return new ArgumentInvalidError(nameof(value), Messages.InvalidSettingValue);
}
return Value(settings).Equals(boolean.ToString());
}
public override Result Set(JsonNode settings, string from)
{
if (!TryParseBool(from, out var value))

View file

@ -21,19 +21,9 @@ public class GuildOption<T> : IGuildOption
public string Name { get; }
protected virtual string Value(JsonNode settings)
{
return Get(settings).ToString() ?? throw new InvalidOperationException();
}
public virtual string Display(JsonNode settings)
{
return Markdown.InlineCode(Value(settings));
}
public virtual Result<bool> ValueEquals(JsonNode settings, string value)
{
return Value(settings).Equals(value);
return Markdown.InlineCode(Get(settings).ToString() ?? throw new InvalidOperationException());
}
/// <summary>

View file

@ -7,7 +7,6 @@ public interface IGuildOption
{
string Name { get; }
string Display(JsonNode settings);
Result<bool> ValueEquals(JsonNode settings, string value);
Result Set(JsonNode settings, string from);
Result Reset(JsonNode settings);
}

View file

@ -0,0 +1,31 @@
using System.Text.Json.Nodes;
using Remora.Results;
namespace TeamOctolings.Octobot.Data.Options;
public sealed class IntOption : GuildOption<int>
{
public IntOption(string name, int defaultValue) : base(name, defaultValue) { }
public override string Display(JsonNode settings)
{
return settings[Name]?.GetValue<string>() ?? "0";
}
public override Result Set(JsonNode settings, string from)
{
if (!int.TryParse(from, out _))
{
return new ArgumentInvalidError(nameof(from), Messages.InvalidSettingValue);
}
settings[Name] = from;
return Result.FromSuccess();
}
public override int Get(JsonNode settings)
{
var property = settings[Name];
return property != null ? Convert.ToInt32(property.GetValue<string>()) : DefaultValue;
}
}

View file

@ -1,5 +1,6 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
namespace TeamOctolings.Octobot.Data.Options;
@ -15,9 +16,9 @@ public sealed class LanguageOption : GuildOption<CultureInfo>
public LanguageOption(string name, string defaultValue) : base(name, CultureInfoCache[defaultValue]) { }
protected override string Value(JsonNode settings)
public override string Display(JsonNode settings)
{
return settings[Name]?.GetValue<string>() ?? "en";
return Markdown.InlineCode(settings[Name]?.GetValue<string>() ?? "en");
}
/// <inheritdoc />

View file

@ -0,0 +1,23 @@
using System.Text.Json.Nodes;
using Remora.Results;
namespace TeamOctolings.Octobot.Data.Options;
/// <inheritdoc />
public sealed class PunishmentOption : GuildOption<string>
{
private static readonly List<string> AllowedValues =
[
"ban", "kick", "mute", "off", "disable", "disabled"
];
public PunishmentOption(string name, string defaultValue) : base(name, defaultValue) { }
/// <inheritdoc />
public override Result Set(JsonNode settings, string from)
{
return AllowedValues.Contains(from.ToLowerInvariant())
? base.Set(settings, from.ToLowerInvariant())
: new ArgumentInvalidError(nameof(from), Messages.InvalidWarnPunishment);
}
}

View file

@ -8,16 +8,6 @@ public sealed class TimeSpanOption : GuildOption<TimeSpan>
{
public TimeSpanOption(string name, TimeSpan defaultValue) : base(name, defaultValue) { }
public override Result<bool> ValueEquals(JsonNode settings, string value)
{
if (!TimeSpanParser.TryParse(value).IsDefined(out var span))
{
return new ArgumentInvalidError(nameof(value), Messages.InvalidSettingValue);
}
return Value(settings).Equals(span.ToString());
}
public override TimeSpan Get(JsonNode settings)
{
var property = settings[Name];

View file

@ -1,9 +1,9 @@
namespace TeamOctolings.Octobot.Data;
public sealed record Reminder
public struct Reminder
{
public required DateTimeOffset At { get; init; }
public required string Text { get; init; }
public required ulong ChannelId { get; init; }
public required ulong MessageId { get; init; }
public DateTimeOffset At { get; init; }
public string Text { get; init; }
public ulong ChannelId { get; init; }
public ulong MessageId { get; init; }
}

View file

@ -0,0 +1,8 @@
namespace TeamOctolings.Octobot.Data;
public struct Warn
{
public ulong WarnedBy { get; init; }
public DateTimeOffset At { get; init; }
public string Reason { get; init; }
}

View file

@ -10,7 +10,7 @@ public static class GuildScheduledEventExtensions
out string? location)
{
endTime = default;
location = null;
location = default;
if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
{
return new ArgumentNullError(nameof(scheduledEvent.EntityMetadata));

View file

@ -25,7 +25,7 @@ public static class LoggerExtensions
if (result.Error is ExceptionError exe)
{
if (exe.Exception is OperationCanceledException)
if (exe.Exception is TaskCanceledException)
{
return;
}

View file

@ -13,16 +13,4 @@ public static class MarkdownExtensions
{
return $"- {text}";
}
/// <summary>
/// Formats a string to use Markdown Quote formatting.
/// </summary>
/// <param name="text">The input text to format.</param>
/// <returns>
/// A markdown-formatted quote string.
/// </returns>
public static string Quote(string text)
{
return $"> {text}";
}
}

View file

@ -23,7 +23,7 @@ public static class ResultExtensions
private static void LogResultStackTrace(Result result)
{
if (result.IsSuccess || result.Error is ExceptionError { Exception: OperationCanceledException })
if (result.IsSuccess)
{
return;
}

View file

@ -633,9 +633,9 @@ namespace TeamOctolings.Octobot {
}
}
internal static string ButtonOpenWebsite {
internal static string ButtonOpenRepository {
get {
return ResourceManager.GetString("ButtonOpenWebsite", resourceCulture);
return ResourceManager.GetString("ButtonOpenRepository", resourceCulture);
}
}
@ -1179,6 +1179,53 @@ namespace TeamOctolings.Octobot {
}
}
internal static string UserWarned {
get {
return ResourceManager.GetString("UserWarned", resourceCulture);
}
}
internal static string UserWarnsRemoved {
get {
return ResourceManager.GetString("UserWarnsRemoved", resourceCulture);
}
}
internal static string YouHaveBeenWarned {
get {
return ResourceManager.GetString("YouHaveBeenWarned", resourceCulture);
}
}
internal static string YourWarningsHaveBeenRevoked {
get {
return ResourceManager.GetString("YourWarningsHaveBeenRevoked", resourceCulture);
}
}
internal static string DescriptionWarns {
get {
return ResourceManager.GetString("DescriptionWarns", resourceCulture);
}
}
internal static string UserHasNoWarnings {
get {
return ResourceManager.GetString("UserHasNoWarnings", resourceCulture);
}
}
internal static string YouHaveNoWarnings {
get {
return ResourceManager.GetString("YouHaveNoWarnings", resourceCulture);
}
}
internal static string ReceivedTooManyWarnings {
get {
return ResourceManager.GetString("ReceivedTooManyWarnings", resourceCulture);
}
}
internal static string ButtonDirty {
get {
return ResourceManager.GetString("ButtonDirty", resourceCulture);
@ -1197,9 +1244,69 @@ namespace TeamOctolings.Octobot {
}
}
internal static string SettingValueEquals {
internal static string ListTargetWarnsTitle {
get {
return ResourceManager.GetString("SettingValueEquals", resourceCulture);
return ResourceManager.GetString("ListTargetWarnsTitle", resourceCulture);
}
}
internal static string ReceivedOn {
get {
return ResourceManager.GetString("ReceivedOn", resourceCulture);
}
}
internal static string UserWarnRemoved {
get {
return ResourceManager.GetString("UserWarnRemoved", resourceCulture);
}
}
internal static string YourWarningHasBeenRevoked {
get {
return ResourceManager.GetString("YourWarningHasBeenRevoked", resourceCulture);
}
}
internal static string WrongWarningNumberSelected {
get {
return ResourceManager.GetString("WrongWarningNumberSelected", resourceCulture);
}
}
internal static string ListExecutorWarnsTitle {
get {
return ResourceManager.GetString("ListExecutorWarnsTitle", resourceCulture);
}
}
internal static string DescriptionPunishmentType {
get {
return ResourceManager.GetString("DescriptionPunishmentType", resourceCulture);
}
}
internal static string WarnThresholdExceeded {
get {
return ResourceManager.GetString("WarnThresholdExceeded", resourceCulture);
}
}
internal static string WarnPunishmentDurationNotSet {
get {
return ResourceManager.GetString("WarnPunishmentDurationNotSet", resourceCulture);
}
}
internal static string WarnThresholdExceededDescription {
get {
return ResourceManager.GetString("WarnThresholdExceededDescription", resourceCulture);
}
}
internal static string InvalidWarnPunishment {
get {
return ResourceManager.GetString("InvalidWarnPunishment", resourceCulture);
}
}
}

View file

@ -399,8 +399,8 @@
<data name="AboutTitleDevelopers" xml:space="preserve">
<value>Developers:</value>
</data>
<data name="ButtonOpenWebsite" xml:space="preserve">
<value>Open Website</value>
<data name="ButtonOpenRepository" xml:space="preserve">
<value>Octobot's source code</value>
</data>
<data name="AboutBot" xml:space="preserve">
<value>About {0}</value>
@ -681,7 +681,124 @@
<data name="SettingsModeratorRole" xml:space="preserve">
<value>Moderator role</value>
</data>
<data name="SettingValueEquals" xml:space="preserve">
<value>The setting value is the same as the input value.</value>
<data name="UserWarned" xml:space="preserve">
<value>{0} received a warning</value>
</data>
<data name="UserWarnsRemoved" xml:space="preserve">
<value>{0} no longer has warnings</value>
</data>
<data name="YouHaveBeenWarned" xml:space="preserve">
<value>You have been warned</value>
</data>
<data name="YourWarningsHaveBeenRevoked" xml:space="preserve">
<value>Your warnings have been revoked</value>
</data>
<data name="DescriptionWarns" xml:space="preserve">
<value>Warns: {0}</value>
</data>
<data name="UserHasNoWarnings" xml:space="preserve">
<value>This user has no warnings!</value>
</data>
<data name="ReceivedTooManyWarnings" xml:space="preserve">
<value>Received too many warnings</value>
</data>
<data name="SettingsWarnPunishment" xml:space="preserve">
<value>Punishment type for warnings</value>
</data>
<data name="SettingsWarnThreshold" xml:space="preserve">
<value>Warnings threshold</value>
</data>
<data name="SettingsWarnPunishmentDuration" xml:space="preserve">
<value>Punishment duration for warnings</value>
</data>
<data name="ListExecutorWarnsTitle" xml:space="preserve">
<value>Here's your warnings, {0}:</value>
</data>
<data name="YouHaveNoWarnings" xml:space="preserve">
<value>You have no warnings!</value>
</data>
<data name="ReceivedOn" xml:space="preserve">
<value>Received on {0}</value>
</data>
<data name="UserWarnRemoved" xml:space="preserve">
<value>Warning #{0} has been removed from {1}</value>
</data>
<data name="YourWarningHasBeenRevoked" xml:space="preserve">
<value>Your warning has been revoked</value>
</data>
<data name="WrongWarningNumberSelected" xml:space="preserve">
<value>Wrong warning number selected!</value>
</data>
<data name="UserCannotWarnBot" xml:space="preserve">
<value>You cannot warn me!</value>
</data>
<data name="UserCannotWarnOwner" xml:space="preserve">
<value>You cannot warn the owner of this guild!</value>
</data>
<data name="UserCannotWarnTarget" xml:space="preserve">
<value>You cannot warn this member!</value>
</data>
<data name="UserCannotWarnThemselves" xml:space="preserve">
<value>You cannot warn yourself!</value>
</data>
<data name="UserCannotUnwarnBot" xml:space="preserve">
<value>You cannot unwarn me!</value>
</data>
<data name="UserCannotUnwarnOwner" xml:space="preserve">
<value>You cannot unwarn the owner of this guild!</value>
</data>
<data name="UserCannotUnwarnTarget" xml:space="preserve">
<value>You cannot unwarn this member!</value>
</data>
<data name="UserCannotUnwarnThemselves" xml:space="preserve">
<value>You cannot unwarn yourself!</value>
</data>
<data name="BotCannotWarnMembers" xml:space="preserve">
<value>I cannot warn members from this guild!</value>
</data>
<data name="BotCannotWarnTarget" xml:space="preserve">
<value>I cannot warn this member!</value>
</data>
<data name="BotCannotUnwarnTarget" xml:space="preserve">
<value>I cannot unwarn this member!</value>
</data>
<data name="UserCannotGetWarnsBot" xml:space="preserve">
<value>You cannot get my warns!</value>
</data>
<data name="UserCannotGetWarnsOwner" xml:space="preserve">
<value>You cannot get owner's warns!</value>
</data>
<data name="UserCannotGetWarnsTarget" xml:space="preserve">
<value>You cannot get warns of this member!</value>
</data>
<data name="UserCannotGetWarnsThemselves" xml:space="preserve">
<value>Use this command without options instead.</value>
</data>
<data name="UserCannotWarnMembers" xml:space="preserve">
<value>You cannot warn members in this guild!</value>
</data>
<data name="UserCannotUnwarnMembers" xml:space="preserve">
<value>You cannot unwarn members in this guild!</value>
</data>
<data name="UserCannotGetWarnsMembers" xml:space="preserve">
<value>You cannot get warns of other members in this guild!</value>
</data>
<data name="ListTargetWarnsTitle" xml:space="preserve">
<value>Warnings given to {0}:</value>
</data>
<data name="DescriptionPunishmentType" xml:space="preserve">
<value>Punishment type: {0}</value>
</data>
<data name="WarnThresholdExceeded" xml:space="preserve">
<value>Warn threshold has been exceeded. ({0})</value>
</data>
<data name="WarnPunishmentDurationNotSet" xml:space="preserve">
<value>Warn Punishment Duration is not set for the current punishment type.</value>
</data>
<data name="WarnThresholdExceededDescription" xml:space="preserve">
<value>Increase the Warn Threshold or set a Warn Punishment.</value>
</data>
<data name="InvalidWarnPunishment" xml:space="preserve">
<value>Invalid Warn Punishment is set.</value>
</data>
</root>

View file

@ -399,8 +399,8 @@
<data name="AboutTitleDevelopers" xml:space="preserve">
<value>Разработчики:</value>
</data>
<data name="ButtonOpenWebsite" xml:space="preserve">
<value>Открыть веб-сайт</value>
<data name="ButtonOpenRepository" xml:space="preserve">
<value>Исходный код Octobot</value>
</data>
<data name="AboutBot" xml:space="preserve">
<value>О боте {0}</value>
@ -681,7 +681,124 @@
<data name="SettingsModeratorRole" xml:space="preserve">
<value>Роль модератора</value>
</data>
<data name="SettingValueEquals" xml:space="preserve">
<value>Значение настройки такое же, как и вводное значение.</value>
<data name="UserWarned" xml:space="preserve">
<value>{0} получил предупреждение</value>
</data>
<data name="UserWarnsRemoved" xml:space="preserve">
<value>{0} больше не имеет предупреждений</value>
</data>
<data name="YouHaveBeenWarned" xml:space="preserve">
<value>Вы получили предупреждение</value>
</data>
<data name="YourWarningsHaveBeenRevoked" xml:space="preserve">
<value>Ваши предупреждения были отозваны</value>
</data>
<data name="DescriptionWarns" xml:space="preserve">
<value>Предупреждений: {0}</value>
</data>
<data name="UserHasNoWarnings" xml:space="preserve">
<value>Этот пользователь не имеет предупреждений!</value>
</data>
<data name="ReceivedTooManyWarnings" xml:space="preserve">
<value>Получил слишком много предупреждений</value>
</data>
<data name="SettingsWarnPunishment" xml:space="preserve">
<value>Тип наказания для предупреждений</value>
</data>
<data name="SettingsWarnThreshold" xml:space="preserve">
<value>Порог предупреждений</value>
</data>
<data name="SettingsWarnPunishmentDuration" xml:space="preserve">
<value>Длительность наказания для предупреждений</value>
</data>
<data name="ListExecutorWarnsTitle" xml:space="preserve">
<value>Вот ваши предупреждения, {0}:</value>
</data>
<data name="YouHaveNoWarnings" xml:space="preserve">
<value>У вас нет предупреждений!</value>
</data>
<data name="ReceivedOn" xml:space="preserve">
<value>Получено {0}</value>
</data>
<data name="UserWarnRemoved" xml:space="preserve">
<value>Предупреждение №{0} было снято с {1}</value>
</data>
<data name="YourWarningHasBeenRevoked" xml:space="preserve">
<value>Ваше предупреждение было отозвано</value>
</data>
<data name="WrongWarningNumberSelected" xml:space="preserve">
<value>Выбрано неверное число предупреждения!</value>
</data>
<data name="UserCannotWarnBot" xml:space="preserve">
<value>Ты не можешь меня предупредить!</value>
</data>
<data name="UserCannotWarnOwner" xml:space="preserve">
<value>Ты не можешь предупредить владельца этого сервера!</value>
</data>
<data name="UserCannotWarnTarget" xml:space="preserve">
<value>Ты не можешь предупредить этого участника!</value>
</data>
<data name="UserCannotWarnThemselves" xml:space="preserve">
<value>Ты не можешь себя предупредить!</value>
</data>
<data name="UserCannotUnwarnBot" xml:space="preserve">
<value>Ты не можешь снять с меня предупреждения!</value>
</data>
<data name="UserCannotUnwarnOwner" xml:space="preserve">
<value>Ты не можешь снять предупреждения с владельца этого сервера!</value>
</data>
<data name="UserCannotUnwarnTarget" xml:space="preserve">
<value>Ты не можешь снять предупреждения с этого участника!</value>
</data>
<data name="UserCannotUnwarnThemselves" xml:space="preserve">
<value>Ты не можешь снять с себя предупреждения!</value>
</data>
<data name="BotCannotUnwarnTarget" xml:space="preserve">
<value>Я не могу снимать предупреждения этого участника!</value>
</data>
<data name="BotCannotWarnTarget" xml:space="preserve">
<value>Я не могу предупредить этого участника!</value>
</data>
<data name="BotCannotWarnMembers" xml:space="preserve">
<value>Я не могу предупреждать участников этого сервера!</value>
</data>
<data name="UserCannotGetWarnsBot" xml:space="preserve">
<value>Ты не можешь просмотреть мои предупреждения!</value>
</data>
<data name="UserCannotGetWarnsOwner" xml:space="preserve">
<value>Ты не можешь просмотреть предупреждения владельца этого сервера!</value>
</data>
<data name="UserCannotGetWarnsTarget" xml:space="preserve">
<value>Ты не можешь просмотреть предупреждения этого участника!</value>
</data>
<data name="UserCannotGetWarnsThemselves" xml:space="preserve">
<value>Вместо этого, используйте эту команду без параметров.</value>
</data>
<data name="UserCannotUnwarnMembers" xml:space="preserve">
<value>Ты не можешь снимать предупреждения с участников этого сервера!</value>
</data>
<data name="UserCannotWarnMembers" xml:space="preserve">
<value>Ты не можешь предупреждать участников этого сервера!</value>
</data>
<data name="UserCannotGetWarnsMembers" xml:space="preserve">
<value>Ты не можешь просматривать предупреждения участников этого сервера!</value>
</data>
<data name="ListTargetWarnsTitle" xml:space="preserve">
<value>Предупреждения пользователя {0}:</value>
</data>
<data name="DescriptionPunishmentType" xml:space="preserve">
<value>Тип наказания: {0}</value>
</data>
<data name="WarnThresholdExceeded" xml:space="preserve">
<value>Превышен порог предупреждений. ({0})</value>
</data>
<data name="WarnPunishmentDurationNotSet" xml:space="preserve">
<value>Длительность наказания предупреждения не установлена для текущего типа наказания.</value>
</data>
<data name="WarnThresholdExceededDescription" xml:space="preserve">
<value>Увеличьте порог предупреждения или установите наказание за предупреждение.</value>
</data>
<data name="InvalidWarnPunishment" xml:space="preserve">
<value>Установлено неверное наказание за предупреждение.</value>
</data>
</root>

View file

@ -39,7 +39,8 @@ public sealed class Program
private static IHostBuilder CreateHostBuilder(string[] args)
{
return Host.CreateDefaultBuilder(args)
.AddDiscordService(services =>
.AddDiscordService(
services =>
{
var configuration = services.GetRequiredService<IConfiguration>();
@ -48,22 +49,25 @@ public sealed class Program
"No bot token has been provided. Set the "
+ "BOT_TOKEN environment variable to a valid token.");
}
).ConfigureServices((_, services) =>
).ConfigureServices(
(_, services) =>
{
services.Configure<DiscordGatewayClientOptions>(options =>
{
options.Intents |= GatewayIntents.MessageContents
| GatewayIntents.GuildMembers
| GatewayIntents.GuildPresences
| GatewayIntents.GuildScheduledEvents;
});
services.Configure<CacheSettings>(cSettings =>
{
cSettings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1));
cSettings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30));
cSettings.SetAbsoluteExpiration<IMessage>(TimeSpan.FromDays(7));
cSettings.SetSlidingExpiration<IMessage>(TimeSpan.FromDays(7));
});
services.Configure<DiscordGatewayClientOptions>(
options =>
{
options.Intents |= GatewayIntents.MessageContents
| GatewayIntents.GuildMembers
| GatewayIntents.GuildPresences
| GatewayIntents.GuildScheduledEvents;
});
services.Configure<CacheSettings>(
cSettings =>
{
cSettings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1));
cSettings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30));
cSettings.SetAbsoluteExpiration<IMessage>(TimeSpan.FromDays(7));
cSettings.SetSlidingExpiration<IMessage>(TimeSpan.FromDays(7));
});
services.AddTransient<IConfigurationBuilder, ConfigurationBuilder>()
// Init
@ -83,13 +87,14 @@ public sealed class Program
.AddHostedService<ScheduledEventUpdateService>()
.AddHostedService<SongUpdateService>();
}
).ConfigureLogging(c => c.AddConsole()
.AddFile("Logs/Octobot-{Date}.log",
outputTemplate: "{Timestamp:o} [{Level:u4}] {Message} {NewLine}{Exception}")
.AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning)
.AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning)
.AddFilter<SerilogLoggerProvider>("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning)
.AddFilter<SerilogLoggerProvider>("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning)
).ConfigureLogging(
c => c.AddConsole()
.AddFile("Logs/Octobot-{Date}.log",
outputTemplate: "{Timestamp:o} [{Level:u4}] {Message} {NewLine}{Exception}")
.AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning)
.AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning)
.AddFilter<SerilogLoggerProvider>("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning)
.AddFilter<SerilogLoggerProvider>("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning)
);
}
}

View file

@ -94,7 +94,7 @@ public sealed class GuildLoadedResponder : IResponder<IGuildCreate>
GuildSettings.PrivateFeedbackChannel.Get(cfg), embedResult: embed, ct: ct);
}
private async Task<Result> SendDataLoadFailed(IGuild guild, GuildData data, IUser bot, CancellationToken ct = default)
private async Task<Result> SendDataLoadFailed(IGuild guild, GuildData data, IUser bot, CancellationToken ct)
{
var channelResult = await _utility.GetEmergencyFeedbackChannel(guild, data, ct);
if (!channelResult.IsDefined(out var channel))
@ -120,6 +120,6 @@ public sealed class GuildLoadedResponder : IResponder<IGuildCreate>
);
return await _channelApi.CreateMessageWithEmbedResultAsync(channel, embedResult: errorEmbed,
components: new[] { new ActionRowComponent([issuesButton]) }, ct: ct);
components: new[] { new ActionRowComponent(new[] { issuesButton }) }, ct: ct);
}
}

View file

@ -81,7 +81,7 @@ public sealed class GuildMemberJoinedResponder : IResponder<IGuildMemberAdd>
}
private async Task<Result> TryReturnRolesAsync(
JsonNode cfg, MemberData memberData, Snowflake guildId, Snowflake userId, CancellationToken ct = default)
JsonNode cfg, MemberData memberData, Snowflake guildId, Snowflake userId, CancellationToken ct)
{
if (!GuildSettings.ReturnRolesOnRejoin.Get(cfg))
{

View file

@ -36,9 +36,13 @@ public sealed class GuildMemberLeftResponder : IResponder<IGuildMemberRemove>
var cfg = data.Settings;
var memberData = data.GetOrCreateMemberData(user.ID);
if (memberData.BannedUntil is not null || memberData.Kicked
|| GuildSettings.WelcomeMessagesChannel.Get(cfg).Empty()
|| GuildSettings.LeaveMessage.Get(cfg) is "off" or "disable" or "disabled")
if (memberData.BannedUntil is not null || memberData.Kicked)
{
return Result.Success;
}
if (GuildSettings.WelcomeMessagesChannel.Get(cfg).Empty()
|| GuildSettings.LeaveMessage.Get(cfg) is "off" or "disable" or "disabled")
{
return Result.Success;
}

View file

@ -66,10 +66,10 @@ public sealed class MessageDeletedResponder : IResponder<IMessageDelete>
return ResultExtensions.FromError(auditLogResult);
}
var deleterResult = Result<IUser>.FromSuccess(message.Author);
var auditLog = auditLogPage.AuditLogEntries.Single();
var auditLog = auditLogPage.AuditLogEntries.SingleOrDefault();
if (auditLog is { UserID: not null }
var deleterResult = Result<IUser>.FromSuccess(message.Author);
if (auditLog.UserID is not null
&& auditLog.Options.Value.ChannelID == gatewayEvent.ChannelID
&& DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2)
{

View file

@ -36,29 +36,40 @@ public sealed class MessageEditedResponder : IResponder<IMessageUpdate>
public async Task<Result> RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default)
{
if (!gatewayEvent.ID.IsDefined(out var messageId))
{
return new ArgumentNullError(nameof(gatewayEvent.ID));
}
if (!gatewayEvent.ChannelID.IsDefined(out var channelId))
{
return new ArgumentNullError(nameof(gatewayEvent.ChannelID));
}
if (!gatewayEvent.GuildID.IsDefined(out var guildId)
|| !gatewayEvent.EditedTimestamp.HasValue
|| gatewayEvent.Author.IsBot.OrDefault(false))
|| !gatewayEvent.Author.IsDefined(out var author)
|| !gatewayEvent.EditedTimestamp.IsDefined(out var timestamp)
|| !gatewayEvent.Content.IsDefined(out var newContent))
{
return Result.Success;
}
var cfg = await _guildData.GetSettings(guildId, ct);
if (GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
if (author.IsBot.OrDefault(false) || GuildSettings.PrivateFeedbackChannel.Get(cfg).Empty())
{
return Result.Success;
}
var cacheKey = new KeyHelpers.MessageCacheKey(gatewayEvent.ChannelID, gatewayEvent.ID);
var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId);
var messageResult = await _cacheService.TryGetValueAsync<IMessage>(
cacheKey, ct);
if (!messageResult.IsDefined(out var message))
{
_ = _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
_ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct);
return Result.Success;
}
if (message.Content == gatewayEvent.Content)
if (message.Content == newContent)
{
return Result.Success;
}
@ -72,22 +83,22 @@ public sealed class MessageEditedResponder : IResponder<IMessageUpdate>
// We don't need to await this since the result is not needed
// NOTE: Because this is not awaited, there may be a race condition depending on how fast clients are able to edit their messages
// NOTE: Awaiting this might not even solve this if the same responder is called asynchronously
_ = _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
_ = _channelApi.GetChannelMessageAsync(channelId, messageId, ct);
var diff = InlineDiffBuilder.Diff(message.Content, gatewayEvent.Content);
var diff = InlineDiffBuilder.Diff(message.Content, newContent);
Messages.Culture = GuildSettings.Language.Get(cfg);
var builder = new StringBuilder()
.AppendLine(diff.AsMarkdown())
.AppendLine(string.Format(Messages.DescriptionActionJumpToMessage,
$"https://discord.com/channels/{guildId}/{gatewayEvent.ChannelID}/{gatewayEvent.ID}")
$"https://discord.com/channels/{guildId}/{channelId}/{messageId}")
);
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author)
.WithDescription(builder.ToString())
.WithTimestamp(gatewayEvent.EditedTimestamp.Value)
.WithTimestamp(timestamp.Value)
.WithColour(ColorsList.Yellow)
.Build();

View file

@ -100,7 +100,8 @@ public sealed class AccessControlService
{
"Ban" => DiscordPermission.BanMembers,
"Kick" => DiscordPermission.KickMembers,
"Mute" or "Unmute" => DiscordPermission.ModerateMembers,
"Mute" or "Unmute" or "Warn" or "Unwarn" or "GetWarns"
=> DiscordPermission.ModerateMembers,
_ => throw new Exception()
});

View file

@ -27,7 +27,7 @@ public sealed class GuildDataService : BackgroundService
return SaveAsync(ct);
}
private Task SaveAsync(CancellationToken ct = default)
private Task SaveAsync(CancellationToken ct)
{
var tasks = new List<Task>();
var datas = _datas.Values.ToArray();
@ -44,7 +44,7 @@ public sealed class GuildDataService : BackgroundService
return Task.WhenAll(tasks);
}
private static async Task SerializeObjectSafelyAsync<T>(T obj, string path, CancellationToken ct = default)
private static async Task SerializeObjectSafelyAsync<T>(T obj, string path, CancellationToken ct)
{
var tempFilePath = path + ".tmp";
await using (var tempFileStream = File.Create(tempFilePath))
@ -75,48 +75,78 @@ public sealed class GuildDataService : BackgroundService
{
var path = $"GuildData/{guildId}";
var memberDataPath = $"{path}/MemberData";
var settingsPath = $"{path}/Settings.json";
var scheduledEventsPath = $"{path}/ScheduledEvents.json";
MigrateDataDirectory(guildId, path);
Directory.CreateDirectory(path);
if (!File.Exists(settingsPath))
{
await File.WriteAllTextAsync(settingsPath, "{}", ct);
}
if (!File.Exists(scheduledEventsPath))
{
await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct);
}
var dataLoadFailed = false;
var jsonSettings = await LoadGuildSettings(settingsPath, ct);
await using var settingsStream = File.OpenRead(settingsPath);
JsonNode? jsonSettings = null;
try
{
jsonSettings = await JsonNode.ParseAsync(settingsStream, cancellationToken: ct);
}
catch (Exception e)
{
_logger.LogError(e, "Guild settings load failed: {Path}", settingsPath);
dataLoadFailed = true;
}
if (jsonSettings is not null)
{
FixJsonSettings(jsonSettings);
}
else
{
dataLoadFailed = true;
}
var events = await LoadScheduledEvents(scheduledEventsPath, ct);
if (events is null)
await using var eventsStream = File.OpenRead(scheduledEventsPath);
Dictionary<ulong, ScheduledEventData>? events = null;
try
{
events = await JsonSerializer.DeserializeAsync<Dictionary<ulong, ScheduledEventData>>(
eventsStream, cancellationToken: ct);
}
catch (Exception e)
{
_logger.LogError(e, "Guild scheduled events load failed: {Path}", scheduledEventsPath);
dataLoadFailed = true;
}
var memberData = new Dictionary<ulong, MemberData>();
foreach (var dataFileInfo in Directory.CreateDirectory(memberDataPath).GetFiles()
.Where(dataFileInfo =>
!memberData.ContainsKey(
ulong.Parse(dataFileInfo.Name.Replace(".json", "").Replace(".tmp", "")))))
foreach (var dataFileInfo in Directory.CreateDirectory(memberDataPath).GetFiles())
{
var data = await LoadMemberData(dataFileInfo, memberDataPath, true, ct);
if (data == null)
await using var dataStream = dataFileInfo.OpenRead();
MemberData? data;
try
{
data = await JsonSerializer.DeserializeAsync<MemberData>(dataStream, cancellationToken: ct);
}
catch (Exception e)
{
_logger.LogError(e, "Member data load failed: {MemberDataPath}/{FileName}", memberDataPath,
dataFileInfo.Name);
dataLoadFailed = true;
continue;
}
memberData.TryAdd(data.Id, data);
if (data is null)
{
continue;
}
memberData.Add(data.Id, data);
}
var finalData = new GuildData(
@ -130,133 +160,6 @@ public sealed class GuildDataService : BackgroundService
return finalData;
}
private async Task<MemberData?> LoadMemberData(FileInfo dataFileInfo, string memberDataPath, bool loadTmp,
CancellationToken ct = default)
{
MemberData? data;
var temporaryPath = $"{dataFileInfo.FullName}.tmp";
var usedInfo = loadTmp && File.Exists(temporaryPath) ? new FileInfo(temporaryPath) : dataFileInfo;
var isTmp = usedInfo.Extension is ".tmp";
try
{
await using var dataStream = usedInfo.OpenRead();
data = await JsonSerializer.DeserializeAsync<MemberData>(dataStream, cancellationToken: ct);
if (isTmp)
{
usedInfo.CopyTo(usedInfo.FullName.Replace(".tmp", ""), true);
usedInfo.Delete();
}
}
catch (Exception e)
{
if (isTmp)
{
_logger.LogWarning(e,
"Unable to load temporary member data file, deleting: {MemberDataPath}/{FileName}", memberDataPath,
usedInfo.Name);
usedInfo.Delete();
return await LoadMemberData(dataFileInfo, memberDataPath, false, ct);
}
_logger.LogError(e, "Member data load failed: {MemberDataPath}/{FileName}", memberDataPath,
usedInfo.Name);
return null;
}
return data;
}
private async Task<Dictionary<ulong, ScheduledEventData>?> LoadScheduledEvents(string scheduledEventsPath,
CancellationToken ct = default)
{
var tempScheduledEventsPath = $"{scheduledEventsPath}.tmp";
if (!File.Exists(scheduledEventsPath) && !File.Exists(tempScheduledEventsPath))
{
return new Dictionary<ulong, ScheduledEventData>();
}
if (File.Exists(tempScheduledEventsPath))
{
_logger.LogWarning("Found temporary scheduled events file, will try to parse and copy to main: ${Path}",
tempScheduledEventsPath);
try
{
await using var tempEventsStream = File.OpenRead(tempScheduledEventsPath);
var events = await JsonSerializer.DeserializeAsync<Dictionary<ulong, ScheduledEventData>>(
tempEventsStream, cancellationToken: ct);
File.Copy(tempScheduledEventsPath, scheduledEventsPath, true);
File.Delete(tempScheduledEventsPath);
_logger.LogInformation("Successfully loaded temporary scheduled events file: ${Path}",
tempScheduledEventsPath);
return events;
}
catch (Exception e)
{
_logger.LogError(e, "Unable to load temporary scheduled events file: {Path}, deleting",
tempScheduledEventsPath);
File.Delete(tempScheduledEventsPath);
}
}
try
{
await using var eventsStream = File.OpenRead(scheduledEventsPath);
return await JsonSerializer.DeserializeAsync<Dictionary<ulong, ScheduledEventData>>(
eventsStream, cancellationToken: ct);
}
catch (Exception e)
{
_logger.LogError(e, "Guild scheduled events load failed: {Path}", scheduledEventsPath);
return null;
}
}
private async Task<JsonNode?> LoadGuildSettings(string settingsPath, CancellationToken ct = default)
{
var tempSettingsPath = $"{settingsPath}.tmp";
if (!File.Exists(settingsPath) && !File.Exists(tempSettingsPath))
{
return new JsonObject();
}
if (File.Exists(tempSettingsPath))
{
_logger.LogWarning("Found temporary settings file, will try to parse and copy to main: ${Path}",
tempSettingsPath);
try
{
await using var tempSettingsStream = File.OpenRead(tempSettingsPath);
var jsonSettings = await JsonNode.ParseAsync(tempSettingsStream, cancellationToken: ct);
File.Copy(tempSettingsPath, settingsPath, true);
File.Delete(tempSettingsPath);
_logger.LogInformation("Successfully loaded temporary settings file: ${Path}", tempSettingsPath);
return jsonSettings;
}
catch (Exception e)
{
_logger.LogError(e, "Unable to load temporary settings file: {Path}, deleting", tempSettingsPath);
File.Delete(tempSettingsPath);
}
}
try
{
await using var settingsStream = File.OpenRead(settingsPath);
return await JsonNode.ParseAsync(settingsStream, cancellationToken: ct);
}
catch (Exception e)
{
_logger.LogError(e, "Guild settings load failed: {Path}", settingsPath);
return null;
}
}
private void MigrateDataDirectory(Snowflake guildId, string newPath)
{
var oldPath = $"{guildId}";

View file

@ -62,7 +62,7 @@ public sealed partial class MemberUpdateService : BackgroundService
}
}
private async Task<Result> TickMemberDatasAsync(Snowflake guildId, CancellationToken ct = default)
private async Task<Result> TickMemberDatasAsync(Snowflake guildId, CancellationToken ct)
{
var guildData = await _guildData.GetData(guildId, ct);
var defaultRole = GuildSettings.DefaultRole.Get(guildData.Settings);
@ -79,7 +79,7 @@ public sealed partial class MemberUpdateService : BackgroundService
private async Task<Result> TickMemberDataAsync(Snowflake guildId, GuildData guildData, Snowflake defaultRole,
MemberData data,
CancellationToken ct = default)
CancellationToken ct)
{
var failedResults = new List<Result>();
var id = data.Id.ToSnowflake();
@ -144,7 +144,7 @@ public sealed partial class MemberUpdateService : BackgroundService
}
private async Task<Result> TryAutoUnbanAsync(
Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct = default)
Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct)
{
if (data.BannedUntil is null || DateTimeOffset.UtcNow <= data.BannedUntil)
{
@ -169,7 +169,7 @@ public sealed partial class MemberUpdateService : BackgroundService
}
private async Task<Result> TryAutoUnmuteAsync(
Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct = default)
Snowflake guildId, Snowflake id, MemberData data, CancellationToken ct)
{
if (data.MutedUntil is null || DateTimeOffset.UtcNow <= data.MutedUntil)
{
@ -188,7 +188,7 @@ public sealed partial class MemberUpdateService : BackgroundService
}
private async Task<Result> FilterNicknameAsync(Snowflake guildId, IUser user, IGuildMember member,
CancellationToken ct = default)
CancellationToken ct)
{
var currentNickname = member.Nickname.IsDefined(out var nickname)
? nickname
@ -226,7 +226,7 @@ public sealed partial class MemberUpdateService : BackgroundService
private static partial Regex IllegalChars();
private async Task<Result> TickReminderAsync(Reminder reminder, IUser user, MemberData data, Snowflake guildId,
CancellationToken ct = default)
CancellationToken ct)
{
if (DateTimeOffset.UtcNow < reminder.At)
{
@ -234,7 +234,7 @@ public sealed partial class MemberUpdateService : BackgroundService
}
var builder = new StringBuilder()
.AppendLine(MarkdownExtensions.Quote(reminder.Text))
.AppendBulletPointLine(string.Format(Messages.DescriptionReminder, Markdown.InlineCode(reminder.Text)))
.AppendBulletPointLine(string.Format(Messages.DescriptionActionJumpToMessage,
$"https://discord.com/channels/{guildId.Value}/{reminder.ChannelId}/{reminder.MessageId}"));

View file

@ -46,7 +46,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
}
}
private async Task<Result> TickScheduledEventsAsync(Snowflake guildId, CancellationToken ct = default)
private async Task<Result> TickScheduledEventsAsync(Snowflake guildId, CancellationToken ct)
{
var failedResults = new List<Result>();
var data = await _guildData.GetData(guildId, ct);
@ -133,7 +133,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
private async Task<Result> TickScheduledEventAsync(
Snowflake guildId, GuildData data, IGuildScheduledEvent scheduledEvent, ScheduledEventData eventData,
CancellationToken ct = default)
CancellationToken ct)
{
if (GuildSettings.AutoStartEvents.Get(data.Settings)
&& DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime
@ -160,7 +160,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
}
private async Task<Result> AutoStartEventAsync(
Snowflake guildId, IGuildScheduledEvent scheduledEvent, CancellationToken ct = default)
Snowflake guildId, IGuildScheduledEvent scheduledEvent, CancellationToken ct)
{
return (Result)await _eventApi.ModifyGuildScheduledEventAsync(
guildId, scheduledEvent.ID,
@ -229,7 +229,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
return await _channelApi.CreateMessageWithEmbedResultAsync(
GuildSettings.EventNotificationChannel.Get(settings), roleMention, embedResult: embed,
components: new[] { new ActionRowComponent([button]) }, ct: ct);
components: new[] { new ActionRowComponent(new[] { button }) }, ct: ct);
}
private static Result<string> GetExternalScheduledEventCreatedEmbedDescription(
@ -319,7 +319,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
}
private async Task<Result> SendScheduledEventCompletedMessage(ScheduledEventData eventData, GuildData data,
CancellationToken ct = default)
CancellationToken ct)
{
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{
@ -351,7 +351,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
}
private async Task<Result> SendScheduledEventCancelledMessage(ScheduledEventData eventData, GuildData data,
CancellationToken ct = default)
CancellationToken ct)
{
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{
@ -405,7 +405,7 @@ public sealed class ScheduledEventUpdateService : BackgroundService
}
private async Task<Result> SendEarlyEventNotificationAsync(
IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct = default)
IGuildScheduledEvent scheduledEvent, GuildData data, CancellationToken ct)
{
if (GuildSettings.EventNotificationChannel.Get(data.Settings).Empty())
{

View file

@ -29,11 +29,7 @@ public sealed class SongUpdateService : BackgroundService
("Callie", "Bomb Rush Blush", new TimeSpan(0, 2, 18)),
("Turquoise October", "Octoling Rendezvous", new TimeSpan(0, 1, 57)),
("Damp Socks feat. Off the Hook", "Tentacle to the Metal", new TimeSpan(0, 2, 51)),
("Off the Hook feat. Dedf1sh", "Spectrum Obligato ~ Ebb & Flow (Out of Order)", new TimeSpan(0, 4, 30)),
("Dedf1sh feat. Off the Hook", "#47 onward", new TimeSpan(0, 4, 40)),
("Free Association", "EchΘ Θnslaught", new TimeSpan(0, 2, 52)),
("Off the Hook", "Short Order", new TimeSpan(0, 3, 36)),
("Deep Cut", "Fins in the Air", new TimeSpan(0, 3, 1))
("Off the Hook", "Fly Octo Fly ~ Ebb & Flow (Octo)", new TimeSpan(0, 3, 5))
];
private static readonly (string Author, string Name, TimeSpan Duration)[] SpecialSongList =
@ -41,7 +37,7 @@ public sealed class SongUpdateService : BackgroundService
("Squid Sisters", "Maritime Memory", new TimeSpan(0, 2, 47))
];
private readonly List<Activity> _activityList = [new("with Remora.Discord", ActivityType.Game)];
private readonly List<Activity> _activityList = [new Activity("with Remora.Discord", ActivityType.Game)];
private readonly DiscordGatewayClient _client;
private readonly GuildDataService _guildData;

View file

@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>2.0.0</Version>
@ -24,14 +24,14 @@
<PackageReference Include="DiffPlex" Version="1.7.2" />
<PackageReference Include="GitInfo" Version="3.3.5" />
<PackageReference Include="Humanizer.Core.ru" Version="2.14.1" />
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0"/>
<PackageReference Include="JetBrains.Annotations" Version="2023.3.0" />
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0"/>
<PackageReference Include="Remora.Commands" Version="11.0.1"/>
<PackageReference Include="Remora.Discord.Caching" Version="40.0.0" />
<PackageReference Include="Remora.Discord.Extensions" Version="6.0.0"/>
<PackageReference Include="Remora.Discord.Hosting" Version="7.0.0" />
<PackageReference Include="Remora.Discord.Interactivity" Version="6.0.0"/>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Remora.Commands" Version="10.0.5" />
<PackageReference Include="Remora.Discord.Caching" Version="39.0.0" />
<PackageReference Include="Remora.Discord.Extensions" Version="5.3.5" />
<PackageReference Include="Remora.Discord.Hosting" Version="6.0.10" />
<PackageReference Include="Remora.Discord.Interactivity" Version="4.5.4" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
</ItemGroup>
<ItemGroup>

View file

@ -67,8 +67,8 @@ public sealed class Utility
builder.Append($"{Mention.Role(role)} ");
}
builder = subscribers.Where(subscriber =>
!data.GetOrCreateMemberData(subscriber.User.ID).Roles.Contains(role.Value))
builder = subscribers.Where(
subscriber => !data.GetOrCreateMemberData(subscriber.User.ID).Roles.Contains(role.Value))
.Aggregate(builder, (current, subscriber) => current.Append($"{Mention.User(subscriber.User)} "));
return builder.ToString();
}
@ -125,7 +125,7 @@ public sealed class Utility
}
}
public async Task<Result<Snowflake>> GetEmergencyFeedbackChannel(IGuild guild, GuildData data, CancellationToken ct = default)
public async Task<Result<Snowflake>> GetEmergencyFeedbackChannel(IGuild guild, GuildData data, CancellationToken ct)
{
var privateFeedback = GuildSettings.PrivateFeedbackChannel.Get(data.Settings);
if (!privateFeedback.Empty())

View file

@ -1,17 +0,0 @@
services:
octobot:
container_name: octobot
build:
context: .
args:
- PUBLISH_OPTIONS
environment:
- BOT_TOKEN
volumes:
- guild-data:/Octobot/GuildData
- logs:/Octobot/Logs
restart: unless-stopped
volumes:
guild-data:
logs:

View file

@ -15,16 +15,23 @@ Veemo! I'm a general-purpose bot for moderation (formerly known as Boyfriend) wr
* Reminding everyone about that new event you made
* Renaming those annoying self-hoisting members
* Log everything from joining the server to deleting messages
* Listen to Inkantation!
* Listen to music!
*...a-a-and more!*
## Building Octobot
Check out the Octobot's Wiki for details.
| [Windows](https://github.com/TeamOctolings/Octobot/wiki/Installing-Windows) | [Linux/macOS](https://github.com/TeamOctolings/Octobot/wiki/Installing-Unix) |
| --- | --- |
1. Install [.NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
2. Go to the [Discord Developer Portal](https://discord.com/developers), create a new application and get a bot token. Don't forget to also enable all intents!
3. Clone this repository and open `Octobot` folder.
```
git clone https://github.com/TeamOctolings/Octobot
cd Octobot
```
4. Run Octobot using `dotnet` with `BOT_TOKEN` variable.
```
dotnet run BOT_TOKEN='ENTER_TOKEN_HERE'
```
## Contributing