1
0
Fork 1
mirror of https://github.com/TeamOctolings/Octobot.git synced 2025-01-31 09:09:00 +03:00

Switch to Remora.Discord (#41)

result checks go brrr

this also involves switching to using Discord's modern stuff like embeds
and interactions

and using brand-new for me programming concepts (dependency injection,
results)

---------

Signed-off-by: Octol1ttle <l1ttleofficial@outlook.com>
Signed-off-by: mctaylors <95250141+mctaylors@users.noreply.github.com>
Co-authored-by: mctaylors <95250141+mctaylors@users.noreply.github.com>
Co-authored-by: nrdk <neroduck@vk.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Octol1ttle 2023-07-09 18:32:14 +05:00 committed by GitHub
parent 2ab7a07784
commit abbb58f801
Signed by: GitHub
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 5011 additions and 3021 deletions

View file

@ -58,6 +58,7 @@ resharper_indent_nested_usings_stmt = true
resharper_indent_nested_while_stmt = true resharper_indent_nested_while_stmt = true
resharper_indent_preprocessor_if = usual_indent resharper_indent_preprocessor_if = usual_indent
resharper_indent_preprocessor_other = usual_indent resharper_indent_preprocessor_other = usual_indent
resharper_int_align_fields = true
resharper_int_align_methods = true resharper_int_align_methods = true
resharper_int_align_parameters = true resharper_int_align_parameters = true
resharper_int_align_properties = true resharper_int_align_properties = true

2
.github/CODEOWNERS vendored
View file

@ -1,4 +1,4 @@
* @TeamOctolings/boyfriend * @TeamOctolings/boyfriend
.github/CODEOWNERS @TeamOctolings/boyfriend-admins .github/CODEOWNERS @TeamOctolings/boyfriend-admins
*.md @mctaylors /docs/ @mctaylors
Messages.tt-ru.resx @mctaylors Messages.tt-ru.resx @mctaylors

76
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View file

@ -0,0 +1,76 @@
name: Bug Report
description: Create a report to help us improve
labels: [ "bug" ]
body:
- type: markdown
attributes:
value: |
We welcome bug reports! Please see our [contribution guidelines](docs/CONTRIBUTING.md#reporting-bugs) for more information on writing a good bug report. This template will help us gather the information we need to start the triage process.
- type: textarea
id: background
attributes:
label: Description
description: Please share a clear and concise description of the problem.
placeholder: Description
validations:
required: true
- type: textarea
id: repro-steps
attributes:
label: Reproduction Steps
description: |
Please include minimal steps to reproduce the problem if possible. E.g.: the smallest possible command/action sequence. If possible include text as text rather than screenshots (so it shows up in searches).
placeholder: Minimal Reproduction
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: |
Provide a description of the expected behavior.
placeholder: Expected behavior
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior
description: |
Provide a description of the actual behavior observed. If applicable please include any error messages, exception stacktraces or memory dumps.
placeholder: Actual behavior
validations:
required: true
- type: textarea
id: known-workarounds
attributes:
label: Known Workarounds
description: |
Please provide a description of any known workarounds.
placeholder: Known Workarounds
validations:
required: false
- type: textarea
id: configuration
attributes:
label: Configuration
description: |
Please provide more information on your configuration:
* Which version of .NET is the bot running on?
* What OS and version, and what distro if applicable?
* What is the architecture (x64, x86, ARM, ARM64)?
* Do you know whether it is specific to that configuration?
* If possible, please provide the Configuration.json for the affected guild
* If applicable, provide the member data JSON for the affected members
placeholder: Configuration
validations:
required: false
- type: textarea
id: other-info
attributes:
label: Other information
description: |
If you have an idea where the problem might lie, let us know that here. Please include any pointers to code, relevant changes, or related issues you know of.
placeholder: Other information
validations:
required: false

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1 @@
blank_issues_enabled: false

View file

@ -0,0 +1,38 @@
name: Feature Request
description: Create a request for a feature you would like
labels: [ "type: enhancement" ]
body:
- type: textarea
id: background
attributes:
label: Description
description: Please share a clear and concise description of the feature you want.
placeholder: Description
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Proposed Solution
description: Please describe the solution you would like.
placeholder: Proposed Solution
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Considered Alternatives
description: |
Please provide a description of any alternative solutions or features you've considered.
placeholder: Considered Alternatives
validations:
required: false
- type: textarea
id: other-info
attributes:
label: Other Information
description: |
Please add any other context or screenshots about the feature request here.
placeholder: Other Information
validations:
required: false

27
.github/README.md vendored
View file

@ -1,27 +0,0 @@
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/95250141/206895339-ef5510c8-8b30-4887-b89c-5dc14a24b18a.png">
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/95250141/206895340-3415d97d-91fd-4fb6-8c17-4e1bf340e1df.png">
<img alt="Boyfriend Logo" src="https://user-images.githubusercontent.com/95250141/206895339-ef5510c8-8b30-4887-b89c-5dc14a24b18a.png">
</picture>
![GitHub License](https://img.shields.io/github/license/TeamOctolings/Boyfriend)
![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/TeamOctolings/Boyfriend/.github/workflows/resharper.yml?branch=master)
![GitHub last commit](https://img.shields.io/github/last-commit/TeamOctolings/Boyfriend)
Beep! I'm a general-purpose bot for moderation written by [@Octol1ttle](https://github.com/Octol1ttle) in C# and Discord.Net
# Features
* Banning, muting, kicking, etc.
* Reminding you about something if you wish
* Reminding everyone about that new event you made
* Log everything from joining the server to deleting messages
*...and more!*
# Getting Started
You can read our [wiki](https://github.com/TeamOctolings/Boyfriend/wiki) in order to assemble your Boyfriend™ and moderate the server.

View file

@ -1,44 +0,0 @@
name: "CodeQL"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
schedule:
- cron: '45 7 * * 2'
jobs:
analyze:
name: Analyze code
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'csharp' ]
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: Build solution
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"

View file

@ -18,9 +18,6 @@ jobs:
contents: read contents: read
security-events: write security-events: write
strategy:
fail-fast: false
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
@ -29,8 +26,8 @@ jobs:
run: dotnet restore run: dotnet restore
- name: ReSharper CLI InspectCode - name: ReSharper CLI InspectCode
uses: muno92/resharper_inspectcode@1.6.13 uses: muno92/resharper_inspectcode@1.7.1
with: with:
solutionPath: ./Boyfriend.sln solutionPath: ./Boyfriend.sln
ignoreIssueType: InvertIf ignoreIssueType: InvertIf, ConvertIfStatementToReturnStatement, ConvertIfStatementToSwitchStatement
solutionWideAnalysis: true solutionWideAnalysis: true

1
.gitignore vendored
View file

@ -1,6 +1,5 @@
.idea/ .idea/
*.user *.user
token.txt
bin/ bin/
obj/ obj/
/packages/ /packages/

View file

@ -1,180 +1,96 @@
using System.Text; using Boyfriend.Commands;
using System.Timers; using Boyfriend.Services;
using Boyfriend.Data; using Microsoft.Extensions.Configuration;
using Discord; using Microsoft.Extensions.DependencyInjection;
using Discord.Rest; using Microsoft.Extensions.Hosting;
using Discord.WebSocket; using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer; using Remora.Commands.Extensions;
using Remora.Discord.API.Abstractions.Gateway.Commands;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Objects;
using Remora.Discord.Caching.Extensions;
using Remora.Discord.Caching.Services;
using Remora.Discord.Commands.Extensions;
using Remora.Discord.Commands.Services;
using Remora.Discord.Gateway;
using Remora.Discord.Gateway.Extensions;
using Remora.Discord.Hosting.Extensions;
using Remora.Discord.Interactivity.Extensions;
using Remora.Rest.Core;
namespace Boyfriend; namespace Boyfriend;
public static class Boyfriend { public class Boyfriend {
public static readonly StringBuilder StringBuilder = new(); public static readonly AllowedMentions NoMentions = new(
Array.Empty<MentionType>(), Array.Empty<Snowflake>(), Array.Empty<Snowflake>());
private static readonly DiscordSocketConfig Config = new() { public static async Task Main(string[] args) {
MessageCacheSize = 250, var host = CreateHostBuilder(args).UseConsoleLifetime().Build();
GatewayIntents var services = host.Services;
= (GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent | GatewayIntents.GuildMembers)
& ~GatewayIntents.GuildInvites,
AlwaysDownloadUsers = true,
AlwaysResolveStickers = false,
AlwaysDownloadDefaultStickers = false,
LargeThreshold = 500
};
private static DateTimeOffset _nextSongAt = DateTimeOffset.MinValue; var slashService = services.GetRequiredService<SlashService>();
private static uint _nextSongIndex; // Providing a guild ID to this call will result in command duplicates!
// To get rid of them, provide the ID of the guild containing duplicates,
// comment out calls to WithCommandGroup in CreateHostBuilder
// then launch the bot again and remove the guild ID
await slashService.UpdateSlashCommandsAsync();
private static readonly (Game Song, TimeSpan Duration)[] ActivityList = { await host.RunAsync();
(new Game("Masayoshi Minoshima (ft. nomico) - Bad Apple!!", ActivityType.Listening), new TimeSpan(0, 3, 40)),
(new Game("Xi - Blue Zenith", ActivityType.Listening), new TimeSpan(0, 4, 16)),
(new Game("UNDEAD CORPORATION - Everything will freeze", ActivityType.Listening), new TimeSpan(0, 3, 18)),
(new Game("Splatoon 3 - Candy-Coated Rocks", ActivityType.Listening), new TimeSpan(0, 2, 39)),
(new Game("RetroSpecter - Overtime", ActivityType.Listening), new TimeSpan(0, 4, 33)),
(new Game("SOOOO - Happppy song", ActivityType.Listening), new TimeSpan(0, 5, 24))
};
public static readonly DiscordSocketClient Client = new(Config);
private static readonly List<Task> GuildTickTasks = new();
private static async Task Main() {
var token = (await File.ReadAllTextAsync("token.txt")).Trim();
Client.Log += Log;
await Client.LoginAsync(TokenType.Bot, token);
await Client.StartAsync();
EventHandler.InitEvents();
var timer = new Timer();
timer.Interval = 1000;
timer.AutoReset = true;
timer.Elapsed += TickAllGuildsAsync;
if (ActivityList.Length is 0) timer.Dispose(); // CodeQL moment
timer.Start();
await Task.Delay(-1);
} }
private static async void TickAllGuildsAsync(object? sender, ElapsedEventArgs e) { private static IHostBuilder CreateHostBuilder(string[] args) {
if (GuildTickTasks.Count is not 0) return; return Host.CreateDefaultBuilder(args)
.AddDiscordService(
services => {
var configuration = services.GetRequiredService<IConfiguration>();
var now = DateTimeOffset.UtcNow; return configuration.GetValue<string?>("BOT_TOKEN")
foreach (var guild in Client.Guilds) GuildTickTasks.Add(TickGuildAsync(guild, now)); ?? throw new InvalidOperationException(
"No bot token has been provided. Set the "
+ "BOT_TOKEN environment variable to a valid token.");
}
).ConfigureServices(
(_, services) => {
services.Configure<DiscordGatewayClientOptions>(
options => options.Intents |= GatewayIntents.MessageContents
| GatewayIntents.GuildMembers
| GatewayIntents.GuildScheduledEvents);
services.Configure<CacheSettings>(
settings => {
settings.SetDefaultAbsoluteExpiration(TimeSpan.FromHours(1));
settings.SetDefaultSlidingExpiration(TimeSpan.FromMinutes(30));
settings.SetAbsoluteExpiration<IMessage>(TimeSpan.FromDays(7));
settings.SetSlidingExpiration<IMessage>(TimeSpan.FromDays(7));
});
if (now >= _nextSongAt) { services.AddTransient<IConfigurationBuilder, ConfigurationBuilder>()
var nextSong = ActivityList[_nextSongIndex]; .AddDiscordCaching()
await Client.SetActivityAsync(nextSong.Song); .AddDiscordCommands(true)
_nextSongAt = now.Add(nextSong.Duration); .AddPreparationErrorEvent<ErrorLoggingPreparationErrorEvent>()
_nextSongIndex++; .AddPostExecutionEvent<ErrorLoggingPostExecutionEvent>()
if (_nextSongIndex >= ActivityList.Length) _nextSongIndex = 0; .AddInteractivity()
} .AddInteractionGroup<InteractionResponders>()
.AddSingleton<GuildDataService>()
try { Task.WaitAll(GuildTickTasks.ToArray()); } catch (AggregateException ex) { .AddSingleton<UtilityService>()
foreach (var exc in ex.InnerExceptions) .AddHostedService<GuildUpdateService>()
await Log( .AddCommandTree()
new LogMessage( .WithCommandGroup<AboutCommandGroup>()
LogSeverity.Error, nameof(Boyfriend), .WithCommandGroup<BanCommandGroup>()
"Exception while ticking guilds", exc)); .WithCommandGroup<ClearCommandGroup>()
} .WithCommandGroup<KickCommandGroup>()
.WithCommandGroup<MuteCommandGroup>()
GuildTickTasks.Clear(); .WithCommandGroup<PingCommandGroup>()
} .WithCommandGroup<RemindCommandGroup>()
.WithCommandGroup<SettingsCommandGroup>();
public static Task Log(LogMessage msg) { var responderTypes = typeof(Boyfriend).Assembly
switch (msg.Severity) { .GetExportedTypes()
case LogSeverity.Critical: .Where(t => t.IsResponder());
Console.ForegroundColor = ConsoleColor.DarkRed; foreach (var responderType in responderTypes) services.AddResponder(responderType);
Console.Error.WriteLine(msg.ToString()); }
break; ).ConfigureLogging(
case LogSeverity.Error: c => c.AddConsole()
Console.ForegroundColor = ConsoleColor.Red; .AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning)
Console.Error.WriteLine(msg.ToString()); .AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning)
break; );
case LogSeverity.Warning:
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine(msg.ToString());
break;
case LogSeverity.Info:
Console.WriteLine(msg.ToString());
break;
case LogSeverity.Verbose:
case LogSeverity.Debug:
default: return Task.CompletedTask;
}
Console.ResetColor();
return Task.CompletedTask;
}
private static async Task TickGuildAsync(SocketGuild guild, DateTimeOffset now) {
var data = GuildData.Get(guild);
var config = data.Preferences;
var saveData = false;
_ = int.TryParse(config["EventEarlyNotificationOffset"], out var offset);
foreach (var schEvent in guild.Events)
if (schEvent.Status is GuildScheduledEventStatus.Scheduled
&& config["AutoStartEvents"] is "true"
&& DateTimeOffset
.Now
>= schEvent.StartTime) await schEvent.StartAsync();
else if (!data.EarlyNotifications.Contains(schEvent.Id)
&& now >= schEvent.StartTime.Subtract(new TimeSpan(0, offset, 0))) {
data.EarlyNotifications.Add(schEvent.Id);
var receivers = config["EventStartedReceivers"];
var role = guild.GetRole(ulong.Parse(config["EventNotificationRole"]));
var mentions = StringBuilder;
if (receivers.Contains("role") && role is not null) mentions.Append($"{role.Mention} ");
if (receivers.Contains("users") || receivers.Contains("interested"))
mentions = (await schEvent.GetUsersAsync(15))
.Where(user => role is null || !((RestGuildUser)user).RoleIds.Contains(role.Id))
.Aggregate(mentions, (current, user) => current.Append($"{user.Mention} "));
await Utils.GetEventNotificationChannel(guild)?.SendMessageAsync(
string.Format(
Messages.EventEarlyNotification,
mentions,
Utils.Wrap(schEvent.Name),
schEvent.StartTime.ToUnixTimeSeconds().ToString()))!;
mentions.Clear();
}
foreach (var mData in data.MemberData.Values) {
var user = guild.GetUser(mData.Id);
if (now >= mData.BannedUntil && await guild.GetBanAsync(mData.Id) is not null)
_ = guild.RemoveBanAsync(mData.Id);
if (!mData.IsInGuild) continue;
if (mData.MutedUntil is null
&& ulong.TryParse(config["StarterRole"], out var starterRoleId)
&& guild.GetRole(starterRoleId) is not null
&& !mData.Roles.Contains(starterRoleId)) _ = user.AddRoleAsync(starterRoleId);
if (now >= mData.MutedUntil) {
saveData = await Utils.UnmuteMemberAsync(
data, Client.CurrentUser.ToString(), user,
Messages.PunishmentExpired);
}
for (var i = mData.Reminders.Count - 1; i >= 0; i--) {
var reminder = mData.Reminders[i];
if (now < reminder.RemindAt) continue;
var channel = guild.GetTextChannel(reminder.ReminderChannel);
var toSend = $"{ReplyEmojis.Reminder} {user.Mention} {Utils.Wrap(reminder.ReminderText)}";
if (channel is not null)
await channel.SendMessageAsync(toSend);
else
await Utils.SendDirectMessage(user, toSend);
mData.Reminders.RemoveAt(i);
saveData = true;
}
}
if (saveData) await data.Save(true);
} }
} }

View file

@ -5,9 +5,9 @@
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>1.0.0</Version> <Version>2.0.0</Version>
<Title>Boyfriend</Title> <Title>Boyfriend</Title>
<Authors>Octol1ttle, mctaylors</Authors> <Authors>Octol1ttle, mctaylors, neroduckale</Authors>
<Copyright>AGPLv3</Copyright> <Copyright>AGPLv3</Copyright>
<PackageProjectUrl>https://github.com/TeamOctolings/Boyfriend</PackageProjectUrl> <PackageProjectUrl>https://github.com/TeamOctolings/Boyfriend</PackageProjectUrl>
<PackageLicenseUrl>https://github.com/TeamOctolings/Boyfriend/blob/master/LICENSE</PackageLicenseUrl> <PackageLicenseUrl>https://github.com/TeamOctolings/Boyfriend/blob/master/LICENSE</PackageLicenseUrl>
@ -19,8 +19,31 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Discord.Net" Version="3.10.0"/> <PackageReference Include="DiffPlex" Version="1.7.1" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" /> <PackageReference Include="Humanizer.Core.ru" Version="2.14.1" />
<PackageReference Include="Humanizer.Core.ru" Version="2.14.1" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1" />
<PackageReference Include="Remora.Discord" Version="2023.3.0" />
</ItemGroup>
<!-- TODO: remove this when done -->
<ItemGroup>
<Compile Remove="old\**" />
<Compile Update="Messages.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Messages.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Remove="old\**" />
<EmbeddedResource Update="Messages.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Messages.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<None Remove="old\**" />
</ItemGroup> </ItemGroup>
</Project> </Project>

18
ColorsList.cs Normal file
View file

@ -0,0 +1,18 @@
using System.Drawing;
namespace Boyfriend;
/// <summary>
/// Contains all colors used in embeds.
/// </summary>
public static class ColorsList {
public static readonly Color Default = Color.Gray;
public static readonly Color Red = Color.Firebrick;
public static readonly Color Green = Color.PaleGreen;
public static readonly Color Yellow = Color.Gold;
public static readonly Color Blue = Color.RoyalBlue;
public static readonly Color Magenta = Color.Orchid;
public static readonly Color Cyan = Color.LightSkyBlue;
public static readonly Color Black = Color.Black;
public static readonly Color White = Color.WhiteSmoke;
}

View file

@ -1,321 +0,0 @@
using System.Text;
using Boyfriend.Commands;
using Boyfriend.Data;
using Discord;
using Discord.Commands;
using Discord.WebSocket;
namespace Boyfriend;
public sealed class CommandProcessor {
private static readonly string Mention = $"<@{Boyfriend.Client.CurrentUser.Id}>";
private static readonly TimeSpan Infinity = TimeSpan.FromMilliseconds(-1);
public static readonly ICommand[] Commands = {
new BanCommand(), new ClearCommand(), new HelpCommand(),
new KickCommand(), new MuteCommand(), new PingCommand(),
new RemindCommand(), new SettingsCommand(), new UnbanCommand(),
new UnmuteCommand()
};
private readonly StringBuilder _stackedPrivateFeedback = new();
private readonly StringBuilder _stackedPublicFeedback = new();
private readonly StringBuilder _stackedReplyMessage = new();
private readonly List<Task> _tasks = new();
public readonly SocketCommandContext Context;
public bool ConfigWriteScheduled = false;
public CommandProcessor(SocketUserMessage message) {
Context = new SocketCommandContext(Boyfriend.Client, message);
}
public async Task HandleCommandAsync() {
var guild = Context.Guild;
var data = GuildData.Get(guild);
Utils.SetCurrentLanguage(guild);
var list = Context.Message.Content.Split("\n");
var cleanList = Context.Message.CleanContent.Split("\n");
for (var i = 0; i < list.Length; i++)
_tasks.Add(RunCommandOnLine(list[i], cleanList[i], data.Preferences["Prefix"]));
try { Task.WaitAll(_tasks.ToArray()); } catch (AggregateException e) {
foreach (var ex in e.InnerExceptions)
await Boyfriend.Log(
new LogMessage(
LogSeverity.Error, nameof(CommandProcessor),
"Exception while executing commands", ex));
}
_tasks.Clear();
if (ConfigWriteScheduled) await data.Save(true);
SendFeedbacks();
}
private async Task RunCommandOnLine(string line, string cleanLine, string prefix) {
var prefixed = line.StartsWith(prefix);
if (!prefixed && !line.StartsWith(Mention)) return;
foreach (var command in Commands) {
var lineNoMention = line.Remove(0, prefixed ? prefix.Length : Mention.Length);
if (!command.Aliases.Contains(lineNoMention.Trim().Split()[0])) continue;
var args = lineNoMention.Trim().Split().Skip(1).ToArray();
var cleanArgs = cleanLine.Split().Skip(lineNoMention.StartsWith(" ") ? 2 : 1).ToArray();
await command.RunAsync(this, args, cleanArgs);
if (_stackedReplyMessage.Length > 0) _ = Context.Channel.TriggerTypingAsync();
return;
}
}
public void Reply(string response, string? customEmoji = null) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage, $"{customEmoji ?? ReplyEmojis.Success} {response}",
Context.Message);
}
public void Audit(string action, bool isPublic = true) {
var format = $"*[{Context.User.Mention}: {action}]*";
var data = GuildData.Get(Context.Guild);
if (isPublic) Utils.SafeAppendToBuilder(_stackedPublicFeedback, format, data.PublicFeedbackChannel);
Utils.SafeAppendToBuilder(_stackedPrivateFeedback, format, data.PrivateFeedbackChannel);
if (_tasks.Count is 0) SendFeedbacks(false);
}
private void SendFeedbacks(bool reply = true) {
var hasReply = _stackedReplyMessage.Length > 0;
if (reply && hasReply)
_ = Context.Message.ReplyAsync(_stackedReplyMessage.ToString(), false, null, AllowedMentions.None);
var data = GuildData.Get(Context.Guild);
var adminChannel = data.PrivateFeedbackChannel;
var systemChannel = data.PublicFeedbackChannel;
if (_stackedPrivateFeedback.Length > 0
&& adminChannel is not null
&& (adminChannel.Id != Context.Message.Channel.Id || !hasReply)) {
_ = Utils.SilentSendAsync(adminChannel, _stackedPrivateFeedback.ToString());
_stackedPrivateFeedback.Clear();
}
if (_stackedPublicFeedback.Length > 0
&& systemChannel is not null
&& systemChannel.Id != adminChannel?.Id
&& (systemChannel.Id != Context.Message.Channel.Id || !hasReply)) {
_ = Utils.SilentSendAsync(systemChannel, _stackedPublicFeedback.ToString());
_stackedPublicFeedback.Clear();
}
}
public string? GetRemaining(string[] from, int startIndex, string? argument) {
if (startIndex >= from.Length && argument is not null)
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.MissingArgument} {Utils.GetMessage($"Missing{argument}")}", Context.Message);
else return string.Join(" ", from, startIndex, from.Length - startIndex);
return null;
}
public (ulong Id, SocketUser? User)? GetUser(string[] args, string[] cleanArgs, int index) {
if (index >= args.Length) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage, $"{ReplyEmojis.MissingArgument} {Messages.MissingUser}",
Context.Message);
return null;
}
var mention = Utils.ParseMention(args[index]);
if (mention is 0) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.InvalidArgument} {string.Format(Messages.InvalidUser, Utils.Wrap(cleanArgs[index]))}",
Context.Message);
return null;
}
var exists = Utils.UserExists(mention);
if (!exists) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.Error} {string.Format(Messages.UserNotFound, Utils.Wrap(cleanArgs[index]))}",
Context.Message);
return null;
}
return (mention, Boyfriend.Client.GetUser(mention));
}
public bool HasPermission(GuildPermission permission) {
if (!Context.Guild.CurrentUser.GuildPermissions.Has(permission)) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.NoPermission} {Utils.GetMessage($"BotCannot{permission}")}",
Context.Message);
return false;
}
if (!GetMember().GuildPermissions.Has(permission)
&& Context.Guild.OwnerId != Context.User.Id) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.NoPermission} {Utils.GetMessage($"UserCannot{permission}")}",
Context.Message);
return false;
}
return true;
}
private SocketGuildUser GetMember() {
return GetMember(Context.User.Id)!;
}
public SocketGuildUser? GetMember(ulong id) {
return Context.Guild.GetUser(id);
}
public SocketGuildUser? GetMember(string[] args, int index) {
if (index >= args.Length) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage, $"{ReplyEmojis.MissingArgument} {Messages.MissingMember}",
Context.Message);
return null;
}
var member = Context.Guild.GetUser(Utils.ParseMention(args[index]));
if (member is null)
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.InvalidArgument} {Messages.InvalidMember}",
Context.Message);
return member;
}
public ulong? GetBan(string[] args, int index) {
if (index >= args.Length) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage, $"{ReplyEmojis.MissingArgument} {Messages.MissingUser}",
Context.Message);
return null;
}
var id = Utils.ParseMention(args[index]);
if (Context.Guild.GetBanAsync(id) is null) {
Utils.SafeAppendToBuilder(_stackedReplyMessage, Messages.UserNotBanned, Context.Message);
return null;
}
return id;
}
public int? GetNumberRange(string[] args, int index, int min, int max, string? argument) {
if (index >= args.Length) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.MissingArgument} {string.Format(Messages.MissingNumber, min.ToString(), max.ToString())}",
Context.Message);
return null;
}
if (!int.TryParse(args[index], out var i)) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.InvalidArgument} {string.Format(Utils.GetMessage($"{argument}Invalid"), min.ToString(), max.ToString(), Utils.Wrap(args[index]))}",
Context.Message);
return null;
}
if (argument is null) return i;
if (i < min) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.InvalidArgument} {string.Format(Utils.GetMessage($"{argument}TooSmall"), min.ToString())}",
Context.Message);
return null;
}
if (i > max) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.InvalidArgument} {string.Format(Utils.GetMessage($"{argument}TooLarge"), max.ToString())}",
Context.Message);
return null;
}
return i;
}
public static TimeSpan GetTimeSpan(string[] args, int index) {
if (index >= args.Length) return Infinity;
var chars = args[index].AsSpan();
var numberBuilder = Boyfriend.StringBuilder;
int days = 0, hours = 0, minutes = 0, seconds = 0;
foreach (var c in chars)
if (char.IsDigit(c)) { numberBuilder.Append(c); } else {
if (numberBuilder.Length is 0) return Infinity;
switch (c) {
case 'd' or 'D' or 'д' or 'Д':
days += int.Parse(numberBuilder.ToString());
numberBuilder.Clear();
break;
case 'h' or 'H' or 'ч' or 'Ч':
hours += int.Parse(numberBuilder.ToString());
numberBuilder.Clear();
break;
case 'm' or 'M' or 'м' or 'М':
minutes += int.Parse(numberBuilder.ToString());
numberBuilder.Clear();
break;
case 's' or 'S' or 'с' or 'С':
seconds += int.Parse(numberBuilder.ToString());
numberBuilder.Clear();
break;
default: return Infinity;
}
}
numberBuilder.Clear();
return new TimeSpan(days, hours, minutes, seconds);
}
public bool CanInteractWith(SocketGuildUser user, string action) {
if (Context.User.Id == user.Id) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.CantInteract} {Utils.GetMessage($"UserCannot{action}Themselves")}", Context.Message);
return false;
}
if (Context.Guild.CurrentUser.Id == user.Id) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.CantInteract} {Utils.GetMessage($"UserCannot{action}Bot")}", Context.Message);
return false;
}
if (Context.Guild.Owner.Id == user.Id) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.CantInteract} {Utils.GetMessage($"UserCannot{action}Owner")}", Context.Message);
return false;
}
if (Context.Guild.CurrentUser.Hierarchy <= user.Hierarchy) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.CantInteract} {Utils.GetMessage($"BotCannot{action}Target")}", Context.Message);
return false;
}
if (Context.Guild.Owner.Id != Context.User.Id && GetMember().Hierarchy <= user.Hierarchy) {
Utils.SafeAppendToBuilder(
_stackedReplyMessage,
$"{ReplyEmojis.CantInteract} {Utils.GetMessage($"UserCannot{action}Target")}", Context.Message);
return false;
}
return true;
}
}

View file

@ -0,0 +1,75 @@
using System.ComponentModel;
using System.Text;
using Boyfriend.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands;
/// <summary>
/// Handles the command to show information about this bot: /about.
/// </summary>
public class AboutCommandGroup : CommandGroup {
private static readonly string[] Developers = { "Octol1ttle", "mctaylors", "neroduckale" };
private readonly ICommandContext _context;
private readonly GuildDataService _dataService;
private readonly FeedbackService _feedbackService;
private readonly IDiscordRestUserAPI _userApi;
public AboutCommandGroup(
ICommandContext context, GuildDataService dataService,
FeedbackService feedbackService, IDiscordRestUserAPI userApi) {
_context = context;
_dataService = dataService;
_feedbackService = feedbackService;
_userApi = userApi;
}
/// <summary>
/// A slash command that shows information about this bot.
/// </summary>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("about")]
[Description("Shows Boyfriend's developers")]
public async Task<Result> SendAboutBotAsync() {
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
return Result.FromError(
new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context"));
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult);
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
Messages.Culture = cfg.GetCulture();
var builder = new StringBuilder().AppendLine(Markdown.Bold(Messages.AboutTitleDevelopers));
foreach (var dev in Developers)
builder.AppendLine($"@{dev} — {$"AboutDeveloper@{dev}".Localized()}");
builder.AppendLine()
.AppendLine(Markdown.Bold(Messages.AboutTitleWiki))
.AppendLine("https://github.com/TeamOctolings/Boyfriend/wiki");
var embed = new EmbedBuilder().WithSmallTitle(Messages.AboutBot, currentUser)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Cyan)
.WithImageUrl(
"https://media.discordapp.net/attachments/837385840946053181/1125009665592393738/boyfriend.png")
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken);
}
}

View file

@ -1,48 +0,0 @@
using Boyfriend.Data;
using Discord;
using Discord.WebSocket;
namespace Boyfriend.Commands;
public sealed class BanCommand : ICommand {
public string[] Aliases { get; } = { "ban", "бан" };
public async Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) {
var toBan = cmd.GetUser(args, cleanArgs, 0);
if (toBan is null || !cmd.HasPermission(GuildPermission.BanMembers)) return;
var memberToBan = cmd.GetMember(toBan.Value.Id);
if (memberToBan is not null && !cmd.CanInteractWith(memberToBan, "Ban")) return;
var duration = CommandProcessor.GetTimeSpan(args, 1);
var reason = cmd.GetRemaining(args, duration.TotalSeconds < 1 ? 1 : 2, "BanReason");
if (reason is not null) await BanUserAsync(cmd, toBan.Value, duration, reason);
}
private static async Task BanUserAsync(
CommandProcessor cmd, (ulong Id, SocketUser? User) toBan, TimeSpan duration,
string reason) {
var author = cmd.Context.User;
var guild = cmd.Context.Guild;
if (toBan.User is not null)
await Utils.SendDirectMessage(
toBan.User,
string.Format(Messages.YouWereBanned, author.Mention, guild.Name, Utils.Wrap(reason)));
var guildBanMessage = $"({author}) {reason}";
await guild.AddBanAsync(toBan.Id, 0, guildBanMessage);
var memberData = GuildData.Get(guild).MemberData[toBan.Id];
memberData.BannedUntil
= duration.TotalSeconds < 1 ? DateTimeOffset.MaxValue : DateTimeOffset.UtcNow.Add(duration);
memberData.Roles.Clear();
cmd.ConfigWriteScheduled = true;
var feedback = string.Format(
Messages.FeedbackUserBanned, $"<@{toBan.Id.ToString()}>",
Utils.GetHumanizedTimeSpan(duration), Utils.Wrap(reason));
cmd.Reply(feedback, ReplyEmojis.Banned);
cmd.Audit(feedback);
}
}

275
Commands/BanCommandGroup.cs Normal file
View file

@ -0,0 +1,275 @@
using System.ComponentModel;
using System.Text;
using Boyfriend.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
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.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands;
/// <summary>
/// Handles commands related to ban management: /ban and /unban.
/// </summary>
public class BanCommandGroup : CommandGroup {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context;
private readonly GuildDataService _dataService;
private readonly FeedbackService _feedbackService;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly IDiscordRestUserAPI _userApi;
private readonly UtilityService _utility;
public BanCommandGroup(
ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService dataService,
FeedbackService feedbackService, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi,
UtilityService utility) {
_context = context;
_channelApi = channelApi;
_dataService = dataService;
_feedbackService = feedbackService;
_guildApi = guildApi;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// A slash command that bans a Discord user with the specified reason.
/// </summary>
/// <param name="target">The user to ban.</param>
/// <param name="duration">The duration for this ban. The user will be automatically unbanned after this duration.</param>
/// <param name="reason">
/// The reason for this ban. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.CreateGuildBanAsync" />.
/// </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.
/// </returns>
/// <seealso cref="UnbanUserAsync" />
[Command("ban", "бан")]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.BanMembers)]
[RequireBotDiscordPermissions(DiscordPermission.BanMembers)]
[Description("Ban user")]
public async Task<Result> BanUserAsync(
[Description("User to ban")] IUser target,
[Description("Ban reason")] string reason,
[Description("Ban duration")]
TimeSpan? duration = null) {
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
return Result.FromError(
new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context"));
// The current user's avatar is used when sending error messages
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult);
var data = await _dataService.GetData(guildId.Value, CancellationToken);
var cfg = data.Configuration;
Messages.Culture = data.Culture;
var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken);
if (existingBanResult.IsDefined()) {
var embed = new EmbedBuilder().WithSmallTitle(Messages.UserAlreadyBanned, currentUser)
.WithColour(ColorsList.Red).Build();
if (!embed.IsDefined(out var alreadyBuilt))
return Result.FromError(embed);
return (Result)await _feedbackService.SendContextualEmbedAsync(alreadyBuilt, ct: CancellationToken);
}
var interactionResult
= await _utility.CheckInteractionsAsync(guildId.Value, userId.Value, target.ID, "Ban", CancellationToken);
if (!interactionResult.IsSuccess)
return Result.FromError(interactionResult);
Result<Embed> responseEmbed;
if (interactionResult.Entity is not null) {
responseEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser)
.WithColour(ColorsList.Red).Build();
} else {
var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken);
if (!userResult.IsDefined(out var user))
return Result.FromError(userResult);
var builder = new StringBuilder().AppendLine(string.Format(Messages.DescriptionActionReason, reason));
if (duration is not null)
builder.Append(
string.Format(
Messages.DescriptionActionExpiresAt,
Markdown.Timestamp(DateTimeOffset.UtcNow.Add(duration.Value))));
var description = builder.ToString();
var dmChannelResult = await _userApi.CreateDMAsync(target.ID, CancellationToken);
if (dmChannelResult.IsDefined(out var dmChannel)) {
var guildResult = await _guildApi.GetGuildAsync(guildId.Value, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild))
return Result.FromError(guildResult);
var dmEmbed = new EmbedBuilder().WithGuildTitle(guild)
.WithTitle(Messages.YouWereBanned)
.WithDescription(description)
.WithActionFooter(user)
.WithCurrentTimestamp()
.WithColour(ColorsList.Red)
.Build();
if (!dmEmbed.IsDefined(out var dmBuilt))
return Result.FromError(dmEmbed);
await _channelApi.CreateMessageAsync(dmChannel.ID, embeds: new[] { dmBuilt }, ct: CancellationToken);
}
var banResult = await _guildApi.CreateGuildBanAsync(
guildId.Value, target.ID, reason: $"({user.GetTag()}) {reason}".EncodeHeader(),
ct: CancellationToken);
if (!banResult.IsSuccess)
return Result.FromError(banResult.Error);
var memberData = data.GetMemberData(target.ID);
memberData.BannedUntil
= duration is not null ? DateTimeOffset.UtcNow.Add(duration.Value) : DateTimeOffset.MaxValue;
memberData.Roles.Clear();
responseEmbed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserBanned, target.GetTag()), target)
.WithColour(ColorsList.Green).Build();
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value)
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) {
var logEmbed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserBanned, target.GetTag()), target)
.WithDescription(description)
.WithActionFooter(user)
.WithCurrentTimestamp()
.WithColour(ColorsList.Red)
.Build();
if (!logEmbed.IsDefined(out var logBuilt))
return Result.FromError(logEmbed);
var builtArray = new[] { logBuilt };
// Not awaiting to reduce response time
if (cfg.PublicFeedbackChannel != channelId.Value)
_ = _channelApi.CreateMessageAsync(
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
ct: CancellationToken);
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel
&& cfg.PrivateFeedbackChannel != channelId.Value)
_ = _channelApi.CreateMessageAsync(
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
ct: CancellationToken);
}
}
if (!responseEmbed.IsDefined(out var built))
return Result.FromError(responseEmbed);
return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken);
}
/// <summary>
/// A slash command that unbans a Discord user with the specified reason.
/// </summary>
/// <param name="target">The user to unban.</param>
/// <param name="reason">
/// The reason for this unban. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.RemoveGuildBanAsync" />.
/// </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.
/// </returns>
/// <seealso cref="BanUserAsync" />
/// <seealso cref="GuildUpdateService.TickGuildAsync"/>
[Command("unban")]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.BanMembers)]
[RequireBotDiscordPermissions(DiscordPermission.BanMembers)]
[Description("Unban user")]
public async Task<Result> UnbanUserAsync(
[Description("User to unban")] IUser target,
[Description("Unban reason")]
string reason) {
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
return Result.FromError(
new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context"));
// The current user's avatar is used when sending error messages
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult);
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
Messages.Culture = cfg.GetCulture();
var existingBanResult = await _guildApi.GetGuildBanAsync(guildId.Value, target.ID, CancellationToken);
if (!existingBanResult.IsDefined()) {
var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotBanned, currentUser)
.WithColour(ColorsList.Red).Build();
if (!embed.IsDefined(out var alreadyBuilt))
return Result.FromError(embed);
return (Result)await _feedbackService.SendContextualEmbedAsync(alreadyBuilt, ct: CancellationToken);
}
// Needed to get the tag and avatar
var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken);
if (!userResult.IsDefined(out var user))
return Result.FromError(userResult);
var unbanResult = await _guildApi.RemoveGuildBanAsync(
guildId.Value, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(),
ct: CancellationToken);
if (!unbanResult.IsSuccess)
return Result.FromError(unbanResult.Error);
var responseEmbed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserUnbanned, target.GetTag()), target)
.WithColour(ColorsList.Green).Build();
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value)
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) {
var logEmbed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserUnbanned, target.GetTag()), target)
.WithDescription(string.Format(Messages.DescriptionActionReason, reason))
.WithActionFooter(user)
.WithCurrentTimestamp()
.WithColour(ColorsList.Green)
.Build();
if (!logEmbed.IsDefined(out var logBuilt))
return Result.FromError(logEmbed);
var builtArray = new[] { logBuilt };
// Not awaiting to reduce response time
if (cfg.PublicFeedbackChannel != channelId.Value)
_ = _channelApi.CreateMessageAsync(
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
ct: CancellationToken);
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel
&& cfg.PrivateFeedbackChannel != channelId.Value)
_ = _channelApi.CreateMessageAsync(
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
ct: CancellationToken);
}
if (!responseEmbed.IsDefined(out var built))
return Result.FromError(responseEmbed);
return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken);
}
}

View file

@ -1,30 +0,0 @@
using System.Diagnostics;
using Discord;
using Discord.WebSocket;
namespace Boyfriend.Commands;
public sealed class ClearCommand : ICommand {
public string[] Aliases { get; } = { "clear", "purge", "очистить", "стереть" };
public async Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) {
if (cmd.Context.Channel is not SocketTextChannel channel) throw new UnreachableException();
if (!cmd.HasPermission(GuildPermission.ManageMessages)) return;
var toDelete = cmd.GetNumberRange(cleanArgs, 0, 1, 200, "ClearAmount");
if (toDelete is null) return;
var messages = await channel.GetMessagesAsync((int)(toDelete + 1)).FlattenAsync();
var user = (SocketGuildUser)cmd.Context.User;
var msgArray = messages.Reverse().ToArray();
await channel.DeleteMessagesAsync(msgArray, Utils.GetRequestOptions(user.ToString()!));
foreach (var msg in msgArray.Where(m => !m.Author.IsBot))
cmd.Audit(
string.Format(
Messages.CachedMessageCleared, msg.Author.Mention,
Utils.MentionChannel(channel.Id),
Utils.Wrap(msg.CleanContent)), false);
}
}

View file

@ -0,0 +1,120 @@
using System.ComponentModel;
using System.Text;
using Boyfriend.Services;
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;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands;
/// <summary>
/// Handles the command to clear messages in a channel: /clear.
/// </summary>
public class ClearCommandGroup : CommandGroup {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context;
private readonly GuildDataService _dataService;
private readonly FeedbackService _feedbackService;
private readonly IDiscordRestUserAPI _userApi;
public ClearCommandGroup(
IDiscordRestChannelAPI channelApi, ICommandContext context, GuildDataService dataService,
FeedbackService feedbackService, IDiscordRestUserAPI userApi) {
_channelApi = channelApi;
_context = context;
_dataService = dataService;
_feedbackService = feedbackService;
_userApi = userApi;
}
/// <summary>
/// A slash command that clears messages in the channel it was executed.
/// </summary>
/// <param name="amount">The amount of messages to clear.</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.
/// </returns>
[Command("clear", "очистить")]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ManageMessages)]
[RequireBotDiscordPermissions(DiscordPermission.ManageMessages)]
[Description("Remove multiple messages")]
public async Task<Result> ClearMessagesAsync(
[Description("Number of messages to remove (2-100)")] [MinValue(2)] [MaxValue(100)]
int amount) {
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
return Result.FromError(
new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context"));
var messagesResult = await _channelApi.GetChannelMessagesAsync(
channelId.Value, limit: amount + 1, ct: CancellationToken);
if (!messagesResult.IsDefined(out var messages))
return Result.FromError(messagesResult);
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
Messages.Culture = cfg.GetCulture();
var idList = new List<Snowflake>(messages.Count);
var builder = new StringBuilder().AppendLine(Mention.Channel(channelId.Value)).AppendLine();
for (var i = messages.Count - 1; i >= 1; i--) { // '>= 1' to skip last message ('Boyfriend is thinking...')
var message = messages[i];
idList.Add(message.ID);
builder.AppendLine(string.Format(Messages.MessageFrom, Mention.User(message.Author)));
builder.Append(message.Content.InBlockCode());
}
var description = builder.ToString();
var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken);
if (!userResult.IsDefined(out var user))
return Result.FromError(userResult);
var deleteResult = await _channelApi.BulkDeleteMessagesAsync(
channelId.Value, idList, user.GetTag().EncodeHeader(), CancellationToken);
if (!deleteResult.IsSuccess)
return Result.FromError(deleteResult.Error);
// The current user's avatar is used when sending messages
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult);
var title = string.Format(Messages.MessagesCleared, amount.ToString());
if (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value) {
var logEmbed = new EmbedBuilder().WithSmallTitle(title, currentUser)
.WithDescription(description)
.WithActionFooter(user)
.WithCurrentTimestamp()
.WithColour(ColorsList.Red)
.Build();
if (!logEmbed.IsDefined(out var logBuilt))
return Result.FromError(logEmbed);
// Not awaiting to reduce response time
if (cfg.PrivateFeedbackChannel != channelId.Value)
_ = _channelApi.CreateMessageAsync(
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { logBuilt },
ct: CancellationToken);
}
var embed = new EmbedBuilder().WithSmallTitle(title, currentUser)
.WithColour(ColorsList.Green).Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken);
}
}

View file

@ -0,0 +1,62 @@
using Microsoft.Extensions.Logging;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Services;
using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
namespace Boyfriend.Commands;
/// <summary>
/// Handles error logging for slash commands that couldn't be successfully prepared.
/// </summary>
public class ErrorLoggingPreparationErrorEvent : IPreparationErrorEvent {
private readonly ILogger<ErrorLoggingPreparationErrorEvent> _logger;
public ErrorLoggingPreparationErrorEvent(ILogger<ErrorLoggingPreparationErrorEvent> logger) {
_logger = logger;
}
/// <summary>
/// Logs a warning using the injected <see cref="ILogger" /> if the <paramref name="preparationResult" /> has not
/// succeeded.
/// </summary>
/// <param name="context">The context of the slash command. Unused.</param>
/// <param name="preparationResult">The result whose success is checked.</param>
/// <param name="ct">The cancellation token for this operation. Unused.</param>
/// <returns>A result which has succeeded.</returns>
public Task<Result> PreparationFailed(
IOperationContext context, IResult preparationResult, CancellationToken ct = default) {
if (!preparationResult.IsSuccess)
_logger.LogWarning("Error in slash command preparation.\n{ErrorMessage}", preparationResult.Error.Message);
return Task.FromResult(Result.FromSuccess());
}
}
/// <summary>
/// Handles error logging for slash command groups.
/// </summary>
public class ErrorLoggingPostExecutionEvent : IPostExecutionEvent {
private readonly ILogger<ErrorLoggingPostExecutionEvent> _logger;
public ErrorLoggingPostExecutionEvent(ILogger<ErrorLoggingPostExecutionEvent> logger) {
_logger = logger;
}
/// <summary>
/// Logs a warning using the injected <see cref="ILogger" /> if the <paramref name="commandResult" /> has not
/// succeeded.
/// </summary>
/// <param name="context">The context of the slash command. Unused.</param>
/// <param name="commandResult">The result whose success is checked.</param>
/// <param name="ct">The cancellation token for this operation. Unused.</param>
/// <returns>A result which has succeeded.</returns>
public Task<Result> AfterExecutionAsync(
ICommandContext context, IResult commandResult, CancellationToken ct = default) {
if (!commandResult.IsSuccess)
_logger.LogWarning("Error in slash command execution.\n{ErrorMessage}", commandResult.Error.Message);
return Task.FromResult(Result.FromSuccess());
}
}

View file

@ -1,21 +0,0 @@
using Boyfriend.Data;
using Humanizer;
namespace Boyfriend.Commands;
public sealed class HelpCommand : ICommand {
public string[] Aliases { get; } = { "help", "помощь", "справка" };
public Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) {
var prefix = GuildData.Get(cmd.Context.Guild).Preferences["Prefix"];
var toSend = Boyfriend.StringBuilder.Append(Messages.CommandHelp);
foreach (var command in CommandProcessor.Commands)
toSend.Append(
$"\n`{prefix}{command.Aliases[0]}`: {Utils.GetMessage($"CommandDescription{command.Aliases[0].Titleize()}")}");
cmd.Reply(toSend.ToString(), ReplyEmojis.Help);
toSend.Clear();
return Task.CompletedTask;
}
}

View file

@ -1,7 +0,0 @@
namespace Boyfriend.Commands;
public interface ICommand {
public string[] Aliases { get; }
public Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs);
}

View file

@ -1,34 +0,0 @@
using Boyfriend.Data;
using Discord;
using Discord.WebSocket;
namespace Boyfriend.Commands;
public sealed class KickCommand : ICommand {
public string[] Aliases { get; } = { "kick", "кик", "выгнать" };
public async Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) {
var toKick = cmd.GetMember(args, 0);
if (toKick is null || !cmd.HasPermission(GuildPermission.KickMembers)) return;
if (cmd.CanInteractWith(toKick, "Kick"))
await KickMemberAsync(cmd, toKick, cmd.GetRemaining(args, 1, "KickReason"));
}
private static async Task KickMemberAsync(CommandProcessor cmd, SocketGuildUser toKick, string? reason) {
if (reason is null) return;
var guildKickMessage = $"({cmd.Context.User}) {reason}";
await Utils.SendDirectMessage(toKick,
string.Format(Messages.YouWereKicked, cmd.Context.User.Mention, cmd.Context.Guild.Name,
Utils.Wrap(reason)));
GuildData.Get(cmd.Context.Guild).MemberData[toKick.Id].Roles.Clear();
cmd.ConfigWriteScheduled = true;
await toKick.KickAsync(guildKickMessage);
var format = string.Format(Messages.FeedbackMemberKicked, toKick.Mention, Utils.Wrap(reason));
cmd.Reply(format, ReplyEmojis.Kicked);
cmd.Audit(format);
}
}

View file

@ -0,0 +1,164 @@
using System.ComponentModel;
using Boyfriend.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Conditions;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands;
/// <summary>
/// Handles the command to kick members of a guild: /kick.
/// </summary>
public class KickCommandGroup : CommandGroup {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context;
private readonly GuildDataService _dataService;
private readonly FeedbackService _feedbackService;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly IDiscordRestUserAPI _userApi;
private readonly UtilityService _utility;
public KickCommandGroup(
ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService dataService,
FeedbackService feedbackService, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi,
UtilityService utility) {
_context = context;
_channelApi = channelApi;
_dataService = dataService;
_feedbackService = feedbackService;
_guildApi = guildApi;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// A slash command that kicks a Discord user with the specified reason.
/// </summary>
/// <param name="target">The user to kick.</param>
/// <param name="reason">
/// The reason for this kick. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.RemoveGuildMemberAsync" />.
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user
/// was kicked and vice-versa.
/// </returns>
[Command("kick", "кик")]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.KickMembers)]
[RequireBotDiscordPermissions(DiscordPermission.KickMembers)]
[Description("Kick member")]
public async Task<Result> KickUserAsync(
[Description("Member to kick")] IUser target,
[Description("Kick reason")] string reason) {
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
return Result.FromError(
new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context"));
// The current user's avatar is used when sending error messages
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult);
var data = await _dataService.GetData(guildId.Value, CancellationToken);
var cfg = data.Configuration;
Messages.Culture = cfg.GetCulture();
var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken);
if (!memberResult.IsSuccess) {
var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, currentUser)
.WithColour(ColorsList.Red).Build();
if (!embed.IsDefined(out var alreadyBuilt))
return Result.FromError(embed);
return (Result)await _feedbackService.SendContextualEmbedAsync(alreadyBuilt, ct: CancellationToken);
}
var interactionResult
= await _utility.CheckInteractionsAsync(guildId.Value, userId.Value, target.ID, "Kick", CancellationToken);
if (!interactionResult.IsSuccess)
return Result.FromError(interactionResult);
Result<Embed> responseEmbed;
if (interactionResult.Entity is not null) {
responseEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser)
.WithColour(ColorsList.Red).Build();
} else {
var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken);
if (!userResult.IsDefined(out var user))
return Result.FromError(userResult);
var dmChannelResult = await _userApi.CreateDMAsync(target.ID, CancellationToken);
if (dmChannelResult.IsDefined(out var dmChannel)) {
var guildResult = await _guildApi.GetGuildAsync(guildId.Value, ct: CancellationToken);
if (!guildResult.IsDefined(out var guild))
return Result.FromError(guildResult);
var dmEmbed = new EmbedBuilder().WithGuildTitle(guild)
.WithTitle(Messages.YouWereKicked)
.WithDescription(string.Format(Messages.DescriptionActionReason, reason))
.WithActionFooter(user)
.WithCurrentTimestamp()
.WithColour(ColorsList.Red)
.Build();
if (!dmEmbed.IsDefined(out var dmBuilt))
return Result.FromError(dmEmbed);
await _channelApi.CreateMessageAsync(dmChannel.ID, embeds: new[] { dmBuilt }, ct: CancellationToken);
}
var kickResult = await _guildApi.RemoveGuildMemberAsync(
guildId.Value, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(),
ct: CancellationToken);
if (!kickResult.IsSuccess)
return Result.FromError(kickResult.Error);
data.GetMemberData(target.ID).Roles.Clear();
responseEmbed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserKicked, target.GetTag()), target)
.WithColour(ColorsList.Green).Build();
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value)
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) {
var logEmbed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserKicked, target.GetTag()), target)
.WithDescription(string.Format(Messages.DescriptionActionReason, reason))
.WithActionFooter(user)
.WithCurrentTimestamp()
.WithColour(ColorsList.Red)
.Build();
if (!logEmbed.IsDefined(out var logBuilt))
return Result.FromError(logEmbed);
var builtArray = new[] { logBuilt };
// Not awaiting to reduce response time
if (cfg.PublicFeedbackChannel != channelId.Value)
_ = _channelApi.CreateMessageAsync(
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
ct: CancellationToken);
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel
&& cfg.PrivateFeedbackChannel != channelId.Value)
_ = _channelApi.CreateMessageAsync(
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
ct: CancellationToken);
}
}
if (!responseEmbed.IsDefined(out var built))
return Result.FromError(responseEmbed);
return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken);
}
}

View file

@ -1,71 +0,0 @@
using Boyfriend.Data;
using Discord;
namespace Boyfriend.Commands;
public sealed class MuteCommand : ICommand {
public string[] Aliases { get; } = { "mute", "timeout", "заглушить", "мут" };
public async Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) {
var toMute = cmd.GetMember(args, 0);
if (toMute is null) return;
var duration = CommandProcessor.GetTimeSpan(args, 1);
var reason = cmd.GetRemaining(args, duration.TotalSeconds < 1 ? 1 : 2, "MuteReason");
if (reason is null) return;
var guildData = GuildData.Get(cmd.Context.Guild);
var role = guildData.MuteRole;
if ((role is not null && toMute.Roles.Contains(role))
|| (toMute.TimedOutUntil is not null
&& toMute.TimedOutUntil.Value
> DateTimeOffset.UtcNow)) {
cmd.Reply(Messages.MemberAlreadyMuted, ReplyEmojis.Error);
return;
}
if (cmd.HasPermission(GuildPermission.ModerateMembers) && cmd.CanInteractWith(toMute, "Mute"))
await MuteMemberAsync(cmd, toMute, duration, guildData, reason);
}
private static async Task MuteMemberAsync(
CommandProcessor cmd, IGuildUser toMute,
TimeSpan duration, GuildData data, string reason) {
var requestOptions = Utils.GetRequestOptions($"({cmd.Context.User}) {reason}");
var role = data.MuteRole;
var hasDuration = duration.TotalSeconds > 0;
var memberData = data.MemberData[toMute.Id];
if (role is not null) {
memberData.MutedUntil = DateTimeOffset.UtcNow.Add(duration);
if (data.Preferences["RemoveRolesOnMute"] is "true") {
memberData.Roles = toMute.RoleIds.ToList();
memberData.Roles.Remove(cmd.Context.Guild.Id);
await toMute.RemoveRolesAsync(memberData.Roles, requestOptions);
}
await toMute.AddRoleAsync(role, requestOptions);
} else {
if (!hasDuration || duration.TotalDays > 28) {
cmd.Reply(Messages.DurationRequiredForTimeOuts, ReplyEmojis.Error);
return;
}
if (toMute.IsBot) {
cmd.Reply(Messages.CannotTimeOutBot, ReplyEmojis.Error);
return;
}
await toMute.SetTimeOutAsync(duration, requestOptions);
}
cmd.ConfigWriteScheduled = true;
var feedback = string.Format(
Messages.FeedbackMemberMuted, toMute.Mention,
Utils.GetHumanizedTimeSpan(duration),
Utils.Wrap(reason));
cmd.Reply(feedback, ReplyEmojis.Muted);
cmd.Audit(feedback);
}
}

View file

@ -0,0 +1,258 @@
using System.ComponentModel;
using System.Text;
using Boyfriend.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
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.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands;
/// <summary>
/// Handles commands related to mute management: /mute and /unmute.
/// </summary>
public class MuteCommandGroup : CommandGroup {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly ICommandContext _context;
private readonly GuildDataService _dataService;
private readonly FeedbackService _feedbackService;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly IDiscordRestUserAPI _userApi;
private readonly UtilityService _utility;
public MuteCommandGroup(
ICommandContext context, IDiscordRestChannelAPI channelApi, GuildDataService dataService,
FeedbackService feedbackService, IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi,
UtilityService utility) {
_context = context;
_channelApi = channelApi;
_dataService = dataService;
_feedbackService = feedbackService;
_guildApi = guildApi;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// A slash command that mutes a Discord user with the specified reason.
/// </summary>
/// <param name="target">The user to mute.</param>
/// <param name="duration">The duration for this mute. The user will be automatically unmuted after this duration.</param>
/// <param name="reason">
/// The reason for this mute. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.ModifyGuildMemberAsync" />.
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user
/// was muted and vice-versa.
/// </returns>
/// <seealso cref="UnmuteUserAsync" />
[Command("mute", "мут")]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ModerateMembers)]
[RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)]
[Description("Mute member")]
public async Task<Result> MuteUserAsync(
[Description("Member to mute")] IUser target,
[Description("Mute reason")] string reason,
[Description("Mute duration")]
TimeSpan duration) {
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
return Result.FromError(
new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context"));
// The current user's avatar is used when sending error messages
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult);
var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken);
if (!memberResult.IsSuccess) {
var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, currentUser)
.WithColour(ColorsList.Red).Build();
if (!embed.IsDefined(out var alreadyBuilt))
return Result.FromError(embed);
return (Result)await _feedbackService.SendContextualEmbedAsync(alreadyBuilt, ct: CancellationToken);
}
var interactionResult
= await _utility.CheckInteractionsAsync(
guildId.Value, userId.Value, target.ID, "Mute", CancellationToken);
if (!interactionResult.IsSuccess)
return Result.FromError(interactionResult);
var data = await _dataService.GetData(guildId.Value, CancellationToken);
var cfg = data.Configuration;
Messages.Culture = data.Culture;
Result<Embed> responseEmbed;
if (interactionResult.Entity is not null) {
responseEmbed = new EmbedBuilder().WithSmallTitle(interactionResult.Entity, currentUser)
.WithColour(ColorsList.Red).Build();
} else {
var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken);
if (!userResult.IsDefined(out var user))
return Result.FromError(userResult);
var until = DateTimeOffset.UtcNow.Add(duration); // >:)
var muteResult = await _guildApi.ModifyGuildMemberAsync(
guildId.Value, target.ID, reason: $"({user.GetTag()}) {reason}".EncodeHeader(),
communicationDisabledUntil: until, ct: CancellationToken);
if (!muteResult.IsSuccess)
return Result.FromError(muteResult.Error);
responseEmbed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserMuted, target.GetTag()), target)
.WithColour(ColorsList.Green).Build();
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value)
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) {
var builder = new StringBuilder().AppendLine(string.Format(Messages.DescriptionActionReason, reason))
.Append(
string.Format(
Messages.DescriptionActionExpiresAt, Markdown.Timestamp(until)));
var logEmbed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserMuted, target.GetTag()), target)
.WithDescription(builder.ToString())
.WithActionFooter(user)
.WithCurrentTimestamp()
.WithColour(ColorsList.Red)
.Build();
if (!logEmbed.IsDefined(out var logBuilt))
return Result.FromError(logEmbed);
var builtArray = new[] { logBuilt };
// Not awaiting to reduce response time
if (cfg.PublicFeedbackChannel != channelId.Value)
_ = _channelApi.CreateMessageAsync(
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
ct: CancellationToken);
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel
&& cfg.PrivateFeedbackChannel != channelId.Value)
_ = _channelApi.CreateMessageAsync(
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
ct: CancellationToken);
}
}
if (!responseEmbed.IsDefined(out var built))
return Result.FromError(responseEmbed);
return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken);
}
/// <summary>
/// A slash command that unmutes a Discord user with the specified reason.
/// </summary>
/// <param name="target">The user to unmute.</param>
/// <param name="reason">
/// The reason for this unmute. Must be encoded with <see cref="Extensions.EncodeHeader" /> when passed to
/// <see cref="IDiscordRestGuildAPI.ModifyGuildMemberAsync" />.
/// </param>
/// <returns>
/// A feedback sending result which may or may not have succeeded. A successful result does not mean that the user
/// was unmuted and vice-versa.
/// </returns>
/// <seealso cref="MuteUserAsync" />
/// <seealso cref="GuildUpdateService.TickGuildAsync"/>
[Command("unmute", "размут")]
[RequireContext(ChannelContext.Guild)]
[RequireDiscordPermission(DiscordPermission.ModerateMembers)]
[RequireBotDiscordPermissions(DiscordPermission.ModerateMembers)]
[Description("Unmute member")]
public async Task<Result> UnmuteUserAsync(
[Description("Member to unmute")]
IUser target,
[Description("Unmute reason")]
string reason) {
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
return Result.FromError(
new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context"));
// The current user's avatar is used when sending error messages
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult);
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
Messages.Culture = cfg.GetCulture();
var memberResult = await _guildApi.GetGuildMemberAsync(guildId.Value, target.ID, CancellationToken);
if (!memberResult.IsSuccess) {
var embed = new EmbedBuilder().WithSmallTitle(Messages.UserNotFoundShort, currentUser)
.WithColour(ColorsList.Red).Build();
if (!embed.IsDefined(out var alreadyBuilt))
return Result.FromError(embed);
return (Result)await _feedbackService.SendContextualEmbedAsync(alreadyBuilt, ct: CancellationToken);
}
var interactionResult
= await _utility.CheckInteractionsAsync(
guildId.Value, userId.Value, target.ID, "Unmute", CancellationToken);
if (!interactionResult.IsSuccess)
return Result.FromError(interactionResult);
// Needed to get the tag and avatar
var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken);
if (!userResult.IsDefined(out var user))
return Result.FromError(userResult);
var unmuteResult = await _guildApi.ModifyGuildMemberAsync(
guildId.Value, target.ID, $"({user.GetTag()}) {reason}".EncodeHeader(),
communicationDisabledUntil: null, ct: CancellationToken);
if (!unmuteResult.IsSuccess)
return Result.FromError(unmuteResult.Error);
var responseEmbed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserUnmuted, target.GetTag()), target)
.WithColour(ColorsList.Green).Build();
if ((cfg.PublicFeedbackChannel is not 0 && cfg.PublicFeedbackChannel != channelId.Value)
|| (cfg.PrivateFeedbackChannel is not 0 && cfg.PrivateFeedbackChannel != channelId.Value)) {
var logEmbed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.UserUnmuted, target.GetTag()), target)
.WithDescription(string.Format(Messages.DescriptionActionReason, reason))
.WithActionFooter(user)
.WithCurrentTimestamp()
.WithColour(ColorsList.Green)
.Build();
if (!logEmbed.IsDefined(out var logBuilt))
return Result.FromError(logEmbed);
var builtArray = new[] { logBuilt };
// Not awaiting to reduce response time
if (cfg.PublicFeedbackChannel != channelId.Value)
_ = _channelApi.CreateMessageAsync(
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
ct: CancellationToken);
if (cfg.PrivateFeedbackChannel != cfg.PublicFeedbackChannel
&& cfg.PrivateFeedbackChannel != channelId.Value)
_ = _channelApi.CreateMessageAsync(
cfg.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: builtArray,
ct: CancellationToken);
}
if (!responseEmbed.IsDefined(out var built))
return Result.FromError(responseEmbed);
return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken);
}
}

View file

@ -1,19 +0,0 @@
namespace Boyfriend.Commands;
public sealed class PingCommand : ICommand {
public string[] Aliases { get; } = { "ping", "latency", "pong", "пинг", "задержка", "понг" };
public Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) {
var builder = Boyfriend.StringBuilder;
builder.Append(Utils.GetBeep())
.Append(
Math.Round(Math.Abs(DateTimeOffset.UtcNow.Subtract(cmd.Context.Message.Timestamp).TotalMilliseconds)))
.Append(Messages.Milliseconds);
cmd.Reply(builder.ToString(), ReplyEmojis.Ping);
builder.Clear();
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,79 @@
using System.ComponentModel;
using Boyfriend.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway;
using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands;
/// <summary>
/// Handles the command to get the time taken for the gateway to respond to the last heartbeat: /ping
/// </summary>
public class PingCommandGroup : CommandGroup {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly DiscordGatewayClient _client;
private readonly ICommandContext _context;
private readonly GuildDataService _dataService;
private readonly FeedbackService _feedbackService;
private readonly IDiscordRestUserAPI _userApi;
public PingCommandGroup(
IDiscordRestChannelAPI channelApi, ICommandContext context, DiscordGatewayClient client,
GuildDataService dataService, FeedbackService feedbackService, IDiscordRestUserAPI userApi) {
_channelApi = channelApi;
_context = context;
_client = client;
_dataService = dataService;
_feedbackService = feedbackService;
_userApi = userApi;
}
/// <summary>
/// A slash command that shows time taken for the gateway to respond to the last heartbeat.
/// </summary>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("ping", "пинг")]
[Description("Get bot latency")]
public async Task<Result> SendPingAsync() {
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out _))
return Result.FromError(
new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context"));
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult);
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
Messages.Culture = cfg.GetCulture();
var latency = _client.Latency.TotalMilliseconds;
if (latency is 0) {
// No heartbeat has occurred, estimate latency from local time and "Boyfriend is thinking..." message
var lastMessageResult = await _channelApi.GetChannelMessagesAsync(
channelId.Value, limit: 1, ct: CancellationToken);
if (!lastMessageResult.IsDefined(out var lastMessage))
return Result.FromError(lastMessageResult);
latency = DateTimeOffset.UtcNow.Subtract(lastMessage.Single().Timestamp).TotalMilliseconds;
}
var embed = new EmbedBuilder().WithSmallTitle(currentUser.GetTag(), currentUser)
.WithTitle($"Beep{Random.Shared.Next(1, 4)}".Localized())
.WithDescription($"{latency:F0}{Messages.Milliseconds}")
.WithColour(latency < 250 ? ColorsList.Green : latency < 500 ? ColorsList.Yellow : ColorsList.Red)
.WithCurrentTimestamp()
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken);
}
}

View file

@ -1,34 +0,0 @@
using Boyfriend.Data;
namespace Boyfriend.Commands;
public sealed class RemindCommand : ICommand {
public string[] Aliases { get; } = { "remind", "reminder", "remindme", "напомни", "напомнить", "напоминание" };
public Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) {
// TODO: actually make this good
var remindIn = CommandProcessor.GetTimeSpan(args, 0);
if (remindIn.TotalSeconds < 1) {
cmd.Reply(Messages.InvalidRemindIn, ReplyEmojis.InvalidArgument);
return Task.CompletedTask;
}
var reminderText = cmd.GetRemaining(cleanArgs, 1, "ReminderText");
if (reminderText is not null) {
var reminderOffset = DateTimeOffset.UtcNow.Add(remindIn);
GuildData.Get(cmd.Context.Guild).MemberData[cmd.Context.User.Id].Reminders.Add(
new Reminder {
RemindAt = reminderOffset,
ReminderText = reminderText,
ReminderChannel = cmd.Context.Channel.Id
});
cmd.ConfigWriteScheduled = true;
var feedback = string.Format(Messages.FeedbackReminderAdded, reminderOffset.ToUnixTimeSeconds().ToString());
cmd.Reply(feedback, ReplyEmojis.Reminder);
}
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,66 @@
using System.ComponentModel;
using Boyfriend.Data;
using Boyfriend.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands;
/// <summary>
/// Handles the command to manage reminders: /remind
/// </summary>
public class RemindCommandGroup : CommandGroup {
private readonly ICommandContext _context;
private readonly GuildDataService _dataService;
private readonly FeedbackService _feedbackService;
private readonly IDiscordRestUserAPI _userApi;
public RemindCommandGroup(
ICommandContext context, GuildDataService dataService, FeedbackService feedbackService,
IDiscordRestUserAPI userApi) {
_context = context;
_dataService = dataService;
_feedbackService = feedbackService;
_userApi = userApi;
}
[Command("remind")]
[Description("Create a reminder")]
public async Task<Result> AddReminderAsync(TimeSpan duration, string text) {
if (!_context.TryGetContextIDs(out var guildId, out var channelId, out var userId))
return Result.FromError(
new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context"));
var userResult = await _userApi.GetUserAsync(userId.Value, CancellationToken);
if (!userResult.IsDefined(out var user))
return Result.FromError(userResult);
var remindAt = DateTimeOffset.UtcNow.Add(duration);
(await _dataService.GetMemberData(guildId.Value, userId.Value, CancellationToken)).Reminders.Add(
new Reminder {
RemindAt = remindAt,
Channel = channelId.Value,
Text = text
});
var embed = new EmbedBuilder().WithSmallTitle(string.Format(Messages.ReminderCreated, user.GetTag()), user)
.WithDescription(string.Format(Messages.DescriptionReminderCreated, Markdown.Timestamp(remindAt)))
.WithColour(ColorsList.Green)
.Build();
if (!embed.IsDefined(out var built))
return Result.FromError(embed);
return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken);
}
}

View file

@ -1,164 +0,0 @@
using Boyfriend.Data;
using Discord;
namespace Boyfriend.Commands;
public sealed class SettingsCommand : ICommand {
public string[] Aliases { get; } = { "settings", "config", "настройки", "конфиг" };
public Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) {
if (!cmd.HasPermission(GuildPermission.ManageGuild)) return Task.CompletedTask;
var guild = cmd.Context.Guild;
var data = GuildData.Get(guild);
var config = data.Preferences;
if (args.Length is 0) {
var currentSettings = Boyfriend.StringBuilder.AppendLine(Messages.CurrentSettings);
foreach (var setting in GuildData.DefaultPreferences) {
var format = "{0}";
var currentValue = config[setting.Key] is "default"
? Messages.DefaultWelcomeMessage
: config[setting.Key];
if (setting.Key.EndsWith("Channel")) {
if (guild.GetTextChannel(ulong.Parse(currentValue)) is not null) format = "<#{0}>";
else currentValue = Messages.ChannelNotSpecified;
} else if (setting.Key.EndsWith("Role")) {
if (guild.GetRole(ulong.Parse(currentValue)) is not null) format = "<@&{0}>";
else currentValue = Messages.RoleNotSpecified;
} else {
if (!IsBool(currentValue)) format = Utils.Wrap("{0}")!;
else currentValue = YesOrNo(currentValue is "true");
}
currentSettings.Append($"{Utils.GetMessage($"Settings{setting.Key}")} (`{setting.Key}`): ")
.AppendFormat(format, currentValue).AppendLine();
}
cmd.Reply(currentSettings.ToString(), ReplyEmojis.SettingsList);
currentSettings.Clear();
return Task.CompletedTask;
}
var selectedSetting = args[0].ToLower();
var exists = false;
foreach (var setting in GuildData.DefaultPreferences.Keys.Where(x => x.ToLower() == selectedSetting)) {
selectedSetting = setting;
exists = true;
break;
}
if (!exists) {
cmd.Reply(Messages.SettingDoesntExist, ReplyEmojis.Error);
return Task.CompletedTask;
}
string? value;
if (args.Length >= 2) {
value = cmd.GetRemaining(args, 1, "Setting");
if (value is null) return Task.CompletedTask;
if (selectedSetting is "EventStartedReceivers") {
value = value.Replace(" ", "").ToLower();
if (value.StartsWith(",")
|| value.Count(x => x is ',') > 1
|| (!value.Contains("interested") && !value.Contains("users") && !value.Contains("role"))) {
cmd.Reply(Messages.InvalidSettingValue, ReplyEmojis.Error);
return Task.CompletedTask;
}
}
} else { value = "reset"; }
if (IsBool(GuildData.DefaultPreferences[selectedSetting]) && !IsBool(value)) {
value = value switch {
"y" or "yes" or "д" or "да" => "true", "n" or "no" or "н" or "нет" => "false", _ => value
};
if (!IsBool(value)) {
cmd.Reply(Messages.InvalidSettingValue, ReplyEmojis.Error);
return Task.CompletedTask;
}
}
var localizedSelectedSetting = Utils.GetMessage($"Settings{selectedSetting}");
var mention = Utils.ParseMention(value);
if (mention is not 0 && selectedSetting is not "WelcomeMessage") value = mention.ToString();
var formatting = Utils.Wrap("{0}")!;
if (selectedSetting is not "WelcomeMessage") {
if (selectedSetting.EndsWith("Channel")) formatting = "<#{0}>";
if (selectedSetting.EndsWith("Role")) formatting = "<@&{0}>";
}
var formattedValue = selectedSetting switch {
"WelcomeMessage" => Utils.Wrap(Messages.DefaultWelcomeMessage),
"EventStartedReceivers" => Utils.Wrap(GuildData.DefaultPreferences[selectedSetting])!,
_ => value is "reset" or "default" ? Messages.SettingNotDefined
: IsBool(value) ? YesOrNo(value is "true")
: string.Format(formatting, value)
};
if (value is "reset" or "default") {
config[selectedSetting] = GuildData.DefaultPreferences[selectedSetting];
} else {
if (value == config[selectedSetting]) {
cmd.Reply(
string.Format(Messages.SettingsNothingChanged, localizedSelectedSetting, formattedValue),
ReplyEmojis.Error);
return Task.CompletedTask;
}
if (selectedSetting is "Lang" && !Utils.CultureInfoCache.ContainsKey(value)) {
var langNotSupported = Boyfriend.StringBuilder.Append($"{Messages.LanguageNotSupported} ");
foreach (var lang in Utils.CultureInfoCache) langNotSupported.Append($"`{lang.Key}`, ");
langNotSupported.Remove(langNotSupported.Length - 2, 2);
cmd.Reply(langNotSupported.ToString(), ReplyEmojis.Error);
langNotSupported.Clear();
return Task.CompletedTask;
}
if (selectedSetting.EndsWith("Channel") && guild.GetTextChannel(mention) is null) {
cmd.Reply(Messages.InvalidChannel, ReplyEmojis.Error);
return Task.CompletedTask;
}
if (selectedSetting.EndsWith("Role") && guild.GetRole(mention) is null) {
cmd.Reply(Messages.InvalidRole, ReplyEmojis.Error);
return Task.CompletedTask;
}
if (selectedSetting.EndsWith("Offset") && !int.TryParse(value, out _)) {
cmd.Reply(Messages.InvalidSettingValue, ReplyEmojis.Error);
return Task.CompletedTask;
}
if (selectedSetting is "MuteRole")
data.MuteRole = guild.GetRole(mention);
config[selectedSetting] = value;
}
if (selectedSetting is "Lang") {
Utils.SetCurrentLanguage(guild);
localizedSelectedSetting = Utils.GetMessage($"Settings{selectedSetting}");
}
cmd.ConfigWriteScheduled = true;
var replyFormat = string.Format(Messages.FeedbackSettingsUpdated, localizedSelectedSetting, formattedValue);
cmd.Reply(replyFormat, ReplyEmojis.SettingsSet);
cmd.Audit(replyFormat, false);
return Task.CompletedTask;
}
private static string YesOrNo(bool isYes) {
return isYes ? Messages.Yes : Messages.No;
}
private static bool IsBool(string value) {
return value is "true" or "false";
}
}

View file

@ -0,0 +1,158 @@
using System.ComponentModel;
using System.Reflection;
using System.Text;
using Boyfriend.Data;
using Boyfriend.Services;
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.Contexts;
using Remora.Discord.Commands.Feedback.Messages;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend.Commands;
/// <summary>
/// Handles the commands to list and modify per-guild settings: /settings and /settings list.
/// </summary>
public class SettingsCommandGroup : CommandGroup {
private readonly ICommandContext _context;
private readonly GuildDataService _dataService;
private readonly FeedbackService _feedbackService;
private readonly IDiscordRestUserAPI _userApi;
public SettingsCommandGroup(
ICommandContext context, GuildDataService dataService,
FeedbackService feedbackService, IDiscordRestUserAPI userApi) {
_context = context;
_dataService = dataService;
_feedbackService = feedbackService;
_userApi = userApi;
}
/// <summary>
/// A slash command that lists current per-guild settings.
/// </summary>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("settings list")]
[Description("Shows settings list for this server")]
[SuppressInteractionResponse(suppress: true)]
public async Task<Result> SendSettingsListAsync() {
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
return Result.FromError(
new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context"));
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult);
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
Messages.Culture = cfg.GetCulture();
var builder = new StringBuilder();
foreach (var setting in typeof(GuildConfiguration).GetProperties()) {
builder.Append(Markdown.InlineCode(setting.Name))
.Append(": ");
var something = setting.GetValue(cfg);
if (something!.GetType() == typeof(List<GuildConfiguration.NotificationReceiver>)) {
var list = (something as List<GuildConfiguration.NotificationReceiver>);
builder.AppendLine(string.Join(", ", list!.Select(v => Markdown.InlineCode(v.ToString()))));
} else { builder.AppendLine(Markdown.InlineCode(something.ToString()!)); }
}
var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingsListTitle, currentUser)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Default)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _feedbackService.SendContextualEmbedAsync(
built, ct: CancellationToken, options: new FeedbackMessageOptions(MessageFlags: MessageFlags.Ephemeral));
}
/// <summary>
/// A slash command that modifies per-guild settings.
/// </summary>
/// <returns>
/// A feedback sending result which may or may not have succeeded.
/// </returns>
[Command("settings")]
[Description("Change settings for this server")]
public async Task<Result> EditSettingsAsync(
[Description("настройка")] string setting,
[Description("значение")] string value) {
if (!_context.TryGetContextIDs(out var guildId, out _, out _))
return Result.FromError(
new ArgumentNullError(nameof(_context), "Unable to retrieve necessary IDs from command context"));
var currentUserResult = await _userApi.GetCurrentUserAsync(CancellationToken);
if (!currentUserResult.IsDefined(out var currentUser))
return Result.FromError(currentUserResult);
var cfg = await _dataService.GetConfiguration(guildId.Value, CancellationToken);
Messages.Culture = cfg.GetCulture();
PropertyInfo? property = null;
try {
foreach (var prop in typeof(GuildConfiguration).GetProperties())
if (string.Equals(setting, prop.Name, StringComparison.CurrentCultureIgnoreCase))
property = prop;
if (property == null || !property.CanWrite)
throw new ApplicationException(Messages.SettingDoesntExist);
var type = property.PropertyType;
if (value is "reset" or "default") { property.SetValue(cfg, null); } else if (type == typeof(string)) {
if (setting == "language" && value is not ("ru" or "en" or "mctaylors-ru"))
throw new ApplicationException(Messages.LanguageNotSupported);
property.SetValue(cfg, value);
} else {
try {
if (type == typeof(bool))
property.SetValue(cfg, Convert.ToBoolean(value));
if (type == typeof(ulong)) {
var id = Convert.ToUInt64(value);
property.SetValue(cfg, id);
}
} catch (Exception e) when (e is FormatException or OverflowException) {
throw new ApplicationException(Messages.InvalidSettingValue);
}
}
} catch (Exception e) {
var failedEmbed = new EmbedBuilder().WithSmallTitle(Messages.SettingNotChanged, currentUser)
.WithDescription(e.Message)
.WithColour(ColorsList.Red)
.Build();
if (!failedEmbed.IsDefined(out var failedBuilt)) return Result.FromError(failedEmbed);
return (Result)await _feedbackService.SendContextualEmbedAsync(failedBuilt, ct: CancellationToken);
}
var builder = new StringBuilder();
builder.Append(Markdown.InlineCode(setting))
.Append($" {Messages.SettingIsNow} ")
.Append(Markdown.InlineCode(value));
var embed = new EmbedBuilder().WithSmallTitle(Messages.SettingSuccessfulyChanged, currentUser)
.WithDescription(builder.ToString())
.WithColour(ColorsList.Green)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _feedbackService.SendContextualEmbedAsync(built, ct: CancellationToken);
}
}

View file

@ -1,25 +0,0 @@
using Discord;
namespace Boyfriend.Commands;
public sealed class UnbanCommand : ICommand {
public string[] Aliases { get; } = { "unban", "pardon", "разбан" };
public async Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) {
if (!cmd.HasPermission(GuildPermission.BanMembers)) return;
var id = cmd.GetBan(args, 0);
if (id is null) return;
var reason = cmd.GetRemaining(args, 1, "UnbanReason");
if (reason is not null) await UnbanUserAsync(cmd, id.Value, reason);
}
private static async Task UnbanUserAsync(CommandProcessor cmd, ulong id, string reason) {
var requestOptions = Utils.GetRequestOptions($"({cmd.Context.User}) {reason}");
await cmd.Context.Guild.RemoveBanAsync(id, requestOptions);
var feedback = string.Format(Messages.FeedbackUserUnbanned, $"<@{id.ToString()}>", Utils.Wrap(reason));
cmd.Reply(feedback);
cmd.Audit(feedback);
}
}

View file

@ -1,36 +0,0 @@
using Boyfriend.Data;
using Discord;
using Discord.WebSocket;
namespace Boyfriend.Commands;
public sealed class UnmuteCommand : ICommand {
public string[] Aliases { get; } = { "unmute", "размут" };
public async Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs) {
if (!cmd.HasPermission(GuildPermission.ModerateMembers)) return;
var toUnmute = cmd.GetMember(args, 0);
if (toUnmute is null) return;
var reason = cmd.GetRemaining(args, 1, "UnmuteReason");
if (reason is not null && cmd.CanInteractWith(toUnmute, "Unmute"))
await UnmuteMemberAsync(cmd, toUnmute, reason);
}
private static async Task UnmuteMemberAsync(CommandProcessor cmd, SocketGuildUser toUnmute,
string reason) {
var isMuted = await Utils.UnmuteMemberAsync(GuildData.Get(cmd.Context.Guild), cmd.Context.User.ToString(),
toUnmute, reason);
if (!isMuted) {
cmd.Reply(Messages.MemberNotMuted, ReplyEmojis.Error);
return;
}
cmd.ConfigWriteScheduled = true;
var feedback = string.Format(Messages.FeedbackMemberUnmuted, toUnmute.Mention, Utils.Wrap(reason));
cmd.Reply(feedback, ReplyEmojis.Unmuted);
cmd.Audit(feedback);
}
}

View file

@ -0,0 +1,90 @@
using System.Globalization;
using Remora.Discord.API.Abstractions.Objects;
namespace Boyfriend.Data;
/// <summary>
/// Stores per-guild settings that can be set by a member
/// with <see cref="DiscordPermission.ManageGuild" /> using the /settings command
/// </summary>
public class GuildConfiguration {
/// <summary>
/// Represents a scheduled event notification receiver.
/// </summary>
/// <remarks>
/// Used to selectively mention guild members when a scheduled event has started or is about to start.
/// </remarks>
public enum NotificationReceiver {
Interested,
Role
}
public static readonly Dictionary<string, CultureInfo> CultureInfoCache = new() {
{ "en", new CultureInfo("en-US") },
{ "ru", new CultureInfo("ru-RU") },
{ "mctaylors-ru", new CultureInfo("tt-RU") }
};
public string Language { get; set; } = "en";
/// <summary>
/// Controls what message should be sent in <see cref="PublicFeedbackChannel" /> when a new member joins the server.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>No message will be sent if set to "off", "disable" or "disabled".</item>
/// <item><see cref="Messages.DefaultWelcomeMessage" /> will be sent if set to "default" or "reset"</item>
/// </list>
/// </remarks>
/// <seealso cref="GuildMemberAddResponder" />
public string WelcomeMessage { get; set; } = "default";
/// <summary>
/// Controls whether or not the <see cref="Messages.Ready" /> message should be sent
/// in <see cref="PrivateFeedbackChannel" /> on startup.
/// </summary>
/// <seealso cref="GuildCreateResponder" />
public bool ReceiveStartupMessages { get; set; }
public bool RemoveRolesOnMute { get; set; }
/// <summary>
/// Controls whether or not a guild member's roles are returned if he/she leaves and then joins back.
/// </summary>
/// <remarks>Roles will not be returned if the member left the guild because of /ban or /kick.</remarks>
public bool ReturnRolesOnRejoin { get; set; }
public bool AutoStartEvents { get; set; }
/// <summary>
/// Controls what channel should all public messages be sent to.
/// </summary>
public ulong PublicFeedbackChannel { get; set; }
/// <summary>
/// Controls what channel should all private, moderator-only messages be sent to.
/// </summary>
public ulong PrivateFeedbackChannel { get; set; }
public ulong EventNotificationChannel { get; set; }
public ulong DefaultRole { get; set; }
public ulong MuteRole { get; set; }
public ulong EventNotificationRole { get; set; }
/// <summary>
/// Controls what guild members should be mentioned when a scheduled event has started or is about to start.
/// </summary>
/// <seealso cref="NotificationReceiver" />
public List<NotificationReceiver> EventStartedReceivers { get; set; }
= new() { NotificationReceiver.Interested, NotificationReceiver.Role };
/// <summary>
/// Controls the amount of time before a scheduled event to send a reminder in <see cref="EventNotificationChannel" />.
/// </summary>
public TimeSpan EventEarlyNotificationOffset { get; set; } = TimeSpan.Zero;
// Do not convert this to a property, else serialization will be attempted
public CultureInfo GetCulture() {
return CultureInfoCache[Language];
}
}

View file

@ -1,142 +1,41 @@
using System.Collections.Concurrent; using System.Globalization;
using System.Diagnostics.CodeAnalysis; using Remora.Rest.Core;
using System.Text.Json;
using Discord;
using Discord.WebSocket;
namespace Boyfriend.Data; namespace Boyfriend.Data;
public record GuildData { /// <summary>
public static readonly Dictionary<string, string> DefaultPreferences = new() { /// Stores information about a guild. This information is not accessible via the Discord API.
{ "Prefix", "!" }, /// </summary>
{ "Lang", "en" }, /// <remarks>This information is stored on disk as a JSON file.</remarks>
{ "ReceiveStartupMessages", "false" }, public class GuildData {
{ "WelcomeMessage", "default" }, public readonly GuildConfiguration Configuration;
{ "SendWelcomeMessages", "true" }, public readonly string ConfigurationPath;
{ "PublicFeedbackChannel", "0" },
{ "PrivateFeedbackChannel", "0" },
{ "StarterRole", "0" },
{ "MuteRole", "0" },
{ "RemoveRolesOnMute", "false" },
{ "ReturnRolesOnRejoin", "false" },
{ "EventStartedReceivers", "interested,role" },
{ "EventNotificationRole", "0" },
{ "EventNotificationChannel", "0" },
{ "EventEarlyNotificationOffset", "0" },
{ "AutoStartEvents", "false" }
};
public static readonly ConcurrentDictionary<ulong, GuildData> GuildDataDictionary = new();
private static readonly JsonSerializerOptions Options = new() {
IncludeFields = true,
WriteIndented = true
};
private readonly string _configurationFile;
private readonly ulong _id;
public readonly List<ulong> EarlyNotifications = new();
public readonly Dictionary<ulong, MemberData> MemberData; public readonly Dictionary<ulong, MemberData> MemberData;
public readonly string MemberDataPath;
public readonly Dictionary<string, string> Preferences; public readonly Dictionary<ulong, ScheduledEventData> ScheduledEvents;
public readonly string ScheduledEventsPath;
private SocketRole? _cachedMuteRole; public GuildData(
GuildConfiguration configuration, string configurationPath,
[SuppressMessage("Performance", "CA1853:Unnecessary call to \'Dictionary.ContainsKey(key)\'")] Dictionary<ulong, ScheduledEventData> scheduledEvents, string scheduledEventsPath,
// https://github.com/dotnet/roslyn-analyzers/issues/6377 Dictionary<ulong, MemberData> memberData, string memberDataPath) {
private GuildData(SocketGuild guild) { Configuration = configuration;
var downloaderTask = guild.DownloadUsersAsync(); ConfigurationPath = configurationPath;
_id = guild.Id; ScheduledEvents = scheduledEvents;
var idString = $"{_id}"; ScheduledEventsPath = scheduledEventsPath;
var memberDataDir = $"{_id}/MemberData"; MemberData = memberData;
_configurationFile = $"{_id}/Configuration.json"; MemberDataPath = memberDataPath;
if (!Directory.Exists(idString)) Directory.CreateDirectory(idString);
if (!Directory.Exists(memberDataDir)) Directory.CreateDirectory(memberDataDir);
if (!File.Exists(_configurationFile)) File.WriteAllText(_configurationFile, "{}");
Preferences
= JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(_configurationFile))
?? new Dictionary<string, string>();
if (Preferences.Keys.Count < DefaultPreferences.Keys.Count)
foreach (var key in DefaultPreferences.Keys.Where(key => !Preferences.ContainsKey(key)))
Preferences.Add(key, DefaultPreferences[key]);
if (Preferences.Keys.Count > DefaultPreferences.Keys.Count)
foreach (var key in Preferences.Keys.Where(key => !DefaultPreferences.ContainsKey(key)))
Preferences.Remove(key);
Preferences.TrimExcess();
MemberData = new Dictionary<ulong, MemberData>();
foreach (var data in Directory.GetFiles(memberDataDir)) {
var deserialised
= JsonSerializer.Deserialize<MemberData>(File.ReadAllText(data), Options);
MemberData.Add(deserialised!.Id, deserialised);
}
downloaderTask.Wait();
foreach (var member in guild.Users) {
if (MemberData.TryGetValue(member.Id, out var memberData)) {
if (!memberData.IsInGuild
&& DateTimeOffset.UtcNow.ToUnixTimeSeconds()
- Math.Max(
memberData.LeftAt.Last().ToUnixTimeSeconds(),
memberData.BannedUntil?.ToUnixTimeSeconds() ?? 0)
> 60 * 60 * 24 * 30) {
File.Delete($"{_id}/MemberData/{memberData.Id}.json");
MemberData.Remove(memberData.Id);
}
if (memberData.MutedUntil is null) {
memberData.Roles = ((IGuildUser)member).RoleIds.ToList();
memberData.Roles.Remove(guild.Id);
}
continue;
}
MemberData.Add(member.Id, new MemberData(member));
}
MemberData.TrimExcess();
} }
public SocketRole? MuteRole { public CultureInfo Culture => Configuration.GetCulture();
get {
if (Preferences["MuteRole"] is "0") return null;
return _cachedMuteRole ??= Boyfriend.Client.GetGuild(_id).Roles
.Single(x => x.Id == ulong.Parse(Preferences["MuteRole"]));
}
set => _cachedMuteRole = value;
}
public SocketTextChannel? PublicFeedbackChannel public MemberData GetMemberData(Snowflake userId) {
=> Boyfriend.Client.GetGuild(_id) if (MemberData.TryGetValue(userId.Value, out var existing)) return existing;
.GetTextChannel(
ulong.Parse(Preferences["PublicFeedbackChannel"]));
public SocketTextChannel? PrivateFeedbackChannel => Boyfriend.Client.GetGuild(_id) var newData = new MemberData(userId.Value, null);
.GetTextChannel( MemberData.Add(userId.Value, newData);
ulong.Parse(
Preferences["PrivateFeedbackChannel"]));
public static GuildData Get(SocketGuild guild) {
if (GuildDataDictionary.TryGetValue(guild.Id, out var stored)) return stored;
var newData = new GuildData(guild);
while (!GuildDataDictionary.ContainsKey(guild.Id)) GuildDataDictionary.TryAdd(guild.Id, newData);
return newData; return newData;
} }
public async Task Save(bool saveMemberData) {
Preferences.TrimExcess();
await File.WriteAllTextAsync(
_configurationFile,
JsonSerializer.Serialize(Preferences));
if (saveMemberData)
foreach (var data in MemberData.Values)
await File.WriteAllTextAsync(
$"{_id}/MemberData/{data.Id}.json",
JsonSerializer.Serialize(data, Options));
}
} }

View file

@ -1,38 +1,18 @@
using System.Text.Json.Serialization; using Remora.Rest.Core;
using Discord;
namespace Boyfriend.Data; namespace Boyfriend.Data;
public record MemberData { /// <summary>
public DateTimeOffset? BannedUntil; /// Stores information about a member
public ulong Id; /// </summary>
public bool IsInGuild; public class MemberData {
public List<DateTimeOffset> JoinedAt; public MemberData(ulong id, DateTimeOffset? bannedUntil) {
public List<DateTimeOffset> LeftAt;
public DateTimeOffset? MutedUntil;
public List<Reminder> Reminders;
public List<ulong> Roles;
[JsonConstructor]
public MemberData(DateTimeOffset? bannedUntil, ulong id, bool isInGuild, List<DateTimeOffset> joinedAt,
List<DateTimeOffset> leftAt, DateTimeOffset? mutedUntil, List<Reminder> reminders, List<ulong> roles) {
BannedUntil = bannedUntil;
Id = id; Id = id;
IsInGuild = isInGuild; BannedUntil = bannedUntil;
JoinedAt = joinedAt;
LeftAt = leftAt;
MutedUntil = mutedUntil;
Reminders = reminders;
Roles = roles;
} }
public MemberData(IGuildUser user) { public ulong Id { get; }
Id = user.Id; public DateTimeOffset? BannedUntil { get; set; }
IsInGuild = true; public List<Snowflake> Roles { get; set; } = new();
JoinedAt = new List<DateTimeOffset> { user.JoinedAt!.Value }; public List<Reminder> Reminders { get; } = new();
LeftAt = new List<DateTimeOffset>();
Roles = user.RoleIds.ToList();
Roles.Remove(user.Guild.Id);
Reminders = new List<Reminder>();
}
} }

View file

@ -1,7 +1,9 @@
namespace Boyfriend.Data; using Remora.Rest.Core;
namespace Boyfriend.Data;
public struct Reminder { public struct Reminder {
public DateTimeOffset RemindAt; public DateTimeOffset RemindAt;
public string ReminderText; public string Text;
public ulong ReminderChannel; public Snowflake Channel;
} }

View file

@ -0,0 +1,17 @@
using Remora.Discord.API.Abstractions.Objects;
namespace Boyfriend.Data;
/// <summary>
/// Stores information about scheduled events. This information is not provided by the Discord API.
/// </summary>
/// <remarks>This information is stored on disk as a JSON file.</remarks>
public class ScheduledEventData {
public ScheduledEventData(GuildScheduledEventStatus status) {
Status = status;
}
public bool EarlyNotificationSent { get; set; }
public DateTimeOffset? ActualStartTime { get; set; }
public GuildScheduledEventStatus Status { get; set; }
}

View file

@ -1,238 +0,0 @@
using System.Diagnostics;
using Boyfriend.Data;
using Discord;
using Discord.Rest;
using Discord.WebSocket;
namespace Boyfriend;
public static class EventHandler {
private static readonly DiscordSocketClient Client = Boyfriend.Client;
private static bool _sendReadyMessages = true;
public static void InitEvents() {
Client.Ready += ReadyEvent;
Client.MessageDeleted += MessageDeletedEvent;
Client.MessageReceived += MessageReceivedEvent;
Client.MessageUpdated += MessageUpdatedEvent;
Client.UserJoined += UserJoinedEvent;
Client.UserLeft += UserLeftEvent;
Client.GuildMemberUpdated += MemberRolesUpdatedEvent;
Client.GuildScheduledEventCreated += ScheduledEventCreatedEvent;
Client.GuildScheduledEventCancelled += ScheduledEventCancelledEvent;
Client.GuildScheduledEventStarted += ScheduledEventStartedEvent;
Client.GuildScheduledEventCompleted += ScheduledEventCompletedEvent;
}
private static Task MemberRolesUpdatedEvent(Cacheable<SocketGuildUser, ulong> oldUser, SocketGuildUser newUser) {
var data = GuildData.Get(newUser.Guild).MemberData[newUser.Id];
if (data.MutedUntil is null) {
data.Roles = ((IGuildUser)newUser).RoleIds.ToList();
data.Roles.Remove(newUser.Guild.Id);
}
return Task.CompletedTask;
}
private static Task ReadyEvent() {
if (!_sendReadyMessages) return Task.CompletedTask;
var i = Random.Shared.Next(3);
foreach (var guild in Client.Guilds) {
Boyfriend.Log(new LogMessage(LogSeverity.Info, nameof(EventHandler), $"Guild \"{guild.Name}\" is READY"));
var data = GuildData.Get(guild);
var config = data.Preferences;
var channel = data.PrivateFeedbackChannel;
if (config["ReceiveStartupMessages"] is not "true" || channel is null) continue;
Utils.SetCurrentLanguage(guild);
_ = channel.SendMessageAsync(string.Format(Messages.Ready, Utils.GetBeep(i)));
}
_sendReadyMessages = false;
return Task.CompletedTask;
}
private static async Task MessageDeletedEvent(
Cacheable<IMessage, ulong> message,
Cacheable<IMessageChannel, ulong> channel) {
var msg = message.Value;
if (channel.Value is not SocketGuildChannel gChannel
|| msg is null or ISystemMessage
|| msg.Author.IsBot) return;
var guild = gChannel.Guild;
Utils.SetCurrentLanguage(guild);
var mention = msg.Author.Mention;
await Task.Delay(500);
var auditLogEnumerator
= (await guild.GetAuditLogsAsync(1, actionType: ActionType.MessageDeleted).FlattenAsync()).GetEnumerator();
if (auditLogEnumerator.MoveNext()) {
var auditLogEntry = auditLogEnumerator.Current!;
if (auditLogEntry.CreatedAt >= DateTimeOffset.UtcNow.Subtract(TimeSpan.FromSeconds(1))
&& auditLogEntry.Data is MessageDeleteAuditLogData data
&& msg.Author.Id == data.Target.Id)
mention = auditLogEntry.User.Mention;
}
auditLogEnumerator.Dispose();
await Utils.SendFeedbackAsync(
string.Format(
Messages.CachedMessageDeleted, msg.Author.Mention,
Utils.MentionChannel(channel.Id),
Utils.Wrap(msg.CleanContent)), guild, mention);
}
private static Task MessageReceivedEvent(IDeletable messageParam) {
if (messageParam is not SocketUserMessage message || message.Author.IsWebhook) return Task.CompletedTask;
_ = message.CleanContent.ToLower() switch {
"whoami" => message.ReplyAsync("`nobody`"),
"сука !!" => message.ReplyAsync("`root`"),
"воооо" => message.ReplyAsync("`removing /...`"),
"пон" => message.ReplyAsync(
"https://cdn.discordapp.com/attachments/837385840946053181/1087236080950055023/vUORS10xPaY-1.jpg"),
"++++" => message.ReplyAsync("#"),
_ => new CommandProcessor(message).HandleCommandAsync()
};
return Task.CompletedTask;
}
private static async Task MessageUpdatedEvent(
Cacheable<IMessage, ulong> messageCached, IMessage messageSocket,
ISocketMessageChannel channel) {
var msg = messageCached.Value;
if (channel is not SocketGuildChannel gChannel
|| msg is null or ISystemMessage
|| msg.CleanContent == messageSocket.CleanContent
|| msg.Author.IsBot) return;
var guild = gChannel.Guild;
Utils.SetCurrentLanguage(guild);
var isLimitedSpace = msg.CleanContent.Length + messageSocket.CleanContent.Length < 1940;
await Utils.SendFeedbackAsync(
string.Format(
Messages.CachedMessageEdited, Utils.MentionChannel(channel.Id),
Utils.Wrap(msg.CleanContent, isLimitedSpace), Utils.Wrap(messageSocket.CleanContent, isLimitedSpace)),
guild, msg.Author.Mention);
}
private static async Task UserJoinedEvent(SocketGuildUser user) {
var guild = user.Guild;
var data = GuildData.Get(guild);
var config = data.Preferences;
Utils.SetCurrentLanguage(guild);
if (config["SendWelcomeMessages"] is "true" && data.PublicFeedbackChannel is not null)
await Utils.SilentSendAsync(
data.PublicFeedbackChannel,
string.Format(
config["WelcomeMessage"] is "default"
? Messages.DefaultWelcomeMessage
: config["WelcomeMessage"], user.Mention, guild.Name));
if (!data.MemberData.ContainsKey(user.Id)) data.MemberData.Add(user.Id, new MemberData(user));
var memberData = data.MemberData[user.Id];
memberData.IsInGuild = true;
memberData.BannedUntil = null;
if (memberData.LeftAt.Count > 0) {
if (memberData.JoinedAt.Contains(user.JoinedAt!.Value))
throw new UnreachableException();
memberData.JoinedAt.Add(user.JoinedAt!.Value);
}
if (DateTimeOffset.UtcNow < memberData.MutedUntil) {
await user.AddRoleAsync(data.MuteRole);
if (config["RemoveRolesOnMute"] is "false" && config["ReturnRolesOnRejoin"] is "true")
await user.AddRolesAsync(memberData.Roles);
} else if (config["ReturnRolesOnRejoin"] is "true") { await user.AddRolesAsync(memberData.Roles); }
}
private static Task UserLeftEvent(SocketGuild guild, SocketUser user) {
var data = GuildData.Get(guild).MemberData[user.Id];
data.IsInGuild = false;
data.LeftAt.Add(DateTimeOffset.UtcNow);
return Task.CompletedTask;
}
private static async Task ScheduledEventCreatedEvent(SocketGuildEvent scheduledEvent) {
var guild = scheduledEvent.Guild;
var eventConfig = GuildData.Get(guild).Preferences;
var channel = Utils.GetEventNotificationChannel(guild);
Utils.SetCurrentLanguage(guild);
if (channel is null) return;
var role = guild.GetRole(ulong.Parse(eventConfig["EventNotificationRole"]));
var mentions = role is not null
? $"{role.Mention} {scheduledEvent.Creator.Mention}"
: $"{scheduledEvent.Creator.Mention}";
var location = Utils.Wrap(scheduledEvent.Location) ?? Utils.MentionChannel(scheduledEvent.Channel.Id);
var descAndLink
= $"\n{Utils.Wrap(scheduledEvent.Description)}\nhttps://discord.com/events/{guild.Id}/{scheduledEvent.Id}";
await Utils.SilentSendAsync(
channel,
string.Format(
Messages.EventCreated, mentions,
Utils.Wrap(scheduledEvent.Name), location,
scheduledEvent.StartTime.ToUnixTimeSeconds().ToString(), descAndLink),
true);
}
private static async Task ScheduledEventCancelledEvent(SocketGuildEvent scheduledEvent) {
var guild = scheduledEvent.Guild;
var eventConfig = GuildData.Get(guild).Preferences;
var channel = Utils.GetEventNotificationChannel(guild);
Utils.SetCurrentLanguage(guild);
if (channel is not null)
await channel.SendMessageAsync(
string.Format(
Messages.EventCancelled, Utils.Wrap(scheduledEvent.Name),
eventConfig["FrowningFace"] is "true" ? $" {Messages.SettingsFrowningFace}" : ""));
}
private static async Task ScheduledEventStartedEvent(SocketGuildEvent scheduledEvent) {
var guild = scheduledEvent.Guild;
var eventConfig = GuildData.Get(guild).Preferences;
var channel = Utils.GetEventNotificationChannel(guild);
Utils.SetCurrentLanguage(guild);
if (channel is null) return;
var receivers = eventConfig["EventStartedReceivers"];
var role = guild.GetRole(ulong.Parse(eventConfig["EventNotificationRole"]));
var mentions = Boyfriend.StringBuilder;
if (receivers.Contains("role") && role is not null) mentions.Append($"{role.Mention} ");
if (receivers.Contains("users") || receivers.Contains("interested"))
mentions = (await scheduledEvent.GetUsersAsync(15))
.Where(user => role is null || !((RestGuildUser)user).RoleIds.Contains(role.Id))
.Aggregate(mentions, (current, user) => current.Append($"{user.Mention} "));
await channel.SendMessageAsync(
string.Format(
Messages.EventStarted, mentions,
Utils.Wrap(scheduledEvent.Name),
Utils.Wrap(scheduledEvent.Location) ?? Utils.MentionChannel(scheduledEvent.Channel.Id)));
mentions.Clear();
}
private static async Task ScheduledEventCompletedEvent(SocketGuildEvent scheduledEvent) {
var guild = scheduledEvent.Guild;
var channel = Utils.GetEventNotificationChannel(guild);
Utils.SetCurrentLanguage(guild);
if (channel is not null)
await channel.SendMessageAsync(
string.Format(
Messages.EventCompleted, Utils.Wrap(scheduledEvent.Name),
Utils.GetHumanizedTimeSpan(DateTimeOffset.UtcNow.Subtract(scheduledEvent.StartTime))));
}
}

335
EventResponders.cs Normal file
View file

@ -0,0 +1,335 @@
using Boyfriend.Data;
using Boyfriend.Services;
using DiffPlex.DiffBuilder;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Caching;
using Remora.Discord.Caching.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Discord.Gateway.Responders;
using Remora.Rest.Core;
using Remora.Results;
// ReSharper disable UnusedType.Global
namespace Boyfriend;
/// <summary>
/// Handles sending a <see cref="Messages.Ready" /> message to a guild that has just initialized if that guild
/// has <see cref="GuildConfiguration.ReceiveStartupMessages" /> enabled
/// </summary>
public class GuildCreateResponder : IResponder<IGuildCreate> {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
private readonly ILogger<GuildCreateResponder> _logger;
private readonly IDiscordRestUserAPI _userApi;
public GuildCreateResponder(
IDiscordRestChannelAPI channelApi, GuildDataService dataService, ILogger<GuildCreateResponder> logger,
IDiscordRestUserAPI userApi) {
_channelApi = channelApi;
_dataService = dataService;
_logger = logger;
_userApi = userApi;
}
public async Task<Result> RespondAsync(IGuildCreate gatewayEvent, CancellationToken ct = default) {
if (!gatewayEvent.Guild.IsT0) return Result.FromSuccess(); // Guild isn't IAvailableGuild
var guild = gatewayEvent.Guild.AsT0;
_logger.LogInformation("Joined guild \"{Name}\"", guild.Name);
var guildConfig = await _dataService.GetConfiguration(guild.ID, ct);
if (!guildConfig.ReceiveStartupMessages)
return Result.FromSuccess();
if (guildConfig.PrivateFeedbackChannel is 0)
return Result.FromSuccess();
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
Messages.Culture = guildConfig.GetCulture();
var i = Random.Shared.Next(1, 4);
var embed = new EmbedBuilder().WithSmallTitle(currentUser.GetTag(), currentUser)
.WithTitle($"Beep{i}".Localized())
.WithDescription(Messages.Ready)
.WithCurrentTimestamp()
.WithColour(ColorsList.Blue)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
guildConfig.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built }, ct: ct);
}
}
/// <summary>
/// Handles logging the contents of a deleted message and the user who deleted the message
/// to a guild's <see cref="GuildConfiguration.PrivateFeedbackChannel" /> if one is set.
/// </summary>
public class MessageDeletedResponder : IResponder<IMessageDelete> {
private readonly IDiscordRestAuditLogAPI _auditLogApi;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
private readonly IDiscordRestUserAPI _userApi;
public MessageDeletedResponder(
IDiscordRestAuditLogAPI auditLogApi, IDiscordRestChannelAPI channelApi,
GuildDataService dataService, IDiscordRestUserAPI userApi) {
_auditLogApi = auditLogApi;
_channelApi = channelApi;
_dataService = dataService;
_userApi = userApi;
}
public async Task<Result> RespondAsync(IMessageDelete gatewayEvent, CancellationToken ct = default) {
if (!gatewayEvent.GuildID.IsDefined(out var guildId)) return Result.FromSuccess();
var guildConfiguration = await _dataService.GetConfiguration(guildId, ct);
if (guildConfiguration.PrivateFeedbackChannel is 0) return Result.FromSuccess();
var messageResult = await _channelApi.GetChannelMessageAsync(gatewayEvent.ChannelID, gatewayEvent.ID, ct);
if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult);
if (string.IsNullOrWhiteSpace(message.Content)) return Result.FromSuccess();
var auditLogResult = await _auditLogApi.GetGuildAuditLogAsync(
guildId, actionType: AuditLogEvent.MessageDelete, limit: 1, ct: ct);
if (!auditLogResult.IsDefined(out var auditLogPage)) return Result.FromError(auditLogResult);
var auditLog = auditLogPage.AuditLogEntries.Single();
if (!auditLog.Options.IsDefined(out var options))
return Result.FromError(new ArgumentNullError(nameof(auditLog.Options)));
var user = message.Author;
if (options.ChannelID == gatewayEvent.ChannelID
&& DateTimeOffset.UtcNow.Subtract(auditLog.ID.Timestamp).TotalSeconds <= 2) {
var userResult = await _userApi.GetUserAsync(auditLog.UserID!.Value, ct);
if (!userResult.IsDefined(out user)) return Result.FromError(userResult);
}
Messages.Culture = guildConfiguration.GetCulture();
var embed = new EmbedBuilder()
.WithSmallTitle(
string.Format(
Messages.CachedMessageDeleted,
message.Author.GetTag()), message.Author)
.WithDescription(
$"{Mention.Channel(gatewayEvent.ChannelID)}\n{message.Content.InBlockCode()}")
.WithActionFooter(user)
.WithTimestamp(message.Timestamp)
.WithColour(ColorsList.Red)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
guildConfiguration.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built },
allowedMentions: Boyfriend.NoMentions, ct: ct);
}
}
/// <summary>
/// Handles logging the difference between an edited message's old and new content
/// to a guild's <see cref="GuildConfiguration.PrivateFeedbackChannel" /> if one is set.
/// </summary>
public class MessageEditedResponder : IResponder<IMessageUpdate> {
private readonly CacheService _cacheService;
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
private readonly IDiscordRestUserAPI _userApi;
public MessageEditedResponder(
CacheService cacheService, IDiscordRestChannelAPI channelApi, GuildDataService dataService,
IDiscordRestUserAPI userApi) {
_cacheService = cacheService;
_channelApi = channelApi;
_dataService = dataService;
_userApi = userApi;
}
public async Task<Result> RespondAsync(IMessageUpdate gatewayEvent, CancellationToken ct = default) {
if (!gatewayEvent.GuildID.IsDefined(out var guildId))
return Result.FromSuccess();
var guildConfiguration = await _dataService.GetConfiguration(guildId, ct);
if (guildConfiguration.PrivateFeedbackChannel is 0)
return Result.FromSuccess();
if (!gatewayEvent.Content.IsDefined(out var newContent))
return Result.FromSuccess();
if (!gatewayEvent.EditedTimestamp.IsDefined(out var timestamp))
return Result.FromSuccess(); // The message wasn't actually edited
if (!gatewayEvent.ChannelID.IsDefined(out var channelId))
return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ChannelID)));
if (!gatewayEvent.ID.IsDefined(out var messageId))
return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ID)));
var cacheKey = new KeyHelpers.MessageCacheKey(channelId, messageId);
var messageResult = await _cacheService.TryGetValueAsync<IMessage>(
cacheKey, ct);
if (!messageResult.IsDefined(out var message)) return Result.FromError(messageResult);
if (message.Content == newContent) return Result.FromSuccess();
// Custom event responders are called earlier than responders responsible for message caching
// This means that subsequent edit logs may contain the wrong content
// We can work around this by evicting the message from the cache
await _cacheService.EvictAsync<IMessage>(cacheKey, ct);
// However, since we evicted the message, subsequent edits won't have a cached instance to work with
// Getting the message will put it back in the cache, resolving all issues
// 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(channelId, messageId, ct);
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
var diff = InlineDiffBuilder.Diff(message.Content, newContent);
Messages.Culture = guildConfiguration.GetCulture();
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.CachedMessageEdited, message.Author.GetTag()), message.Author)
.WithDescription($"https://discord.com/channels/{guildId}/{channelId}/{messageId}\n{diff.AsMarkdown()}")
.WithUserFooter(currentUser)
.WithTimestamp(timestamp.Value)
.WithColour(ColorsList.Yellow)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
guildConfiguration.PrivateFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built },
allowedMentions: Boyfriend.NoMentions, ct: ct);
}
}
/// <summary>
/// Handles sending a guild's <see cref="GuildConfiguration.WelcomeMessage" /> if one is set.
/// If <see cref="GuildConfiguration.ReturnRolesOnRejoin"/> is enabled, roles will be returned.
/// </summary>
/// <seealso cref="GuildConfiguration.WelcomeMessage" />
public class GuildMemberAddResponder : IResponder<IGuildMemberAdd> {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
private readonly IDiscordRestGuildAPI _guildApi;
public GuildMemberAddResponder(
IDiscordRestChannelAPI channelApi, GuildDataService dataService, IDiscordRestGuildAPI guildApi) {
_channelApi = channelApi;
_dataService = dataService;
_guildApi = guildApi;
}
public async Task<Result> RespondAsync(IGuildMemberAdd gatewayEvent, CancellationToken ct = default) {
if (!gatewayEvent.User.IsDefined(out var user))
return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.User)));
var data = await _dataService.GetData(gatewayEvent.GuildID, ct);
var cfg = data.Configuration;
if (cfg.PublicFeedbackChannel is 0 || cfg.WelcomeMessage is "off" or "disable" or "disabled")
return Result.FromSuccess();
if (cfg.ReturnRolesOnRejoin) {
var result = await _guildApi.ModifyGuildMemberAsync(
gatewayEvent.GuildID, user.ID, roles: data.GetMemberData(user.ID).Roles, ct: ct);
if (!result.IsSuccess) return Result.FromError(result.Error);
}
Messages.Culture = data.Culture;
var welcomeMessage = cfg.WelcomeMessage is "default" or "reset"
? Messages.DefaultWelcomeMessage
: cfg.WelcomeMessage;
var guildResult = await _guildApi.GetGuildAsync(gatewayEvent.GuildID, ct: ct);
if (!guildResult.IsDefined(out var guild)) return Result.FromError(guildResult);
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(welcomeMessage, user.GetTag(), guild.Name), user)
.WithGuildFooter(guild)
.WithTimestamp(gatewayEvent.JoinedAt)
.WithColour(ColorsList.Green)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
cfg.PublicFeedbackChannel.ToDiscordSnowflake(), embeds: new[] { built },
allowedMentions: Boyfriend.NoMentions, ct: ct);
}
}
/// <summary>
/// Handles sending a notification when a scheduled event has been cancelled
/// in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set.
/// </summary>
public class GuildScheduledEventDeleteResponder : IResponder<IGuildScheduledEventDelete> {
private readonly IDiscordRestChannelAPI _channelApi;
private readonly GuildDataService _dataService;
public GuildScheduledEventDeleteResponder(IDiscordRestChannelAPI channelApi, GuildDataService dataService) {
_channelApi = channelApi;
_dataService = dataService;
}
public async Task<Result> RespondAsync(IGuildScheduledEventDelete gatewayEvent, CancellationToken ct = default) {
var guildData = await _dataService.GetData(gatewayEvent.GuildID, ct);
guildData.ScheduledEvents.Remove(gatewayEvent.ID.Value);
if (guildData.Configuration.EventNotificationChannel is 0)
return Result.FromSuccess();
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventCancelled, gatewayEvent.Name))
.WithDescription(":(")
.WithColour(ColorsList.Red)
.WithCurrentTimestamp()
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
return (Result)await _channelApi.CreateMessageAsync(
guildData.Configuration.EventNotificationChannel.ToDiscordSnowflake(), embeds: new[] { built }, ct: ct);
}
}
/// <summary>
/// Handles updating <see cref="MemberData.Roles" /> when a guild member is updated.
/// </summary>
public class GuildMemberUpdateResponder : IResponder<IGuildMemberUpdate> {
private readonly GuildDataService _dataService;
public GuildMemberUpdateResponder(GuildDataService dataService) {
_dataService = dataService;
}
public async Task<Result> RespondAsync(IGuildMemberUpdate gatewayEvent, CancellationToken ct = default) {
var memberData = await _dataService.GetMemberData(gatewayEvent.GuildID, gatewayEvent.User.ID, ct);
memberData.Roles = gatewayEvent.Roles.ToList();
return Result.FromSuccess();
}
}
/// <summary>
/// Handles sending replies to easter egg messages.
/// </summary>
public class MessageCreateResponder : IResponder<IMessageCreate> {
private readonly IDiscordRestChannelAPI _channelApi;
public MessageCreateResponder(IDiscordRestChannelAPI channelApi) {
_channelApi = channelApi;
}
public Task<Result> RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct = default) {
_ = _channelApi.CreateMessageAsync(
gatewayEvent.ChannelID, ct: ct, content: gatewayEvent.Content switch {
"whoami" => "`nobody`",
"сука !!" => "`root`",
"воооо" => "`removing /...`",
"пон" =>
"https://cdn.discordapp.com/attachments/837385840946053181/1087236080950055023/vUORS10xPaY-1.jpg",
"++++" => "#",
_ => default(Optional<string>)
});
return Task.FromResult(Result.FromSuccess());
}
}

189
Extensions.cs Normal file
View file

@ -0,0 +1,189 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Text;
using DiffPlex.DiffBuilder.Model;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Contexts;
using Remora.Discord.Commands.Extensions;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
namespace Boyfriend;
public static class Extensions {
/// <summary>
/// Adds a footer with the <paramref name="user" />'s avatar and tag (@username or username#0000).
/// </summary>
/// <param name="builder">The builder to add the footer to.</param>
/// <param name="user">The user whose tag and avatar to add.</param>
/// <returns>The builder with the added footer.</returns>
public static EmbedBuilder WithUserFooter(this EmbedBuilder builder, IUser user) {
var avatarUrlResult = CDN.GetUserAvatarUrl(user, imageSize: 256);
var avatarUrl = avatarUrlResult.IsSuccess
? avatarUrlResult.Entity.AbsoluteUri
: CDN.GetDefaultUserAvatarUrl(user, imageSize: 256).Entity.AbsoluteUri;
return builder.WithFooter(new EmbedFooter(user.GetTag(), avatarUrl));
}
/// <summary>
/// Adds a footer representing that an action was performed by a <paramref name="user" />.
/// </summary>
/// <param name="builder">The builder to add the footer to.</param>
/// <param name="user">The user that performed the action whose tag and avatar to use.</param>
/// <returns>The builder with the added footer.</returns>
public static EmbedBuilder WithActionFooter(this EmbedBuilder builder, IUser user) {
var avatarUrlResult = CDN.GetUserAvatarUrl(user, imageSize: 256);
var avatarUrl = avatarUrlResult.IsSuccess
? avatarUrlResult.Entity.AbsoluteUri
: CDN.GetDefaultUserAvatarUrl(user, imageSize: 256).Entity.AbsoluteUri;
return builder.WithFooter(
new EmbedFooter($"{Messages.IssuedBy}:\n{user.GetTag()}", avatarUrl));
}
/// <summary>
/// Adds a title using the author field, making it smaller than using the title field.
/// </summary>
/// <param name="builder">The builder to add the small title to.</param>
/// <param name="text">The text of the small title.</param>
/// <param name="avatarSource">The user whose avatar to use in the small title.</param>
/// <param name="url">The URL that will be opened if a user clicks on the small title.</param>
/// <returns>The builder with the added small title in the author field.</returns>
public static EmbedBuilder WithSmallTitle(
this EmbedBuilder builder, string text, IUser? avatarSource = null, string? url = default) {
Uri? avatarUrl = null;
if (avatarSource is not null) {
var avatarUrlResult = CDN.GetUserAvatarUrl(avatarSource, imageSize: 256);
avatarUrl = avatarUrlResult.IsSuccess
? avatarUrlResult.Entity
: CDN.GetDefaultUserAvatarUrl(avatarSource, imageSize: 256).Entity;
}
builder.Author = new EmbedAuthorBuilder(text, url, avatarUrl?.AbsoluteUri);
return builder;
}
/// <summary>
/// Adds a footer representing that the action was performed in the <paramref name="guild" />.
/// </summary>
/// <param name="builder">The builder to add the footer to.</param>
/// <param name="guild">The guild whose name and icon to use.</param>
/// <returns>The builder with the added footer.</returns>
public static EmbedBuilder WithGuildFooter(this EmbedBuilder builder, IGuild guild) {
var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256);
var iconUrl = iconUrlResult.IsSuccess
? iconUrlResult.Entity.AbsoluteUri
: default(Optional<string>);
return builder.WithFooter(new EmbedFooter(guild.Name, iconUrl));
}
/// <summary>
/// Adds a title representing that the action happened in the <paramref name="guild" />.
/// </summary>
/// <param name="builder">The builder to add the title to.</param>
/// <param name="guild">The guild whose name and icon to use.</param>
/// <returns>The builder with the added title.</returns>
public static EmbedBuilder WithGuildTitle(this EmbedBuilder builder, IGuild guild) {
var iconUrlResult = CDN.GetGuildIconUrl(guild, imageSize: 256);
var iconUrl = iconUrlResult.IsSuccess
? iconUrlResult.Entity.AbsoluteUri
: null;
builder.Author = new EmbedAuthorBuilder(guild.Name, iconUrl: iconUrl);
return builder;
}
/// <summary>
/// Adds a scheduled event's cover image.
/// </summary>
/// <param name="builder">The builder to add the image to.</param>
/// <param name="eventId">The ID of the scheduled event whose image to use.</param>
/// <param name="imageHashOptional">The Optional containing the image hash.</param>
/// <returns>The builder with the added cover image.</returns>
public static EmbedBuilder WithEventCover(
this EmbedBuilder builder, Snowflake eventId, Optional<IImageHash?> imageHashOptional) {
if (!imageHashOptional.IsDefined(out var imageHash)) return builder;
var iconUrlResult = CDN.GetGuildScheduledEventCoverUrl(eventId, imageHash, imageSize: 1024);
return iconUrlResult.IsDefined(out var iconUrl) ? builder.WithImageUrl(iconUrl.AbsoluteUri) : builder;
}
/// <summary>
/// Sanitizes a string for use in <see cref="Markdown.BlockCode(string)" /> by inserting zero-width spaces in between
/// symbols used to format the string with block code.
/// </summary>
/// <param name="s">The string to sanitize.</param>
/// <returns>The sanitized string that can be safely used in <see cref="Markdown.BlockCode(string)" />.</returns>
private static string SanitizeForBlockCode(this string s) {
return s.Replace("```", "```");
}
/// <summary>
/// Sanitizes a string (see <see cref="SanitizeForBlockCode" />) and formats the string with block code.
/// </summary>
/// <param name="s">The string to sanitize and format.</param>
/// <returns>The sanitized string formatted with <see cref="Markdown.BlockCode(string)" />.</returns>
public static string InBlockCode(this string s) {
s = s.SanitizeForBlockCode();
return $"```{s.SanitizeForBlockCode()}{(s.EndsWith("`") || string.IsNullOrWhiteSpace(s) ? " " : "")}```";
}
public static string Localized(this string key) {
return Messages.ResourceManager.GetString(key, Messages.Culture) ?? key;
}
/// <summary>
/// Encodes a string to allow its transmission in request headers.
/// </summary>
/// <remarks>Used when encountering "Request headers must contain only ASCII characters".</remarks>
/// <param name="s">The string to encode.</param>
/// <returns>An encoded string with spaces kept intact.</returns>
public static string EncodeHeader(this string s) {
return WebUtility.UrlEncode(s).Replace('+', ' ');
}
public static string AsMarkdown(this DiffPaneModel model) {
var builder = new StringBuilder();
foreach (var line in model.Lines) {
if (line.Type is ChangeType.Deleted)
builder.Append("-- ");
if (line.Type is ChangeType.Inserted)
builder.Append("++ ");
if (line.Type is not ChangeType.Imaginary)
builder.AppendLine(line.Text);
}
return Markdown.BlockCode(builder.ToString().SanitizeForBlockCode(), "diff");
}
public static string GetTag(this IUser user) {
return user.Discriminator is 0000 ? $"@{user.Username}" : $"{user.Username}#{user.Discriminator:0000}";
}
public static Snowflake ToDiscordSnowflake(this ulong id) {
return DiscordSnowflake.New(id);
}
public static TResult? MaxOrDefault<TSource, TResult>(
this IEnumerable<TSource> source, Func<TSource, TResult> selector) {
var list = source.ToList();
return list.Any() ? list.Max(selector) : default;
}
public static bool TryGetContextIDs(
this ICommandContext context, [NotNullWhen(true)] out Snowflake? guildId,
[NotNullWhen(true)] out Snowflake? channelId, [NotNullWhen(true)] out Snowflake? userId) {
guildId = null;
channelId = null;
userId = null;
return context.TryGetGuildID(out guildId)
&& context.TryGetChannelID(out channelId)
&& context.TryGetUserID(out userId);
}
}

36
InteractionResponders.cs Normal file
View file

@ -0,0 +1,36 @@
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.Commands.Feedback.Messages;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Interactivity;
using Remora.Results;
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedMember.Global
namespace Boyfriend;
/// <summary>
/// Handles responding to various interactions.
/// </summary>
public class InteractionResponders : InteractionGroup {
private readonly FeedbackService _feedbackService;
public InteractionResponders(FeedbackService feedbackService) {
_feedbackService = feedbackService;
}
/// <summary>
/// A button that will output an ephemeral embed containing the information about a scheduled event.
/// </summary>
/// <param name="state">The ID of the guild and scheduled event, encoded as "guildId:eventId".</param>
/// <returns>An ephemeral feedback sending result which may or may not have succeeded.</returns>
[Button("scheduled-event-details")]
public async Task<Result> OnStatefulButtonClicked(string? state = null) {
if (state is null) return Result.FromError(new ArgumentNullError(nameof(state)));
var idArray = state.Split(':');
return (Result)await _feedbackService.SendContextualAsync(
$"https://discord.com/events/{idArray[0]}/{idArray[1]}",
options: new FeedbackMessageOptions(MessageFlags: MessageFlags.Ephemeral));
}
}

1670
Messages.Designer.cs generated

File diff suppressed because it is too large Load diff

View file

@ -1,145 +1,133 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<root> <root>
<!-- <!--
Microsoft ResX Schema Microsoft ResX Schema
Version 2.0 Version 2.0
The primary goals of this format is to allow a simple XML format The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes various data types are done through the TypeConverter classes
associated with the data types. associated with the data types.
Example: Example:
... ado.net/XML headers & schema ... ... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader> <resheader name="resmimetype">text/microsoft-resx</resheader><resheader name="version">2.0</resheader><resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader><resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader><data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data><data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data><data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"><value>[base64 mime encoded serialized .NET Framework object]</value></data><data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"><value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value><comment>This is a comment</comment></data>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple There are any number of "resheader" rows that contain simple
name/value pairs. name/value pairs.
Each data row contains a name, and value. The row also contains a Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture. text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the Classes that don't support this are serialized and stored with the
mimetype set. mimetype set.
The mimetype is used for serialized objects, and tells the The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly: extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below. read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64 mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64 mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64 mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter : using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
--> -->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true"> <xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType> <xsd:complexType>
<xsd:sequence> <xsd:choice maxOccurs="unbounded">
<xsd:element name="value" type="xsd:string" minOccurs="0" /> <xsd:element name="metadata">
</xsd:sequence> <xsd:complexType>
<xsd:attribute name="name" use="required" type="xsd:string" /> <xsd:sequence>
<xsd:attribute name="type" type="xsd:string" /> <xsd:element name="value" type="xsd:string" minOccurs="0" />
<xsd:attribute name="mimetype" type="xsd:string" /> </xsd:sequence>
<xsd:attribute ref="xml:space" /> <xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
<xsd:element name="assembly"> </xsd:schema>
<xsd:complexType> <resheader name="resmimetype">
<xsd:attribute name="alias" type="xsd:string" /> <value>text/microsoft-resx</value>
<xsd:attribute name="name" type="xsd:string" /> </resheader>
</xsd:complexType> <resheader name="version">
</xsd:element> <value>2.0</value>
<xsd:element name="data"> </resheader>
<xsd:complexType> <resheader name="reader">
<xsd:sequence> <value> System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> </resheader>
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> <resheader name="writer">
</xsd:sequence> <value> System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> </resheader>
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> <data name="Ready" xml:space="preserve">
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> <value>I'm ready!</value>
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Ready" xml:space="preserve">
<value>{0}I'm ready!</value>
</data> </data>
<data name="CachedMessageDeleted" xml:space="preserve"> <data name="CachedMessageDeleted" xml:space="preserve">
<value>Deleted message from {0} in channel {1}: {2}</value> <value>Deleted message by {0}:</value>
</data> </data>
<data name="CachedMessageCleared" xml:space="preserve"> <data name="CachedMessageCleared" xml:space="preserve">
<value>Cleared message from {0} in channel {1}: {2}</value> <value>Cleared message from {0} in channel {1}: {2}</value>
</data> </data>
<data name="CachedMessageEdited" xml:space="preserve"> <data name="CachedMessageEdited" xml:space="preserve">
<value>Edited message in channel {0}: {1} -&gt; {2}</value> <value>Edited message by {0}:</value>
</data> </data>
<data name="DefaultWelcomeMessage" xml:space="preserve"> <data name="DefaultWelcomeMessage" xml:space="preserve">
<value>{0}, welcome to {1}</value> <value>{0}, welcome to {1}</value>
</data> </data>
<data name="Beep1" xml:space="preserve"> <data name="Beep1" xml:space="preserve">
<value>Bah! </value> <value>Bah!</value>
</data> </data>
<data name="Beep2" xml:space="preserve"> <data name="Beep2" xml:space="preserve">
<value>Bop! </value> <value>Bop!</value>
</data> </data>
<data name="Beep3" xml:space="preserve"> <data name="Beep3" xml:space="preserve">
<value>Beep! </value> <value>Beep!</value>
</data> </data>
<data name="CommandNoPermissionBot" xml:space="preserve"> <data name="CommandNoPermissionBot" xml:space="preserve">
<value>I do not have permission to execute this command!</value> <value>I do not have permission to execute this command!</value>
@ -148,8 +136,8 @@
<value>You do not have permission to execute this command!</value> <value>You do not have permission to execute this command!</value>
</data> </data>
<data name="YouWereBanned" xml:space="preserve"> <data name="YouWereBanned" xml:space="preserve">
<value>You were banned by {0} in guild `{1}` for {2}</value> <value>You were banned</value>
</data> </data>
<data name="PunishmentExpired" xml:space="preserve"> <data name="PunishmentExpired" xml:space="preserve">
<value>Punishment expired</value> <value>Punishment expired</value>
</data> </data>
@ -163,8 +151,8 @@
<value>Command help:</value> <value>Command help:</value>
</data> </data>
<data name="YouWereKicked" xml:space="preserve"> <data name="YouWereKicked" xml:space="preserve">
<value>You were kicked by {0} in guild `{1}` for {2}</value> <value>You were kicked</value>
</data> </data>
<data name="Milliseconds" xml:space="preserve"> <data name="Milliseconds" xml:space="preserve">
<value>ms</value> <value>ms</value>
</data> </data>
@ -177,148 +165,148 @@
<data name="RoleNotSpecified" xml:space="preserve"> <data name="RoleNotSpecified" xml:space="preserve">
<value>Not specified</value> <value>Not specified</value>
</data> </data>
<data name="CurrentSettings" xml:space="preserve"> <data name="CurrentSettings" xml:space="preserve">
<value>Current settings:</value> <value>Current settings:</value>
</data> </data>
<data name="SettingsLang" xml:space="preserve"> <data name="SettingsLang" xml:space="preserve">
<value>Language</value> <value>Language</value>
</data> </data>
<data name="SettingsPrefix" xml:space="preserve"> <data name="SettingsPrefix" xml:space="preserve">
<value>Prefix</value> <value>Prefix</value>
</data> </data>
<data name="SettingsRemoveRolesOnMute" xml:space="preserve"> <data name="SettingsRemoveRolesOnMute" xml:space="preserve">
<value>Remove roles on mute</value> <value>Remove roles on mute</value>
</data> </data>
<data name="SettingsSendWelcomeMessages" xml:space="preserve"> <data name="SettingsSendWelcomeMessages" xml:space="preserve">
<value>Send welcome messages</value> <value>Send welcome messages</value>
</data> </data>
<data name="SettingsMuteRole" xml:space="preserve"> <data name="SettingsMuteRole" xml:space="preserve">
<value>Mute role</value> <value>Mute role</value>
</data> </data>
<data name="LanguageNotSupported" xml:space="preserve"> <data name="LanguageNotSupported" xml:space="preserve">
<value>Language not supported! Supported languages:</value> <value>Language not supported!</value>
</data> </data>
<data name="Yes" xml:space="preserve"> <data name="Yes" xml:space="preserve">
<value>Yes</value> <value>Yes</value>
</data> </data>
<data name="No" xml:space="preserve"> <data name="No" xml:space="preserve">
<value>No</value> <value>No</value>
</data> </data>
<data name="UserNotBanned" xml:space="preserve"> <data name="UserNotBanned" xml:space="preserve">
<value>This user is not banned!</value> <value>This user is not banned!</value>
</data> </data>
<data name="MemberNotMuted" xml:space="preserve"> <data name="MemberNotMuted" xml:space="preserve">
<value>Member not muted!</value> <value>Member not muted!</value>
</data> </data>
<data name="SettingsWelcomeMessage" xml:space="preserve"> <data name="SettingsWelcomeMessage" xml:space="preserve">
<value>Welcome message</value> <value>Welcome message</value>
</data> </data>
<data name="ClearAmountInvalid" xml:space="preserve"> <data name="ClearAmountInvalid" xml:space="preserve">
<value>You need to specify an integer from {0} to {1} instead of {2}!</value> <value>You need to specify an integer from {0} to {1} instead of {2}!</value>
</data> </data>
<data name="FeedbackUserBanned" xml:space="preserve"> <data name="UserBanned" xml:space="preserve">
<value>Banned {0} for{1}: {2}</value> <value>{0} was banned</value>
</data> </data>
<data name="SettingDoesntExist" xml:space="preserve"> <data name="SettingDoesntExist" xml:space="preserve">
<value>That setting doesn't exist!</value> <value>That setting doesn't exist!</value>
</data> </data>
<data name="SettingsReceiveStartupMessages" xml:space="preserve"> <data name="SettingsReceiveStartupMessages" xml:space="preserve">
<value>Receive startup messages</value> <value>Receive startup messages</value>
</data> </data>
<data name="InvalidSettingValue" xml:space="preserve"> <data name="InvalidSettingValue" xml:space="preserve">
<value>Invalid setting value specified!</value> <value>Invalid setting value specified!</value>
</data> </data>
<data name="InvalidRole" xml:space="preserve"> <data name="InvalidRole" xml:space="preserve">
<value>This role does not exist!</value> <value>This role does not exist!</value>
</data> </data>
<data name="InvalidChannel" xml:space="preserve"> <data name="InvalidChannel" xml:space="preserve">
<value>This channel does not exist!</value> <value>This channel does not exist!</value>
</data> </data>
<data name="DurationRequiredForTimeOuts" xml:space="preserve"> <data name="DurationRequiredForTimeOuts" xml:space="preserve">
<value>I cannot mute someone for more than 28 days using timeouts! Either specify a duration shorter than 28 days, or set a mute role in settings</value> <value>I cannot mute someone for more than 28 days using timeouts! Either specify a duration shorter than 28 days, or set a mute role in settings</value>
</data> </data>
<data name="CannotTimeOutBot" xml:space="preserve"> <data name="CannotTimeOutBot" xml:space="preserve">
<value>I cannot use time-outs on other bots! Try to set a mute role in settings</value> <value>I cannot use time-outs on other bots! Try to set a mute role in settings</value>
</data> </data>
<data name="EventCreated" xml:space="preserve"> <data name="EventCreated" xml:space="preserve">
<value>{0} has created event {1}! It will take place in {2} and will start &lt;t:{3}:R&gt;! \n {4}</value> <value>{0} has created event {1}! It will take place in {2} and will start &lt;t:{3}:R&gt;! \n {4}</value>
</data> </data>
<data name="SettingsEventNotificationRole" xml:space="preserve"> <data name="SettingsEventNotificationRole" xml:space="preserve">
<value>Role for event creation notifications</value> <value>Role for event creation notifications</value>
</data> </data>
<data name="SettingsEventNotificationChannel" xml:space="preserve"> <data name="SettingsEventNotificationChannel" xml:space="preserve">
<value>Channel for event notifications</value> <value>Channel for event notifications</value>
</data> </data>
<data name="SettingsEventStartedReceivers" xml:space="preserve"> <data name="SettingsEventStartedReceivers" xml:space="preserve">
<value>Event start notifications receivers</value> <value>Event start notifications receivers</value>
</data> </data>
<data name="EventStarted" xml:space="preserve"> <data name="EventStarted" xml:space="preserve">
<value>{0}Event {1} is starting at {2}!</value> <value>Event "{0}" started</value>
</data> </data>
<data name="SettingsFrowningFace" xml:space="preserve"> <data name="SettingsFrowningFace" xml:space="preserve">
<value>:(</value> <value>:(</value>
</data> </data>
<data name="EventCancelled" xml:space="preserve"> <data name="EventCancelled" xml:space="preserve">
<value>Event {0} is cancelled!{1}</value> <value>Event "{0}" is cancelled!</value>
</data> </data>
<data name="EventCompleted" xml:space="preserve"> <data name="EventCompleted" xml:space="preserve">
<value>Event {0} has completed! Duration:{1}</value> <value>Event "{0}" has completed!</value>
</data> </data>
<data name="Ever" xml:space="preserve"> <data name="Ever" xml:space="preserve">
<value>ever</value> <value>ever</value>
</data> </data>
<data name="FeedbackMessagesCleared" xml:space="preserve"> <data name="MessagesCleared" xml:space="preserve">
<value>Deleted {0} messages in {1}</value> <value>Cleared {0} messages</value>
</data> </data>
<data name="FeedbackMemberKicked" xml:space="preserve"> <data name="FeedbackMemberKicked" xml:space="preserve">
<value>Kicked {0}: {1}</value> <value>Kicked {0}: {1}</value>
</data> </data>
<data name="FeedbackMemberMuted" xml:space="preserve"> <data name="FeedbackMemberMuted" xml:space="preserve">
<value>Muted {0} for{1}: {2}</value> <value>Muted {0} for{1}: {2}</value>
</data> </data>
<data name="FeedbackUserUnbanned" xml:space="preserve"> <data name="FeedbackUserUnbanned" xml:space="preserve">
<value>Unbanned {0}: {1}</value> <value>Unbanned {0}: {1}</value>
</data> </data>
<data name="FeedbackMemberUnmuted" xml:space="preserve"> <data name="FeedbackMemberUnmuted" xml:space="preserve">
<value>Unmuted {0}: {1}</value> <value>Unmuted {0}: {1}</value>
</data> </data>
<data name="SettingsNothingChanged" xml:space="preserve"> <data name="SettingsNothingChanged" xml:space="preserve">
<value>Nothing changed! `{0}` is already set to {1}</value> <value>Nothing changed! `{0}` is already set to {1}</value>
</data> </data>
<data name="SettingNotDefined" xml:space="preserve"> <data name="SettingNotDefined" xml:space="preserve">
<value>Not specified</value> <value>Not specified</value>
</data> </data>
<data name="FeedbackSettingsUpdated" xml:space="preserve"> <data name="FeedbackSettingsUpdated" xml:space="preserve">
<value>Value of setting `{0}` is now set to {1}</value> <value>Value of setting `{0}` is now set to {1}</value>
</data> </data>
<data name="CommandDescriptionBan" xml:space="preserve"> <data name="CommandDescriptionBan" xml:space="preserve">
<value>Bans a user</value> <value>Bans a user</value>
</data> </data>
<data name="CommandDescriptionClear" xml:space="preserve"> <data name="CommandDescriptionClear" xml:space="preserve">
<value>Deletes a specified amount of messages in this channel</value> <value>Deletes a specified amount of messages in this channel</value>
</data> </data>
<data name="CommandDescriptionHelp" xml:space="preserve"> <data name="CommandDescriptionHelp" xml:space="preserve">
<value>Shows this message</value> <value>Shows this message</value>
</data> </data>
<data name="CommandDescriptionKick" xml:space="preserve"> <data name="CommandDescriptionKick" xml:space="preserve">
<value>Kicks a member</value> <value>Kicks a member</value>
</data> </data>
<data name="CommandDescriptionMute" xml:space="preserve"> <data name="CommandDescriptionMute" xml:space="preserve">
<value>Mutes a member</value> <value>Mutes a member</value>
</data> </data>
<data name="CommandDescriptionPing" xml:space="preserve"> <data name="CommandDescriptionPing" xml:space="preserve">
<value>Shows (inaccurate) latency</value> <value>Shows (inaccurate) latency</value>
</data> </data>
<data name="CommandDescriptionSettings" xml:space="preserve"> <data name="CommandDescriptionSettings" xml:space="preserve">
<value>Allows you to change certain preferences for this guild</value> <value>Allows you to change certain preferences for this guild</value>
</data> </data>
<data name="CommandDescriptionUnban" xml:space="preserve"> <data name="CommandDescriptionUnban" xml:space="preserve">
<value>Unbans a user</value> <value>Unbans a user</value>
</data> </data>
<data name="CommandDescriptionUnmute" xml:space="preserve"> <data name="CommandDescriptionUnmute" xml:space="preserve">
<value>Unmutes a member</value> <value>Unmutes a member</value>
</data> </data>
<data name="MissingNumber" xml:space="preserve"> <data name="MissingNumber" xml:space="preserve">
<value>You need to specify an integer from {0} to {1}!</value> <value>You need to specify an integer from {0} to {1}!</value>
</data> </data>
<data name="MissingUser" xml:space="preserve"> <data name="MissingUser" xml:space="preserve">
@ -331,8 +319,8 @@
<value>You need to specify a guild member!</value> <value>You need to specify a guild member!</value>
</data> </data>
<data name="InvalidMember" xml:space="preserve"> <data name="InvalidMember" xml:space="preserve">
<value>You need to specify a member of this guild!</value> <value>You need to specify a member of this guild!</value>
</data> </data>
<data name="UserCannotBanMembers" xml:space="preserve"> <data name="UserCannotBanMembers" xml:space="preserve">
<value>You cannot ban users from this guild!</value> <value>You cannot ban users from this guild!</value>
</data> </data>
@ -345,94 +333,94 @@
<data name="UserCannotModerateMembers" xml:space="preserve"> <data name="UserCannotModerateMembers" xml:space="preserve">
<value>You cannot moderate members in this guild!</value> <value>You cannot moderate members in this guild!</value>
</data> </data>
<data name="UserCannotManageGuild" xml:space="preserve"> <data name="UserCannotManageGuild" xml:space="preserve">
<value>You cannot manage this guild!</value> <value>You cannot manage this guild!</value>
</data> </data>
<data name="BotCannotBanMembers" xml:space="preserve"> <data name="BotCannotBanMembers" xml:space="preserve">
<value>I cannot ban users from this guild!</value> <value>I cannot ban users from this guild!</value>
</data> </data>
<data name="BotCannotManageMessages" xml:space="preserve"> <data name="BotCannotManageMessages" xml:space="preserve">
<value>I cannot manage messages in this guild!</value> <value>I cannot manage messages in this guild!</value>
</data> </data>
<data name="BotCannotKickMembers" xml:space="preserve"> <data name="BotCannotKickMembers" xml:space="preserve">
<value>I cannot kick members from this guild!</value> <value>I cannot kick members from this guild!</value>
</data> </data>
<data name="BotCannotModerateMembers" xml:space="preserve"> <data name="BotCannotModerateMembers" xml:space="preserve">
<value>I cannot moderate members in this guild!</value> <value>I cannot moderate members in this guild!</value>
</data> </data>
<data name="BotCannotManageGuild" xml:space="preserve"> <data name="BotCannotManageGuild" xml:space="preserve">
<value>I cannot manage this guild!</value> <value>I cannot manage this guild!</value>
</data> </data>
<data name="MissingBanReason" xml:space="preserve"> <data name="MissingBanReason" xml:space="preserve">
<value>You need to specify a reason to ban this user!</value> <value>You need to specify a reason to ban this user!</value>
</data> </data>
<data name="MissingKickReason" xml:space="preserve"> <data name="MissingKickReason" xml:space="preserve">
<value>You need to specify a reason to kick this member!</value> <value>You need to specify a reason to kick this member!</value>
</data> </data>
<data name="MissingMuteReason" xml:space="preserve"> <data name="MissingMuteReason" xml:space="preserve">
<value>You need to specify a reason to mute this member!</value> <value>You need to specify a reason to mute this member!</value>
</data> </data>
<data name="MissingUnbanReason" xml:space="preserve"> <data name="MissingUnbanReason" xml:space="preserve">
<value>You need to specify a reason to unban this user!</value> <value>You need to specify a reason to unban this user!</value>
</data> </data>
<data name="MissingUnmuteReason" xml:space="preserve"> <data name="MissingUnmuteReason" xml:space="preserve">
<value>You need to specify a reason for unmute this member!</value> <value>You need to specify a reason for unmute this member!</value>
</data> </data>
<data name="UserCannotBanOwner" xml:space="preserve"> <data name="UserCannotBanOwner" xml:space="preserve">
<value>You cannot ban the owner of this guild!</value> <value>You cannot ban the owner of this guild!</value>
</data> </data>
<data name="UserCannotBanThemselves" xml:space="preserve"> <data name="UserCannotBanThemselves" xml:space="preserve">
<value>You cannot ban yourself!</value> <value>You cannot ban yourself!</value>
</data> </data>
<data name="UserCannotBanBot" xml:space="preserve"> <data name="UserCannotBanBot" xml:space="preserve">
<value>You cannot ban me!</value> <value>You cannot ban me!</value>
</data> </data>
<data name="BotCannotBanTarget" xml:space="preserve"> <data name="BotCannotBanTarget" xml:space="preserve">
<value>I cannot ban this user!</value> <value>I cannot ban this user!</value>
</data> </data>
<data name="UserCannotBanTarget" xml:space="preserve"> <data name="UserCannotBanTarget" xml:space="preserve">
<value>You cannot ban this user!</value> <value>You cannot ban this user!</value>
</data> </data>
<data name="UserCannotKickOwner" xml:space="preserve"> <data name="UserCannotKickOwner" xml:space="preserve">
<value>You cannot kick the owner of this guild!</value> <value>You cannot kick the owner of this guild!</value>
</data> </data>
<data name="UserCannotKickThemselves" xml:space="preserve"> <data name="UserCannotKickThemselves" xml:space="preserve">
<value>You cannot kick yourself!</value> <value>You cannot kick yourself!</value>
</data> </data>
<data name="UserCannotKickBot" xml:space="preserve"> <data name="UserCannotKickBot" xml:space="preserve">
<value>You cannot kick me!</value> <value>You cannot kick me!</value>
</data> </data>
<data name="BotCannotKickTarget" xml:space="preserve"> <data name="BotCannotKickTarget" xml:space="preserve">
<value>I cannot kick this member!</value> <value>I cannot kick this member!</value>
</data> </data>
<data name="UserCannotKickTarget" xml:space="preserve"> <data name="UserCannotKickTarget" xml:space="preserve">
<value>You cannot kick this member!</value> <value>You cannot kick this member!</value>
</data> </data>
<data name="UserCannotMuteOwner" xml:space="preserve"> <data name="UserCannotMuteOwner" xml:space="preserve">
<value>You cannot mute the owner of this guild!</value> <value>You cannot mute the owner of this guild!</value>
</data> </data>
<data name="UserCannotMuteThemselves" xml:space="preserve"> <data name="UserCannotMuteThemselves" xml:space="preserve">
<value>You cannot mute yourself!</value> <value>You cannot mute yourself!</value>
</data> </data>
<data name="UserCannotMuteBot" xml:space="preserve"> <data name="UserCannotMuteBot" xml:space="preserve">
<value>You cannot mute me!</value> <value>You cannot mute me!</value>
</data> </data>
<data name="BotCannotMuteTarget" xml:space="preserve"> <data name="BotCannotMuteTarget" xml:space="preserve">
<value>I cannot mute this member!</value> <value>I cannot mute this member!</value>
</data> </data>
<data name="UserCannotMuteTarget" xml:space="preserve"> <data name="UserCannotMuteTarget" xml:space="preserve">
<value>You cannot mute this member!</value> <value>You cannot mute this member!</value>
</data> </data>
<data name="UserCannotUnmuteOwner" xml:space="preserve"> <data name="UserCannotUnmuteOwner" xml:space="preserve">
<value>You don't need to unmute the owner of this guild!</value> <value>You don't need to unmute the owner of this guild!</value>
</data> </data>
<data name="UserCannotUnmuteThemselves" xml:space="preserve"> <data name="UserCannotUnmuteThemselves" xml:space="preserve">
<value>You are muted!</value> <value>You are muted!</value>
</data> </data>
<data name="UserCannotUnmuteBot" xml:space="preserve"> <data name="UserCannotUnmuteBot" xml:space="preserve">
<value>...</value> <value>...</value>
</data> </data>
<data name="BotCannotUnmuteTarget" xml:space="preserve"> <data name="BotCannotUnmuteTarget" xml:space="preserve">
<value>I cannot unmute this member!</value> <value>I cannot unmute this member!</value>
</data> </data>
<data name="UserCannotUnmuteTarget" xml:space="preserve"> <data name="UserCannotUnmuteTarget" xml:space="preserve">
@ -445,33 +433,129 @@
<value>Early event start notification offset</value> <value>Early event start notification offset</value>
</data> </data>
<data name="UserNotFound" xml:space="preserve"> <data name="UserNotFound" xml:space="preserve">
<value>I could not find this user in any guild I'm a member of! Check if the ID is correct and that the user was on this server no longer than 30 days ago</value> <value>I could not find this user in any guild I'm a member of! Check if the ID is correct and that the user was on this server no longer than 30 days ago</value>
</data> </data>
<data name="SettingsStarterRole" xml:space="preserve"> <data name="SettingsDefaultRole" xml:space="preserve">
<value>Starter role</value> <value>Default role</value>
</data> </data>
<data name="CommandDescriptionRemind" xml:space="preserve"> <data name="CommandDescriptionRemind" xml:space="preserve">
<value>Adds a reminder</value> <value>Adds a reminder</value>
</data> </data>
<data name="SettingsPublicFeedbackChannel" xml:space="preserve"> <data name="SettingsPublicFeedbackChannel" xml:space="preserve">
<value>Channel for public notifications</value> <value>Channel for public notifications</value>
</data> </data>
<data name="SettingsPrivateFeedbackChannel" xml:space="preserve"> <data name="SettingsPrivateFeedbackChannel" xml:space="preserve">
<value>Channel for private notifications</value> <value>Channel for private notifications</value>
</data> </data>
<data name="SettingsReturnRolesOnRejoin" xml:space="preserve"> <data name="SettingsReturnRolesOnRejoin" xml:space="preserve">
<value>Return roles on rejoin</value> <value>Return roles on rejoin</value>
</data> </data>
<data name="SettingsAutoStartEvents" xml:space="preserve"> <data name="SettingsAutoStartEvents" xml:space="preserve">
<value>Automatically start scheduled events</value> <value>Automatically start scheduled events</value>
</data> </data>
<data name="MissingReminderText" xml:space="preserve"> <data name="MissingReminderText" xml:space="preserve">
<value>You need to specify reminder text!</value> <value>You need to specify reminder text!</value>
</data> </data>
<data name="FeedbackReminderAdded" xml:space="preserve"> <data name="DescriptionReminderCreated" xml:space="preserve">
<value>OK, I'll mention you on &lt;t:{0}:f&gt;</value> <value>OK, I'll mention you on {0}</value>
</data> </data>
<data name="InvalidRemindIn" xml:space="preserve"> <data name="InvalidRemindIn" xml:space="preserve">
<value>You need to specify when I should send you the reminder!</value> <value>You need to specify when I should send you the reminder!</value>
</data>
<data name="IssuedBy" xml:space="preserve">
<value>Issued by</value>
</data>
<data name="EventCreatedTitle" xml:space="preserve">
<value>{0} has created a new event:</value>
</data>
<data name="DescriptionLocalEventCreated" xml:space="preserve">
<value>The event will start at {0} in {1}</value>
</data>
<data name="DescriptionExternalEventCreated" xml:space="preserve">
<value>The event will start at {0} until {1} in {2}</value>
</data>
<data name="EventDetailsButton" xml:space="preserve">
<value>Event details</value>
</data>
<data name="EventDuration" xml:space="preserve">
<value>The event has lasted for `{0}`</value>
</data>
<data name="DescriptionLocalEventStarted" xml:space="preserve">
<value>The event is happening at {0}</value>
</data>
<data name="DescriptionExternalEventStarted" xml:space="preserve">
<value>The event is happening at {0} until {1}</value>
</data>
<data name="UserAlreadyBanned" xml:space="preserve">
<value>This user is already banned!</value>
</data>
<data name="UserUnbanned" xml:space="preserve">
<value>{0} was unbanned</value>
</data>
<data name="UserMuted" xml:space="preserve">
<value>{0} was muted</value>
</data>
<data name="UserUnmuted" xml:space="preserve">
<value>{0} was unmuted</value>
</data>
<data name="UserNotMuted" xml:space="preserve">
<value>This member is not muted!</value>
</data>
<data name="UserNotFoundShort" xml:space="preserve">
<value>I could not find this user!</value>
</data>
<data name="UserKicked" xml:space="preserve">
<value>{0} was kicked</value>
</data>
<data name="DescriptionActionReason" xml:space="preserve">
<value>Reason: {0}</value>
</data>
<data name="DescriptionActionExpiresAt" xml:space="preserve">
<value>Expires at: {0}</value>
</data>
<data name="UserAlreadyMuted" xml:space="preserve">
<value>This user is already muted!</value>
</data>
<data name="MessageFrom" xml:space="preserve">
<value>From {0}:</value>
</data>
<data name="AboutTitleDevelopers" xml:space="preserve">
<value>Developers:</value>
</data>
<data name="AboutTitleWiki" xml:space="preserve">
<value>Boyfriend's Wiki Page:</value>
</data>
<data name="AboutBot" xml:space="preserve">
<value>About Boyfriend</value>
</data>
<data name="AboutDeveloper@mctaylors" xml:space="preserve">
<value>logo and embed designer, Boyfriend's Wiki creator</value>
</data>
<data name="AboutDeveloper@Octol1ttle" xml:space="preserve">
<value>main developer</value>
</data>
<data name="AboutDeveloper@neroduckale" xml:space="preserve">
<value>developer</value>
</data>
<data name="ReminderCreated" xml:space="preserve">
<value>Reminder for {0} created</value>
</data>
<data name="Reminder" xml:space="preserve">
<value>Reminder for {0}</value>
</data>
<data name="DescriptionReminder" xml:space="preserve">
<value>You asked me to remind you {0}</value>
</data>
<data name="SettingsListTitle" xml:space="preserve">
<value>Boyfriend's Settings</value>
</data>
<data name="SettingSuccessfulyChanged" xml:space="preserve">
<value>Setting successfuly changed</value>
</data>
<data name="SettingNotChanged" xml:space="preserve">
<value>Setting not changed</value>
</data>
<data name="SettingIsNow" xml:space="preserve">
<value>is now</value>
</data> </data>
</root> </root>

View file

@ -1,145 +1,133 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<root> <root>
<!-- <!--
Microsoft ResX Schema Microsoft ResX Schema
Version 2.0 Version 2.0
The primary goals of this format is to allow a simple XML format The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes various data types are done through the TypeConverter classes
associated with the data types. associated with the data types.
Example: Example:
... ado.net/XML headers & schema ... ... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader> <resheader name="resmimetype">text/microsoft-resx</resheader><resheader name="version">2.0</resheader><resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader><resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader><data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data><data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data><data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"><value>[base64 mime encoded serialized .NET Framework object]</value></data><data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"><value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value><comment>This is a comment</comment></data>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple There are any number of "resheader" rows that contain simple
name/value pairs. name/value pairs.
Each data row contains a name, and value. The row also contains a Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture. text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the Classes that don't support this are serialized and stored with the
mimetype set. mimetype set.
The mimetype is used for serialized objects, and tells the The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly: extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below. read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64 mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64 mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64 mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter : using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
--> -->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true"> <xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType> <xsd:complexType>
<xsd:sequence> <xsd:choice maxOccurs="unbounded">
<xsd:element name="value" type="xsd:string" minOccurs="0" /> <xsd:element name="metadata">
</xsd:sequence> <xsd:complexType>
<xsd:attribute name="name" use="required" type="xsd:string" /> <xsd:sequence>
<xsd:attribute name="type" type="xsd:string" /> <xsd:element name="value" type="xsd:string" minOccurs="0" />
<xsd:attribute name="mimetype" type="xsd:string" /> </xsd:sequence>
<xsd:attribute ref="xml:space" /> <xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
<xsd:element name="assembly"> </xsd:schema>
<xsd:complexType> <resheader name="resmimetype">
<xsd:attribute name="alias" type="xsd:string" /> <value>text/microsoft-resx</value>
<xsd:attribute name="name" type="xsd:string" /> </resheader>
</xsd:complexType> <resheader name="version">
</xsd:element> <value>2.0</value>
<xsd:element name="data"> </resheader>
<xsd:complexType> <resheader name="reader">
<xsd:sequence> <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> </resheader>
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> <resheader name="writer">
</xsd:sequence> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> </resheader>
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> <data name="Ready" xml:space="preserve">
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> <value>Я запустился!</value>
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Ready" xml:space="preserve">
<value>{0}Я запустился!</value>
</data> </data>
<data name="CachedMessageDeleted" xml:space="preserve"> <data name="CachedMessageDeleted" xml:space="preserve">
<value>Удалено сообщение от {0} в канале {1}: {2}</value> <value>Сообщение {0} удалено:</value>
</data> </data>
<data name="CachedMessageCleared" xml:space="preserve"> <data name="CachedMessageCleared" xml:space="preserve">
<value>Очищено сообщение от {0} в канале {1}: {2}</value> <value>Очищено сообщение от {0} в канале {1}: {2}</value>
</data> </data>
<data name="CachedMessageEdited" xml:space="preserve"> <data name="CachedMessageEdited" xml:space="preserve">
<value>Отредактировано сообщение в канале {0}: {1} -&gt; {2}</value> <value>Сообщение {0} отредактировано:</value>
</data> </data>
<data name="DefaultWelcomeMessage" xml:space="preserve"> <data name="DefaultWelcomeMessage" xml:space="preserve">
<value>{0}, добро пожаловать на сервер {1}</value> <value>{0}, добро пожаловать на сервер {1}</value>
</data> </data>
<data name="Beep1" xml:space="preserve"> <data name="Beep1" xml:space="preserve">
<value>Бап! </value> <value>Бап!</value>
</data> </data>
<data name="Beep2" xml:space="preserve"> <data name="Beep2" xml:space="preserve">
<value>Боп! </value> <value>Боп!</value>
</data> </data>
<data name="Beep3" xml:space="preserve"> <data name="Beep3" xml:space="preserve">
<value>Бип! </value> <value>Бип!</value>
</data> </data>
<data name="CommandNoPermissionBot" xml:space="preserve"> <data name="CommandNoPermissionBot" xml:space="preserve">
<value>У меня недостаточно прав для выполнения этой команды!</value> <value>У меня недостаточно прав для выполнения этой команды!</value>
@ -147,9 +135,6 @@
<data name="CommandNoPermissionUser" xml:space="preserve"> <data name="CommandNoPermissionUser" xml:space="preserve">
<value>У тебя недостаточно прав для выполнения этой команды!</value> <value>У тебя недостаточно прав для выполнения этой команды!</value>
</data> </data>
<data name="YouWereBanned" xml:space="preserve">
<value>Тебя забанил {0} на сервере `{1}` за {2}</value>
</data>
<data name="PunishmentExpired" xml:space="preserve"> <data name="PunishmentExpired" xml:space="preserve">
<value>Время наказания истекло</value> <value>Время наказания истекло</value>
</data> </data>
@ -163,8 +148,8 @@
<value>Справка по командам:</value> <value>Справка по командам:</value>
</data> </data>
<data name="YouWereKicked" xml:space="preserve"> <data name="YouWereKicked" xml:space="preserve">
<value>Тебя кикнул {0} на сервере `{1}` за {2}</value> <value>Вы были выгнаны</value>
</data> </data>
<data name="Milliseconds" xml:space="preserve"> <data name="Milliseconds" xml:space="preserve">
<value>мс</value> <value>мс</value>
</data> </data>
@ -177,148 +162,148 @@
<data name="RoleNotSpecified" xml:space="preserve"> <data name="RoleNotSpecified" xml:space="preserve">
<value>Не указана</value> <value>Не указана</value>
</data> </data>
<data name="CurrentSettings" xml:space="preserve"> <data name="CurrentSettings" xml:space="preserve">
<value>Текущие настройки:</value> <value>Текущие настройки:</value>
</data> </data>
<data name="SettingsLang" xml:space="preserve"> <data name="SettingsLang" xml:space="preserve">
<value>Язык</value> <value>Язык</value>
</data> </data>
<data name="SettingsPrefix" xml:space="preserve"> <data name="SettingsPrefix" xml:space="preserve">
<value>Префикс</value> <value>Префикс</value>
</data> </data>
<data name="SettingsRemoveRolesOnMute" xml:space="preserve"> <data name="SettingsRemoveRolesOnMute" xml:space="preserve">
<value>Удалять роли при муте</value> <value>Удалять роли при муте</value>
</data> </data>
<data name="SettingsSendWelcomeMessages" xml:space="preserve"> <data name="SettingsSendWelcomeMessages" xml:space="preserve">
<value>Отправлять приветствия</value> <value>Отправлять приветствия</value>
</data> </data>
<data name="SettingsMuteRole" xml:space="preserve"> <data name="SettingsMuteRole" xml:space="preserve">
<value>Роль мута</value> <value>Роль мута</value>
</data> </data>
<data name="LanguageNotSupported" xml:space="preserve"> <data name="LanguageNotSupported" xml:space="preserve">
<value>Язык не поддерживается! Поддерживаемые языки:</value> <value>Язык не поддерживается! </value>
</data> </data>
<data name="Yes" xml:space="preserve"> <data name="Yes" xml:space="preserve">
<value>Да</value> <value>Да</value>
</data> </data>
<data name="No" xml:space="preserve"> <data name="No" xml:space="preserve">
<value>Нет</value> <value>Нет</value>
</data> </data>
<data name="UserNotBanned" xml:space="preserve"> <data name="UserNotBanned" xml:space="preserve">
<value>Этот пользователь не забанен!</value> <value>Этот пользователь не забанен!</value>
</data> </data>
<data name="MemberNotMuted" xml:space="preserve"> <data name="MemberNotMuted" xml:space="preserve">
<value>Участник не заглушен!</value> <value>Участник не заглушен!</value>
</data> </data>
<data name="SettingsWelcomeMessage" xml:space="preserve"> <data name="SettingsWelcomeMessage" xml:space="preserve">
<value>Приветствие</value> <value>Приветствие</value>
</data> </data>
<data name="ClearAmountInvalid" xml:space="preserve"> <data name="ClearAmountInvalid" xml:space="preserve">
<value>Надо указать целое число от {0} до {1} вместо {2}!</value> <value>Надо указать целое число от {0} до {1} вместо {2}!</value>
</data> </data>
<data name="FeedbackUserBanned" xml:space="preserve"> <data name="UserBanned" xml:space="preserve">
<value>Забанен {0} на{1}: {2}</value> <value>{0} был(-а) забанен(-а)</value>
</data> </data>
<data name="SettingDoesntExist" xml:space="preserve"> <data name="SettingDoesntExist" xml:space="preserve">
<value>Такая настройка не существует!</value> <value>Такая настройка не существует!</value>
</data> </data>
<data name="SettingsReceiveStartupMessages" xml:space="preserve"> <data name="SettingsReceiveStartupMessages" xml:space="preserve">
<value>Получать сообщения о запуске</value> <value>Получать сообщения о запуске</value>
</data> </data>
<data name="InvalidSettingValue" xml:space="preserve"> <data name="InvalidSettingValue" xml:space="preserve">
<value>Указано недействительное значение для настройки!</value> <value>Указано недействительное значение для настройки!</value>
</data> </data>
<data name="InvalidRole" xml:space="preserve"> <data name="InvalidRole" xml:space="preserve">
<value>Эта роль не существует!</value> <value>Эта роль не существует!</value>
</data> </data>
<data name="InvalidChannel" xml:space="preserve"> <data name="InvalidChannel" xml:space="preserve">
<value>Этот канал не существует!</value> <value>Этот канал не существует!</value>
</data> </data>
<data name="DurationRequiredForTimeOuts" xml:space="preserve"> <data name="DurationRequiredForTimeOuts" xml:space="preserve">
<value>Я не могу заглушить кого-то на более чем 28 дней, используя тайм-ауты! Или укажи продолжительность менее 28 дней, или установи роль мута в настройках</value> <value>Я не могу заглушить кого-то на более чем 28 дней, используя тайм-ауты! Или укажи продолжительность менее 28 дней, или установи роль мута в настройках</value>
</data> </data>
<data name="CannotTimeOutBot" xml:space="preserve"> <data name="CannotTimeOutBot" xml:space="preserve">
<value>Я не могу использовать тайм-ауты на других ботах! Попробуй указать роль мута в настройках</value> <value>Я не могу использовать тайм-ауты на других ботах! Попробуй указать роль мута в настройках</value>
</data> </data>
<data name="EventCreated" xml:space="preserve"> <data name="EventCreated" xml:space="preserve">
<value>{0} создал событие {1}! Оно пройдёт в {2} и начнётся &lt;t:{3}:R&gt;!{4}</value> <value>{0} создал событие {1}! Оно пройдёт в {2} и начнётся &lt;t:{3}:R&gt;!{4}</value>
</data> </data>
<data name="SettingsEventNotificationRole" xml:space="preserve"> <data name="SettingsEventNotificationRole" xml:space="preserve">
<value>Роль для уведомлений о создании событий</value> <value>Роль для уведомлений о создании событий</value>
</data> </data>
<data name="SettingsEventNotificationChannel" xml:space="preserve"> <data name="SettingsEventNotificationChannel" xml:space="preserve">
<value>Канал для уведомлений о событиях</value> <value>Канал для уведомлений о событиях</value>
</data> </data>
<data name="SettingsEventStartedReceivers" xml:space="preserve"> <data name="SettingsEventStartedReceivers" xml:space="preserve">
<value>Получатели уведомлений о начале событий</value> <value>Получатели уведомлений о начале событий</value>
</data> </data>
<data name="EventStarted" xml:space="preserve"> <data name="EventStarted" xml:space="preserve">
<value>{0}Событие {1} начинается в {2}!</value> <value>Событие "{0}" началось</value>
</data> </data>
<data name="SettingsFrowningFace" xml:space="preserve"> <data name="SettingsFrowningFace" xml:space="preserve">
<value>:( </value> <value>:( </value>
</data> </data>
<data name="EventCancelled" xml:space="preserve"> <data name="EventCancelled" xml:space="preserve">
<value>Событие {0} отменено!{1}</value> <value>Событие "{0}" отменено!</value>
</data> </data>
<data name="EventCompleted" xml:space="preserve"> <data name="EventCompleted" xml:space="preserve">
<value>Событие {0} завершено! Продолжительность:{1}</value> <value>Событие "{0}" завершено!</value>
</data> </data>
<data name="Ever" xml:space="preserve"> <data name="Ever" xml:space="preserve">
<value>всегда</value> <value>всегда</value>
</data> </data>
<data name="FeedbackMessagesCleared" xml:space="preserve"> <data name="MessagesCleared" xml:space="preserve">
<value>Удалено {0} сообщений в {1}</value> <value>Очищено {0} сообщений</value>
</data> </data>
<data name="FeedbackMemberKicked" xml:space="preserve"> <data name="FeedbackMemberKicked" xml:space="preserve">
<value>Выгнан {0}: {1}</value> <value>Выгнан {0}: {1}</value>
</data> </data>
<data name="FeedbackMemberMuted" xml:space="preserve"> <data name="FeedbackMemberMuted" xml:space="preserve">
<value>Заглушен {0} на{1}: {2}</value> <value>Заглушен {0} на{1}: {2}</value>
</data> </data>
<data name="FeedbackUserUnbanned" xml:space="preserve"> <data name="FeedbackUserUnbanned" xml:space="preserve">
<value>Возвращён из бана {0}: {1}</value> <value>Возвращён из бана {0}: {1}</value>
</data> </data>
<data name="FeedbackMemberUnmuted" xml:space="preserve"> <data name="FeedbackMemberUnmuted" xml:space="preserve">
<value>Разглушен {0}: {1}</value> <value>Разглушен {0}: {1}</value>
</data> </data>
<data name="SettingsNothingChanged" xml:space="preserve"> <data name="SettingsNothingChanged" xml:space="preserve">
<value>Ничего не изменилось! Значение настройки `{0}` уже {1}</value> <value>Ничего не изменилось! Значение настройки `{0}` уже {1}</value>
</data> </data>
<data name="SettingNotDefined" xml:space="preserve"> <data name="SettingNotDefined" xml:space="preserve">
<value>Не указано</value> <value>Не указано</value>
</data> </data>
<data name="FeedbackSettingsUpdated" xml:space="preserve"> <data name="FeedbackSettingsUpdated" xml:space="preserve">
<value>Значение настройки `{0}` теперь установлено на {1}</value> <value>Значение настройки `{0}` теперь установлено на {1}</value>
</data> </data>
<data name="CommandDescriptionBan" xml:space="preserve"> <data name="CommandDescriptionBan" xml:space="preserve">
<value>Банит пользователя</value> <value>Банит пользователя</value>
</data> </data>
<data name="CommandDescriptionClear" xml:space="preserve"> <data name="CommandDescriptionClear" xml:space="preserve">
<value>Удаляет указанное количество сообщений в этом канале</value> <value>Удаляет указанное количество сообщений в этом канале</value>
</data> </data>
<data name="CommandDescriptionHelp" xml:space="preserve"> <data name="CommandDescriptionHelp" xml:space="preserve">
<value>Показывает эту справку</value> <value>Показывает эту справку</value>
</data> </data>
<data name="CommandDescriptionKick" xml:space="preserve"> <data name="CommandDescriptionKick" xml:space="preserve">
<value>Выгоняет участника</value> <value>Выгоняет участника</value>
</data> </data>
<data name="CommandDescriptionMute" xml:space="preserve"> <data name="CommandDescriptionMute" xml:space="preserve">
<value>Глушит участника</value> <value>Глушит участника</value>
</data> </data>
<data name="CommandDescriptionPing" xml:space="preserve"> <data name="CommandDescriptionPing" xml:space="preserve">
<value>Показывает (неточную) задержку</value> <value>Показывает (неточную) задержку</value>
</data> </data>
<data name="CommandDescriptionSettings" xml:space="preserve"> <data name="CommandDescriptionSettings" xml:space="preserve">
<value>Позволяет менять некоторые настройки под этот сервер</value> <value>Позволяет менять некоторые настройки под этот сервер</value>
</data> </data>
<data name="CommandDescriptionUnban" xml:space="preserve"> <data name="CommandDescriptionUnban" xml:space="preserve">
<value>Возвращает пользователя из бана</value> <value>Возвращает пользователя из бана</value>
</data> </data>
<data name="CommandDescriptionUnmute" xml:space="preserve"> <data name="CommandDescriptionUnmute" xml:space="preserve">
<value>Разглушает участника</value> <value>Разглушает участника</value>
</data> </data>
<data name="MissingNumber" xml:space="preserve"> <data name="MissingNumber" xml:space="preserve">
<value>Надо указать целое число от {0} до {1}!</value> <value>Надо указать целое число от {0} до {1}!</value>
</data> </data>
<data name="MissingUser" xml:space="preserve"> <data name="MissingUser" xml:space="preserve">
@ -331,8 +316,8 @@
<value>Надо указать участника сервера!</value> <value>Надо указать участника сервера!</value>
</data> </data>
<data name="InvalidMember" xml:space="preserve"> <data name="InvalidMember" xml:space="preserve">
<value>Надо указать участника этого сервера!</value> <value>Надо указать участника этого сервера!</value>
</data> </data>
<data name="UserCannotBanMembers" xml:space="preserve"> <data name="UserCannotBanMembers" xml:space="preserve">
<value>Ты не можешь банить пользователей на этом сервере!</value> <value>Ты не можешь банить пользователей на этом сервере!</value>
</data> </data>
@ -345,94 +330,94 @@
<data name="UserCannotModerateMembers" xml:space="preserve"> <data name="UserCannotModerateMembers" xml:space="preserve">
<value>Ты не можешь модерировать участников этого сервера!</value> <value>Ты не можешь модерировать участников этого сервера!</value>
</data> </data>
<data name="UserCannotManageGuild" xml:space="preserve"> <data name="UserCannotManageGuild" xml:space="preserve">
<value>Ты не можешь настраивать этот сервер!</value> <value>Ты не можешь настраивать этот сервер!</value>
</data> </data>
<data name="BotCannotBanMembers" xml:space="preserve"> <data name="BotCannotBanMembers" xml:space="preserve">
<value>Я не могу банить пользователей на этом сервере!</value> <value>Я не могу банить пользователей на этом сервере!</value>
</data> </data>
<data name="BotCannotManageMessages" xml:space="preserve"> <data name="BotCannotManageMessages" xml:space="preserve">
<value>Я не могу управлять сообщениями этого сервера!</value> <value>Я не могу управлять сообщениями этого сервера!</value>
</data> </data>
<data name="BotCannotKickMembers" xml:space="preserve"> <data name="BotCannotKickMembers" xml:space="preserve">
<value>Я не могу выгонять участников с этого сервера!</value> <value>Я не могу выгонять участников с этого сервера!</value>
</data> </data>
<data name="BotCannotModerateMembers" xml:space="preserve"> <data name="BotCannotModerateMembers" xml:space="preserve">
<value>Я не могу модерировать участников этого сервера!</value> <value>Я не могу модерировать участников этого сервера!</value>
</data> </data>
<data name="BotCannotManageGuild" xml:space="preserve"> <data name="BotCannotManageGuild" xml:space="preserve">
<value>Я не могу настраивать этот сервер!</value> <value>Я не могу настраивать этот сервер!</value>
</data> </data>
<data name="MissingBanReason" xml:space="preserve"> <data name="MissingBanReason" xml:space="preserve">
<value>Надо указать причину для бана этого участника!</value> <value>Надо указать причину для бана этого участника!</value>
</data> </data>
<data name="MissingKickReason" xml:space="preserve"> <data name="MissingKickReason" xml:space="preserve">
<value>Надо указать причину для кика этого участника!</value> <value>Надо указать причину для кика этого участника!</value>
</data> </data>
<data name="MissingMuteReason" xml:space="preserve"> <data name="MissingMuteReason" xml:space="preserve">
<value>Надо указать причину для мута этого участника!</value> <value>Надо указать причину для мута этого участника!</value>
</data> </data>
<data name="MissingUnbanReason" xml:space="preserve"> <data name="MissingUnbanReason" xml:space="preserve">
<value>Надо указать причину для разбана этого пользователя!</value> <value>Надо указать причину для разбана этого пользователя!</value>
</data> </data>
<data name="MissingUnmuteReason" xml:space="preserve"> <data name="MissingUnmuteReason" xml:space="preserve">
<value>Надо указать причину для размута этого участника!</value> <value>Надо указать причину для размута этого участника!</value>
</data> </data>
<data name="UserCannotBanBot" xml:space="preserve"> <data name="UserCannotBanBot" xml:space="preserve">
<value>Ты не можешь меня забанить!</value> <value>Ты не можешь меня забанить!</value>
</data> </data>
<data name="UserCannotBanOwner" xml:space="preserve"> <data name="UserCannotBanOwner" xml:space="preserve">
<value>Ты не можешь забанить владельца этого сервера!</value> <value>Ты не можешь забанить владельца этого сервера!</value>
</data> </data>
<data name="UserCannotBanTarget" xml:space="preserve"> <data name="UserCannotBanTarget" xml:space="preserve">
<value>Ты не можешь забанить этого участника!</value> <value>Ты не можешь забанить этого участника!</value>
</data> </data>
<data name="UserCannotBanThemselves" xml:space="preserve"> <data name="UserCannotBanThemselves" xml:space="preserve">
<value>Ты не можешь себя забанить!</value> <value>Ты не можешь себя забанить!</value>
</data> </data>
<data name="BotCannotBanTarget" xml:space="preserve"> <data name="BotCannotBanTarget" xml:space="preserve">
<value>Я не могу забанить этого пользователя!</value> <value>Я не могу забанить этого пользователя!</value>
</data> </data>
<data name="UserCannotKickOwner" xml:space="preserve"> <data name="UserCannotKickOwner" xml:space="preserve">
<value>Ты не можешь выгнать владельца этого сервера!</value> <value>Ты не можешь выгнать владельца этого сервера!</value>
</data> </data>
<data name="UserCannotKickThemselves" xml:space="preserve"> <data name="UserCannotKickThemselves" xml:space="preserve">
<value>Ты не можешь себя выгнать!</value> <value>Ты не можешь себя выгнать!</value>
</data> </data>
<data name="UserCannotKickBot" xml:space="preserve"> <data name="UserCannotKickBot" xml:space="preserve">
<value>Ты не можешь меня выгнать!</value> <value>Ты не можешь меня выгнать!</value>
</data> </data>
<data name="BotCannotKickTarget" xml:space="preserve"> <data name="BotCannotKickTarget" xml:space="preserve">
<value>Я не могу выгнать этого участника</value> <value>Я не могу выгнать этого участника</value>
</data> </data>
<data name="UserCannotKickTarget" xml:space="preserve"> <data name="UserCannotKickTarget" xml:space="preserve">
<value>Ты не можешь выгнать этого участника!</value> <value>Ты не можешь выгнать этого участника!</value>
</data> </data>
<data name="UserCannotMuteOwner" xml:space="preserve"> <data name="UserCannotMuteOwner" xml:space="preserve">
<value>Ты не можешь заглушить владельца этого сервера!</value> <value>Ты не можешь заглушить владельца этого сервера!</value>
</data> </data>
<data name="UserCannotMuteThemselves" xml:space="preserve"> <data name="UserCannotMuteThemselves" xml:space="preserve">
<value>Ты не можешь себя заглушить!</value> <value>Ты не можешь себя заглушить!</value>
</data> </data>
<data name="UserCannotMuteBot" xml:space="preserve"> <data name="UserCannotMuteBot" xml:space="preserve">
<value>Ты не можешь заглушить меня!</value> <value>Ты не можешь заглушить меня!</value>
</data> </data>
<data name="BotCannotMuteTarget" xml:space="preserve"> <data name="BotCannotMuteTarget" xml:space="preserve">
<value>Я не могу заглушить этого пользователя!</value> <value>Я не могу заглушить этого пользователя!</value>
</data> </data>
<data name="UserCannotMuteTarget" xml:space="preserve"> <data name="UserCannotMuteTarget" xml:space="preserve">
<value>Ты не можешь заглушить этого участника!</value> <value>Ты не можешь заглушить этого участника!</value>
</data> </data>
<data name="UserCannotUnmuteOwner" xml:space="preserve"> <data name="UserCannotUnmuteOwner" xml:space="preserve">
<value>Тебе не надо возвращать из мута владельца этого сервера!</value> <value>Тебе не надо возвращать из мута владельца этого сервера!</value>
</data> </data>
<data name="UserCannotUnmuteThemselves" xml:space="preserve"> <data name="UserCannotUnmuteThemselves" xml:space="preserve">
<value>Ты заглушен!</value> <value>Ты заглушен!</value>
</data> </data>
<data name="UserCannotUnmuteBot" xml:space="preserve"> <data name="UserCannotUnmuteBot" xml:space="preserve">
<value>... </value> <value>... </value>
</data> </data>
<data name="UserCannotUnmuteTarget" xml:space="preserve"> <data name="UserCannotUnmuteTarget" xml:space="preserve">
<value>Ты не можешь вернуть из мута этого пользователя!</value> <value>Ты не можешь вернуть из мута этого пользователя!</value>
</data> </data>
<data name="BotCannotUnmuteTarget" xml:space="preserve"> <data name="BotCannotUnmuteTarget" xml:space="preserve">
@ -445,33 +430,132 @@
<value>Офсет отправки преждевременного уведомления о начале события</value> <value>Офсет отправки преждевременного уведомления о начале события</value>
</data> </data>
<data name="UserNotFound" xml:space="preserve"> <data name="UserNotFound" xml:space="preserve">
<value>Я не смог найти этого пользователя ни в одном из серверов, в которых я есть. Проверь правильность ID и нахождение пользователя на этом сервере максимум 30 дней назад</value> <value>Я не смог найти этого пользователя ни в одном из серверов, в которых я есть. Проверь правильность ID и нахождение пользователя на этом сервере максимум 30 дней назад</value>
</data> </data>
<data name="SettingsStarterRole" xml:space="preserve"> <data name="SettingsDefaultRole" xml:space="preserve">
<value>Начальная роль</value> <value>Общая роль</value>
</data> </data>
<data name="CommandDescriptionRemind" xml:space="preserve"> <data name="CommandDescriptionRemind" xml:space="preserve">
<value>Добавляет напоминание</value> <value>Добавляет напоминание</value>
</data> </data>
<data name="SettingsPublicFeedbackChannel" xml:space="preserve"> <data name="SettingsPublicFeedbackChannel" xml:space="preserve">
<value>Канал для публичных уведомлений</value> <value>Канал для публичных уведомлений</value>
</data> </data>
<data name="SettingsPrivateFeedbackChannel" xml:space="preserve"> <data name="SettingsPrivateFeedbackChannel" xml:space="preserve">
<value>Канал для приватных уведомлений</value> <value>Канал для приватных уведомлений</value>
</data> </data>
<data name="SettingsReturnRolesOnRejoin" xml:space="preserve"> <data name="SettingsReturnRolesOnRejoin" xml:space="preserve">
<value>Возвращать роли при перезаходе</value> <value>Возвращать роли при перезаходе</value>
</data>
<data name="SettingsAutoStartEvents" xml:space="preserve">
<value>Автоматически начинать события</value>
</data>
<data name="MissingReminderText" xml:space="preserve">
<value>Тебе нужно указать текст напоминания!</value>
</data>
<data name="DescriptionReminderCreated" xml:space="preserve">
<value>Хорошо, я упомяну тебя {0}</value>
</data> </data>
<data name="SettingsAutoStartEvents" xml:space="preserve"> <data name="InvalidRemindIn" xml:space="preserve">
<value>Автоматически начинать события</value> <value>Нужно указать время, через которое придёт напоминание!</value>
</data>
<data name="IssuedBy" xml:space="preserve">
<value>Ответственный</value>
</data>
<data name="EventCreatedTitle" xml:space="preserve">
<value>{0} создаёт новое событие:</value>
</data>
<data name="DescriptionLocalEventCreated" xml:space="preserve">
<value>Событие пройдёт {0} в канале {1}</value>
</data>
<data name="DescriptionExternalEventCreated" xml:space="preserve">
<value>Событие пройдёт с {0} до {1} в {2}</value>
</data>
<data name="EventDetailsButton" xml:space="preserve">
<value>Подробнее о событии</value>
</data>
<data name="EventDuration" xml:space="preserve">
<value>Событие длилось `{0}`</value>
</data>
<data name="DescriptionLocalEventStarted" xml:space="preserve">
<value>Событие происходит в {0}</value>
</data>
<data name="DescriptionExternalEventStarted" xml:space="preserve">
<value>Событие происходит в {0} до {1}</value>
</data>
<data name="UserAlreadyBanned" xml:space="preserve">
<value>Этот пользователь уже забанен!</value>
</data>
<data name="UserUnbanned" xml:space="preserve">
<value>{0} был(-а) разбанен(-а)</value>
</data>
<data name="UserMuted" xml:space="preserve">
<value>{0} был(-а) заглушен(-а)</value>
</data>
<data name="UserNotMuted" xml:space="preserve">
<value>Этот участник не заглушен!</value>
</data>
<data name="UserUnmuted" xml:space="preserve">
<value>{0} был(-а) разглушен(-а)</value>
</data>
<data name="UserNotFoundShort" xml:space="preserve">
<value>Я не смог найти этого пользователя!</value>
</data>
<data name="UserKicked" xml:space="preserve">
<value>{0} был(-а) выгнан(-а)</value>
</data>
<data name="DescriptionActionReason" xml:space="preserve">
<value>Причина: {0}</value>
</data>
<data name="DescriptionActionExpiresAt" xml:space="preserve">
<value>Закончится: {0}</value>
</data>
<data name="UserAlreadyMuted" xml:space="preserve">
<value>Этот пользователь уже в муте!</value>
</data>
<data name="YouWereBanned" xml:space="preserve">
<value>Вы были забанены</value>
</data>
<data name="MessageFrom" xml:space="preserve">
<value>От {0}:</value>
</data>
<data name="AboutTitleDevelopers" xml:space="preserve">
<value>Разработчики:</value>
</data>
<data name="AboutTitleWiki" xml:space="preserve">
<value>Страница Boyfriend's Wiki:</value>
</data>
<data name="AboutBot" xml:space="preserve">
<value>О Boyfriend</value>
</data>
<data name="AboutDeveloper@neroduckale" xml:space="preserve">
<value>разрабочик</value>
</data>
<data name="AboutDeveloper@Octol1ttle" xml:space="preserve">
<value>основной разработчик</value>
</data>
<data name="AboutDeveloper@mctaylors" xml:space="preserve">
<value>дизайнер лого и эмбедов, создатель Boyfriend's Wiki</value>
</data>
<data name="ReminderCreated" xml:space="preserve">
<value>Напоминание для {0} создано</value>
</data> </data>
<data name="MissingReminderText" xml:space="preserve"> <data name="Reminder" xml:space="preserve">
<value>Тебе нужно указать текст напоминания!</value> <value>Напоминание для {0}</value>
</data> </data>
<data name="FeedbackReminderAdded" xml:space="preserve"> <data name="DescriptionReminder" xml:space="preserve">
<value>Хорошо, я упомяну тебя &lt;t:{0}:f&gt;</value> <value>Вы просили напомнить вам {0}</value>
</data> </data>
<data name="InvalidRemindIn" xml:space="preserve"> <data name="SettingsListTitle" xml:space="preserve">
<value>Нужно указать время, через которое придёт напоминание!</value> <value>Настройки Boyfriend</value>
</data>
<data name="SettingSuccessfulyChanged" xml:space="preserve">
<value>Настройка успешно изменена</value>
</data>
<data name="SettingNotChanged" xml:space="preserve">
<value>Настройка не редактирована</value>
</data>
<data name="SettingIsNow" xml:space="preserve">
<value>теперь имеет значение</value>
</data> </data>
</root> </root>

View file

@ -1,145 +1,133 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<root> <root>
<!-- <!--
Microsoft ResX Schema Microsoft ResX Schema
Version 2.0 Version 2.0
The primary goals of this format is to allow a simple XML format The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes various data types are done through the TypeConverter classes
associated with the data types. associated with the data types.
Example: Example:
... ado.net/XML headers & schema ... ... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader> <resheader name="resmimetype">text/microsoft-resx</resheader><resheader name="version">2.0</resheader><resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader><resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader><data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data><data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data><data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"><value>[base64 mime encoded serialized .NET Framework object]</value></data><data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"><value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value><comment>This is a comment</comment></data>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple There are any number of "resheader" rows that contain simple
name/value pairs. name/value pairs.
Each data row contains a name, and value. The row also contains a Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture. text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the Classes that don't support this are serialized and stored with the
mimetype set. mimetype set.
The mimetype is used for serialized objects, and tells the The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly: extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below. read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64 mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64 mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter : System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64 mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter : using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding. : and then encoded with base64 encoding.
--> -->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true"> <xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType> <xsd:complexType>
<xsd:sequence> <xsd:choice maxOccurs="unbounded">
<xsd:element name="value" type="xsd:string" minOccurs="0" /> <xsd:element name="metadata">
</xsd:sequence> <xsd:complexType>
<xsd:attribute name="name" use="required" type="xsd:string" /> <xsd:sequence>
<xsd:attribute name="type" type="xsd:string" /> <xsd:element name="value" type="xsd:string" minOccurs="0" />
<xsd:attribute name="mimetype" type="xsd:string" /> </xsd:sequence>
<xsd:attribute ref="xml:space" /> <xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType> </xsd:complexType>
</xsd:element> </xsd:element>
<xsd:element name="assembly"> </xsd:schema>
<xsd:complexType> <resheader name="resmimetype">
<xsd:attribute name="alias" type="xsd:string" /> <value>text/microsoft-resx</value>
<xsd:attribute name="name" type="xsd:string" /> </resheader>
</xsd:complexType> <resheader name="version">
</xsd:element> <value>2.0</value>
<xsd:element name="data"> </resheader>
<xsd:complexType> <resheader name="reader">
<xsd:sequence> <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> </resheader>
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> <resheader name="writer">
</xsd:sequence> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 </value>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> </resheader>
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> <data name="Ready" xml:space="preserve">
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> <value>я родился!</value>
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Ready" xml:space="preserve">
<value>{0}я родился!</value>
</data> </data>
<data name="CachedMessageDeleted" xml:space="preserve"> <data name="CachedMessageDeleted" xml:space="preserve">
<value>вырезано сообщение от {0} в канале {1}: {2}</value> <value>сообщение {0} вырезано:</value>
</data> </data>
<data name="CachedMessageCleared" xml:space="preserve"> <data name="CachedMessageCleared" xml:space="preserve">
<value>вырезано сообщение (используя `!clear`) от {0} в канале {1}: {2}</value> <value>вырезано сообщение (используя `!clear`) от {0} в канале {1}: {2}</value>
</data> </data>
<data name="CachedMessageEdited" xml:space="preserve"> <data name="CachedMessageEdited" xml:space="preserve">
<value>переделано сообщение от {0}: {1} -&gt; {2}</value> <value>сообщение {0} переделано:</value>
</data> </data>
<data name="DefaultWelcomeMessage" xml:space="preserve"> <data name="DefaultWelcomeMessage" xml:space="preserve">
<value>{0}, добро пожаловать на сервер {1}</value> <value>{0}, добро пожаловать на сервер {1}</value>
</data> </data>
<data name="Beep1" xml:space="preserve"> <data name="Beep1" xml:space="preserve">
<value>брах! </value> <value>брах!</value>
</data> </data>
<data name="Beep2" xml:space="preserve"> <data name="Beep2" xml:space="preserve">
<value>брох! </value> <value>брох!</value>
</data> </data>
<data name="Beep3" xml:space="preserve"> <data name="Beep3" xml:space="preserve">
<value>брух! </value> <value>брух!</value>
</data> </data>
<data name="CommandNoPermissionBot" xml:space="preserve"> <data name="CommandNoPermissionBot" xml:space="preserve">
<value>у меня прав нету, сделай что нибудь.</value> <value>у меня прав нету, сделай что нибудь.</value>
@ -148,8 +136,8 @@
<value>у тебя прав нету, твои проблемы.</value> <value>у тебя прав нету, твои проблемы.</value>
</data> </data>
<data name="YouWereBanned" xml:space="preserve"> <data name="YouWereBanned" xml:space="preserve">
<value>здарова, тебя крч забанил {0} на сервере `{1}` за {2}</value> <value>вы были забанены</value>
</data> </data>
<data name="PunishmentExpired" xml:space="preserve"> <data name="PunishmentExpired" xml:space="preserve">
<value>время бана закончиловсь</value> <value>время бана закончиловсь</value>
</data> </data>
@ -163,8 +151,8 @@
<value>туториал по приколам:</value> <value>туториал по приколам:</value>
</data> </data>
<data name="YouWereKicked" xml:space="preserve"> <data name="YouWereKicked" xml:space="preserve">
<value>здарова, тебя крч кикнул {0} на сервере `{1}` за {2}</value> <value>вы были кикнуты</value>
</data> </data>
<data name="Milliseconds" xml:space="preserve"> <data name="Milliseconds" xml:space="preserve">
<value>мс</value> <value>мс</value>
</data> </data>
@ -175,150 +163,150 @@
<value>*тут ничего нет*</value> <value>*тут ничего нет*</value>
</data> </data>
<data name="RoleNotSpecified" xml:space="preserve"> <data name="RoleNotSpecified" xml:space="preserve">
<value>нъет</value> <value>нъет</value>
</data> </data>
<data name="CurrentSettings" xml:space="preserve"> <data name="CurrentSettings" xml:space="preserve">
<value>настройки:</value> <value>настройки:</value>
</data> </data>
<data name="SettingsLang" xml:space="preserve"> <data name="SettingsLang" xml:space="preserve">
<value>язык</value> <value>язык</value>
</data> </data>
<data name="SettingsPrefix" xml:space="preserve"> <data name="SettingsPrefix" xml:space="preserve">
<value>префикс</value> <value>префикс</value>
</data> </data>
<data name="SettingsRemoveRolesOnMute" xml:space="preserve"> <data name="SettingsRemoveRolesOnMute" xml:space="preserve">
<value>удалять звание при муте</value> <value>удалять звание при муте</value>
</data> </data>
<data name="SettingsSendWelcomeMessages" xml:space="preserve"> <data name="SettingsSendWelcomeMessages" xml:space="preserve">
<value>разглашать о том что пришел новый шизоид</value> <value>разглашать о том что пришел новый шизоид</value>
</data> </data>
<data name="SettingsMuteRole" xml:space="preserve"> <data name="SettingsMuteRole" xml:space="preserve">
<value>звание замученного</value> <value>звание замученного</value>
</data>
<data name="LanguageNotSupported" xml:space="preserve">
<value>такого языка нету, ты шо, есть только такие:</value>
</data> </data>
<data name="Yes" xml:space="preserve"> <data name="LanguageNotSupported" xml:space="preserve">
<value>такого языка нету...</value>
</data>
<data name="Yes" xml:space="preserve">
<value>да</value> <value>да</value>
</data> </data>
<data name="No" xml:space="preserve"> <data name="No" xml:space="preserve">
<value>нъет</value> <value>нъет</value>
</data> </data>
<data name="UserNotBanned" xml:space="preserve"> <data name="UserNotBanned" xml:space="preserve">
<value>шизик не забанен</value> <value>шизик не забанен</value>
</data> </data>
<data name="MemberNotMuted" xml:space="preserve"> <data name="MemberNotMuted" xml:space="preserve">
<value>шизоид не замучен!</value> <value>шизоид не замучен!</value>
</data> </data>
<data name="SettingsWelcomeMessage" xml:space="preserve"> <data name="SettingsWelcomeMessage" xml:space="preserve">
<value>здравствуйте (типо настройка)</value> <value>здравствуйте (типо настройка)</value>
</data> </data>
<data name="ClearAmountInvalid" xml:space="preserve"> <data name="ClearAmountInvalid" xml:space="preserve">
<value>выбери число от {0} до {1} вместо {2}!</value> <value>выбери число от {0} до {1} вместо {2}!</value>
</data> </data>
<data name="FeedbackUserBanned" xml:space="preserve"> <data name="UserBanned" xml:space="preserve">
<value>забанен {0} на{1}: {2}</value> <value>{0} забанен</value>
</data> </data>
<data name="SettingDoesntExist" xml:space="preserve"> <data name="SettingDoesntExist" xml:space="preserve">
<value>такой прикол не существует</value> <value>такой прикол не существует</value>
</data> </data>
<data name="SettingsReceiveStartupMessages" xml:space="preserve"> <data name="SettingsReceiveStartupMessages" xml:space="preserve">
<value>получать инфу о старте бота</value> <value>получать инфу о старте бота</value>
</data> </data>
<data name="InvalidSettingValue" xml:space="preserve"> <data name="InvalidSettingValue" xml:space="preserve">
<value>криво настроил прикол, давай по новой</value> <value>криво настроил прикол, давай по новой</value>
</data> </data>
<data name="InvalidRole" xml:space="preserve"> <data name="InvalidRole" xml:space="preserve">
<value>этого звания нету, ты шо</value> <value>этого звания нету, ты шо</value>
</data> </data>
<data name="InvalidChannel" xml:space="preserve"> <data name="InvalidChannel" xml:space="preserve">
<value>этого канала нету, ты шо</value> <value>этого канала нету, ты шо</value>
</data> </data>
<data name="DurationRequiredForTimeOuts" xml:space="preserve"> <data name="DurationRequiredForTimeOuts" xml:space="preserve">
<value>ты шо, мутить больше чем на 28 дней таймаут не разрешает, вот настроишь роль мута, тогда поговорим</value> <value>ты шо, мутить больше чем на 28 дней таймаут не разрешает, вот настроишь роль мута, тогда поговорим</value>
</data> </data>
<data name="CannotTimeOutBot" xml:space="preserve"> <data name="CannotTimeOutBot" xml:space="preserve">
<value>я не могу замутить ботов, сделай что нибудь</value> <value>я не могу замутить ботов, сделай что нибудь</value>
</data> </data>
<data name="EventCreated" xml:space="preserve"> <data name="EventCreated" xml:space="preserve">
<value>{0} приготовил новый квест {1}! он пройдёт в {2} и начнётся &lt;t:{3}:R&gt;!{4}</value> <value>{0} приготовил новую движуху {1}! она пройдёт в {2} и начнётся &lt;t:{3}:R&gt;!{4}</value>
</data> </data>
<data name="SettingsEventNotificationRole" xml:space="preserve"> <data name="SettingsEventNotificationRole" xml:space="preserve">
<value>роль для уведомлений о создании квеста</value> <value>роль для уведомлений о создании движухи</value>
</data> </data>
<data name="SettingsEventNotificationChannel" xml:space="preserve"> <data name="SettingsEventNotificationChannel" xml:space="preserve">
<value>канал для уведомлений о квестах</value> <value>канал для уведомлений о движухах</value>
</data> </data>
<data name="SettingsEventStartedReceivers" xml:space="preserve"> <data name="SettingsEventStartedReceivers" xml:space="preserve">
<value>получатели уведомлений о начале квеста</value> <value>получатели уведомлений о начале движух</value>
</data> </data>
<data name="EventStarted" xml:space="preserve"> <data name="EventStarted" xml:space="preserve">
<value>{0}квест {1} начинается в {2}!</value> <value>движуха "{0}" начинается</value>
</data> </data>
<data name="SettingsFrowningFace" xml:space="preserve"> <data name="SettingsFrowningFace" xml:space="preserve">
<value>оъмъомоъемъъео(((( </value> <value>оъмъомоъемъъео((((</value>
</data> </data>
<data name="EventCancelled" xml:space="preserve"> <data name="EventCancelled" xml:space="preserve">
<value>квест {0} отменен!{1}</value> <value>движуха "{0}" отменен!</value>
</data> </data>
<data name="EventCompleted" xml:space="preserve"> <data name="EventCompleted" xml:space="preserve">
<value>квест {0} завершен! все это длилось{1}</value> <value>движуха "{0}" завершен!</value>
</data> </data>
<data name="Ever" xml:space="preserve"> <data name="Ever" xml:space="preserve">
<value>всегда</value> <value>всегда</value>
</data> </data>
<data name="FeedbackMessagesCleared" xml:space="preserve"> <data name="MessagesCleared" xml:space="preserve">
<value>удалено {0} сообщений в {1}</value> <value>вырезано {0} забавных сообщений</value>
</data> </data>
<data name="FeedbackMemberKicked" xml:space="preserve"> <data name="FeedbackMemberKicked" xml:space="preserve">
<value>выгнан {0}: {1}</value> <value>выгнан {0}: {1}</value>
</data> </data>
<data name="FeedbackMemberMuted" xml:space="preserve"> <data name="FeedbackMemberMuted" xml:space="preserve">
<value>замучен {0} на{1}: {2}</value> <value>замучен {0} на{1}: {2}</value>
</data> </data>
<data name="FeedbackUserUnbanned" xml:space="preserve"> <data name="FeedbackUserUnbanned" xml:space="preserve">
<value>раззабанен {0}: {1}</value> <value>раззабанен {0}: {1}</value>
</data> </data>
<data name="FeedbackMemberUnmuted" xml:space="preserve"> <data name="FeedbackMemberUnmuted" xml:space="preserve">
<value>раззамучен {0}: {1}</value> <value>раззамучен {0}: {1}</value>
</data> </data>
<data name="SettingsNothingChanged" xml:space="preserve"> <data name="SettingsNothingChanged" xml:space="preserve">
<value>ты все сломал! значение прикола `{0}` и так {1}</value> <value>ты все сломал! значение прикола `{0}` и так {1}</value>
</data> </data>
<data name="SettingNotDefined" xml:space="preserve"> <data name="SettingNotDefined" xml:space="preserve">
<value>нъет</value> <value>нъет</value>
</data> </data>
<data name="FeedbackSettingsUpdated" xml:space="preserve"> <data name="FeedbackSettingsUpdated" xml:space="preserve">
<value>прикол для `{0}` теперь установлен на {1}</value> <value>прикол для `{0}` теперь установлен на {1}</value>
</data> </data>
<data name="CommandDescriptionBan" xml:space="preserve"> <data name="CommandDescriptionBan" xml:space="preserve">
<value>возводит великий банхаммер над шизоидом</value> <value>возводит великий банхаммер над шизоидом</value>
</data> </data>
<data name="CommandDescriptionClear" xml:space="preserve"> <data name="CommandDescriptionClear" xml:space="preserve">
<value>удаляет сообщения. сколько хош, столько и удалит</value> <value>удаляет сообщения. сколько хош, столько и удалит</value>
</data> </data>
<data name="CommandDescriptionHelp" xml:space="preserve"> <data name="CommandDescriptionHelp" xml:space="preserve">
<value>показывает то, что ты сейчас видишь прямо сейчас</value> <value>показывает то, что ты сейчас видишь прямо сейчас</value>
</data> </data>
<data name="CommandDescriptionKick" xml:space="preserve"> <data name="CommandDescriptionKick" xml:space="preserve">
<value>выпинывает шизоида</value> <value>выпинывает шизоида</value>
</data> </data>
<data name="CommandDescriptionMute" xml:space="preserve"> <data name="CommandDescriptionMute" xml:space="preserve">
<value>мутит шизоида</value> <value>мутит шизоида</value>
</data> </data>
<data name="CommandDescriptionPing" xml:space="preserve"> <data name="CommandDescriptionPing" xml:space="preserve">
<value>показывает пинг (сверхмегаточный (нет))</value> <value>показывает пинг (сверхмегаточный (нет))</value>
</data> </data>
<data name="CommandDescriptionSettings" xml:space="preserve"> <data name="CommandDescriptionSettings" xml:space="preserve">
<value>настройки бота под этот сервер</value> <value>настройки бота под этот сервер</value>
</data> </data>
<data name="CommandDescriptionUnban" xml:space="preserve"> <data name="CommandDescriptionUnban" xml:space="preserve">
<value>отводит великий банхаммер от шизоида</value> <value>отводит великий банхаммер от шизоида</value>
</data> </data>
<data name="CommandDescriptionUnmute" xml:space="preserve"> <data name="CommandDescriptionUnmute" xml:space="preserve">
<value>раззамучивает шизоида</value> <value>раззамучивает шизоида</value>
</data> </data>
<data name="MissingNumber" xml:space="preserve"> <data name="MissingNumber" xml:space="preserve">
<value>укажи целое число от {0} до {1}</value> <value>укажи целое число от {0} до {1}</value>
</data> </data>
<data name="MissingUser" xml:space="preserve"> <data name="MissingUser" xml:space="preserve">
@ -331,8 +319,8 @@
<value>укажи самого шизика</value> <value>укажи самого шизика</value>
</data> </data>
<data name="InvalidMember" xml:space="preserve"> <data name="InvalidMember" xml:space="preserve">
<value>укажи шизоида сервера!</value> <value>укажи шизоида сервера!</value>
</data> </data>
<data name="UserCannotBanMembers" xml:space="preserve"> <data name="UserCannotBanMembers" xml:space="preserve">
<value>бан</value> <value>бан</value>
</data> </data>
@ -345,133 +333,229 @@
<data name="UserCannotModerateMembers" xml:space="preserve"> <data name="UserCannotModerateMembers" xml:space="preserve">
<value>тебе нельзя управлять шизоидами</value> <value>тебе нельзя управлять шизоидами</value>
</data> </data>
<data name="UserCannotManageGuild" xml:space="preserve"> <data name="UserCannotManageGuild" xml:space="preserve">
<value>тебе нельзя редактировать дурку</value> <value>тебе нельзя редактировать дурку</value>
</data> </data>
<data name="BotCannotBanMembers" xml:space="preserve"> <data name="BotCannotBanMembers" xml:space="preserve">
<value>я не могу ваще никого банить чел.</value> <value>я не могу ваще никого банить чел.</value>
</data> </data>
<data name="BotCannotManageMessages" xml:space="preserve"> <data name="BotCannotManageMessages" xml:space="preserve">
<value>я не могу исправлять орфографический кринж участников, сделай что нибудь.</value> <value>я не могу исправлять орфографический кринж участников, сделай что нибудь.</value>
</data> </data>
<data name="BotCannotKickMembers" xml:space="preserve"> <data name="BotCannotKickMembers" xml:space="preserve">
<value>я не могу ваще никого кикать чел.</value> <value>я не могу ваще никого кикать чел.</value>
</data> </data>
<data name="BotCannotModerateMembers" xml:space="preserve"> <data name="BotCannotModerateMembers" xml:space="preserve">
<value>я не могу контроллировать за всеми ними, сделай что нибудь.</value> <value>я не могу контроллировать за всеми ними, сделай что нибудь.</value>
</data> </data>
<data name="BotCannotManageGuild" xml:space="preserve"> <data name="BotCannotManageGuild" xml:space="preserve">
<value>я не могу этому серверу хоть че либо нибудь изменить, сделай что нибудь.</value> <value>я не могу этому серверу хоть че либо нибудь изменить, сделай что нибудь.</value>
</data> </data>
<data name="MissingBanReason" xml:space="preserve"> <data name="MissingBanReason" xml:space="preserve">
<value>укажи зачем банить шизика</value> <value>укажи зачем банить шизика</value>
</data> </data>
<data name="MissingKickReason" xml:space="preserve"> <data name="MissingKickReason" xml:space="preserve">
<value>укажи зачем кикать шизика</value> <value>укажи зачем кикать шизика</value>
</data> </data>
<data name="MissingMuteReason" xml:space="preserve"> <data name="MissingMuteReason" xml:space="preserve">
<value>укажи зачем мутить шизика</value> <value>укажи зачем мутить шизика</value>
</data> </data>
<data name="MissingUnbanReason" xml:space="preserve"> <data name="MissingUnbanReason" xml:space="preserve">
<value>укажи зачем раззабанивать шизика</value> <value>укажи зачем раззабанивать шизика</value>
</data> </data>
<data name="MissingUnmuteReason" xml:space="preserve"> <data name="MissingUnmuteReason" xml:space="preserve">
<value>укажи зачам размучивать шизика</value> <value>укажи зачам размучивать шизика</value>
</data> </data>
<data name="UserCannotBanBot" xml:space="preserve"> <data name="UserCannotBanBot" xml:space="preserve">
<value>ээбля френдли фаер огонь по своим</value> <value>ээбля френдли фаер огонь по своим</value>
</data> </data>
<data name="UserCannotBanOwner" xml:space="preserve"> <data name="UserCannotBanOwner" xml:space="preserve">
<value>бан админу нельзя</value> <value>бан админу нельзя</value>
</data> </data>
<data name="UserCannotBanTarget" xml:space="preserve"> <data name="UserCannotBanTarget" xml:space="preserve">
<value>бан этому шизику нельзя</value> <value>бан этому шизику нельзя</value>
</data> </data>
<data name="UserCannotBanThemselves" xml:space="preserve"> <data name="UserCannotBanThemselves" xml:space="preserve">
<value>самобан нельзя</value> <value>самобан нельзя</value>
</data> </data>
<data name="BotCannotBanTarget" xml:space="preserve"> <data name="BotCannotBanTarget" xml:space="preserve">
<value>я не могу его забанить...</value> <value>я не могу его забанить...</value>
</data> </data>
<data name="UserCannotKickOwner" xml:space="preserve"> <data name="UserCannotKickOwner" xml:space="preserve">
<value>кик админу нельзя</value> <value>кик админу нельзя</value>
</data> </data>
<data name="UserCannotKickThemselves" xml:space="preserve"> <data name="UserCannotKickThemselves" xml:space="preserve">
<value>самокик нельзя</value> <value>самокик нельзя</value>
</data> </data>
<data name="UserCannotKickBot" xml:space="preserve"> <data name="UserCannotKickBot" xml:space="preserve">
<value>ээбля френдли фаер огонь по своим</value> <value>ээбля френдли фаер огонь по своим</value>
</data> </data>
<data name="BotCannotKickTarget" xml:space="preserve"> <data name="BotCannotKickTarget" xml:space="preserve">
<value>я не могу его кикнуть...</value> <value>я не могу его кикнуть...</value>
</data> </data>
<data name="UserCannotKickTarget" xml:space="preserve"> <data name="UserCannotKickTarget" xml:space="preserve">
<value>кик этому шизику нельзя</value> <value>кик этому шизику нельзя</value>
</data> </data>
<data name="UserCannotMuteOwner" xml:space="preserve"> <data name="UserCannotMuteOwner" xml:space="preserve">
<value>мут админу нельзя</value> <value>мут админу нельзя</value>
</data> </data>
<data name="UserCannotMuteThemselves" xml:space="preserve"> <data name="UserCannotMuteThemselves" xml:space="preserve">
<value>самомут нельзя</value> <value>самомут нельзя</value>
</data> </data>
<data name="UserCannotMuteBot" xml:space="preserve"> <data name="UserCannotMuteBot" xml:space="preserve">
<value>ээбля френдли фаер огонь по своим</value> <value>ээбля френдли фаер огонь по своим</value>
</data> </data>
<data name="BotCannotMuteTarget" xml:space="preserve"> <data name="BotCannotMuteTarget" xml:space="preserve">
<value>я не могу его замутить...</value> <value>я не могу его замутить...</value>
</data> </data>
<data name="UserCannotMuteTarget" xml:space="preserve"> <data name="UserCannotMuteTarget" xml:space="preserve">
<value>мут этому шизику нельзя</value> <value>мут этому шизику нельзя</value>
</data> </data>
<data name="UserCannotUnmuteOwner" xml:space="preserve"> <data name="UserCannotUnmuteOwner" xml:space="preserve">
<value>сильно</value> <value>сильно</value>
</data> </data>
<data name="UserCannotUnmuteThemselves" xml:space="preserve"> <data name="UserCannotUnmuteThemselves" xml:space="preserve">
<value>ты замучен.</value> <value>ты замучен.</value>
</data> </data>
<data name="UserCannotUnmuteBot" xml:space="preserve"> <data name="UserCannotUnmuteBot" xml:space="preserve">
<value>... </value> <value>... </value>
</data> </data>
<data name="UserCannotUnmuteTarget" xml:space="preserve"> <data name="UserCannotUnmuteTarget" xml:space="preserve">
<value>тебе нельзя раззамучивать</value> <value>тебе нельзя раззамучивать</value>
</data> </data>
<data name="BotCannotUnmuteTarget" xml:space="preserve"> <data name="BotCannotUnmuteTarget" xml:space="preserve">
<value>я не могу его раззамутить...</value> <value>я не могу его раззамутить...</value>
</data> </data>
<data name="EventEarlyNotification" xml:space="preserve"> <data name="EventEarlyNotification" xml:space="preserve">
<value>{0}квест {1} начнется &lt;t:{2}:R&gt;!</value> <value>{0}движуха {1} начнется &lt;t:{2}:R&gt;!</value>
</data> </data>
<data name="SettingsEventEarlyNotificationOffset" xml:space="preserve"> <data name="SettingsEventEarlyNotificationOffset" xml:space="preserve">
<value>заранее пнуть в минутах до начала квеста</value> <value>заранее пнуть в минутах до начала движухи</value>
</data> </data>
<data name="UserNotFound" xml:space="preserve"> <data name="UserNotFound" xml:space="preserve">
<value>у нас такого шизоида нету, проверь, валиден ли ID уважаемого (я забываю о шизоидах если они ливнули минимум месяц назад)</value> <value>у нас такого шизоида нету, проверь, валиден ли ID уважаемого (я забываю о шизоидах если они ливнули минимум месяц назад)</value>
</data> </data>
<data name="SettingsStarterRole" xml:space="preserve"> <data name="SettingsDefaultRole" xml:space="preserve">
<value>базовое звание</value> <value>дефолтное звание</value>
</data> </data>
<data name="CommandDescriptionRemind" xml:space="preserve"> <data name="CommandDescriptionRemind" xml:space="preserve">
<value>крафтит напоминалку</value> <value>крафтит напоминалку</value>
</data> </data>
<data name="SettingsPrivateFeedbackChannel" xml:space="preserve"> <data name="SettingsPrivateFeedbackChannel" xml:space="preserve">
<value>канал для секретных уведомлений</value> <value>канал для секретных уведомлений</value>
</data> </data>
<data name="SettingsPublicFeedbackChannel" xml:space="preserve"> <data name="SettingsPublicFeedbackChannel" xml:space="preserve">
<value>канал для не секретных уведомлений</value> <value>канал для не секретных уведомлений</value>
</data> </data>
<data name="SettingsReturnRolesOnRejoin" xml:space="preserve"> <data name="SettingsReturnRolesOnRejoin" xml:space="preserve">
<value>вернуть звания при переподключении в дурку</value> <value>вернуть звания при переподключении в дурку</value>
</data>
<data name="SettingsAutoStartEvents" xml:space="preserve">
<value>автоматом стартить движухи</value>
</data>
<data name="MissingReminderText" xml:space="preserve">
<value>для крафта напоминалки нужен текст</value>
</data>
<data name="DescriptionReminderCreated" xml:space="preserve">
<value>вас понял, упоминание будет {0}</value>
</data> </data>
<data name="SettingsAutoStartEvents" xml:space="preserve"> <data name="InvalidRemindIn" xml:space="preserve">
<value>автоматом стартить квесты</value> <value>шизоид у меня на часах такого нету</value>
</data>
<data name="IssuedBy" xml:space="preserve">
<value>ответственный</value>
</data>
<data name="EventCreatedTitle" xml:space="preserve">
<value>{0} создает новое событие:</value>
</data>
<data name="DescriptionLocalEventCreated" xml:space="preserve">
<value>движуха произойдет {0} в канале {1}</value>
</data>
<data name="DescriptionExternalEventCreated" xml:space="preserve">
<value>движуха будет происходить с {0} до {1} в {2}</value>
</data>
<data name="EventDetailsButton" xml:space="preserve">
<value>побольше о движухе</value>
</data>
<data name="EventDuration" xml:space="preserve">
<value>все это длилось `{0}`</value>
</data>
<data name="DescriptionLocalEventStarted" xml:space="preserve">
<value>движуха происходит в {0}</value>
</data>
<data name="DescriptionExternalEventStarted" xml:space="preserve">
<value>движуха происходит в {0} до {1}</value>
</data>
<data name="UserAlreadyBanned" xml:space="preserve">
<value>этот шизоид уже лежит в бане</value>
</data>
<data name="UserUnbanned" xml:space="preserve">
<value>{0} раззабанен</value>
</data>
<data name="UserMuted" xml:space="preserve">
<value>{0} в муте</value>
</data>
<data name="UserUnmuted" xml:space="preserve">
<value>{0} в размуте</value>
</data>
<data name="UserNotMuted" xml:space="preserve">
<value>этого шизоида никто не мутил.</value>
</data>
<data name="UserNotFoundShort" xml:space="preserve">
<value>у нас такого шизоида нету...</value>
</data>
<data name="UserKicked" xml:space="preserve">
<value>{0} вышел с посторонней помощью</value>
</data>
<data name="DescriptionActionReason" xml:space="preserve">
<value>причина: {0}</value>
</data>
<data name="DescriptionActionExpiresAt" xml:space="preserve">
<value>до: {0}</value>
</data>
<data name="UserAlreadyMuted" xml:space="preserve">
<value>этот шизоид УЖЕ замучился</value>
</data>
<data name="MessageFrom" xml:space="preserve">
<value>от {0}</value>
</data>
<data name="AboutTitleDevelopers" xml:space="preserve">
<value>девелоперы:</value>
</data>
<data name="AboutTitleWiki" xml:space="preserve">
<value>страничка Boyfriend's Wiki:</value>
</data>
<data name="AboutBot" xml:space="preserve">
<value>немного о Boyfriend</value>
</data>
<data name="AboutDeveloper@mctaylors" xml:space="preserve">
<value>скучный лого/эмбед дизайнер создавший Boyfriend's Wiki</value>
</data>
<data name="AboutDeveloper@neroduckale" xml:space="preserve">
<value>ВАЖНЫЙ соучастник кодинг-стримов @Octol1ttle</value>
</data>
<data name="AboutDeveloper@Octol1ttle" xml:space="preserve">
<value>САМЫЙ ВАЖНЫЙ чел написавший кода больше всех (99.99%)</value>
</data>
<data name="ReminderCreated" xml:space="preserve">
<value>напоминалка для {0} скрафченА</value>
</data> </data>
<data name="MissingReminderText" xml:space="preserve"> <data name="Reminder" xml:space="preserve">
<value>для крафта напоминалки нужен текст</value> <value>напоминалка для {0}</value>
</data> </data>
<data name="FeedbackReminderAdded" xml:space="preserve"> <data name="DescriptionReminder" xml:space="preserve">
<value>вас понял, упоминание будет &lt;t:{0}:f&gt;</value> <value>ты хотел чтоб я напомнил тебе {0}</value>
</data> </data>
<data name="InvalidRemindIn" xml:space="preserve"> <data name="SettingsListTitle" xml:space="preserve">
<value>шизоид у меня на часах такого нету</value> <value>приколы Boyfriend</value>
</data>
<data name="SettingSuccessfulyChanged" xml:space="preserve">
<value>прикол редактирован</value>
</data>
<data name="SettingNotChanged" xml:space="preserve">
<value>прикол сдох</value>
</data>
<data name="SettingIsNow" xml:space="preserve">
<value>стало</value>
</data> </data>
</root> </root>

View file

@ -1,19 +0,0 @@
namespace Boyfriend;
public static class ReplyEmojis {
public const string Success = ":white_check_mark:";
public const string Error = ":x:";
public const string MissingArgument = ":keyboard:";
public const string InvalidArgument = ":construction:";
public const string NoPermission = ":no_entry_sign:";
public const string CantInteract = ":vertical_traffic_light:";
public const string Help = ":page_facing_up:";
public const string SettingsList = ":gear:";
public const string SettingsSet = ":control_knobs:";
public const string Ping = ":signal_strength:";
public const string Banned = ":hammer:";
public const string Kicked = ":police_car:";
public const string Muted = ":mute:";
public const string Unmuted = ":loud_sound:";
public const string Reminder = ":alarm_clock:";
}

View file

@ -0,0 +1,109 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Boyfriend.Data;
using Microsoft.Extensions.Hosting;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Rest.Core;
namespace Boyfriend.Services;
/// <summary>
/// Handles saving, loading, initializing and providing <see cref="GuildData" />.
/// </summary>
public class GuildDataService : IHostedService {
private readonly ConcurrentDictionary<Snowflake, GuildData> _datas = new();
private readonly IDiscordRestGuildAPI _guildApi;
// https://github.com/dotnet/aspnetcore/issues/39139
public GuildDataService(
IHostApplicationLifetime lifetime, IDiscordRestGuildAPI guildApi) {
_guildApi = guildApi;
lifetime.ApplicationStopping.Register(ApplicationStopping);
}
public Task StartAsync(CancellationToken ct) {
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct) {
return Task.CompletedTask;
}
private void ApplicationStopping() {
SaveAsync(CancellationToken.None).GetAwaiter().GetResult();
}
private async Task SaveAsync(CancellationToken ct) {
var tasks = new List<Task>();
foreach (var data in _datas.Values) {
await using var configStream = File.OpenWrite(data.ConfigurationPath);
tasks.Add(JsonSerializer.SerializeAsync(configStream, data.Configuration, cancellationToken: ct));
await using var eventsStream = File.OpenWrite(data.ScheduledEventsPath);
tasks.Add(JsonSerializer.SerializeAsync(eventsStream, data.ScheduledEvents, cancellationToken: ct));
foreach (var memberData in data.MemberData.Values) {
await using var memberDataStream = File.OpenWrite($"{data.MemberDataPath}/{memberData.Id}.json");
tasks.Add(JsonSerializer.SerializeAsync(memberDataStream, memberData, cancellationToken: ct));
}
}
await Task.WhenAll(tasks);
}
public async Task<GuildData> GetData(Snowflake guildId, CancellationToken ct = default) {
return _datas.TryGetValue(guildId, out var data) ? data : await InitializeData(guildId, ct);
}
private async Task<GuildData> InitializeData(Snowflake guildId, CancellationToken ct = default) {
var idString = $"{guildId}";
var memberDataPath = $"{guildId}/MemberData";
var configurationPath = $"{guildId}/Configuration.json";
var scheduledEventsPath = $"{guildId}/ScheduledEvents.json";
if (!Directory.Exists(idString)) Directory.CreateDirectory(idString);
if (!Directory.Exists(memberDataPath)) Directory.CreateDirectory(memberDataPath);
if (!File.Exists(configurationPath)) await File.WriteAllTextAsync(configurationPath, "{}", ct);
if (!File.Exists(scheduledEventsPath)) await File.WriteAllTextAsync(scheduledEventsPath, "{}", ct);
await using var configurationStream = File.OpenRead(configurationPath);
var configuration
= JsonSerializer.DeserializeAsync<GuildConfiguration>(
configurationStream, cancellationToken: ct);
await using var eventsStream = File.OpenRead(scheduledEventsPath);
var events
= JsonSerializer.DeserializeAsync<Dictionary<ulong, ScheduledEventData>>(
eventsStream, cancellationToken: ct);
var memberData = new Dictionary<ulong, MemberData>();
foreach (var dataPath in Directory.GetFiles(memberDataPath)) {
await using var dataStream = File.OpenRead(dataPath);
var data = await JsonSerializer.DeserializeAsync<MemberData>(dataStream, cancellationToken: ct);
if (data is null) continue;
var memberResult = await _guildApi.GetGuildMemberAsync(guildId, data.Id.ToDiscordSnowflake(), ct);
if (memberResult.IsSuccess)
data.Roles = memberResult.Entity.Roles.ToList();
memberData.Add(data.Id, data);
}
var finalData = new GuildData(
await configuration ?? new GuildConfiguration(), configurationPath,
await events ?? new Dictionary<ulong, ScheduledEventData>(), scheduledEventsPath,
memberData, memberDataPath);
while (!_datas.ContainsKey(guildId)) _datas.TryAdd(guildId, finalData);
return finalData;
}
public async Task<GuildConfiguration> GetConfiguration(Snowflake guildId, CancellationToken ct = default) {
return (await GetData(guildId, ct)).Configuration;
}
public async Task<MemberData> GetMemberData(Snowflake guildId, Snowflake userId, CancellationToken ct = default) {
return (await GetData(guildId, ct)).GetMemberData(userId);
}
public ICollection<Snowflake> GetGuildIds() {
return _datas.Keys;
}
}

View file

@ -0,0 +1,389 @@
using Boyfriend.Data;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Gateway.Commands;
using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Extensions.Formatting;
using Remora.Discord.Gateway;
using Remora.Discord.Gateway.Responders;
using Remora.Discord.Interactivity;
using Remora.Rest.Core;
using Remora.Results;
namespace Boyfriend.Services;
/// <summary>
/// Handles executing guild updates (also called "ticks") once per second.
/// </summary>
public class GuildUpdateService : BackgroundService {
private static readonly (string Name, TimeSpan Duration)[] SongList = {
("UNDEAD CORPORATION - The Empress", new TimeSpan(0, 4, 34)),
("UNDEAD CORPORATION - Everything will freeze", new TimeSpan(0, 3, 17)),
("Splatoon 3 - Rockagilly Blues (Yoko & the Gold Bazookas)", new TimeSpan(0, 3, 37)),
("Splatoon 3 - Seep and Destroy", new TimeSpan(0, 2, 42)),
("IA - A Tale of Six Trillion Years and a Night", new TimeSpan(0, 3, 40)),
("Manuel - Gas Gas Gas", new TimeSpan(0, 3, 17)),
("Camellia - Flamewall", new TimeSpan(0, 6, 50))
};
private readonly List<Activity> _activityList = new(1) { new Activity("with Remora.Discord", ActivityType.Game) };
private readonly IDiscordRestChannelAPI _channelApi;
private readonly DiscordGatewayClient _client;
private readonly GuildDataService _dataService;
private readonly IDiscordRestGuildScheduledEventAPI _eventApi;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly ILogger<GuildUpdateService> _logger;
private readonly IDiscordRestUserAPI _userApi;
private readonly UtilityService _utility;
private DateTimeOffset _nextSongAt = DateTimeOffset.MinValue;
private uint _nextSongIndex;
public GuildUpdateService(
IDiscordRestChannelAPI channelApi, DiscordGatewayClient client, GuildDataService dataService,
IDiscordRestGuildScheduledEventAPI eventApi, IDiscordRestGuildAPI guildApi, ILogger<GuildUpdateService> logger,
IDiscordRestUserAPI userApi, UtilityService utility) {
_channelApi = channelApi;
_client = client;
_dataService = dataService;
_eventApi = eventApi;
_guildApi = guildApi;
_logger = logger;
_userApi = userApi;
_utility = utility;
}
/// <summary>
/// Activates a periodic timer with a 1 second interval and adds guild update tasks on each timer tick.
/// Additionally, updates the current presence with songs from <see cref="SongList"/>.
/// </summary>
/// <remarks>If update tasks take longer than 1 second, the next timer tick will be skipped.</remarks>
/// <param name="ct">The cancellation token for this operation.</param>
protected override async Task ExecuteAsync(CancellationToken ct) {
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
var tasks = new List<Task>();
while (await timer.WaitForNextTickAsync(ct)) {
var guildIds = _dataService.GetGuildIds();
if (guildIds.Count > 0 && DateTimeOffset.UtcNow >= _nextSongAt) {
var nextSong = SongList[_nextSongIndex];
_activityList[0] = new Activity(nextSong.Name, ActivityType.Listening);
_client.SubmitCommand(
new UpdatePresence(
UserStatus.Online, false, DateTimeOffset.UtcNow, _activityList));
_nextSongAt = DateTimeOffset.UtcNow.Add(nextSong.Duration);
_nextSongIndex++;
if (_nextSongIndex >= SongList.Length) _nextSongIndex = 0;
}
tasks.AddRange(guildIds.Select(id => TickGuildAsync(id, ct)));
await Task.WhenAll(tasks);
tasks.Clear();
}
}
/// <summary>
/// Runs an update ("tick") for a guild with the provided <paramref name="guildId" />.
/// </summary>
/// <remarks>
/// This method does the following:
/// <list type="bullet">
/// <item>Automatically unbans users once their ban period has expired.</item>
/// <item>Automatically grants members the guild's <see cref="GuildConfiguration.DefaultRole"/> if one is set.</item>
/// <item>Sends reminders about an upcoming scheduled event.</item>
/// <item>Automatically starts scheduled events if <see cref="GuildConfiguration.AutoStartEvents"/> is enabled.</item>
/// <item>Sends scheduled event start notifications.</item>
/// <item>Sends scheduled event completion notifications.</item>
/// <item>Sends reminders to members.</item>
/// </list>
/// This is done here and not in a <see cref="IResponder{TGatewayEvent}" /> for the following reasons:
/// <list type="bullet">
/// <item>
/// Downtime would affect the reliability of notifications and automatic unbans if this logic were to be in a
/// <see cref="IResponder{TGatewayEvent}" />.
/// </item>
/// <item>The Discord API doesn't provide necessary information about scheduled event updates.</item>
/// </list>
/// </remarks>
/// <param name="guildId">The ID of the guild to update.</param>
/// <param name="ct">The cancellation token for this operation.</param>
private async Task TickGuildAsync(Snowflake guildId, CancellationToken ct = default) {
var data = await _dataService.GetData(guildId, ct);
Messages.Culture = data.Culture;
var defaultRoleSnowflake = data.Configuration.DefaultRole.ToDiscordSnowflake();
foreach (var memberData in data.MemberData.Values) {
var userId = memberData.Id.ToDiscordSnowflake();
if (defaultRoleSnowflake.Value is not 0 && !memberData.Roles.Contains(defaultRoleSnowflake))
_ = _guildApi.AddGuildMemberRoleAsync(
guildId, userId, defaultRoleSnowflake, ct: ct);
if (DateTimeOffset.UtcNow > memberData.BannedUntil) {
var unbanResult = await _guildApi.RemoveGuildBanAsync(
guildId, userId, Messages.PunishmentExpired.EncodeHeader(), ct);
if (unbanResult.IsSuccess)
memberData.BannedUntil = null;
else
_logger.LogWarning(
"Error in automatic user unban request.\n{ErrorMessage}", unbanResult.Error.Message);
}
var userResult = await _userApi.GetUserAsync(userId, ct);
if (!userResult.IsDefined(out var user)) continue;
for (var i = memberData.Reminders.Count - 1; i >= 0; i--) {
var reminder = memberData.Reminders[i];
if (DateTimeOffset.UtcNow < reminder.RemindAt) continue;
var embed = new EmbedBuilder().WithSmallTitle(
string.Format(Messages.Reminder, user.GetTag()), user)
.WithDescription(
string.Format(Messages.DescriptionReminder, Markdown.InlineCode(reminder.Text)))
.WithColour(ColorsList.Magenta)
.Build();
if (!embed.IsDefined(out var built)) continue;
var messageResult = await _channelApi.CreateMessageAsync(
reminder.Channel, Mention.User(user), embeds: new[] { built }, ct: ct);
if (!messageResult.IsSuccess)
_logger.LogWarning(
"Error in reminder send.\n{ErrorMessage}", messageResult.Error.Message);
memberData.Reminders.Remove(reminder);
}
}
var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct);
if (!eventsResult.IsDefined(out var events)) return;
if (data.Configuration.EventNotificationChannel is 0) return;
foreach (var scheduledEvent in events) {
if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value)) {
data.ScheduledEvents.Add(scheduledEvent.ID.Value, new ScheduledEventData(scheduledEvent.Status));
} else {
var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value];
if (storedEvent.Status == scheduledEvent.Status) {
if (DateTimeOffset.UtcNow >= scheduledEvent.ScheduledStartTime) {
if (data.Configuration.AutoStartEvents
&& scheduledEvent.Status is not GuildScheduledEventStatus.Active) {
var startResult = await _eventApi.ModifyGuildScheduledEventAsync(
guildId, scheduledEvent.ID,
status: GuildScheduledEventStatus.Active, ct: ct);
if (!startResult.IsSuccess)
_logger.LogWarning(
"Error in automatic scheduled event start request.\n{ErrorMessage}",
startResult.Error.Message);
}
} else if (data.Configuration.EventEarlyNotificationOffset != TimeSpan.Zero
&& !storedEvent.EarlyNotificationSent
&& DateTimeOffset.UtcNow
>= scheduledEvent.ScheduledStartTime - data.Configuration.EventEarlyNotificationOffset) {
var earlyResult = await SendScheduledEventUpdatedMessage(scheduledEvent, data, true, ct);
if (earlyResult.IsSuccess)
storedEvent.EarlyNotificationSent = true;
else
_logger.LogWarning(
"Error in scheduled event early notification sender.\n{ErrorMessage}",
earlyResult.Error.Message);
}
continue;
}
storedEvent.Status = scheduledEvent.Status;
}
var result = scheduledEvent.Status switch {
GuildScheduledEventStatus.Scheduled =>
await SendScheduledEventCreatedMessage(scheduledEvent, data.Configuration, ct),
GuildScheduledEventStatus.Active or GuildScheduledEventStatus.Completed =>
await SendScheduledEventUpdatedMessage(scheduledEvent, data, false, ct),
_ => Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status)))
};
if (!result.IsSuccess)
_logger.LogWarning("Error in guild update.\n{ErrorMessage}", result.Error.Message);
}
}
/// <summary>
/// Handles sending a notification, mentioning the <see cref="GuildConfiguration.EventNotificationRole" /> if one is
/// set,
/// when a scheduled event is created
/// in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set.
/// </summary>
/// <param name="scheduledEvent">The scheduled event that has just been created.</param>
/// <param name="config">The configuration of the guild containing the scheduled event.</param>
/// <param name="ct">The cancellation token for this operation.</param>
/// <returns>A notification sending result which may or may not have succeeded.</returns>
private async Task<Result> SendScheduledEventCreatedMessage(
IGuildScheduledEvent scheduledEvent, GuildConfiguration config, CancellationToken ct = default) {
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
if (!scheduledEvent.CreatorID.IsDefined(out var creatorId))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.CreatorID)));
var creatorResult = await _userApi.GetUserAsync(creatorId.Value, ct);
if (!creatorResult.IsDefined(out var creator)) return Result.FromError(creatorResult);
string embedDescription;
var eventDescription = scheduledEvent.Description is { HasValue: true, Value: not null }
? scheduledEvent.Description.Value
: string.Empty;
switch (scheduledEvent.EntityType) {
case GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice:
if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ChannelID)));
embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote(
string.Format(
Messages.DescriptionLocalEventCreated,
Markdown.Timestamp(scheduledEvent.ScheduledStartTime),
Mention.Channel(channelId)
))}";
break;
case GuildScheduledEventEntityType.External:
if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.EntityMetadata)));
if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)));
if (!metadata.Location.IsDefined(out var location))
return Result.FromError(new ArgumentNullError(nameof(metadata.Location)));
embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote(
string.Format(
Messages.DescriptionExternalEventCreated,
Markdown.Timestamp(scheduledEvent.ScheduledStartTime),
Markdown.Timestamp(endTime),
Markdown.InlineCode(location)
))}";
break;
default:
return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType)));
}
var embed = new EmbedBuilder()
.WithSmallTitle(string.Format(Messages.EventCreatedTitle, creator.GetTag()), creator)
.WithTitle(scheduledEvent.Name)
.WithDescription(embedDescription)
.WithEventCover(scheduledEvent.ID, scheduledEvent.Image)
.WithUserFooter(currentUser)
.WithCurrentTimestamp()
.WithColour(ColorsList.White)
.Build();
if (!embed.IsDefined(out var built)) return Result.FromError(embed);
var roleMention = config.EventNotificationRole is not 0
? Mention.Role(config.EventNotificationRole.ToDiscordSnowflake())
: string.Empty;
var button = new ButtonComponent(
ButtonComponentStyle.Primary,
Messages.EventDetailsButton,
new PartialEmoji(Name: "📋"),
CustomIDHelpers.CreateButtonIDWithState(
"scheduled-event-details", $"{scheduledEvent.GuildID}:{scheduledEvent.ID}")
);
return (Result)await _channelApi.CreateMessageAsync(
config.EventNotificationChannel.ToDiscordSnowflake(), roleMention, embeds: new[] { built },
components: new[] { new ActionRowComponent(new[] { button }) }, ct: ct);
}
/// <summary>
/// Handles sending a notification, mentioning the <see cref="GuildConfiguration.EventStartedReceivers" />s,
/// when a scheduled event is about to start, has started or completed
/// in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set.
/// </summary>
/// <param name="scheduledEvent">The scheduled event that is about to start, has started or completed.</param>
/// <param name="data">The data for the guild containing the scheduled event.</param>
/// <param name="early">Controls whether or not a reminder for the scheduled event should be sent instead of the event started/completed notification</param>
/// <param name="ct">The cancellation token for this operation</param>
/// <returns>A reminder/notification sending result which may or may not have succeeded.</returns>
private async Task<Result> SendScheduledEventUpdatedMessage(
IGuildScheduledEvent scheduledEvent, GuildData data, bool early, CancellationToken ct = default) {
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
var embed = new EmbedBuilder();
string? content = null;
if (early)
embed.WithSmallTitle(string.Format(Messages.EventEarlyNotification, scheduledEvent.Name), currentUser)
.WithColour(ColorsList.Default);
else
switch (scheduledEvent.Status) {
case GuildScheduledEventStatus.Active:
data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow;
string embedDescription;
switch (scheduledEvent.EntityType) {
case GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice:
if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ChannelID)));
embedDescription = string.Format(
Messages.DescriptionLocalEventStarted,
Mention.Channel(channelId)
);
break;
case GuildScheduledEventEntityType.External:
if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.EntityMetadata)));
if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime))
return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)));
if (!metadata.Location.IsDefined(out var location))
return Result.FromError(new ArgumentNullError(nameof(metadata.Location)));
embedDescription = string.Format(
Messages.DescriptionExternalEventStarted,
Markdown.InlineCode(location),
Markdown.Timestamp(endTime)
);
break;
default:
return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType)));
}
var contentResult = await _utility.GetEventNotificationMentions(
scheduledEvent, data.Configuration, ct);
if (!contentResult.IsDefined(out content))
return Result.FromError(contentResult);
embed.WithTitle(string.Format(Messages.EventStarted, scheduledEvent.Name))
.WithDescription(embedDescription)
.WithColour(ColorsList.Green);
break;
case GuildScheduledEventStatus.Completed:
embed.WithTitle(string.Format(Messages.EventCompleted, scheduledEvent.Name))
.WithDescription(
string.Format(
Messages.EventDuration,
DateTimeOffset.UtcNow.Subtract(
data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime
?? scheduledEvent.ScheduledStartTime).ToString()))
.WithColour(ColorsList.Black);
data.ScheduledEvents.Remove(scheduledEvent.ID.Value);
break;
case GuildScheduledEventStatus.Canceled:
case GuildScheduledEventStatus.Scheduled:
default: return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status)));
}
var result = embed.WithCurrentTimestamp().Build();
if (!result.IsDefined(out var built)) return Result.FromError(result);
return (Result)await _channelApi.CreateMessageAsync(
data.Configuration.EventNotificationChannel.ToDiscordSnowflake(),
content ?? default(Optional<string>), embeds: new[] { built }, ct: ct);
}
}

140
Services/UtilityService.cs Normal file
View file

@ -0,0 +1,140 @@
using System.Text;
using Boyfriend.Data;
using Microsoft.Extensions.Hosting;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Extensions.Formatting;
using Remora.Rest.Core;
using Remora.Results;
namespace Boyfriend.Services;
/// <summary>
/// Provides utility methods that cannot be transformed to extension methods because they require usage
/// of some Discord APIs.
/// </summary>
public class UtilityService : IHostedService {
private readonly IDiscordRestGuildScheduledEventAPI _eventApi;
private readonly IDiscordRestGuildAPI _guildApi;
private readonly IDiscordRestUserAPI _userApi;
public UtilityService(
IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, IDiscordRestGuildScheduledEventAPI eventApi) {
_guildApi = guildApi;
_userApi = userApi;
_eventApi = eventApi;
}
public Task StartAsync(CancellationToken ct) {
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct) {
return Task.CompletedTask;
}
/// <summary>
/// Checks whether or not a member can interact with another member
/// </summary>
/// <param name="guildId">The ID of the guild in which an operation is being performed.</param>
/// <param name="interacterId">The executor of the operation.</param>
/// <param name="targetId">The target of the operation.</param>
/// <param name="action">The operation.</param>
/// <param name="ct">The cancellation token for this operation.</param>
/// <returns>
/// <list type="bullet">
/// <item>A result which has succeeded with a null string if the member can interact with the target.</item>
/// <item>
/// A result which has succeeded with a non-null string containing the error message if the member cannot
/// interact with the target.
/// </item>
/// <item>A result which has failed if an error occurred during the execution of this method.</item>
/// </list>
/// </returns>
public async Task<Result<string?>> CheckInteractionsAsync(
Snowflake guildId, Snowflake interacterId, Snowflake targetId, string action, CancellationToken ct = default) {
if (interacterId == targetId)
return Result<string?>.FromSuccess($"UserCannot{action}Themselves".Localized());
var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
if (!currentUserResult.IsDefined(out var currentUser))
return Result<string?>.FromError(currentUserResult);
if (currentUser.ID == targetId)
return Result<string?>.FromSuccess($"UserCannot{action}Bot".Localized());
var guildResult = await _guildApi.GetGuildAsync(guildId, ct: ct);
if (!guildResult.IsDefined(out var guild))
return Result<string?>.FromError(guildResult);
if (targetId == guild.OwnerID) return Result<string?>.FromSuccess($"UserCannot{action}Owner".Localized());
var targetMemberResult = await _guildApi.GetGuildMemberAsync(guildId, targetId, ct);
if (!targetMemberResult.IsDefined(out var targetMember))
return Result<string?>.FromSuccess(null);
var currentMemberResult = await _guildApi.GetGuildMemberAsync(guildId, currentUser.ID, ct);
if (!currentMemberResult.IsDefined(out var currentMember))
return Result<string?>.FromError(currentMemberResult);
var rolesResult = await _guildApi.GetGuildRolesAsync(guildId, ct);
if (!rolesResult.IsDefined(out var roles))
return Result<string?>.FromError(rolesResult);
var targetRoles = roles.Where(r => targetMember.Roles.Contains(r.ID)).ToList();
var botRoles = roles.Where(r => currentMember.Roles.Contains(r.ID));
var targetBotRoleDiff = targetRoles.MaxOrDefault(r => r.Position) - botRoles.MaxOrDefault(r => r.Position);
if (targetBotRoleDiff >= 0)
return Result<string?>.FromSuccess($"BotCannot{action}Target".Localized());
if (interacterId == guild.OwnerID)
return Result<string?>.FromSuccess(null);
var interacterResult = await _guildApi.GetGuildMemberAsync(guildId, interacterId, ct);
if (!interacterResult.IsDefined(out var interacter))
return Result<string?>.FromError(interacterResult);
var interacterRoles = roles.Where(r => interacter.Roles.Contains(r.ID));
var targetInteracterRoleDiff
= targetRoles.MaxOrDefault(r => r.Position) - interacterRoles.MaxOrDefault(r => r.Position);
if (targetInteracterRoleDiff >= 0)
return Result<string?>.FromSuccess($"UserCannot{action}Target".Localized());
return Result<string?>.FromSuccess(null);
}
/// <summary>
/// Gets the string mentioning all <see cref="GuildConfiguration.NotificationReceiver" />s related to a scheduled
/// event.
/// </summary>
/// <remarks>
/// If the guild configuration enables <see cref="GuildConfiguration.NotificationReceiver.Role" />, then the
/// <see cref="GuildConfiguration.EventNotificationRole" /> will also be mentioned.
/// </remarks>
/// <param name="scheduledEvent">
/// The scheduled event whose subscribers will be mentioned if the guild configuration enables
/// <see cref="GuildConfiguration.NotificationReceiver.Interested" />.
/// </param>
/// <param name="config">The configuration of the guild containing the scheduled event</param>
/// <param name="ct">The cancellation token for this operation.</param>
/// <returns>A result containing the string which may or may not have succeeded.</returns>
public async Task<Result<string>> GetEventNotificationMentions(
IGuildScheduledEvent scheduledEvent, GuildConfiguration config, CancellationToken ct = default) {
var builder = new StringBuilder();
var receivers = config.EventStartedReceivers;
var role = config.EventNotificationRole.ToDiscordSnowflake();
var usersResult = await _eventApi.GetGuildScheduledEventUsersAsync(
scheduledEvent.GuildID, scheduledEvent.ID, withMember: true, ct: ct);
if (!usersResult.IsDefined(out var users)) return Result<string>.FromError(usersResult);
if (receivers.Contains(GuildConfiguration.NotificationReceiver.Role) && role.Value is not 0)
builder.Append($"{Mention.Role(role)} ");
if (receivers.Contains(GuildConfiguration.NotificationReceiver.Interested))
builder = users.Where(
user => {
if (!user.GuildMember.IsDefined(out var member)) return true;
return !member.Roles.Contains(role);
})
.Aggregate(builder, (current, user) => current.Append($"{Mention.User(user.User)} "));
return builder.ToString();
}
}

168
Utils.cs
View file

@ -1,168 +0,0 @@
using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using Boyfriend.Data;
using Discord;
using Discord.Net;
using Discord.WebSocket;
using Humanizer;
using Humanizer.Localisation;
namespace Boyfriend;
public static partial class Utils {
public static readonly Dictionary<string, CultureInfo> CultureInfoCache = new() {
{ "ru", new CultureInfo("ru-RU") },
{ "en", new CultureInfo("en-US") },
{ "mctaylors-ru", new CultureInfo("tt-RU") }
};
private static readonly Dictionary<string, string> ReflectionMessageCache = new();
private static readonly AllowedMentions AllowRoles = new() {
AllowedTypes = AllowedMentionTypes.Roles
};
public static string GetBeep(int i = -1) {
return GetMessage($"Beep{(i < 0 ? Random.Shared.Next(3) + 1 : ++i)}");
}
public static string? Wrap(string? original, bool limitedSpace = false) {
if (original is null) return null;
var maxChars = limitedSpace ? 970 : 1940;
if (original.Length > maxChars) original = original[..maxChars];
var style = original.Contains('\n') ? "```" : "`";
return $"{style}{original}{(original.Equals("") ? " " : "")}{style}";
}
public static string MentionChannel(ulong id) {
return $"<#{id}>";
}
public static ulong ParseMention(string mention) {
return ulong.TryParse(NumbersOnlyRegex().Replace(mention, ""), out var id) ? id : 0;
}
public static async Task SendDirectMessage(SocketUser user, string toSend) {
try { await user.SendMessageAsync(toSend); } catch (HttpException e) {
if (e.DiscordCode is not DiscordErrorCode.CannotSendMessageToUser) throw;
}
}
public static async Task SilentSendAsync(SocketTextChannel? channel, string text, bool allowRoles = false) {
try {
if (channel is null || text.Length is 0 or > 2000)
throw new UnreachableException($"Message length is out of range: {text.Length}");
await channel.SendMessageAsync(text, false, null, null, allowRoles ? AllowRoles : AllowedMentions.None);
} catch (Exception e) {
await Boyfriend.Log(
new LogMessage(
LogSeverity.Error, nameof(Utils),
"Exception while silently sending message", e));
}
}
public static RequestOptions GetRequestOptions(string reason) {
var options = RequestOptions.Default;
options.AuditLogReason = reason;
return options;
}
public static string GetMessage(string name) {
var propertyName = name;
name = $"{Messages.Culture}/{name}";
if (ReflectionMessageCache.TryGetValue(name, out var cachedMessage)) return cachedMessage;
var toReturn =
typeof(Messages).GetProperty(propertyName, BindingFlags.NonPublic | BindingFlags.Static)?.GetValue(null)
?.ToString();
if (toReturn is null) {
Console.Error.WriteLine($@"Could not find localized property: {propertyName}");
return name;
}
ReflectionMessageCache.Add(name, toReturn);
return toReturn;
}
public static async Task
SendFeedbackAsync(string feedback, SocketGuild guild, string mention, bool sendPublic = false) {
var data = GuildData.Get(guild);
var adminChannel = data.PrivateFeedbackChannel;
var systemChannel = data.PublicFeedbackChannel;
var toSend = $"*[{mention}: {feedback}]*";
if (adminChannel is not null) await SilentSendAsync(adminChannel, toSend);
if (sendPublic && systemChannel is not null) await SilentSendAsync(systemChannel, toSend);
}
public static string GetHumanizedTimeSpan(TimeSpan span) {
return span.TotalSeconds < 1
? Messages.Ever
: $" {span.Humanize(2, minUnit: TimeUnit.Second, maxUnit: TimeUnit.Month, culture: Messages.Culture.Name.Contains("RU") ? CultureInfoCache["ru"] : Messages.Culture)}";
}
public static void SetCurrentLanguage(SocketGuild guild) {
Messages.Culture = CultureInfoCache[GuildData.Get(guild).Preferences["Lang"]];
}
public static void SafeAppendToBuilder(StringBuilder appendTo, string appendWhat, SocketTextChannel? channel) {
if (channel is null) return;
if (appendTo.Length + appendWhat.Length > 2000) {
_ = SilentSendAsync(channel, appendTo.ToString());
appendTo.Clear();
}
appendTo.AppendLine(appendWhat);
}
public static void SafeAppendToBuilder(StringBuilder appendTo, string appendWhat, SocketUserMessage message) {
if (appendTo.Length + appendWhat.Length > 2000) {
_ = message.ReplyAsync(appendTo.ToString(), false, null, AllowedMentions.None);
appendTo.Clear();
}
appendTo.AppendLine(appendWhat);
}
public static SocketTextChannel? GetEventNotificationChannel(SocketGuild guild) {
return guild.GetTextChannel(
ParseMention(
GuildData.Get(guild)
.Preferences["EventNotificationChannel"]));
}
public static bool UserExists(ulong id) {
return Boyfriend.Client.GetUser(id) is not null || UserInMemberData(id);
}
private static bool UserInMemberData(ulong id) {
return GuildData.GuildDataDictionary.Values.Any(gData => gData.MemberData.Values.Any(mData => mData.Id == id));
}
public static async Task<bool> UnmuteMemberAsync(
GuildData data, string modDiscrim, SocketGuildUser toUnmute,
string reason) {
var requestOptions = GetRequestOptions($"({modDiscrim}) {reason}");
var role = data.MuteRole;
if (role is not null) {
if (!toUnmute.Roles.Contains(role)) return false;
if (data.Preferences["RemoveRolesOnMute"] is "true")
await toUnmute.AddRolesAsync(data.MemberData[toUnmute.Id].Roles, requestOptions);
await toUnmute.RemoveRoleAsync(role, requestOptions);
data.MemberData[toUnmute.Id].MutedUntil = null;
} else {
if (toUnmute.TimedOutUntil is null || toUnmute.TimedOutUntil.Value < DateTimeOffset.UtcNow) return false;
await toUnmute.RemoveTimeOutAsync(requestOptions);
}
return true;
}
[GeneratedRegex("[^0-9]")]
private static partial Regex NumbersOnlyRegex();
}

128
docs/CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement via the "Report Content" feature or via email at
l1ttleofficial@outlook.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

68
docs/CONTRIBUTING.md Normal file
View file

@ -0,0 +1,68 @@
# Contributing Guidelines
Thank you for showing interest in the development of Boyfriend. We aim to provide a good collaborating environment for
everyone involved, and as such have decided to list some of the most important things to keep in mind in the process.
Before starting, please read our [Code of Conduct](CODE_OF_CONDUCT.md)
## Reporting bugs
A **bug** is a situation in which there is something clearly wrong with the bot. Examples of applicable bug reports are:
- The bot doesn't reply to a command
- The bot sends the same message twice
- The bot takes a long time to a respond if I use this specific command
- An embed the bot sent has incorrect information in it
To track bug reports, we primarily use GitHub **issues**. When opening an issue, please keep in mind the following:
- Before opening the issue, please search for any similar existing issues using the text search bar and the issue
labels. This includes both open and closed issues (we may have already fixed something, but the fix hasn't yet been
released).
- When opening the issue, please fill out as much of the issue template as you can. In particular, please make sure to
include console output and screenshots as much as possible.
- We may ask you for follow-up information to reproduce or debug the problem. Please look out for this and provide
follow-up info if we request it.
## Submitting pull requests
While pull requests from unaffiliated contributors are welcome, please note that the core team *may* be focused on
internal issues that haven't been published to the issue tracker yet. Reviewing PRs is done on a best-effort basis, so
please be aware that it may take a while before a core maintainer gets around to review your change.
The [issue tracker](https://github.com/TeamOctolings/Boyfriend/issues) should provide plenty of issues to start with.
Make sure to check that an issue you're planning to resolve does not already have people working on it and that there
are no PRs associated with it
In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't
seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the
correct direction on how to address it.
If you'd like to propose a subjective change to one of the UI/UX aspects of the bot, or there is a bigger task you'd
like to work on, but there is no corresponding issue yet for it, **please open an issue first** to avoid wasted effort.
Aside from the above, below is a brief checklist of things to watch out when you're preparing your code changes:
- Make sure you're comfortable with the principles of object-oriented programming, the syntax of C\# and your
development environment.
- Make sure you are familiar with [git](https://git-scm.com/)
and [the pull request workflow](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/proposing-changes-to-your-work-with-pull-requests).
- Please do not make code changes via the GitHub web interface.
- Please make sure your development environment respects the .editorconfig file present in the repository. Our code
style differs from most C\# projects and is closer to something you see in Java projects.
- Please test your changes. We expect most new features and bugfixes to be tested in an environment similar to
production.
After you're done with your changes and you wish to open the PR, please observe the following recommendations:
- Please submit the pull request from
a [topic branch](https://git-scm.com/book/en/v2/Git-Branching-Branching-Workflows#_topic_branch) (not `master`), and
keep the *Allow edits from maintainers* check box selected, so that we can push fixes to your PR if necessary.
- Please avoid pushing untested or incomplete code.
- Please do not force-push or rebase unless we ask you to.
- Please do not merge `master` continually if there are no conflicts to resolve. We will do this for you when the change
is ready for merge.
We are highly committed to quality when it comes to Boyfriend. This means that contributions from less experienced
community members can take multiple rounds of review to get to a mergeable state. We try our utmost best to never
conflate a person with the code they authored, and to keep the discussion focused on the code at all times. Please
consider our comments and requests a learning experience.

48
docs/README.md Normal file
View file

@ -0,0 +1,48 @@
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/95250141/206895339-ef5510c8-8b30-4887-b89c-5dc14a24b18a.png">
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/95250141/206895340-3415d97d-91fd-4fb6-8c17-4e1bf340e1df.png">
<img alt="Boyfriend Logo" src="https://user-images.githubusercontent.com/95250141/206895339-ef5510c8-8b30-4887-b89c-5dc14a24b18a.png">
</picture>
![GitHub License](https://img.shields.io/github/license/TeamOctolings/Boyfriend)
![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/TeamOctolings/Boyfriend/.github/workflows/resharper.yml?branch=master)
![GitHub last commit](https://img.shields.io/github/last-commit/TeamOctolings/Boyfriend)
![CodeFactor](https://img.shields.io/codefactor/grade/github/TeamOctolings/Boyfriend)
Beep! I'm a general-purpose bot for moderation written by [@Octol1ttle](https://github.com/Octol1ttle) in C# and
Discord.Net
## Features
* Banning, muting, kicking, etc.
* Reminding you about something if you wish
* Reminding everyone about that new event you made
* Log everything from joining the server to deleting messages
*...and more!*
## Installing and running Boyfriend
You can read our [wiki](https://github.com/TeamOctolings/Boyfriend/wiki) in order to assemble your Boyfriend™ and
moderate the server.
## Contributing
When it comes to contributing to the project, the two main things you can do to help out are reporting issues and
submitting pull requests. Please refer to the [contributing guidelines](CONTRIBUTING.md) to understand how to help in
the most effective way possible.
## Special Thanks
![JetBrains Logo (Main) logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg)
[JetBrains](https://www.jetbrains.com/), creators of [ReSharper](https://www.jetbrains.com/resharper)
and [Rider](https://www.jetbrains.com/rider), supports Boyfriend with one of
their [Open Source Licenses](https://jb.gg/OpenSourceSupport).
Rider is the recommended IDE when working with Boyfriend, and most of the Boyfriend team uses it.
Additionally, ReSharper command-line tools made by JetBrains are used for status checks on pull requests to ensure code
quality even when not using ReSharper or Rider.