forked from TeamInklings/Octobot
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:
parent
2ab7a07784
commit
abbb58f801
54 changed files with 5011 additions and 3021 deletions
|
@ -58,6 +58,7 @@ resharper_indent_nested_usings_stmt = true
|
|||
resharper_indent_nested_while_stmt = true
|
||||
resharper_indent_preprocessor_if = usual_indent
|
||||
resharper_indent_preprocessor_other = usual_indent
|
||||
resharper_int_align_fields = true
|
||||
resharper_int_align_methods = true
|
||||
resharper_int_align_parameters = true
|
||||
resharper_int_align_properties = true
|
||||
|
|
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
@ -1,4 +1,4 @@
|
|||
* @TeamOctolings/boyfriend
|
||||
.github/CODEOWNERS @TeamOctolings/boyfriend-admins
|
||||
*.md @mctaylors
|
||||
/docs/ @mctaylors
|
||||
Messages.tt-ru.resx @mctaylors
|
||||
|
|
76
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
76
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal 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
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
blank_issues_enabled: false
|
38
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
38
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal 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
27
.github/README.md
vendored
|
@ -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.
|
44
.github/workflows/codeql.yml
vendored
44
.github/workflows/codeql.yml
vendored
|
@ -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}}"
|
7
.github/workflows/resharper.yml
vendored
7
.github/workflows/resharper.yml
vendored
|
@ -18,9 +18,6 @@ jobs:
|
|||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
@ -29,8 +26,8 @@ jobs:
|
|||
run: dotnet restore
|
||||
|
||||
- name: ReSharper CLI InspectCode
|
||||
uses: muno92/resharper_inspectcode@1.6.13
|
||||
uses: muno92/resharper_inspectcode@1.7.1
|
||||
with:
|
||||
solutionPath: ./Boyfriend.sln
|
||||
ignoreIssueType: InvertIf
|
||||
ignoreIssueType: InvertIf, ConvertIfStatementToReturnStatement, ConvertIfStatementToSwitchStatement
|
||||
solutionWideAnalysis: true
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,6 +1,5 @@
|
|||
.idea/
|
||||
*.user
|
||||
token.txt
|
||||
bin/
|
||||
obj/
|
||||
/packages/
|
||||
|
|
248
Boyfriend.cs
248
Boyfriend.cs
|
@ -1,180 +1,96 @@
|
|||
using System.Text;
|
||||
using System.Timers;
|
||||
using Boyfriend.Data;
|
||||
using Discord;
|
||||
using Discord.Rest;
|
||||
using Discord.WebSocket;
|
||||
using Timer = System.Timers.Timer;
|
||||
using Boyfriend.Commands;
|
||||
using Boyfriend.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
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;
|
||||
|
||||
public static class Boyfriend {
|
||||
public static readonly StringBuilder StringBuilder = new();
|
||||
public class Boyfriend {
|
||||
public static readonly AllowedMentions NoMentions = new(
|
||||
Array.Empty<MentionType>(), Array.Empty<Snowflake>(), Array.Empty<Snowflake>());
|
||||
|
||||
private static readonly DiscordSocketConfig Config = new() {
|
||||
MessageCacheSize = 250,
|
||||
GatewayIntents
|
||||
= (GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent | GatewayIntents.GuildMembers)
|
||||
& ~GatewayIntents.GuildInvites,
|
||||
AlwaysDownloadUsers = true,
|
||||
AlwaysResolveStickers = false,
|
||||
AlwaysDownloadDefaultStickers = false,
|
||||
LargeThreshold = 500
|
||||
};
|
||||
public static async Task Main(string[] args) {
|
||||
var host = CreateHostBuilder(args).UseConsoleLifetime().Build();
|
||||
var services = host.Services;
|
||||
|
||||
private static DateTimeOffset _nextSongAt = DateTimeOffset.MinValue;
|
||||
private static uint _nextSongIndex;
|
||||
var slashService = services.GetRequiredService<SlashService>();
|
||||
// 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 = {
|
||||
(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);
|
||||
await host.RunAsync();
|
||||
}
|
||||
|
||||
private static async void TickAllGuildsAsync(object? sender, ElapsedEventArgs e) {
|
||||
if (GuildTickTasks.Count is not 0) return;
|
||||
private static IHostBuilder CreateHostBuilder(string[] args) {
|
||||
return Host.CreateDefaultBuilder(args)
|
||||
.AddDiscordService(
|
||||
services => {
|
||||
var configuration = services.GetRequiredService<IConfiguration>();
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var guild in Client.Guilds) GuildTickTasks.Add(TickGuildAsync(guild, now));
|
||||
|
||||
if (now >= _nextSongAt) {
|
||||
var nextSong = ActivityList[_nextSongIndex];
|
||||
await Client.SetActivityAsync(nextSong.Song);
|
||||
_nextSongAt = now.Add(nextSong.Duration);
|
||||
_nextSongIndex++;
|
||||
if (_nextSongIndex >= ActivityList.Length) _nextSongIndex = 0;
|
||||
return configuration.GetValue<string?>("BOT_TOKEN")
|
||||
?? 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));
|
||||
});
|
||||
|
||||
try { Task.WaitAll(GuildTickTasks.ToArray()); } catch (AggregateException ex) {
|
||||
foreach (var exc in ex.InnerExceptions)
|
||||
await Log(
|
||||
new LogMessage(
|
||||
LogSeverity.Error, nameof(Boyfriend),
|
||||
"Exception while ticking guilds", exc));
|
||||
services.AddTransient<IConfigurationBuilder, ConfigurationBuilder>()
|
||||
.AddDiscordCaching()
|
||||
.AddDiscordCommands(true)
|
||||
.AddPreparationErrorEvent<ErrorLoggingPreparationErrorEvent>()
|
||||
.AddPostExecutionEvent<ErrorLoggingPostExecutionEvent>()
|
||||
.AddInteractivity()
|
||||
.AddInteractionGroup<InteractionResponders>()
|
||||
.AddSingleton<GuildDataService>()
|
||||
.AddSingleton<UtilityService>()
|
||||
.AddHostedService<GuildUpdateService>()
|
||||
.AddCommandTree()
|
||||
.WithCommandGroup<AboutCommandGroup>()
|
||||
.WithCommandGroup<BanCommandGroup>()
|
||||
.WithCommandGroup<ClearCommandGroup>()
|
||||
.WithCommandGroup<KickCommandGroup>()
|
||||
.WithCommandGroup<MuteCommandGroup>()
|
||||
.WithCommandGroup<PingCommandGroup>()
|
||||
.WithCommandGroup<RemindCommandGroup>()
|
||||
.WithCommandGroup<SettingsCommandGroup>();
|
||||
var responderTypes = typeof(Boyfriend).Assembly
|
||||
.GetExportedTypes()
|
||||
.Where(t => t.IsResponder());
|
||||
foreach (var responderType in responderTypes) services.AddResponder(responderType);
|
||||
}
|
||||
|
||||
GuildTickTasks.Clear();
|
||||
}
|
||||
|
||||
public static Task Log(LogMessage msg) {
|
||||
switch (msg.Severity) {
|
||||
case LogSeverity.Critical:
|
||||
Console.ForegroundColor = ConsoleColor.DarkRed;
|
||||
Console.Error.WriteLine(msg.ToString());
|
||||
break;
|
||||
case LogSeverity.Error:
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine(msg.ToString());
|
||||
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);
|
||||
).ConfigureLogging(
|
||||
c => c.AddConsole()
|
||||
.AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning)
|
||||
.AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,9 +5,9 @@
|
|||
<TargetFramework>net7.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>2.0.0</Version>
|
||||
<Title>Boyfriend</Title>
|
||||
<Authors>Octol1ttle, mctaylors</Authors>
|
||||
<Authors>Octol1ttle, mctaylors, neroduckale</Authors>
|
||||
<Copyright>AGPLv3</Copyright>
|
||||
<PackageProjectUrl>https://github.com/TeamOctolings/Boyfriend</PackageProjectUrl>
|
||||
<PackageLicenseUrl>https://github.com/TeamOctolings/Boyfriend/blob/master/LICENSE</PackageLicenseUrl>
|
||||
|
@ -19,8 +19,31 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Discord.Net" Version="3.10.0"/>
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="DiffPlex" Version="1.7.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>
|
||||
</Project>
|
||||
|
|
18
ColorsList.cs
Normal file
18
ColorsList.cs
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
75
Commands/AboutCommandGroup.cs
Normal file
75
Commands/AboutCommandGroup.cs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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
275
Commands/BanCommandGroup.cs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
120
Commands/ClearCommandGroup.cs
Normal file
120
Commands/ClearCommandGroup.cs
Normal 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);
|
||||
}
|
||||
}
|
62
Commands/ErrorLoggingEvents.cs
Normal file
62
Commands/ErrorLoggingEvents.cs
Normal 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());
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
namespace Boyfriend.Commands;
|
||||
|
||||
public interface ICommand {
|
||||
public string[] Aliases { get; }
|
||||
|
||||
public Task RunAsync(CommandProcessor cmd, string[] args, string[] cleanArgs);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
164
Commands/KickCommandGroup.cs
Normal file
164
Commands/KickCommandGroup.cs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
258
Commands/MuteCommandGroup.cs
Normal file
258
Commands/MuteCommandGroup.cs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
79
Commands/PingCommandGroup.cs
Normal file
79
Commands/PingCommandGroup.cs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
66
Commands/RemindCommandGroup.cs
Normal file
66
Commands/RemindCommandGroup.cs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
}
|
158
Commands/SettingsCommandGroup.cs
Normal file
158
Commands/SettingsCommandGroup.cs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
90
Data/GuildConfiguration.cs
Normal file
90
Data/GuildConfiguration.cs
Normal 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];
|
||||
}
|
||||
}
|
|
@ -1,142 +1,41 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using System.Globalization;
|
||||
using Remora.Rest.Core;
|
||||
|
||||
namespace Boyfriend.Data;
|
||||
|
||||
public record GuildData {
|
||||
public static readonly Dictionary<string, string> DefaultPreferences = new() {
|
||||
{ "Prefix", "!" },
|
||||
{ "Lang", "en" },
|
||||
{ "ReceiveStartupMessages", "false" },
|
||||
{ "WelcomeMessage", "default" },
|
||||
{ "SendWelcomeMessages", "true" },
|
||||
{ "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();
|
||||
/// <summary>
|
||||
/// Stores information about a guild. This information is not accessible via the Discord API.
|
||||
/// </summary>
|
||||
/// <remarks>This information is stored on disk as a JSON file.</remarks>
|
||||
public class GuildData {
|
||||
public readonly GuildConfiguration Configuration;
|
||||
public readonly string ConfigurationPath;
|
||||
|
||||
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;
|
||||
|
||||
[SuppressMessage("Performance", "CA1853:Unnecessary call to \'Dictionary.ContainsKey(key)\'")]
|
||||
// https://github.com/dotnet/roslyn-analyzers/issues/6377
|
||||
private GuildData(SocketGuild guild) {
|
||||
var downloaderTask = guild.DownloadUsersAsync();
|
||||
_id = guild.Id;
|
||||
var idString = $"{_id}";
|
||||
var memberDataDir = $"{_id}/MemberData";
|
||||
_configurationFile = $"{_id}/Configuration.json";
|
||||
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);
|
||||
public GuildData(
|
||||
GuildConfiguration configuration, string configurationPath,
|
||||
Dictionary<ulong, ScheduledEventData> scheduledEvents, string scheduledEventsPath,
|
||||
Dictionary<ulong, MemberData> memberData, string memberDataPath) {
|
||||
Configuration = configuration;
|
||||
ConfigurationPath = configurationPath;
|
||||
ScheduledEvents = scheduledEvents;
|
||||
ScheduledEventsPath = scheduledEventsPath;
|
||||
MemberData = memberData;
|
||||
MemberDataPath = memberDataPath;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
public CultureInfo Culture => Configuration.GetCulture();
|
||||
|
||||
if (memberData.MutedUntil is null) {
|
||||
memberData.Roles = ((IGuildUser)member).RoleIds.ToList();
|
||||
memberData.Roles.Remove(guild.Id);
|
||||
}
|
||||
public MemberData GetMemberData(Snowflake userId) {
|
||||
if (MemberData.TryGetValue(userId.Value, out var existing)) return existing;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
MemberData.Add(member.Id, new MemberData(member));
|
||||
}
|
||||
|
||||
MemberData.TrimExcess();
|
||||
}
|
||||
|
||||
public SocketRole? MuteRole {
|
||||
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
|
||||
=> Boyfriend.Client.GetGuild(_id)
|
||||
.GetTextChannel(
|
||||
ulong.Parse(Preferences["PublicFeedbackChannel"]));
|
||||
|
||||
public SocketTextChannel? PrivateFeedbackChannel => Boyfriend.Client.GetGuild(_id)
|
||||
.GetTextChannel(
|
||||
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);
|
||||
var newData = new MemberData(userId.Value, null);
|
||||
MemberData.Add(userId.Value, 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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,38 +1,18 @@
|
|||
using System.Text.Json.Serialization;
|
||||
using Discord;
|
||||
using Remora.Rest.Core;
|
||||
|
||||
namespace Boyfriend.Data;
|
||||
|
||||
public record MemberData {
|
||||
public DateTimeOffset? BannedUntil;
|
||||
public ulong Id;
|
||||
public bool IsInGuild;
|
||||
public List<DateTimeOffset> JoinedAt;
|
||||
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;
|
||||
/// <summary>
|
||||
/// Stores information about a member
|
||||
/// </summary>
|
||||
public class MemberData {
|
||||
public MemberData(ulong id, DateTimeOffset? bannedUntil) {
|
||||
Id = id;
|
||||
IsInGuild = isInGuild;
|
||||
JoinedAt = joinedAt;
|
||||
LeftAt = leftAt;
|
||||
MutedUntil = mutedUntil;
|
||||
Reminders = reminders;
|
||||
Roles = roles;
|
||||
BannedUntil = bannedUntil;
|
||||
}
|
||||
|
||||
public MemberData(IGuildUser user) {
|
||||
Id = user.Id;
|
||||
IsInGuild = true;
|
||||
JoinedAt = new List<DateTimeOffset> { user.JoinedAt!.Value };
|
||||
LeftAt = new List<DateTimeOffset>();
|
||||
Roles = user.RoleIds.ToList();
|
||||
Roles.Remove(user.Guild.Id);
|
||||
Reminders = new List<Reminder>();
|
||||
}
|
||||
public ulong Id { get; }
|
||||
public DateTimeOffset? BannedUntil { get; set; }
|
||||
public List<Snowflake> Roles { get; set; } = new();
|
||||
public List<Reminder> Reminders { get; } = new();
|
||||
}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
namespace Boyfriend.Data;
|
||||
using Remora.Rest.Core;
|
||||
|
||||
namespace Boyfriend.Data;
|
||||
|
||||
public struct Reminder {
|
||||
public DateTimeOffset RemindAt;
|
||||
public string ReminderText;
|
||||
public ulong ReminderChannel;
|
||||
public string Text;
|
||||
public Snowflake Channel;
|
||||
}
|
||||
|
|
17
Data/ScheduledEventData.cs
Normal file
17
Data/ScheduledEventData.cs
Normal 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; }
|
||||
}
|
238
EventHandler.cs
238
EventHandler.cs
|
@ -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
335
EventResponders.cs
Normal 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
189
Extensions.cs
Normal 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
36
InteractionResponders.cs
Normal 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));
|
||||
}
|
||||
}
|
412
Messages.Designer.cs
generated
412
Messages.Designer.cs
generated
|
@ -18,7 +18,7 @@ namespace Boyfriend {
|
|||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Messages {
|
||||
|
@ -60,7 +60,61 @@ namespace Boyfriend {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Bah! .
|
||||
/// Looks up a localized string similar to About Boyfriend.
|
||||
/// </summary>
|
||||
internal static string AboutBot {
|
||||
get {
|
||||
return ResourceManager.GetString("AboutBot", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to logo and embed designer, Boyfriend's Wiki creator.
|
||||
/// </summary>
|
||||
internal static string AboutDeveloper_mctaylors {
|
||||
get {
|
||||
return ResourceManager.GetString("AboutDeveloper@mctaylors", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to developer.
|
||||
/// </summary>
|
||||
internal static string AboutDeveloper_neroduckale {
|
||||
get {
|
||||
return ResourceManager.GetString("AboutDeveloper@neroduckale", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to main developer.
|
||||
/// </summary>
|
||||
internal static string AboutDeveloper_Octol1ttle {
|
||||
get {
|
||||
return ResourceManager.GetString("AboutDeveloper@Octol1ttle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Developers:.
|
||||
/// </summary>
|
||||
internal static string AboutTitleDevelopers {
|
||||
get {
|
||||
return ResourceManager.GetString("AboutTitleDevelopers", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Boyfriend's Wiki Page:.
|
||||
/// </summary>
|
||||
internal static string AboutTitleWiki {
|
||||
get {
|
||||
return ResourceManager.GetString("AboutTitleWiki", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Bah!.
|
||||
/// </summary>
|
||||
internal static string Beep1 {
|
||||
get {
|
||||
|
@ -69,7 +123,7 @@ namespace Boyfriend {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Bop! .
|
||||
/// Looks up a localized string similar to Bop!.
|
||||
/// </summary>
|
||||
internal static string Beep2 {
|
||||
get {
|
||||
|
@ -78,7 +132,7 @@ namespace Boyfriend {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Beep! .
|
||||
/// Looks up a localized string similar to Beep!.
|
||||
/// </summary>
|
||||
internal static string Beep3 {
|
||||
get {
|
||||
|
@ -168,7 +222,16 @@ namespace Boyfriend {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Deleted message from {0} in channel {1}: {2}.
|
||||
/// Looks up a localized string similar to Cleared message from {0} in channel {1}: {2}.
|
||||
/// </summary>
|
||||
internal static string CachedMessageCleared {
|
||||
get {
|
||||
return ResourceManager.GetString("CachedMessageCleared", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Deleted message by {0}:.
|
||||
/// </summary>
|
||||
internal static string CachedMessageDeleted {
|
||||
get {
|
||||
|
@ -177,7 +240,7 @@ namespace Boyfriend {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Edited message in channel {0}: {1} -> {2}.
|
||||
/// Looks up a localized string similar to Edited message by {0}:.
|
||||
/// </summary>
|
||||
internal static string CachedMessageEdited {
|
||||
get {
|
||||
|
@ -365,6 +428,78 @@ namespace Boyfriend {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Expires at: {0}.
|
||||
/// </summary>
|
||||
internal static string DescriptionActionExpiresAt {
|
||||
get {
|
||||
return ResourceManager.GetString("DescriptionActionExpiresAt", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Reason: {0}.
|
||||
/// </summary>
|
||||
internal static string DescriptionActionReason {
|
||||
get {
|
||||
return ResourceManager.GetString("DescriptionActionReason", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The event will start at {0} until {1} in {2}.
|
||||
/// </summary>
|
||||
internal static string DescriptionExternalEventCreated {
|
||||
get {
|
||||
return ResourceManager.GetString("DescriptionExternalEventCreated", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The event is happening at {0} until {1}.
|
||||
/// </summary>
|
||||
internal static string DescriptionExternalEventStarted {
|
||||
get {
|
||||
return ResourceManager.GetString("DescriptionExternalEventStarted", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The event will start at {0} in {1}.
|
||||
/// </summary>
|
||||
internal static string DescriptionLocalEventCreated {
|
||||
get {
|
||||
return ResourceManager.GetString("DescriptionLocalEventCreated", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The event is happening at {0}.
|
||||
/// </summary>
|
||||
internal static string DescriptionLocalEventStarted {
|
||||
get {
|
||||
return ResourceManager.GetString("DescriptionLocalEventStarted", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to You asked me to remind you {0}.
|
||||
/// </summary>
|
||||
internal static string DescriptionReminder {
|
||||
get {
|
||||
return ResourceManager.GetString("DescriptionReminder", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to OK, I'll mention you on {0}.
|
||||
/// </summary>
|
||||
internal static string DescriptionReminderCreated {
|
||||
get {
|
||||
return ResourceManager.GetString("DescriptionReminderCreated", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to 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.
|
||||
/// </summary>
|
||||
|
@ -375,7 +510,7 @@ namespace Boyfriend {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Event {0} is cancelled!{1}.
|
||||
/// Looks up a localized string similar to Event "{0}" is cancelled!.
|
||||
/// </summary>
|
||||
internal static string EventCancelled {
|
||||
get {
|
||||
|
@ -384,7 +519,7 @@ namespace Boyfriend {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Event {0} has completed! Duration:{1}.
|
||||
/// Looks up a localized string similar to Event "{0}" has completed!.
|
||||
/// </summary>
|
||||
internal static string EventCompleted {
|
||||
get {
|
||||
|
@ -401,6 +536,33 @@ namespace Boyfriend {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to {0} has created a new event:.
|
||||
/// </summary>
|
||||
internal static string EventCreatedTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("EventCreatedTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Event details.
|
||||
/// </summary>
|
||||
internal static string EventDetailsButton {
|
||||
get {
|
||||
return ResourceManager.GetString("EventDetailsButton", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to The event has lasted for `{0}`.
|
||||
/// </summary>
|
||||
internal static string EventDuration {
|
||||
get {
|
||||
return ResourceManager.GetString("EventDuration", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to {0}Event {1} will start <t:{2}:R>!.
|
||||
/// </summary>
|
||||
|
@ -411,7 +573,7 @@ namespace Boyfriend {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to {0}Event {1} is starting at {2}!.
|
||||
/// Looks up a localized string similar to Event "{0}" started.
|
||||
/// </summary>
|
||||
internal static string EventStarted {
|
||||
get {
|
||||
|
@ -455,15 +617,6 @@ namespace Boyfriend {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Deleted {0} messages in {1}.
|
||||
/// </summary>
|
||||
internal static string FeedbackMessagesCleared {
|
||||
get {
|
||||
return ResourceManager.GetString("FeedbackMessagesCleared", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Value of setting `{0}` is now set to {1}.
|
||||
/// </summary>
|
||||
|
@ -473,15 +626,6 @@ namespace Boyfriend {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Banned {0} for{1}: {2}.
|
||||
/// </summary>
|
||||
internal static string FeedbackUserBanned {
|
||||
get {
|
||||
return ResourceManager.GetString("FeedbackUserBanned", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Unbanned {0}: {1}.
|
||||
/// </summary>
|
||||
|
@ -501,7 +645,7 @@ namespace Boyfriend {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to You did not specify a member of this guild!.
|
||||
/// Looks up a localized string similar to You need to specify a member of this guild!.
|
||||
/// </summary>
|
||||
internal static string InvalidMember {
|
||||
get {
|
||||
|
@ -509,6 +653,15 @@ namespace Boyfriend {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to You need to specify when I should send you the reminder!.
|
||||
/// </summary>
|
||||
internal static string InvalidRemindIn {
|
||||
get {
|
||||
return ResourceManager.GetString("InvalidRemindIn", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to This role does not exist!.
|
||||
/// </summary>
|
||||
|
@ -537,7 +690,16 @@ namespace Boyfriend {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Language not supported! Supported languages:.
|
||||
/// Looks up a localized string similar to Issued by.
|
||||
/// </summary>
|
||||
internal static string IssuedBy {
|
||||
get {
|
||||
return ResourceManager.GetString("IssuedBy", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Language not supported!.
|
||||
/// </summary>
|
||||
internal static string LanguageNotSupported {
|
||||
get {
|
||||
|
@ -563,6 +725,24 @@ namespace Boyfriend {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to From {0}:.
|
||||
/// </summary>
|
||||
internal static string MessageFrom {
|
||||
get {
|
||||
return ResourceManager.GetString("MessageFrom", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Cleared {0} messages.
|
||||
/// </summary>
|
||||
internal static string MessagesCleared {
|
||||
get {
|
||||
return ResourceManager.GetString("MessagesCleared", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to ms.
|
||||
/// </summary>
|
||||
|
@ -672,7 +852,7 @@ namespace Boyfriend {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to {0}I'm ready!.
|
||||
/// Looks up a localized string similar to I'm ready!.
|
||||
/// </summary>
|
||||
internal static string Ready {
|
||||
get {
|
||||
|
@ -680,6 +860,24 @@ namespace Boyfriend {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Reminder for {0}.
|
||||
/// </summary>
|
||||
internal static string Reminder {
|
||||
get {
|
||||
return ResourceManager.GetString("Reminder", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Reminder for {0} created.
|
||||
/// </summary>
|
||||
internal static string ReminderCreated {
|
||||
get {
|
||||
return ResourceManager.GetString("ReminderCreated", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Not specified.
|
||||
/// </summary>
|
||||
|
@ -698,6 +896,24 @@ namespace Boyfriend {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to is now.
|
||||
/// </summary>
|
||||
internal static string SettingIsNow {
|
||||
get {
|
||||
return ResourceManager.GetString("SettingIsNow", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Setting not changed.
|
||||
/// </summary>
|
||||
internal static string SettingNotChanged {
|
||||
get {
|
||||
return ResourceManager.GetString("SettingNotChanged", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Not specified.
|
||||
/// </summary>
|
||||
|
@ -716,6 +932,15 @@ namespace Boyfriend {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Default role.
|
||||
/// </summary>
|
||||
internal static string SettingsDefaultRole {
|
||||
get {
|
||||
return ResourceManager.GetString("SettingsDefaultRole", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Early event start notification offset.
|
||||
/// </summary>
|
||||
|
@ -770,6 +995,15 @@ namespace Boyfriend {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Boyfriend's Settings.
|
||||
/// </summary>
|
||||
internal static string SettingsListTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("SettingsListTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Mute role.
|
||||
/// </summary>
|
||||
|
@ -852,11 +1086,11 @@ namespace Boyfriend {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Starter role.
|
||||
/// Looks up a localized string similar to Setting successfuly changed.
|
||||
/// </summary>
|
||||
internal static string SettingsStarterRole {
|
||||
internal static string SettingSuccessfulyChanged {
|
||||
get {
|
||||
return ResourceManager.GetString("SettingsStarterRole", resourceCulture);
|
||||
return ResourceManager.GetString("SettingSuccessfulyChanged", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -869,6 +1103,33 @@ namespace Boyfriend {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to This user is already banned!.
|
||||
/// </summary>
|
||||
internal static string UserAlreadyBanned {
|
||||
get {
|
||||
return ResourceManager.GetString("UserAlreadyBanned", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to This user is already muted!.
|
||||
/// </summary>
|
||||
internal static string UserAlreadyMuted {
|
||||
get {
|
||||
return ResourceManager.GetString("UserAlreadyMuted", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to {0} was banned.
|
||||
/// </summary>
|
||||
internal static string UserBanned {
|
||||
get {
|
||||
return ResourceManager.GetString("UserBanned", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to You cannot ban me!.
|
||||
/// </summary>
|
||||
|
@ -1058,6 +1319,24 @@ namespace Boyfriend {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to {0} was kicked.
|
||||
/// </summary>
|
||||
internal static string UserKicked {
|
||||
get {
|
||||
return ResourceManager.GetString("UserKicked", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to {0} was muted.
|
||||
/// </summary>
|
||||
internal static string UserMuted {
|
||||
get {
|
||||
return ResourceManager.GetString("UserMuted", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to This user is not banned!.
|
||||
/// </summary>
|
||||
|
@ -1076,6 +1355,42 @@ namespace Boyfriend {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to I could not find this user!.
|
||||
/// </summary>
|
||||
internal static string UserNotFoundShort {
|
||||
get {
|
||||
return ResourceManager.GetString("UserNotFoundShort", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to This member is not muted!.
|
||||
/// </summary>
|
||||
internal static string UserNotMuted {
|
||||
get {
|
||||
return ResourceManager.GetString("UserNotMuted", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to {0} was unbanned.
|
||||
/// </summary>
|
||||
internal static string UserUnbanned {
|
||||
get {
|
||||
return ResourceManager.GetString("UserUnbanned", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to {0} was unmuted.
|
||||
/// </summary>
|
||||
internal static string UserUnmuted {
|
||||
get {
|
||||
return ResourceManager.GetString("UserUnmuted", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Yes.
|
||||
/// </summary>
|
||||
|
@ -1086,7 +1401,7 @@ namespace Boyfriend {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to You were banned by {0} in guild `{1}` for {2}.
|
||||
/// Looks up a localized string similar to You were banned.
|
||||
/// </summary>
|
||||
internal static string YouWereBanned {
|
||||
get {
|
||||
|
@ -1095,39 +1410,12 @@ namespace Boyfriend {
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to You were kicked by {0} in guild `{1}` for {2}.
|
||||
/// Looks up a localized string similar to You were kicked.
|
||||
/// </summary>
|
||||
internal static string YouWereKicked {
|
||||
get {
|
||||
return ResourceManager.GetString("YouWereKicked", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to OK, I'll mention you on <t:{0}:f>.
|
||||
/// </summary>
|
||||
internal static string FeedbackReminderAdded {
|
||||
get {
|
||||
return ResourceManager.GetString("FeedbackReminderAdded", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to You need to specify when I should send you the reminder!.
|
||||
/// </summary>
|
||||
internal static string InvalidRemindIn {
|
||||
get {
|
||||
return ResourceManager.GetString("InvalidRemindIn", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Deleted message using cleanup from {0} in channel {1}: {2}.
|
||||
/// </summary>
|
||||
internal static string CachedMessageCleared {
|
||||
get {
|
||||
return ResourceManager.GetString("CachedMessageCleared", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
154
Messages.resx
154
Messages.resx
|
@ -13,19 +13,7 @@
|
|||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<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="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>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
@ -112,34 +100,34 @@
|
|||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<value>I'm ready!</value>
|
||||
</data>
|
||||
<data name="CachedMessageDeleted" xml:space="preserve">
|
||||
<value>Deleted message from {0} in channel {1}: {2}</value>
|
||||
<value>Deleted message by {0}:</value>
|
||||
</data>
|
||||
<data name="CachedMessageCleared" xml:space="preserve">
|
||||
<value>Cleared message from {0} in channel {1}: {2}</value>
|
||||
</data>
|
||||
<data name="CachedMessageEdited" xml:space="preserve">
|
||||
<value>Edited message in channel {0}: {1} -> {2}</value>
|
||||
<value>Edited message by {0}:</value>
|
||||
</data>
|
||||
<data name="DefaultWelcomeMessage" xml:space="preserve">
|
||||
<value>{0}, welcome to {1}</value>
|
||||
</data>
|
||||
<data name="Beep1" xml:space="preserve">
|
||||
<value>Bah! </value>
|
||||
<value>Bah!</value>
|
||||
</data>
|
||||
<data name="Beep2" xml:space="preserve">
|
||||
<value>Bop! </value>
|
||||
<value>Bop!</value>
|
||||
</data>
|
||||
<data name="Beep3" xml:space="preserve">
|
||||
<value>Beep! </value>
|
||||
<value>Beep!</value>
|
||||
</data>
|
||||
<data name="CommandNoPermissionBot" xml:space="preserve">
|
||||
<value>I do not have permission to execute this command!</value>
|
||||
|
@ -148,7 +136,7 @@
|
|||
<value>You do not have permission to execute this command!</value>
|
||||
</data>
|
||||
<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 name="PunishmentExpired" xml:space="preserve">
|
||||
<value>Punishment expired</value>
|
||||
|
@ -163,7 +151,7 @@
|
|||
<value>Command help:</value>
|
||||
</data>
|
||||
<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 name="Milliseconds" xml:space="preserve">
|
||||
<value>ms</value>
|
||||
|
@ -196,7 +184,7 @@
|
|||
<value>Mute role</value>
|
||||
</data>
|
||||
<data name="LanguageNotSupported" xml:space="preserve">
|
||||
<value>Language not supported! Supported languages:</value>
|
||||
<value>Language not supported!</value>
|
||||
</data>
|
||||
<data name="Yes" xml:space="preserve">
|
||||
<value>Yes</value>
|
||||
|
@ -216,8 +204,8 @@
|
|||
<data name="ClearAmountInvalid" xml:space="preserve">
|
||||
<value>You need to specify an integer from {0} to {1} instead of {2}!</value>
|
||||
</data>
|
||||
<data name="FeedbackUserBanned" xml:space="preserve">
|
||||
<value>Banned {0} for{1}: {2}</value>
|
||||
<data name="UserBanned" xml:space="preserve">
|
||||
<value>{0} was banned</value>
|
||||
</data>
|
||||
<data name="SettingDoesntExist" xml:space="preserve">
|
||||
<value>That setting doesn't exist!</value>
|
||||
|
@ -253,22 +241,22 @@
|
|||
<value>Event start notifications receivers</value>
|
||||
</data>
|
||||
<data name="EventStarted" xml:space="preserve">
|
||||
<value>{0}Event {1} is starting at {2}!</value>
|
||||
<value>Event "{0}" started</value>
|
||||
</data>
|
||||
<data name="SettingsFrowningFace" xml:space="preserve">
|
||||
<value>:(</value>
|
||||
</data>
|
||||
<data name="EventCancelled" xml:space="preserve">
|
||||
<value>Event {0} is cancelled!{1}</value>
|
||||
<value>Event "{0}" is cancelled!</value>
|
||||
</data>
|
||||
<data name="EventCompleted" xml:space="preserve">
|
||||
<value>Event {0} has completed! Duration:{1}</value>
|
||||
<value>Event "{0}" has completed!</value>
|
||||
</data>
|
||||
<data name="Ever" xml:space="preserve">
|
||||
<value>ever</value>
|
||||
</data>
|
||||
<data name="FeedbackMessagesCleared" xml:space="preserve">
|
||||
<value>Deleted {0} messages in {1}</value>
|
||||
<data name="MessagesCleared" xml:space="preserve">
|
||||
<value>Cleared {0} messages</value>
|
||||
</data>
|
||||
<data name="FeedbackMemberKicked" xml:space="preserve">
|
||||
<value>Kicked {0}: {1}</value>
|
||||
|
@ -447,8 +435,8 @@
|
|||
<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>
|
||||
</data>
|
||||
<data name="SettingsStarterRole" xml:space="preserve">
|
||||
<value>Starter role</value>
|
||||
<data name="SettingsDefaultRole" xml:space="preserve">
|
||||
<value>Default role</value>
|
||||
</data>
|
||||
<data name="CommandDescriptionRemind" xml:space="preserve">
|
||||
<value>Adds a reminder</value>
|
||||
|
@ -468,10 +456,106 @@
|
|||
<data name="MissingReminderText" xml:space="preserve">
|
||||
<value>You need to specify reminder text!</value>
|
||||
</data>
|
||||
<data name="FeedbackReminderAdded" xml:space="preserve">
|
||||
<value>OK, I'll mention you on <t:{0}:f></value>
|
||||
<data name="DescriptionReminderCreated" xml:space="preserve">
|
||||
<value>OK, I'll mention you on {0}</value>
|
||||
</data>
|
||||
<data name="InvalidRemindIn" xml:space="preserve">
|
||||
<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>
|
||||
</root>
|
||||
|
|
158
Messages.ru.resx
158
Messages.ru.resx
|
@ -13,19 +13,7 @@
|
|||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<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="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>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
@ -112,43 +100,40 @@
|
|||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<value>Я запустился!</value>
|
||||
</data>
|
||||
<data name="CachedMessageDeleted" xml:space="preserve">
|
||||
<value>Удалено сообщение от {0} в канале {1}: {2}</value>
|
||||
<value>Сообщение {0} удалено:</value>
|
||||
</data>
|
||||
<data name="CachedMessageCleared" xml:space="preserve">
|
||||
<value>Очищено сообщение от {0} в канале {1}: {2}</value>
|
||||
</data>
|
||||
<data name="CachedMessageEdited" xml:space="preserve">
|
||||
<value>Отредактировано сообщение в канале {0}: {1} -> {2}</value>
|
||||
<value>Сообщение {0} отредактировано:</value>
|
||||
</data>
|
||||
<data name="DefaultWelcomeMessage" xml:space="preserve">
|
||||
<value>{0}, добро пожаловать на сервер {1}</value>
|
||||
</data>
|
||||
<data name="Beep1" xml:space="preserve">
|
||||
<value>Бап! </value>
|
||||
<value>Бап!</value>
|
||||
</data>
|
||||
<data name="Beep2" xml:space="preserve">
|
||||
<value>Боп! </value>
|
||||
<value>Боп!</value>
|
||||
</data>
|
||||
<data name="Beep3" xml:space="preserve">
|
||||
<value>Бип! </value>
|
||||
<value>Бип!</value>
|
||||
</data>
|
||||
<data name="CommandNoPermissionBot" xml:space="preserve">
|
||||
<value>У меня недостаточно прав для выполнения этой команды!</value>
|
||||
</data>
|
||||
<data name="CommandNoPermissionUser" xml:space="preserve">
|
||||
<value>У тебя недостаточно прав для выполнения этой команды!</value>
|
||||
</data>
|
||||
<data name="YouWereBanned" xml:space="preserve">
|
||||
<value>Тебя забанил {0} на сервере `{1}` за {2}</value>
|
||||
</data>
|
||||
<data name="PunishmentExpired" xml:space="preserve">
|
||||
<value>Время наказания истекло</value>
|
||||
|
@ -163,7 +148,7 @@
|
|||
<value>Справка по командам:</value>
|
||||
</data>
|
||||
<data name="YouWereKicked" xml:space="preserve">
|
||||
<value>Тебя кикнул {0} на сервере `{1}` за {2}</value>
|
||||
<value>Вы были выгнаны</value>
|
||||
</data>
|
||||
<data name="Milliseconds" xml:space="preserve">
|
||||
<value>мс</value>
|
||||
|
@ -196,7 +181,7 @@
|
|||
<value>Роль мута</value>
|
||||
</data>
|
||||
<data name="LanguageNotSupported" xml:space="preserve">
|
||||
<value>Язык не поддерживается! Поддерживаемые языки:</value>
|
||||
<value>Язык не поддерживается! </value>
|
||||
</data>
|
||||
<data name="Yes" xml:space="preserve">
|
||||
<value>Да</value>
|
||||
|
@ -216,8 +201,8 @@
|
|||
<data name="ClearAmountInvalid" xml:space="preserve">
|
||||
<value>Надо указать целое число от {0} до {1} вместо {2}!</value>
|
||||
</data>
|
||||
<data name="FeedbackUserBanned" xml:space="preserve">
|
||||
<value>Забанен {0} на{1}: {2}</value>
|
||||
<data name="UserBanned" xml:space="preserve">
|
||||
<value>{0} был(-а) забанен(-а)</value>
|
||||
</data>
|
||||
<data name="SettingDoesntExist" xml:space="preserve">
|
||||
<value>Такая настройка не существует!</value>
|
||||
|
@ -253,22 +238,22 @@
|
|||
<value>Получатели уведомлений о начале событий</value>
|
||||
</data>
|
||||
<data name="EventStarted" xml:space="preserve">
|
||||
<value>{0}Событие {1} начинается в {2}!</value>
|
||||
<value>Событие "{0}" началось</value>
|
||||
</data>
|
||||
<data name="SettingsFrowningFace" xml:space="preserve">
|
||||
<value>:( </value>
|
||||
</data>
|
||||
<data name="EventCancelled" xml:space="preserve">
|
||||
<value>Событие {0} отменено!{1}</value>
|
||||
<value>Событие "{0}" отменено!</value>
|
||||
</data>
|
||||
<data name="EventCompleted" xml:space="preserve">
|
||||
<value>Событие {0} завершено! Продолжительность:{1}</value>
|
||||
<value>Событие "{0}" завершено!</value>
|
||||
</data>
|
||||
<data name="Ever" xml:space="preserve">
|
||||
<value>всегда</value>
|
||||
</data>
|
||||
<data name="FeedbackMessagesCleared" xml:space="preserve">
|
||||
<value>Удалено {0} сообщений в {1}</value>
|
||||
<data name="MessagesCleared" xml:space="preserve">
|
||||
<value>Очищено {0} сообщений</value>
|
||||
</data>
|
||||
<data name="FeedbackMemberKicked" xml:space="preserve">
|
||||
<value>Выгнан {0}: {1}</value>
|
||||
|
@ -447,8 +432,8 @@
|
|||
<data name="UserNotFound" xml:space="preserve">
|
||||
<value>Я не смог найти этого пользователя ни в одном из серверов, в которых я есть. Проверь правильность ID и нахождение пользователя на этом сервере максимум 30 дней назад</value>
|
||||
</data>
|
||||
<data name="SettingsStarterRole" xml:space="preserve">
|
||||
<value>Начальная роль</value>
|
||||
<data name="SettingsDefaultRole" xml:space="preserve">
|
||||
<value>Общая роль</value>
|
||||
</data>
|
||||
<data name="CommandDescriptionRemind" xml:space="preserve">
|
||||
<value>Добавляет напоминание</value>
|
||||
|
@ -468,10 +453,109 @@
|
|||
<data name="MissingReminderText" xml:space="preserve">
|
||||
<value>Тебе нужно указать текст напоминания!</value>
|
||||
</data>
|
||||
<data name="FeedbackReminderAdded" xml:space="preserve">
|
||||
<value>Хорошо, я упомяну тебя <t:{0}:f></value>
|
||||
<data name="DescriptionReminderCreated" xml:space="preserve">
|
||||
<value>Хорошо, я упомяну тебя {0}</value>
|
||||
</data>
|
||||
<data name="InvalidRemindIn" xml:space="preserve">
|
||||
<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 name="Reminder" xml:space="preserve">
|
||||
<value>Напоминание для {0}</value>
|
||||
</data>
|
||||
<data name="DescriptionReminder" xml:space="preserve">
|
||||
<value>Вы просили напомнить вам {0}</value>
|
||||
</data>
|
||||
<data name="SettingsListTitle" xml:space="preserve">
|
||||
<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>
|
||||
</root>
|
||||
|
|
|
@ -13,19 +13,7 @@
|
|||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<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="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>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
@ -112,34 +100,34 @@
|
|||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<value>я родился!</value>
|
||||
</data>
|
||||
<data name="CachedMessageDeleted" xml:space="preserve">
|
||||
<value>вырезано сообщение от {0} в канале {1}: {2}</value>
|
||||
<value>сообщение {0} вырезано:</value>
|
||||
</data>
|
||||
<data name="CachedMessageCleared" xml:space="preserve">
|
||||
<value>вырезано сообщение (используя `!clear`) от {0} в канале {1}: {2}</value>
|
||||
</data>
|
||||
<data name="CachedMessageEdited" xml:space="preserve">
|
||||
<value>переделано сообщение от {0}: {1} -> {2}</value>
|
||||
<value>сообщение {0} переделано:</value>
|
||||
</data>
|
||||
<data name="DefaultWelcomeMessage" xml:space="preserve">
|
||||
<value>{0}, добро пожаловать на сервер {1}</value>
|
||||
</data>
|
||||
<data name="Beep1" xml:space="preserve">
|
||||
<value>брах! </value>
|
||||
<value>брах!</value>
|
||||
</data>
|
||||
<data name="Beep2" xml:space="preserve">
|
||||
<value>брох! </value>
|
||||
<value>брох!</value>
|
||||
</data>
|
||||
<data name="Beep3" xml:space="preserve">
|
||||
<value>брух! </value>
|
||||
<value>брух!</value>
|
||||
</data>
|
||||
<data name="CommandNoPermissionBot" xml:space="preserve">
|
||||
<value>у меня прав нету, сделай что нибудь.</value>
|
||||
|
@ -148,7 +136,7 @@
|
|||
<value>у тебя прав нету, твои проблемы.</value>
|
||||
</data>
|
||||
<data name="YouWereBanned" xml:space="preserve">
|
||||
<value>здарова, тебя крч забанил {0} на сервере `{1}` за {2}</value>
|
||||
<value>вы были забанены</value>
|
||||
</data>
|
||||
<data name="PunishmentExpired" xml:space="preserve">
|
||||
<value>время бана закончиловсь</value>
|
||||
|
@ -163,7 +151,7 @@
|
|||
<value>туториал по приколам:</value>
|
||||
</data>
|
||||
<data name="YouWereKicked" xml:space="preserve">
|
||||
<value>здарова, тебя крч кикнул {0} на сервере `{1}` за {2}</value>
|
||||
<value>вы были кикнуты</value>
|
||||
</data>
|
||||
<data name="Milliseconds" xml:space="preserve">
|
||||
<value>мс</value>
|
||||
|
@ -196,7 +184,7 @@
|
|||
<value>звание замученного</value>
|
||||
</data>
|
||||
<data name="LanguageNotSupported" xml:space="preserve">
|
||||
<value>такого языка нету, ты шо, есть только такие:</value>
|
||||
<value>такого языка нету...</value>
|
||||
</data>
|
||||
<data name="Yes" xml:space="preserve">
|
||||
<value>да</value>
|
||||
|
@ -216,8 +204,8 @@
|
|||
<data name="ClearAmountInvalid" xml:space="preserve">
|
||||
<value>выбери число от {0} до {1} вместо {2}!</value>
|
||||
</data>
|
||||
<data name="FeedbackUserBanned" xml:space="preserve">
|
||||
<value>забанен {0} на{1}: {2}</value>
|
||||
<data name="UserBanned" xml:space="preserve">
|
||||
<value>{0} забанен</value>
|
||||
</data>
|
||||
<data name="SettingDoesntExist" xml:space="preserve">
|
||||
<value>такой прикол не существует</value>
|
||||
|
@ -241,34 +229,34 @@
|
|||
<value>я не могу замутить ботов, сделай что нибудь</value>
|
||||
</data>
|
||||
<data name="EventCreated" xml:space="preserve">
|
||||
<value>{0} приготовил новый квест {1}! он пройдёт в {2} и начнётся <t:{3}:R>!{4}</value>
|
||||
<value>{0} приготовил новую движуху {1}! она пройдёт в {2} и начнётся <t:{3}:R>!{4}</value>
|
||||
</data>
|
||||
<data name="SettingsEventNotificationRole" xml:space="preserve">
|
||||
<value>роль для уведомлений о создании квеста</value>
|
||||
<value>роль для уведомлений о создании движухи</value>
|
||||
</data>
|
||||
<data name="SettingsEventNotificationChannel" xml:space="preserve">
|
||||
<value>канал для уведомлений о квестах</value>
|
||||
<value>канал для уведомлений о движухах</value>
|
||||
</data>
|
||||
<data name="SettingsEventStartedReceivers" xml:space="preserve">
|
||||
<value>получатели уведомлений о начале квеста</value>
|
||||
<value>получатели уведомлений о начале движух</value>
|
||||
</data>
|
||||
<data name="EventStarted" xml:space="preserve">
|
||||
<value>{0}квест {1} начинается в {2}!</value>
|
||||
<value>движуха "{0}" начинается</value>
|
||||
</data>
|
||||
<data name="SettingsFrowningFace" xml:space="preserve">
|
||||
<value>оъмъомоъемъъео(((( </value>
|
||||
<value>оъмъомоъемъъео((((</value>
|
||||
</data>
|
||||
<data name="EventCancelled" xml:space="preserve">
|
||||
<value>квест {0} отменен!{1}</value>
|
||||
<value>движуха "{0}" отменен!</value>
|
||||
</data>
|
||||
<data name="EventCompleted" xml:space="preserve">
|
||||
<value>квест {0} завершен! все это длилось{1}</value>
|
||||
<value>движуха "{0}" завершен!</value>
|
||||
</data>
|
||||
<data name="Ever" xml:space="preserve">
|
||||
<value>всегда</value>
|
||||
</data>
|
||||
<data name="FeedbackMessagesCleared" xml:space="preserve">
|
||||
<value>удалено {0} сообщений в {1}</value>
|
||||
<data name="MessagesCleared" xml:space="preserve">
|
||||
<value>вырезано {0} забавных сообщений</value>
|
||||
</data>
|
||||
<data name="FeedbackMemberKicked" xml:space="preserve">
|
||||
<value>выгнан {0}: {1}</value>
|
||||
|
@ -439,16 +427,16 @@
|
|||
<value>я не могу его раззамутить...</value>
|
||||
</data>
|
||||
<data name="EventEarlyNotification" xml:space="preserve">
|
||||
<value>{0}квест {1} начнется <t:{2}:R>!</value>
|
||||
<value>{0}движуха {1} начнется <t:{2}:R>!</value>
|
||||
</data>
|
||||
<data name="SettingsEventEarlyNotificationOffset" xml:space="preserve">
|
||||
<value>заранее пнуть в минутах до начала квеста</value>
|
||||
<value>заранее пнуть в минутах до начала движухи</value>
|
||||
</data>
|
||||
<data name="UserNotFound" xml:space="preserve">
|
||||
<value>у нас такого шизоида нету, проверь, валиден ли ID уважаемого (я забываю о шизоидах если они ливнули минимум месяц назад)</value>
|
||||
</data>
|
||||
<data name="SettingsStarterRole" xml:space="preserve">
|
||||
<value>базовое звание</value>
|
||||
<data name="SettingsDefaultRole" xml:space="preserve">
|
||||
<value>дефолтное звание</value>
|
||||
</data>
|
||||
<data name="CommandDescriptionRemind" xml:space="preserve">
|
||||
<value>крафтит напоминалку</value>
|
||||
|
@ -463,15 +451,111 @@
|
|||
<value>вернуть звания при переподключении в дурку</value>
|
||||
</data>
|
||||
<data name="SettingsAutoStartEvents" xml:space="preserve">
|
||||
<value>автоматом стартить квесты</value>
|
||||
<value>автоматом стартить движухи</value>
|
||||
</data>
|
||||
<data name="MissingReminderText" xml:space="preserve">
|
||||
<value>для крафта напоминалки нужен текст</value>
|
||||
</data>
|
||||
<data name="FeedbackReminderAdded" xml:space="preserve">
|
||||
<value>вас понял, упоминание будет <t:{0}:f></value>
|
||||
<data name="DescriptionReminderCreated" xml:space="preserve">
|
||||
<value>вас понял, упоминание будет {0}</value>
|
||||
</data>
|
||||
<data name="InvalidRemindIn" xml:space="preserve">
|
||||
<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 name="Reminder" xml:space="preserve">
|
||||
<value>напоминалка для {0}</value>
|
||||
</data>
|
||||
<data name="DescriptionReminder" xml:space="preserve">
|
||||
<value>ты хотел чтоб я напомнил тебе {0}</value>
|
||||
</data>
|
||||
<data name="SettingsListTitle" xml:space="preserve">
|
||||
<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>
|
||||
</root>
|
||||
|
|
|
@ -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:";
|
||||
}
|
109
Services/GuildDataService.cs
Normal file
109
Services/GuildDataService.cs
Normal 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;
|
||||
}
|
||||
}
|
389
Services/GuildUpdateService.cs
Normal file
389
Services/GuildUpdateService.cs
Normal 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
140
Services/UtilityService.cs
Normal 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
168
Utils.cs
|
@ -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
128
docs/CODE_OF_CONDUCT.md
Normal 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
68
docs/CONTRIBUTING.md
Normal 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
48
docs/README.md
Normal 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.
|
Reference in a new issue