From e09e17c2818da0e224e393f058d6575194ee0b52 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Fri, 3 Apr 2026 04:49:21 +0200 Subject: [PATCH 01/48] Added audit Co-authored-by: Copilot --- docs/code-quality-audit.md | 424 +++++++++++++++++++++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 docs/code-quality-audit.md diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md new file mode 100644 index 00000000..d21a1e13 --- /dev/null +++ b/docs/code-quality-audit.md @@ -0,0 +1,424 @@ +# Code Quality Audit Report + +**Date:** 2026-04-03 +**Scope:** Full codebase review — refactoring opportunities, code duplication, patterns, bugs +**Excludes:** New features, test implementation (gaps noted only) + +--- + +## Executive Summary + +The UDC-Bot codebase has a solid feature set but suffers from several structural +issues common in organically grown projects. The most impactful problems are: + +1. **God classes** — `UserService`, `BotSettings`, and `UserModule` carry too many + responsibilities. +2. **Zero test coverage** — the `DiscordBot.Tests/` project is empty. +3. **Thread-safety bugs** — multiple shared collections accessed without + synchronization. +4. **Resource leaks** — `HttpClient` created per-request instead of reused; + database connections potentially undisposed. +5. **Pervasive code duplication** — embed building, error handling, and HTTP + patterns repeated dozens of times. + +--- + +## Findings by Category + +### 1. God Classes & SRP Violations + +| Class | File | Responsibilities | Severity | +|-------|------|-----------------|----------| +| `UserService` | `Services/UserService.cs` | XP, karma, muting, code formatting warnings, everyone-mention scold, profile card generation, welcome messages, avatar ops, data persistence, level calculation (~11 concerns) | Critical | +| `BotSettings` | `Settings/Deserialized/Settings.cs` | 60+ properties across channels, roles, API keys, casino, tips, recruitment, weather in one flat class | High | +| `UserModule` | `Modules/UserModule.cs` | 1 000+ lines; text commands, web scraping, role management, search, profile display all in one module | High | +| `UpdateService` | `Services/UpdateService.cs` | Bot data, user muting lifecycle, FAQ loading, RSS feeds, Wikipedia downloading (5 concerns) | High | +| `CasinoSlashModule` | `Modules/Casino/CasinoSlashModule.cs` | 500+ lines; token commands, game commands, admin commands, statistics, nested `TokenCommands` class | High | +| `WebUtil` | `Utils/WebUtil.cs` | HTTP fetching, HTML parsing, JSON deserialization, error handling, logging (5 concerns) | Medium | +| `ICasinoRepo` | `Extensions/CasinoRepository.cs` | 37+ SQL method signatures in one interface | Medium | + +**Recommended splits:** + +- `UserService` → `XpService`, `KarmaService`, `ProfileCardService`, + `WelcomeService`, `CodeFormattingService` +- `BotSettings` → `ChannelSettings`, `RoleSettings`, `CasinoSettings`, + `TipSettings`, `RecruitmentSettings`, `ApiKeySettings` +- `ICasinoRepo` → `ICasinoUserRepo`, `ITokenTransactionRepo`, + `ICasinoAdminRepo` + +--- + +### 2. Code Duplication + +#### 2a. `HttpClient` creation (repeated everywhere) + +Files: `AirportService.cs`, `UserService.cs`, `TipService.cs`, `WebUtil.cs` + +```csharp +// Pattern found in 4+ places +using (var http = new HttpClient()) { ... } +HttpClient client = new(); // sometimes without using +``` + +**Fix:** Register a shared `IHttpClientFactory` in DI and inject it. + +#### 2b. Embed construction boilerplate + +Files: `ModerationService.cs`, `RecruitService.cs`, `CannedResponseService.cs`, +and ~20 other locations. + +```csharp +var builder = new EmbedBuilder() + .WithColor(color) + .WithTimestamp(...) + .FooterInChannel(...); +``` + +**Fix:** Create an `EmbedFactory` helper with preset methods per use-case. + +#### 2c. Service name constant + +Every service file declares `private const string ServiceName = "...";` +identically. Could be extracted to a base class or generated via `nameof`. + +#### 2d. Fire-and-forget `Task.Run` with pragma suppression + +Files: `RecruitService.cs`, `UnityHelpService.cs` + +```csharp +#pragma warning disable CS4014 +Task.Run(() => ...); +#pragma warning restore CS4014 +``` + +**Fix:** Create a `SafeFireAndForget()` extension that logs exceptions. + +#### 2e. `ContainsInviteLink()` — three identical overloads + +File: `MessageExtensions.cs` — same regex for `IUserMessage`, `string`, and +`IMessage`. Should be a single implementation on `string` with the others +delegating. + +#### 2f. Cooldown calculation pattern + +File: `UserServiceExtensions.cs` — `Days()`, `Hours()`, `Minutes()`, +`Seconds()`, `Milliseconds()` all follow the exact same structure with +`cooldowns.HasUser()` check. + +#### 2g. Weather command overloads + +File: `WeatherModule.cs` — each weather command (Temp, Weather, Pollution, Time) +duplicated as `(IUser)` and `(params string[])` overloads with near-identical +bodies. + +#### 2h. Mute logic overloads + +File: `ModerationModule.cs` — three `MuteUser` overloads with near-identical +role-add / logging / cooldown / DM logic. + +--- + +### 3. Potential Bugs + +#### 3a. Thread-safety issues (Critical) + +| Location | Issue | +|----------|-------| +| `Program.cs` — `_isInitialized` flag | Not thread-safe; `Ready` event could fire twice before flag is set. Use `Interlocked.CompareExchange`. | +| `GameService.cs` — `List` | Plain `List` mutated from multiple event handlers. Use `ConcurrentDictionary` or lock. | +| `UserService.cs` — `_xpCooldown` dictionary | Modified from multiple async tasks without synchronization. | +| `ReminderService.cs` — `_reminders` list | Modified while iterating in `CheckReminders`. | +| `GameSession.cs` — `GameData` dictionary | `AddPlayer` / `RemovePlayer` without locks. | +| `RecruitService.cs` — `_botSanityCheck` dictionary | Used as a lock mechanism but is not thread-safe. | + +#### 3b. Resource leaks (High) + +| Location | Issue | +|----------|-------| +| `WebUtil.cs` — `new HttpClient()` per call | Starves sockets under load. Use `IHttpClientFactory` or a static instance. | +| `AirportService.cs` — `HttpClient client = new()` | Created without `using`, never disposed. | +| `DatabaseService.cs` — `Query` property | Returns new `MySqlConnection` each access; may never be disposed by caller. | +| `UserService.cs` — `GenerateProfileCard()` | MagickImage objects not consistently disposed. | +| `FuzzTable.cs` — `File.ReadLines()` | Not wrapped in `using`; handle may leak on exception. | + +#### 3c. Null-reference risks (Medium) + +| Location | Issue | +|----------|-------| +| `ModerationService` — `MessageDeleted` handler | `_botAnnouncementChannel.Id` — channel could be null. | +| `FeedService` — `HandleFeed` method | `item.Links[0]` — no bounds check. | +| `UserExtensions` — `HasRoleGroup` overload | `user as SocketGuildUser` result used without null check. | +| `ContextExtension` — `IsOnlyReplyingToAuthor` | `context.Message.ReferencedMessage.Author.Id` — no null check. | +| `SkinModuleJsonConverter` — `ReadJson` | `Type.GetType(t)` can return null; `jo["Type"]` can be null. | +| `CustomTextSkinModule` — `GetDrawables` | `prop.GetValue(data, null)` cast to `dynamic` — value could be null. | +| `RoleAttributes` — `CheckPermissionsAsync` | Direct cast `(SocketGuildUser)` crashes in DM context. | + +#### 3d. Logic bugs + +| Location | Issue | Severity | +|----------|-------|----------| +| `EmbedModule.cs` — `SendEmbedToChannel` reaction-polling loop | `i++` inside a `for(int i=0; i<10; i++)` loop — counter incremented twice per iteration, halving the confirmation window from 20s to 10s. Additionally, the loop continues polling after confirmation is received (no `break` on `confirmedEmbed = true`). | High | +| `WeatherModule.cs` — `WeatherEmbed` sunrise/sunset block | `res.sys.sunrise > 0` checked twice; second should be `res.sys.sunset`. Copy-paste bug. Low impact: output uses correct `sunset` variable, but sunset line is suppressed when `sunrise == 0 && sunset > 0`. | Low | +| `StringExtensions.cs` — `MessageSplitToSize` | If no newlines exist, `LastIndexOf("\n")` returns -1/0, risking infinite loop or empty string. | Medium | +| `AirportModule.cs` — `FlyTo` day-of-week calc | Day-of-week calculation may have off-by-one when Sunday (`DayOfWeek = 0`) is involved. | Medium | +| `Blackjack` — `DoubleDown` method | No check that player has sufficient tokens before doubling bet. Could create negative balances. | Medium | + +#### 3e. `async void` event handlers (Medium-High) + +Several event subscriptions use `async void` delegate signatures (e.g., +`_client.MessageReceived += Thanks` in `UserService`). `async void` methods are +fire-and-forget: unhandled exceptions inside them crash the process instead of +being caught. All async event handlers should be wrapped in try-catch or use a +safe-fire-and-forget pattern. + +#### 3f. Session / memory leaks (Medium) + +| Location | Issue | +|----------|-------| +| `GameSession.cs` | `ExpiryTime` commented out — sessions can live forever with no cleanup. | +| `Program.cs` | All services are `Singleton` — never disposed; database connections held forever. | +| `Program.cs` | `await Task.Delay(-1)` — no graceful shutdown; no `CancellationToken`. | + +--- + +### 4. Long Methods (> 50 lines) + +| File | Method | ~Lines | Issue | +|------|--------|--------|-------| +| `UserService.cs` | Constructor | 150 | Initialization, regex compilation, event hookup all mixed | +| `UserService.cs` | `GenerateProfileCard()` | 150 | DB queries, image manipulation, HTTP, file I/O in one method | +| `UserService.cs` | `Thanks()` | 100 | Regex matching, DB calls, cooldown checks combined | +| `UserModule.cs` | `SearchResults` | 120+ | Web scraping, HTML parsing, URL manipulation, embed building | +| `FeedService.cs` | `GetReleaseNotes()` | 100 | Complex HTML parsing with nested loops | +| `AirportModule.cs` | `FlyTo` | 95 | API calls, coordinate lookups, embed building | +| `EmbedModule.cs` | `SendEmbedToChannel` | 90+ | Reaction polling, message creation, confirmation | +| `UpdateService.cs` | `DownloadDocDatabase()` | 80 | Web scraping, parsing, file I/O | +| `CasinoSlashModule.cs` | `DisplayTransactionHistory` | 80+ | Query, pagination, admin checks, embed formatting | +| `PokerHelper.cs` | Hand evaluation | 200 | Complex hand ranking with edge cases | + +--- + +### 5. Hardcoded Values That Should Be In Config + +| File | Value | Purpose | +|------|-------|---------| +| `UserService.cs` | `39` minutes | Miku cooldown | +| `UserService.cs` | `800` | Code block warning length threshold | +| `UnityHelpService.cs` | `10` min, `14` hr, `20` hr, `3` days | Thread close/idle timers | +| `RecruitService.cs` | `120` chars, `60` sec | Min message length, delete delay | +| `ReminderService.cs` | `10` | Max reminders per user | +| `AirportService.cs` | API URLs | Test vs production URLs | +| `TipService.cs` | `"tips.json"` | Filename | +| `StringExtensions.cs` | `1990` | Discord max message length (should use constant) | +| `UserServiceExtensions.cs` | `9999` days | "Permanent" duration | +| `WeatherModule.cs` | `22.5, 67.5, 112.5...` | Wind direction angles | +| `CasinoSlashModule.Games.cs` | Game name mapping | Hardcoded switch expression | +| `Skin modules` | Pixel coordinates | Layout-specific X/Y positions | +| `BotSettings` | `300`, `21600`, `86400` seconds | Various delay timers | + +--- + +### 6. Architecture & Design Issues + +#### 6a. Business logic in command handlers + +Several modules contain significant business logic that should live in services: + +- `AirportModule.FlyTo` — flight calculation and coordinate fetching +- `UserModule.SearchResults` — web scraping and HTML parsing +- `WeatherModule.TemperatureEmbed` — formatting and calculation +- `CasinoSlashModule.DisplayTransactionHistory` — complex query and pagination +- `UserSlashModule.Duel` — AI action loops, timeout handling, component builders +- `TipModule.Tip` — file path handling, attachment creation, DB persistence + +#### 6b. Static mutable state in modules + +`UserSlashModule._activeDuels` is a `ConcurrentDictionary` held as static state +in a module. This should be in a service for proper lifecycle management and +recovery. + +#### 6c. Inconsistent command patterns + +- Mixed text commands (`UserModule`) vs slash commands (`UserSlashModule`) for + similar functionality. +- Inconsistent use of `Priority` attribute across modules. +- Inconsistent alias patterns. +- Different `InteractionModuleBase` generic parameterization. +- Event handler naming varies: `MessageReceived` vs `OnMessageReceived` vs + `GatewayOnMessageReceived`. + +#### 6d. No configuration validation + +`BotSettings` has no `Validate()` method. Critical fields like `Token`, +`GuildId`, `DbConnectionString` could be empty/null/zero without detection until +a runtime crash. + +#### 6e. Singleton-only DI + +`Program.cs` registers every service as `Singleton`. No consideration for +`Scoped` or `Transient` lifetimes. Services holding database connections or +disposable resources are never cleaned up. + +#### 6f. No graceful shutdown + +`await Task.Delay(-1)` blocks forever. No `CancellationToken`, no shutdown +signal handling, no resource cleanup on exit. + +--- + +### 7. Skin System Issues + +| Issue | Severity | Details | +|-------|----------|---------| +| Duplicate `RectangleD` struct | Low | Defined in both `Skin/RectangleD.cs` and `Domain/RectangleD.cs` | +| Reflection in `SkinModuleJsonConverter` | Medium | `Type.GetType()` on every deserialization; no caching; no null check | +| Magic threshold in avatar color sampling | Medium | `650` RGB sum threshold unexplained | +| Inconsistent coordinate types | Low | Some modules use `int`, others use `double` | +| Hardcoded pixel positions | Medium | Skin modules have layout-specific coordinates baked in | +| `CustomTextSkinModule` null risk | Medium | `prop.GetValue()` result cast to `dynamic`, `.ToString()` called without null check | +| Text rendering setup duplication | Medium | All text skin modules repeat the same `StrokeColor`/`FillColor`/`FontPointSize` initialization | + +--- + +### 8. Dead Code & Commented-Out Code + +| File | What | Notes | +|------|------|-------| +| `UserService.cs` | `MikuCheck` event subscription | Commented out | +| `UserService.cs` | `_mikuCooldownTime` initialization | Commented out | +| `UpdateService.cs` | `UpdateUserRanks` task | Commented out | +| `AirportModule.cs` | Flight details (seats, bags, fees) | Commented out | +| `UserModule.cs` | Entire `CompileCode` method | Commented out with note "Not really a required feature" | +| `FeedService.cs` | TODO about other entities | Stale | +| `TipService.cs` | TODO about image attachment | Stale | +| `GameSession.cs` | `ExpiryTime`, `UserId` properties | Commented out | +| `DiscordGameSession.cs` | "Reload Embed" and "Custom" bet buttons | Commented out | + +--- + +### 9. Missing Error Handling + +| File | Method | Issue | +|------|--------|-------| +| `AirportService.cs` | `GetFlightTickets()` | Returns null without logging | +| `DatabaseService.cs` | Constructor | Bare catch block; continues silently | +| `ReminderService.cs` | `LoadReminders()` | No handling for corrupted file | +| `TipService.cs` | `CommitTipDatabase()` | No try-catch for file write failures | +| `UnityHelpService.cs` | Message fetching | Missing null checks on `GetMessageAsync` results | +| `TicketModule.cs` | `Complaint` | No validation that `Settings.ComplaintCategoryId` is valid | +| `CasinoSlashModule.Games.cs` | `DoAction` | `Enum.Parse` with no try-catch | +| `Program.cs` | `DeserializeSettings()` | No error handling; exception propagates uncaught | +| `Program.cs` | `Ready` handler | No try-catch around service initialization | + +--- + +### 10. Security Concerns + +| Location | Issue | Severity | +|----------|-------|----------| +| `EmbedModule.cs` — `BuildEmbedFromUrl` | SSRF risk: `IsValidHost()` allow-list exists but `attachment.Url` from Discord CDN bypasses it. Pastebin/hastebin URLs could also contain redirects. User-supplied URLs are downloaded server-side. | Medium | +| `EmbedModule.cs` | Uses deprecated `WebClient` (obsolete since .NET 6) — should switch to `HttpClient` via `IHttpClientFactory` | Low | +| `CasinoRepository.cs` — 37+ SQL methods | SQL injection surface is large. All queries are likely parameterized via Insight.Database, but this should be verified explicitly. | Low (verify) | +| `RoleAttributes.cs` | Direct cast `(SocketGuildUser)` crashes in DM context — precondition bypass could allow unauthorized command execution if the exception is caught upstream | Medium | + +--- + +### 11. Naming Inconsistencies + +| Pattern | Examples | Issue | +|---------|----------|-------| +| Service name strings | Some include "Service" suffix, some don't | Inconsistent | +| Method naming | `GetOrAddUser` vs `GetOrCreateCasinoUser` | No consistent convention | +| Async method naming | `Thanks()` (async void) vs `Thanks` (async Task) | Some void, some Task | +| Private field prefix | `_settings` in some classes, `Settings` in others | Inconsistent underscore | +| Event handlers | `MessageReceived`, `OnMessageReceived`, `GatewayOnMessageReceived` | Three different conventions | +| Data model casing | `Kategory` vs `Category`, `Keyimage` vs `KeyImage`, `Pubdate` vs `PubDate` in `UnityAPI.cs` | Inconsistent | +| `HasAnyPingableMention` | Exists in both `MessageExtensions` and `ContextExtension` with different behavior | Confusing | + +--- + +### 12. Test Coverage + +**Current state: 0%** — `DiscordBot.Tests/` exists as an empty project stub with +no `.cs` files and no test framework configured. + +**Highest-priority areas for testing:** + +1. Attribute preconditions (role checks, channel checks) +2. Extension methods (string splitting, cooldown logic, message extensions) +3. Casino game logic (hand evaluation, bet validation, session management) +4. Service business logic (karma calculation, XP, reminders) +5. Skin module rendering pipeline + +--- + +## Prioritized Action Plan + +### Immediate (Bugs) + +1. Fix `_isInitialized` race condition in `Program.cs` — use + `Interlocked.CompareExchange` +2. Replace `List` with `ConcurrentDictionary` in + `GameService.cs` +3. Fix double `i++` in `EmbedModule.cs` reaction-polling loop (and add + `break` after confirmation) +4. Fix sunrise/sunset copy-paste bug in `WeatherModule.cs` +5. Add `using` to all `HttpClient` instances or switch to `IHttpClientFactory`; + replace deprecated `WebClient` usage +6. Add null checks in `RoleAttributes.cs` for DM context safety +7. Wrap all `async void` event handlers in try-catch + +### Short-term (Architecture) + +1. Split `UserService` into focused services +2. Split `BotSettings` into domain-specific config classes +3. Add `BotSettings.Validate()` post-deserialization +4. Extract business logic from command handlers into services +5. Register `IHttpClientFactory` in DI; remove manual `HttpClient` creation +6. Add graceful shutdown support with `CancellationToken` +7. Move static module state (`_activeDuels`) to services + +### Medium-term (Quality) + +1. Create `EmbedFactory` to reduce embed construction duplication +2. Create `SafeFireAndForget()` extension to replace `#pragma` + `Task.Run` +3. Consolidate `ContainsInviteLink()` overloads +4. Add configuration validation for all settings +5. Audit service lifetimes — consider `Scoped` for interaction-scoped services +6. Remove all dead/commented-out code +7. Standardize naming conventions (event handlers, async methods, service + constants) + +### Long-term (Sustainability) + +1. Set up test project with xUnit and write tests for critical paths +2. Split `ICasinoRepo` into focused interfaces +3. Extract `IWebClient` / `IHtmlParser` from `WebUtil` for testability +4. Implement session expiry and cleanup for casino game sessions +5. Refactor skin module hierarchy — intermediate base classes, coordinate config +6. Consolidate duplicate `RectangleD` struct +7. Replace `string[][]` database in `UpdateService` with typed structures + +--- + +## Findings Summary + +| Category | Critical | High | Medium | Low | Total | +|----------|----------|------|--------|-----|-------| +| Thread safety | 3 | 3 | — | — | 6 | +| Resource leaks | — | 5 | — | — | 5 | +| Null-reference risks | — | 2 | 5 | — | 7 | +| Logic bugs | — | 1 | 3 | 1 | 5 | +| God classes / SRP | — | 4 | 3 | — | 7 | +| Code duplication | — | 2 | 6 | — | 8 | +| Long methods | — | 4 | 6 | — | 10 | +| Hardcoded values | — | — | 7 | 6 | 13 | +| Missing error handling | — | 2 | 5 | 2 | 9 | +| Architecture / design | — | 3 | 4 | — | 7 | +| Dead code | — | — | 4 | 5 | 9 | +| Naming inconsistencies | — | — | 3 | 4 | 7 | +| Security | — | — | 3 | 1 | 4 | +| Test coverage | 1 | — | — | — | 1 | +| Skin system | — | — | 4 | 3 | 7 | +| Async void handlers | — | 1 | — | — | 1 | +| **Total** | **4** | **27** | **53** | **22** | **106** | From 8d22f3ff59756f7ae818e7bd6da50ce028ff2bbc Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 6 Apr 2026 19:55:14 +0200 Subject: [PATCH 02/48] fix(startup): use Interlocked.CompareExchange for _isInitialized flag Replace plain bool with atomic int + CompareExchange to prevent double-initialization race when Discord.Net Ready event fires twice on rapid reconnect. --- DiscordBot/Program.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index f0f22093..e9db2a17 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -14,7 +14,7 @@ namespace DiscordBot; public class Program { - private bool _isInitialized = false; + private int _isInitialized = 0; private static Rules _rules; private static BotSettings _settings; @@ -52,7 +52,7 @@ private async Task MainAsync() { // Ready can be called additional times if the bot disconnects for long enough, // so we need to make sure we only initialize commands and such for the bot once if it manages to re-establish connection - if (_isInitialized) return Task.CompletedTask; + if (Interlocked.CompareExchange(ref _isInitialized, 1, 0) != 0) return Task.CompletedTask; _interactionService = new InteractionService(_client); _commandService = new CommandService(new CommandServiceConfig @@ -69,7 +69,6 @@ private async Task MainAsync() logger.LogChannelAndFile("Bot Started.", ExtendedLogSeverity.Positive); LoggingService.LogToConsole("Bot is connected.", ExtendedLogSeverity.Positive); - _isInitialized = true; _unityHelpService = _services.GetRequiredService(); _recruitService = _services.GetRequiredService(); From 8290ba01454ee04d1c5cf566b9fadbb279512615 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 6 Apr 2026 19:55:21 +0200 Subject: [PATCH 03/48] fix(casino): replace List with ConcurrentDictionary for active sessions List was not thread-safe under concurrent Discord event handlers. Switch to ConcurrentDictionary and use TryGetValue/TryRemove for safe concurrent access. --- DiscordBot/Services/Casino/GameService.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/DiscordBot/Services/Casino/GameService.cs b/DiscordBot/Services/Casino/GameService.cs index 68dc4fa8..abaddfcf 100644 --- a/DiscordBot/Services/Casino/GameService.cs +++ b/DiscordBot/Services/Casino/GameService.cs @@ -9,7 +9,7 @@ public class GameService { private readonly ILoggingService _loggingService; private readonly BotSettings _settings; - private readonly List _activeSessions = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _activeSessions = new(); private readonly CasinoService _casinoService; public GameService(ILoggingService loggingService, BotSettings settings, CasinoService casinoService) @@ -45,7 +45,7 @@ public IDiscordGameSession CreateGameSession(CasinoGame game, int maxSeats, Disc { var gameInstance = GetGameInstance(game); var session = CreateDiscordGameSession(game, gameInstance, maxSeats == 0 ? gameInstance.MaxPlayers : maxSeats, client, user, guild); - _activeSessions.Add(session); + _activeSessions[session.Id] = session; return session; } @@ -57,12 +57,14 @@ public IDiscordGameSession PlayAgain(IDiscordGameSession session) public IDiscordGameSession? GetActiveSession(string id) { - return _activeSessions.FirstOrDefault(s => s.Id.ToString() == id); + if (Guid.TryParse(id, out var guid) && _activeSessions.TryGetValue(guid, out var session)) + return session; + return null; } public void RemoveGameSession(IDiscordGameSession session) { - _activeSessions.Remove(session); + _activeSessions.TryRemove(session.Id, out _); } public async Task JoinGame(IDiscordGameSession session, ulong userId) From 737043bc7eee811d8272627c775753db36714928 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 6 Apr 2026 19:55:28 +0200 Subject: [PATCH 04/48] fix(embed): remove extra i++ and add break on confirmation Reaction-polling loop incremented counter twice per iteration, halving the confirmation window from 20s to 10s. Also added break when embed is confirmed to stop unnecessary polling. --- DiscordBot/Modules/EmbedModule.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/DiscordBot/Modules/EmbedModule.cs b/DiscordBot/Modules/EmbedModule.cs index f46bd30a..f761624d 100644 --- a/DiscordBot/Modules/EmbedModule.cs +++ b/DiscordBot/Modules/EmbedModule.cs @@ -233,7 +233,6 @@ private async Task SendEmbedToChannel(Discord.Embed embed, IMessageChannel chann var reactions = await message.GetReactionUsersAsync(_thumbUpEmote, 10).FlattenAsync(); if (reactions.Count() > 1) { - // Just in case other people are trying to react to the message,we check all reactions and confirm we got one from the user generating the embed. foreach (var reaction in reactions) { if (reaction.Id == Context.User.Id) @@ -244,7 +243,7 @@ private async Task SendEmbedToChannel(Discord.Embed embed, IMessageChannel chann } } - i++; + if (confirmedEmbed) break; } await tempEmbed.DeleteAsync(); From 46df6f868a857bb66dc2f6e734fefb46831297d1 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 6 Apr 2026 19:55:35 +0200 Subject: [PATCH 05/48] fix(weather): check sunset instead of sunrise for sunset display Copy-paste bug: second condition checked res.sys.sunrise > 0 instead of res.sys.sunset, suppressing sunset info when sunrise was zero but sunset was valid. --- DiscordBot/Modules/Weather/WeatherModule.cs | 60 ++++++++++----------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/DiscordBot/Modules/Weather/WeatherModule.cs b/DiscordBot/Modules/Weather/WeatherModule.cs index 4da268fd..061f1b2d 100644 --- a/DiscordBot/Modules/Weather/WeatherModule.cs +++ b/DiscordBot/Modules/Weather/WeatherModule.cs @@ -12,12 +12,12 @@ namespace DiscordBot.Modules; public class WeatherModule : ModuleBase { #region Dependency Injection - + public WeatherService WeatherService { get; set; } public UserExtendedService UserExtendedService { get; set; } - + #endregion - + private List AQI_Index = new List() {"Invalid", "Good", "Fair", "Moderate", "Poor", "Very Poor"}; @@ -35,7 +35,7 @@ public async Task WeatherHelp() } #region Temperature - + private async Task TemperatureEmbed(string city, string replaceCityWith = "") { WeatherContainer.Result res = await WeatherService.GetWeather(city: city); @@ -50,7 +50,7 @@ private async Task TemperatureEmbed(string city, string replaceCit return builder; } - + [Command("Temperature"), HideFromHelp] [Summary("Attempts to provide the temperature of the user provided.")] [Alias("temp"), Priority(20)] @@ -59,7 +59,7 @@ public async Task Temperature(IUser user = null) user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; - + var city = await UserExtendedService.GetUserDefaultCity(user); var builder = await TemperatureEmbed(city, user.GetUserPreferredName()); if (builder == null) @@ -68,7 +68,7 @@ public async Task Temperature(IUser user = null) await ReplyAsync(embed: builder.Build()); } - + [Command("Temperature")] [Summary("Attempts to provide the temperature of the city provided.")] [Alias("temp"), Priority(20)] @@ -80,11 +80,11 @@ public async Task Temperature(params string[] city) await ReplyAsync(embed: builder.Build()); } - + #endregion // Temperature #region Weather - + private async Task WeatherEmbed(string city, string replaceCityWith = "") { WeatherContainer.Result res = await WeatherService.GetWeather(city: city); @@ -92,18 +92,18 @@ private async Task WeatherEmbed(string city, string replaceCityWit return null; string extraInfo = string.Empty; - + DateTime sunrise = DateTime.UnixEpoch.AddSeconds(res.sys.sunrise) .AddSeconds(res.timezone); DateTime sunset = DateTime.UnixEpoch.AddSeconds(res.sys.sunset) .AddSeconds(res.timezone); - + // Sun rise/set if (res.sys.sunrise > 0) extraInfo += $"Sunrise **{sunrise:hh\\:mmtt}**, "; - if (res.sys.sunrise > 0) + if (res.sys.sunset > 0) extraInfo += $"Sunset **{sunset:hh\\:mmtt}**\n"; - + if (res.main.Temp > 0 && res.rain != null) { if (res.rain.Rain3h > 0) @@ -128,10 +128,10 @@ private async Task WeatherEmbed(string city, string replaceCityWit .WithFooter( $"{res.clouds.all}% cloud cover with {GetWindDirection((float)res.wind.Deg)} {Math.Round((res.wind.Speed * 60f * 60f) / 1000f, 2)} km/h winds & {res.main.Humidity}% humidity.") .WithColor(GetColour(res.main.Temp)); - + return builder; } - + [Command("Weather"), HideFromHelp, Priority(20)] [Summary("Attempts to provide the weather of the user provided.")] public async Task CurentWeather(IUser user = null) @@ -139,7 +139,7 @@ public async Task CurentWeather(IUser user = null) user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; - + var city = await UserExtendedService.GetUserDefaultCity(user); var builder = await WeatherEmbed(city, user.GetUserPreferredName()); if (builder == null) @@ -159,7 +159,7 @@ public async Task CurentWeather(params string[] city) await ReplyAsync(embed: builder.Build()); } - + #endregion // Weather #region Pollution @@ -216,7 +216,7 @@ public async Task Pollution(IUser user = null) user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; - + var city = await UserExtendedService.GetUserDefaultCity(user); var builder = await PollutionEmbed(city, user.GetUserPreferredName()); if (builder == null) @@ -225,7 +225,7 @@ public async Task Pollution(IUser user = null) await ReplyAsync(embed: builder.Build()); } - + [Command("Pollution"), Priority(21)] [Summary("Attempts to provide the pollution conditions of the city provided.")] public async Task Pollution(params string[] city) @@ -236,11 +236,11 @@ public async Task Pollution(params string[] city) await ReplyAsync(embed: builder.Build()); } - + #endregion // Pollution #region Time - + private async Task TimeEmbed(string city, string replaceCityWith = "") { WeatherContainer.Result res = await WeatherService.GetWeather(city: city); @@ -256,7 +256,7 @@ private async Task TimeEmbed(string city, string replaceCityWith = return builder; } - + [Command("Time"), HideFromHelp, Priority(22)] [Summary("Attempts to provide the time of the user provided.")] public async Task Time(IUser user = null) @@ -264,7 +264,7 @@ public async Task Time(IUser user = null) user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) return; - + var city = await UserExtendedService.GetUserDefaultCity(user); var builder = await TimeEmbed(city, user.GetUserPreferredName()); if (builder == null) @@ -273,7 +273,7 @@ public async Task Time(IUser user = null) await ReplyAsync(embed: builder.Build()); } - + [Command("Time"), Priority(22)] [Summary("Attempts to provide the time of the city/location provided.")] public async Task Time(params string[] city) @@ -284,9 +284,9 @@ public async Task Time(params string[] city) await ReplyAsync(embed: builder.Build()); } - + #endregion // Time - + #region Utility Methods private async Task IsResultsValid(T res) @@ -313,18 +313,18 @@ private Color GetColour(float temp) _ => new Color(255, 0, 0) }; } - + private async Task DoesUserHaveDefaultCity(IUser user) { // If they do, return true if (await UserExtendedService.DoesUserHaveDefaultCity(user)) return true; - + // Otherwise respond and return false var uname = user.GetUserPreferredName(); await ReplyAsync($"User {uname} does not have a default city set."); return false; } - + private static string GetWindDirection(float windDeg) { if (windDeg < 22.5) @@ -345,6 +345,6 @@ private static string GetWindDirection(float windDeg) return "NW"; return "N"; } - + #endregion Utility Methods } From 46e8b2558f0e3e4c65d39c73b1a5559559f23942 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 6 Apr 2026 19:55:41 +0200 Subject: [PATCH 06/48] fix(auth): use pattern matching instead of unsafe cast in role attributes Replace (SocketGuildUser) direct cast with 'is not' pattern matching. Returns PreconditionResult.FromError in DM context instead of throwing InvalidCastException. --- DiscordBot/Attributes/RoleAttributes.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/DiscordBot/Attributes/RoleAttributes.cs b/DiscordBot/Attributes/RoleAttributes.cs index d4e337ce..a0c6d639 100644 --- a/DiscordBot/Attributes/RoleAttributes.cs +++ b/DiscordBot/Attributes/RoleAttributes.cs @@ -10,7 +10,8 @@ public class RequireAdminAttribute : PreconditionAttribute { public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) { - var user = (SocketGuildUser)context.Message.Author; + if (context.Message.Author is not SocketGuildUser user) + return Task.FromResult(PreconditionResult.FromError("This command can only be used in a server.")); if (user.Roles.Any(x => x.Permissions.Administrator)) return Task.FromResult(PreconditionResult.FromSuccess()); return Task.FromResult(PreconditionResult.FromError(user + " attempted to use admin only command!")); @@ -22,7 +23,9 @@ public class RequireModeratorAttribute : PreconditionAttribute { public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) { - var user = (SocketGuildUser)context.Message.Author; + if (context.Message.Author is not SocketGuildUser user) + return Task.FromResult(PreconditionResult.FromError("This command can only be used in a server.")); + var settings = services.GetRequiredService(); if (user.Roles.Any(x => x.Id == settings.ModeratorRoleId)) return Task.FromResult(PreconditionResult.FromSuccess()); From cdb3b7f28706e9dd53ba6eac1338a183e9561190 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 6 Apr 2026 19:55:46 +0200 Subject: [PATCH 07/48] docs: update code quality audit with completed fix checkmarks --- docs/code-quality-audit.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md index d21a1e13..0ccc698e 100644 --- a/docs/code-quality-audit.md +++ b/docs/code-quality-audit.md @@ -319,7 +319,7 @@ signal handling, no resource cleanup on exit. | `EmbedModule.cs` — `BuildEmbedFromUrl` | SSRF risk: `IsValidHost()` allow-list exists but `attachment.Url` from Discord CDN bypasses it. Pastebin/hastebin URLs could also contain redirects. User-supplied URLs are downloaded server-side. | Medium | | `EmbedModule.cs` | Uses deprecated `WebClient` (obsolete since .NET 6) — should switch to `HttpClient` via `IHttpClientFactory` | Low | | `CasinoRepository.cs` — 37+ SQL methods | SQL injection surface is large. All queries are likely parameterized via Insight.Database, but this should be verified explicitly. | Low (verify) | -| `RoleAttributes.cs` | Direct cast `(SocketGuildUser)` crashes in DM context — precondition bypass could allow unauthorized command execution if the exception is caught upstream | Medium | +| `RoleAttributes.cs` | ~~Direct cast `(SocketGuildUser)` crashes in DM context — precondition bypass could allow unauthorized command execution if the exception is caught upstream~~ ✅ Fixed | ~~Medium~~ | --- @@ -356,16 +356,16 @@ no `.cs` files and no test framework configured. ### Immediate (Bugs) -1. Fix `_isInitialized` race condition in `Program.cs` — use - `Interlocked.CompareExchange` -2. Replace `List` with `ConcurrentDictionary` in - `GameService.cs` -3. Fix double `i++` in `EmbedModule.cs` reaction-polling loop (and add - `break` after confirmation) -4. Fix sunrise/sunset copy-paste bug in `WeatherModule.cs` +1. ~~Fix `_isInitialized` race condition in `Program.cs` — use + `Interlocked.CompareExchange`~~ ✅ +2. ~~Replace `List` with `ConcurrentDictionary` in + `GameService.cs`~~ ✅ +3. ~~Fix double `i++` in `EmbedModule.cs` reaction-polling loop (and add + `break` after confirmation)~~ ✅ +4. ~~Fix sunrise/sunset copy-paste bug in `WeatherModule.cs`~~ ✅ 5. Add `using` to all `HttpClient` instances or switch to `IHttpClientFactory`; replace deprecated `WebClient` usage -6. Add null checks in `RoleAttributes.cs` for DM context safety +6. ~~Add null checks in `RoleAttributes.cs` for DM context safety~~ ✅ 7. Wrap all `async void` event handlers in try-catch ### Short-term (Architecture) From 6cce87a848a83d338dfc596bfdc4eba22d559948 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 6 Apr 2026 20:02:49 +0200 Subject: [PATCH 08/48] refactor(http): replace HttpClient/WebClient instantiation with IHttpClientFactory - Add Microsoft.Extensions.Http package and register AddHttpClient() in DI - WebUtil: use static HttpClient singleton instead of per-call instantiation - TipService: inject IHttpClientFactory for attachment downloads - UserService: inject IHttpClientFactory for avatar downloads - EmbedModule: replace deprecated WebClient with async HttpClient via factory - Prevents socket exhaustion under load (resolves audit item 5) --- DiscordBot/DiscordBot.csproj | 1 + DiscordBot/Modules/EmbedModule.cs | 14 +++++++------- DiscordBot/Program.cs | 1 + DiscordBot/Services/Tips/TipService.cs | 6 ++++-- DiscordBot/Services/UserService.cs | 7 +++++-- DiscordBot/Utils/WebUtil.cs | 5 +++-- 6 files changed, 21 insertions(+), 13 deletions(-) diff --git a/DiscordBot/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj index 8d7e9c26..a760216e 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -12,6 +12,7 @@ + diff --git a/DiscordBot/Modules/EmbedModule.cs b/DiscordBot/Modules/EmbedModule.cs index f761624d..4a4a705b 100644 --- a/DiscordBot/Modules/EmbedModule.cs +++ b/DiscordBot/Modules/EmbedModule.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net.Http; using System.Text; using Discord.Commands; using DiscordBot.Attributes; @@ -10,6 +10,7 @@ namespace DiscordBot.Modules; [RequireAdmin] public class EmbedModule : ModuleBase { + public IHttpClientFactory HttpClientFactory { get; set; } #pragma warning disable 0649 private class Embed @@ -73,7 +74,7 @@ public async Task EmbedCommand(IMessageChannel channel = null, ulong messageId = return; } var attachment = Context.Message.Attachments.ElementAt(0); - var embed = BuildEmbedFromUrl(attachment.Url); + var embed = await BuildEmbedFromUrl(attachment.Url); await SendEmbedToChannel(embed, channel, messageId); } @@ -104,7 +105,7 @@ public async Task EmbedCommand(string url, IMessageChannel channel = null, ulong return null; } string download_url = GetDownUrlFromUri(uriResult); - var builtEmbed = BuildEmbedFromUrl(download_url); + var builtEmbed = await BuildEmbedFromUrl(download_url); if (builtEmbed.Length == 0) { await ReplyAsync("Failed to generate embed from url.").DeleteAfterSeconds(seconds: 10f); @@ -113,11 +114,10 @@ public async Task EmbedCommand(string url, IMessageChannel channel = null, ulong return builtEmbed; } - private Discord.Embed BuildEmbedFromUrl(string url) + private async Task BuildEmbedFromUrl(string url) { - WebClient webClient = new(); - byte[] buffer = webClient.DownloadData(url); - webClient.Dispose(); + using var client = HttpClientFactory.CreateClient(); + var buffer = await client.GetByteArrayAsync(url); string json = Encoding.UTF8.GetString(buffer); return BuildEmbed(json); diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index e9db2a17..3b7aa5ca 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -84,6 +84,7 @@ private async Task MainAsync() private IServiceProvider ConfigureServices() => new ServiceCollection() + .AddHttpClient() .AddSingleton(_settings) .AddSingleton(_rules) .AddSingleton(_userSettings) diff --git a/DiscordBot/Services/Tips/TipService.cs b/DiscordBot/Services/Tips/TipService.cs index 24cd0404..28dca3a9 100644 --- a/DiscordBot/Services/Tips/TipService.cs +++ b/DiscordBot/Services/Tips/TipService.cs @@ -18,6 +18,7 @@ public class TipService private readonly BotSettings _settings; private readonly ILoggingService _loggingService; + private readonly IHttpClientFactory _httpClientFactory; private readonly string _imageDirectory; private ConcurrentDictionary> _tips = new(); @@ -26,10 +27,11 @@ public class TipService private Regex keywordPattern = null; - public TipService(BotSettings settings, ILoggingService loggingService) + public TipService(BotSettings settings, ILoggingService loggingService, IHttpClientFactory httpClientFactory) { _settings = settings; _loggingService = loggingService; + _httpClientFactory = httpClientFactory; if (string.IsNullOrEmpty(_settings.ServerRootPath)) { @@ -154,7 +156,7 @@ public async Task AddTip(IUserMessage message, string keywords, string content) attachment.Filename.Substring(attachment.Filename.LastIndexOf('.')); var filePath = GetTipPath(newFileName); - using var client = new HttpClient(); + using var client = _httpClientFactory.CreateClient(); await using var stream = await client.GetStreamAsync(attachment.Url); await using var file = File.Create(filePath); await stream.CopyToAsync(file); diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index fbfc169b..6e489663 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -51,6 +51,8 @@ public class UserService private readonly UpdateService _updateService; + private readonly IHttpClientFactory _httpClientFactory; + private readonly Dictionary _xpCooldown; private readonly int _xpMaxCooldown; private readonly int _xpMaxPerMessage; @@ -68,7 +70,7 @@ public class UserService _welcomeNoticeUsers.Any() ? _welcomeNoticeUsers.Min(x => x.time) : DateTime.MaxValue; public UserService(DiscordSocketClient client, DatabaseService databaseService, ILoggingService loggingService, - UpdateService updateService, + UpdateService updateService, IHttpClientFactory httpClientFactory, BotSettings settings, UserSettings userSettings) { _client = client; @@ -76,6 +78,7 @@ public UserService(DiscordSocketClient client, DatabaseService databaseService, _databaseService = databaseService; _loggingService = loggingService; _updateService = updateService; + _httpClientFactory = httpClientFactory; _settings = settings; MutedUsers = new Dictionary(); _xpCooldown = new Dictionary(); @@ -380,7 +383,7 @@ public async Task GenerateProfileCard(IUser user) { Stream stream; - using (var http = new HttpClient()) + using (var http = _httpClientFactory.CreateClient()) { stream = await http.GetStreamAsync(new Uri(avatarUrl)); } diff --git a/DiscordBot/Utils/WebUtil.cs b/DiscordBot/Utils/WebUtil.cs index 80cd6591..85a541b4 100644 --- a/DiscordBot/Utils/WebUtil.cs +++ b/DiscordBot/Utils/WebUtil.cs @@ -7,15 +7,16 @@ namespace DiscordBot.Utils; public static class WebUtil { + private static readonly HttpClient SharedClient = new(); + /// /// Returns the content of a URL as a string, or an empty string if the request fails. /// public static async Task GetContent(string url) { - using var client = new HttpClient(); try { - var response = await client.GetAsync(url); + var response = await SharedClient.GetAsync(url); return await response.Content.ReadAsStringAsync(); } catch (Exception e) From 24b8f70dc2a3bf574b49fc28dfe48d1befef9ab4 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 6 Apr 2026 20:12:32 +0200 Subject: [PATCH 09/48] fix(events): wrap async event handlers in try-catch guards - Add EventGuard helper class with Guarded overloads for 1/2/3-arg handlers - Wrap all Discord event subscriptions in UserService, CommandHandlingService, ModerationService, and IntroductionWatcherService with EventGuard.Guarded - Add try-catch to async void UpdateLoop to prevent process crash - Prevents unobserved exceptions from silently failing (resolves audit item 7) --- DiscordBot/Extensions/TaskExtensions.cs | 24 ++++++++++++++ DiscordBot/Services/CommandHandlingService.cs | 8 ++--- .../Moderation/IntroductionWatcherService.cs | 2 +- DiscordBot/Services/ModerationService.cs | 10 +++--- DiscordBot/Services/UserService.cs | 31 ++++++++++++------- 5 files changed, 53 insertions(+), 22 deletions(-) diff --git a/DiscordBot/Extensions/TaskExtensions.cs b/DiscordBot/Extensions/TaskExtensions.cs index 66fffa30..f4cdf5a9 100644 --- a/DiscordBot/Extensions/TaskExtensions.cs +++ b/DiscordBot/Extensions/TaskExtensions.cs @@ -1,5 +1,29 @@ namespace DiscordBot.Extensions; +public static class EventGuard +{ + public static Func Guarded(Func handler, string name) => + async arg => + { + try { await handler(arg); } + catch (Exception e) { LoggingService.LogToConsole($"[{name}] Unhandled exception: {e}", LogSeverity.Error); } + }; + + public static Func Guarded(Func handler, string name) => + async (a1, a2) => + { + try { await handler(a1, a2); } + catch (Exception e) { LoggingService.LogToConsole($"[{name}] Unhandled exception: {e}", LogSeverity.Error); } + }; + + public static Func Guarded(Func handler, string name) => + async (a1, a2, a3) => + { + try { await handler(a1, a2, a3); } + catch (Exception e) { LoggingService.LogToConsole($"[{name}] Unhandled exception: {e}", LogSeverity.Error); } + }; +} + public static class TaskExtensions { public static Task DeleteAfterTime(this IDeletable message, int seconds = 0, int minutes = 0, int hours = 0, int days = 0) => message?.DeleteAfterTimeSpan(new TimeSpan(days, hours, minutes, seconds)); diff --git a/DiscordBot/Services/CommandHandlingService.cs b/DiscordBot/Services/CommandHandlingService.cs index f2f8b199..e7eb6fc1 100644 --- a/DiscordBot/Services/CommandHandlingService.cs +++ b/DiscordBot/Services/CommandHandlingService.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using System.Text; using Discord.Commands; using Discord.Interactions; @@ -60,8 +60,8 @@ ILoggingService loggingService _loggingService = loggingService; // Events - _client.MessageReceived += HandleCommand; - _client.InteractionCreated += HandleInteraction; + _client.MessageReceived += EventGuard.Guarded(HandleCommand, nameof(HandleCommand)); + _client.InteractionCreated += EventGuard.Guarded(HandleInteraction, nameof(HandleInteraction)); if (settings.GuildId == default) { @@ -315,4 +315,4 @@ public async Task GetCommandHistory(int count = 10) } return commandHistory.ToString(); } -} \ No newline at end of file +} diff --git a/DiscordBot/Services/Moderation/IntroductionWatcherService.cs b/DiscordBot/Services/Moderation/IntroductionWatcherService.cs index 6a82aa91..4c58b3d8 100644 --- a/DiscordBot/Services/Moderation/IntroductionWatcherService.cs +++ b/DiscordBot/Services/Moderation/IntroductionWatcherService.cs @@ -40,7 +40,7 @@ public IntroductionWatcherService(DiscordSocketClient client, ILoggingService lo return; } - _client.MessageReceived += MessageReceived; + _client.MessageReceived += EventGuard.Guarded(MessageReceived, nameof(MessageReceived)); } private async Task MessageReceived(SocketMessage message) diff --git a/DiscordBot/Services/ModerationService.cs b/DiscordBot/Services/ModerationService.cs index d9320086..3bc98dbe 100644 --- a/DiscordBot/Services/ModerationService.cs +++ b/DiscordBot/Services/ModerationService.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using Discord.WebSocket; using DiscordBot.Settings; @@ -25,9 +25,9 @@ public ModerationService(DiscordSocketClient client, BotSettings settings, ILogg _loggingService = loggingService; _commandHandlingService = commandHandlingService; - client.MessageDeleted += MessageDeleted; - client.MessageUpdated += MessageUpdated; - client.MessageReceived += MessageReceived; + client.MessageDeleted += EventGuard.Guarded, Cacheable>(MessageDeleted, nameof(MessageDeleted)); + client.MessageUpdated += EventGuard.Guarded, SocketMessage, ISocketMessageChannel>(MessageUpdated, nameof(MessageUpdated)); + client.MessageReceived += EventGuard.Guarded(MessageReceived, nameof(MessageReceived)); if (settings.BotAnnouncementChannel != null) _botAnnouncementChannel = _client.GetChannel(settings.BotAnnouncementChannel.Id) as IMessageChannel; @@ -150,4 +150,4 @@ public async Task GetBotCommandHistory(int count) { return await _commandHandlingService.GetCommandHistory(count); } -} \ No newline at end of file +} diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index 6e489663..66f953ba 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -160,18 +160,18 @@ Init Code analysis /* Event subscriptions */ - _client.MessageReceived += UpdateXp; - _client.MessageReceived += Thanks; - _client.MessageUpdated += ThanksEdited; + _client.MessageReceived += EventGuard.Guarded(UpdateXp, nameof(UpdateXp)); + _client.MessageReceived += EventGuard.Guarded(Thanks, nameof(Thanks)); + _client.MessageUpdated += EventGuard.Guarded, SocketMessage, ISocketMessageChannel>(ThanksEdited, nameof(ThanksEdited)); //_client.MessageReceived += MikuCheck; - _client.MessageReceived += CodeCheck; - _client.MessageReceived += ScoldForAtEveryoneUsage; - _client.UserJoined += UserJoined; - _client.GuildMemberUpdated += UserUpdated; - _client.UserLeft += UserLeft; + _client.MessageReceived += EventGuard.Guarded(CodeCheck, nameof(CodeCheck)); + _client.MessageReceived += EventGuard.Guarded(ScoldForAtEveryoneUsage, nameof(ScoldForAtEveryoneUsage)); + _client.UserJoined += EventGuard.Guarded(UserJoined, nameof(UserJoined)); + _client.GuildMemberUpdated += EventGuard.Guarded, SocketGuildUser>(UserUpdated, nameof(UserUpdated)); + _client.UserLeft += EventGuard.Guarded(UserLeft, nameof(UserLeft)); - _client.MessageReceived += CheckForWelcomeMessage; - _client.UserIsTyping += UserIsTyping; + _client.MessageReceived += EventGuard.Guarded(CheckForWelcomeMessage, nameof(CheckForWelcomeMessage)); + _client.UserIsTyping += EventGuard.Guarded, Cacheable>(UserIsTyping, nameof(UserIsTyping)); LoadData(); UpdateLoop(); @@ -207,8 +207,15 @@ private async void UpdateLoop() { while (true) { - await Task.Delay(10000); - SaveData(); + try + { + await Task.Delay(10000); + SaveData(); + } + catch (Exception e) + { + LoggingService.LogToConsole($"[UpdateLoop] Unhandled exception: {e}", LogSeverity.Error); + } } // ReSharper disable once FunctionNeverReturns } From c0373d2853fe6ab26147495108d0d9dd6e419f40 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 6 Apr 2026 20:13:07 +0200 Subject: [PATCH 10/48] docs(audit): mark items 5 and 7 as completed in action plan --- docs/code-quality-audit.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md index 0ccc698e..34d8c163 100644 --- a/docs/code-quality-audit.md +++ b/docs/code-quality-audit.md @@ -363,10 +363,10 @@ no `.cs` files and no test framework configured. 3. ~~Fix double `i++` in `EmbedModule.cs` reaction-polling loop (and add `break` after confirmation)~~ ✅ 4. ~~Fix sunrise/sunset copy-paste bug in `WeatherModule.cs`~~ ✅ -5. Add `using` to all `HttpClient` instances or switch to `IHttpClientFactory`; - replace deprecated `WebClient` usage +5. ~~Add `using` to all `HttpClient` instances or switch to `IHttpClientFactory`; + replace deprecated `WebClient` usage~~ ✅ 6. ~~Add null checks in `RoleAttributes.cs` for DM context safety~~ ✅ -7. Wrap all `async void` event handlers in try-catch +7. ~~Wrap all `async void` event handlers in try-catch~~ ✅ ### Short-term (Architecture) @@ -374,7 +374,7 @@ no `.cs` files and no test framework configured. 2. Split `BotSettings` into domain-specific config classes 3. Add `BotSettings.Validate()` post-deserialization 4. Extract business logic from command handlers into services -5. Register `IHttpClientFactory` in DI; remove manual `HttpClient` creation +5. ~~Register `IHttpClientFactory` in DI; remove manual `HttpClient` creation~~ ✅ 6. Add graceful shutdown support with `CancellationToken` 7. Move static module state (`_activeDuels`) to services From 14dc0b7a8476a0acf14a33e34e00f260b9b882c2 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 6 Apr 2026 20:16:01 +0200 Subject: [PATCH 11/48] Fix formatting --- DiscordBot/Services/CommandHandlingService.cs | 46 +++++++++---------- .../Moderation/IntroductionWatcherService.cs | 14 +++--- DiscordBot/Services/ModerationService.cs | 14 +++--- DiscordBot/Services/Tips/TipService.cs | 14 +++--- DiscordBot/Utils/WebUtil.cs | 16 +++---- 5 files changed, 52 insertions(+), 52 deletions(-) diff --git a/DiscordBot/Services/CommandHandlingService.cs b/DiscordBot/Services/CommandHandlingService.cs index e7eb6fc1..181aacb6 100644 --- a/DiscordBot/Services/CommandHandlingService.cs +++ b/DiscordBot/Services/CommandHandlingService.cs @@ -25,7 +25,7 @@ public class CommandHandlingService { private const string ServiceName = "CommandHandlingService"; public bool IsInitialized { get; private set; } - + private readonly DiscordSocketClient _client; private readonly CommandService _commandService; private readonly InteractionService _interactionService; @@ -39,7 +39,7 @@ public class CommandHandlingService // Tuple of string moduleName, bool orderByName = false, bool includeArgs = true, bool includeModuleName = true for a dictionary private readonly Dictionary<(string moduleName, bool orderByName, bool includeArgs, bool includeModuleName), string> _commandList = new(); private readonly Dictionary<(string moduleName, bool orderByName, bool includeArgs, bool includeModuleName), List> _commandListMessages = new(); - + // A Collection to store the command history private const int MaxCommandHistory = 200; private readonly List _commandHistory = new List(MaxCommandHistory); @@ -62,13 +62,13 @@ ILoggingService loggingService // Events _client.MessageReceived += EventGuard.Guarded(HandleCommand, nameof(HandleCommand)); _client.InteractionCreated += EventGuard.Guarded(HandleInteraction, nameof(HandleInteraction)); - + if (settings.GuildId == default) { _loggingService.Log(LogBehaviour.Console | LogBehaviour.File, $"{ServiceName}: GuildId not set, commands will not be registered.", ExtendedLogSeverity.Critical); return; } - + _commandPrefix = settings.Prefix; if (_commandPrefix == default) { @@ -98,7 +98,7 @@ ILoggingService loggingService await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: {moduleInfos.Sum(x => x.AutocompleteCommands.Count)} 'AutoComplete' commands.", ExtendedLogSeverity.Positive); await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: {moduleInfos.Sum(x => x.ModalCommands.Count)} 'Modal' commands.", ExtendedLogSeverity.Positive); await _loggingService.Log(LogBehaviour.Console, $"{ServiceName}: {moduleInfos.Sum(x => x.ComponentCommands.Count)} 'Component' commands.", ExtendedLogSeverity.Positive); - + //TODO Consider global commands? Maybe an attribute? await _interactionService.RegisterCommandsToGuildAsync(settings.GuildId); @@ -110,9 +110,9 @@ ILoggingService loggingService } }); } - + #region Command Lists - + /// Generates a command list that can provide users with information. Commands require [Command][Summary] and [Priority](If not ordering by name) /// The results are cached, so this method can be called frequently without performance issues. /// List of strings that can be sent to the user without worry of being over the message length limit. @@ -140,24 +140,24 @@ public string GetCommandList(string moduleName, bool orderByName = false, bool i } return commandResults; } - + private void GenerateCommandListOutputs( (string moduleName, bool orderByName, bool includeArgs, bool includeModuleName) input) { // If we don't have the command list, we need to build it. var commandList = new StringBuilder(); commandList.Append($"__{input.moduleName} Commands__\n"); - + // Gets all of the commands in the module, and sorts them by priority. var commands = GetOrganizedCommandInfo(input); - + foreach (var c in commands) { commandList.Append($"**{(input.includeModuleName ? input.moduleName + " " : string.Empty)}{c.Name}** : {c.Summary} {GetArguments(input.includeArgs, c.Parameters)}\n"); } - + string commandListString = commandList.ToString(); - _commandList[input] = commandListString; + _commandList[input] = commandListString; _commandListMessages[input] = commandListString.MessageSplitToSize(); } @@ -166,7 +166,7 @@ public List SearchForCommand((string moduleName, bool orderByName, bool { // If we don't have the command list, we need to build it. var commandList = new StringBuilder(); - + // Gets all of the commands in the module, and sorts them by priority. var commands = GetOrganizedCommandInfo(input, search); @@ -180,7 +180,7 @@ public List SearchForCommand((string moduleName, bool orderByName, bool return commandList.ToString().MessageSplitToSize(); } - + private string GetArguments(bool getArgs, IReadOnlyList arguments) { if (!getArgs) return string.Empty; @@ -202,12 +202,12 @@ private IEnumerable GetOrganizedCommandInfo( var hideFromHelp = new HideFromHelpAttribute(); var requireModerator = new RequireModeratorAttribute(); var requireAdmin = new RequireAdminAttribute(); - + // Generates a list of commands that doesn't include any that have the ``HideFromHelp`` attribute. // Adds commands that use the same Module, and contains the search query if given. - var commands = + var commands = _commandService.Commands.Where(x => - x.Module.Name == input.moduleName && + x.Module.Name == input.moduleName && !x.Attributes.Contains(hideFromHelp) && (search == string.Empty || x.Name.Contains(search, StringComparison.CurrentCultureIgnoreCase)) ); @@ -215,7 +215,7 @@ private IEnumerable GetOrganizedCommandInfo( commands = onlyNormalUsers ? commands.Where(x => !x.Preconditions.Any(y => y.TypeId == requireModerator.TypeId || y.TypeId == requireAdmin.TypeId)) : commands; - + // Orders the list either by name or by priority, if no priority is given we push it to the end. commands = input.orderByName ? commands.OrderBy(c => c.Name) @@ -262,7 +262,7 @@ private async Task HandleCommand(SocketMessage messageParam) if (resultString == string.Empty) return; } - + AddToCommandHistory(message, resultString); await context.Channel.SendMessageAsync(resultString).DeleteAfterSeconds(10); } @@ -276,7 +276,7 @@ private async Task HandleInteraction(SocketInteraction arg) // Execute the command and retrieve the result. IResult result = await _interactionService.ExecuteCommandAsync(ctx, _services); //TODO maybe do something if result is anything but success - + // TODO: (James) Need to "AddToCommandHistory" for interactions } catch (Exception ex) @@ -284,7 +284,7 @@ private async Task HandleInteraction(SocketInteraction arg) LoggingService.LogToConsole(ex.ToString(), LogSeverity.Error); } } - + public void AddToCommandHistory(SocketUserMessage message, string error = default) { _commandHistory.Add(new CommandHistoryInfo() @@ -299,14 +299,14 @@ public void AddToCommandHistory(SocketUserMessage message, string error = defaul if (_commandHistory.Count > MaxCommandHistory) _commandHistory.RemoveAt(0); } - + public async Task GetCommandHistory(int count = 10) { if (count > _commandHistory.Count) count = _commandHistory.Count; if (count == 0) count = 10; - + var commandHistory = new StringBuilder(); for (var i = _commandHistory.Count - 1; i >= 0 && count > 0; i--, count--) { diff --git a/DiscordBot/Services/Moderation/IntroductionWatcherService.cs b/DiscordBot/Services/Moderation/IntroductionWatcherService.cs index 4c58b3d8..00196c59 100644 --- a/DiscordBot/Services/Moderation/IntroductionWatcherService.cs +++ b/DiscordBot/Services/Moderation/IntroductionWatcherService.cs @@ -8,18 +8,18 @@ namespace DiscordBot.Services; public class IntroductionWatcherService { private const string ServiceName = "IntroductionWatcherService"; - + private readonly DiscordSocketClient _client; private readonly ILoggingService _loggingService; private readonly SocketChannel _introductionChannel; private readonly HashSet _uniqueUsers = new HashSet(MaxMessagesToTrack + 1); private readonly Queue _orderedUsers = new Queue(MaxMessagesToTrack + 1); - + private SocketRole ModeratorRole { get; set; } private const int MaxMessagesToTrack = 1000; - + public IntroductionWatcherService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings) { _client = client; @@ -32,14 +32,14 @@ public IntroductionWatcherService(DiscordSocketClient client, ILoggingService lo LoggingService.LogServiceDisabled(ServiceName, nameof(settings.IntroductionWatcherServiceEnabled)); return; } - + _introductionChannel = client.GetChannel(settings.IntroductionChannel.Id); if (_introductionChannel == null) { _loggingService.LogAction($"[{ServiceName}] Error: Could not find introduction channel.", ExtendedLogSeverity.Warning); return; } - + _client.MessageReceived += EventGuard.Guarded(MessageReceived, nameof(MessageReceived)); } @@ -58,7 +58,7 @@ private async Task MessageReceived(SocketMessage message) await _loggingService.LogChannelAndFile( $"[{ServiceName}]: Duplicate introduction from {message.Author.GetUserLoggingString()} [Message deleted]"); } - + _uniqueUsers.Add(message.Author.Id); _orderedUsers.Enqueue(message.Author.Id); if (_orderedUsers.Count > MaxMessagesToTrack) @@ -66,7 +66,7 @@ await _loggingService.LogChannelAndFile( var oldestUser = _orderedUsers.Dequeue(); _uniqueUsers.Remove(oldestUser); } - + await Task.CompletedTask; } } diff --git a/DiscordBot/Services/ModerationService.cs b/DiscordBot/Services/ModerationService.cs index 3bc98dbe..9338dd1a 100644 --- a/DiscordBot/Services/ModerationService.cs +++ b/DiscordBot/Services/ModerationService.cs @@ -9,11 +9,11 @@ public class ModerationService private readonly ILoggingService _loggingService; private readonly DiscordSocketClient _client; private readonly CommandHandlingService _commandHandlingService; - + private const int MaxMessageLength = 800; - private static readonly Color DeletedMessageColor = new (200, 128, 128); - private static readonly Color EditedMessageColor = new (255, 255, 128); - + private static readonly Color DeletedMessageColor = new(200, 128, 128); + private static readonly Color EditedMessageColor = new(255, 255, 128); + private readonly IMessageChannel _botAnnouncementChannel; private readonly IMessageChannel _memeChannel; private readonly bool _moderatorNoInviteLinks; @@ -43,7 +43,7 @@ private async Task MessageDeleted(Cacheable message, Cacheable< await _loggingService.LogChannelAndFile($"An uncached Message snowflake:`{message.Id}` was deleted from channel <#{(await channel.GetOrDownloadAsync()).Id}>"); return; } - + if (message.Value.Author.IsBot || channel.Id == _botAnnouncementChannel.Id) return; // Check the author is even in the guild @@ -64,7 +64,7 @@ private async Task MessageDeleted(Cacheable message, Cacheable< .AddField($"Deleted Message {(content.Length != message.Value.Content.Length ? "(truncated)" : "")}", content); var embed = builder.Build(); - + await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); } @@ -123,7 +123,7 @@ private async Task MessageUpdated(Cacheable before, SocketMessa await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); } - + // MessageReceived private async Task MessageReceived(SocketMessage message) { diff --git a/DiscordBot/Services/Tips/TipService.cs b/DiscordBot/Services/Tips/TipService.cs index 28dca3a9..469632b6 100644 --- a/DiscordBot/Services/Tips/TipService.cs +++ b/DiscordBot/Services/Tips/TipService.cs @@ -13,7 +13,7 @@ namespace DiscordBot.Services.Tips; public class TipService { - private const string ServiceName = "TipService"; + private const string ServiceName = "TipService"; private const string DatabaseName = "tips.json"; private readonly BotSettings _settings; @@ -39,7 +39,7 @@ public TipService(BotSettings settings, ILoggingService loggingService, IHttpCli _isRunning = false; return; } - + if (string.IsNullOrEmpty(_settings.TipImageDirectory)) { _loggingService.LogAction($"[{ServiceName}] TipImageDirectory not set, service will not run.", ExtendedLogSeverity.Warning); @@ -51,13 +51,13 @@ public TipService(BotSettings settings, ILoggingService loggingService, IHttpCli Initialize(); } - + private void Initialize() { if (_isRunning) return; _readOnly = false; - var jsonPath = GetTipPath(DatabaseName);; + var jsonPath = GetTipPath(DatabaseName); ; if (!Directory.Exists(_imageDirectory)) { _loggingService.LogAction($"[{ServiceName}] Tip directory {_imageDirectory} did not exist.", ExtendedLogSeverity.Info); @@ -160,7 +160,7 @@ public async Task AddTip(IUserMessage message, string keywords, string content) await using var stream = await client.GetStreamAsync(attachment.Url); await using var file = File.Create(filePath); await stream.CopyToAsync(file); - + imagePaths.Add(newFileName); } @@ -267,10 +267,10 @@ public async Task ReplaceTip(IUserMessage message, Tip tip, string content) public async Task ReloadTipDatabase() { - var jsonPath = GetTipPath(DatabaseName);; + var jsonPath = GetTipPath(DatabaseName); ; if (File.Exists(jsonPath)) { - var json = File.ReadAllText(jsonPath); + var json = File.ReadAllText(jsonPath); _tips = JsonConvert.DeserializeObject>>(json); _loggingService.LogAction( $"[{ServiceName}] Tip index has {_tips.Count} keywords.", diff --git a/DiscordBot/Utils/WebUtil.cs b/DiscordBot/Utils/WebUtil.cs index 85a541b4..ce715c75 100644 --- a/DiscordBot/Utils/WebUtil.cs +++ b/DiscordBot/Utils/WebUtil.cs @@ -25,7 +25,7 @@ public static async Task GetContent(string url) return ""; } } - + /// /// Returns the Html document of a url, or null if the request fails. /// Internally calls GetContent and parses the result. @@ -44,7 +44,7 @@ public static async Task GetHtmlDocument(string url) return null; } } - + /// /// Returns the Html node of a url and xpath, or null if the request fails. /// Internally calls GetHtmlDocument and parses the result with xpath. @@ -61,7 +61,7 @@ public static async Task GetHtmlNode(string url, string xpath) return null; } } - + /// /// Returns the Html nodes of a url and xpath, or null if the request fails. /// @@ -76,8 +76,8 @@ public static async Task GetHtmlNodes(string url, string xpa { return null; } - } - + } + /// /// Returns the decoded inner text of a url and xpath, or an empty string if the request fails. /// @@ -93,7 +93,7 @@ public static async Task GetHtmlNodeInnerText(string url, string xpath) return string.Empty; } } - + /// /// Returns the content of a url as a sanitized XML string, or an empty string if the request fails. /// @@ -113,7 +113,7 @@ public static async Task GetXMLContent(string url) return string.Empty; } } - + /// /// Returns a deserialized object from a JSON string. If the string is empty or can't be deserialized, it returns the default value of the type. /// @@ -130,7 +130,7 @@ public static async Task GetObjectFromJson(string url) return default; } } - + /// /// Returns a deserialized object from a JSON string, or null if the string is empty or can't be deserialized. /// From 7361fc2a2b3db716ba8ac61c961c41993f48fd19 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 6 Apr 2026 20:23:00 +0200 Subject: [PATCH 12/48] refactor(services): extract ProfileCardService from UserService - Move GenerateProfileCard(), GetSkinData(), GetXpLow/High to ProfileCardService - Register ProfileCardService in DI, inject into UserModule - Remove unused IHttpClientFactory, ImageMagick, Skin imports from UserService - Part of audit item S1: split UserService into focused services --- DiscordBot/Modules/ProfileModule.cs | 43 ++++++ DiscordBot/Modules/UserModule.cs | 32 ----- DiscordBot/Program.cs | 1 + DiscordBot/Services/ProfileCardService.cs | 154 ++++++++++++++++++++++ DiscordBot/Services/UserService.cs | 137 +------------------ 5 files changed, 199 insertions(+), 168 deletions(-) create mode 100644 DiscordBot/Modules/ProfileModule.cs create mode 100644 DiscordBot/Services/ProfileCardService.cs diff --git a/DiscordBot/Modules/ProfileModule.cs b/DiscordBot/Modules/ProfileModule.cs new file mode 100644 index 00000000..afca94b0 --- /dev/null +++ b/DiscordBot/Modules/ProfileModule.cs @@ -0,0 +1,43 @@ +using Discord.Commands; +using DiscordBot.Services; + +namespace DiscordBot.Modules; + +[Group("UserModule"), Alias("")] +public class ProfileModule : ModuleBase +{ + public ProfileCardService ProfileCardService { get; set; } + public ILoggingService LoggingService { get; set; } + + [Command("Profile"), Priority(2)] + [Summary("Display your profile card.")] + public async Task DisplayProfile() + { + await DisplayProfile(Context.Message.Author); + } + + [Command("Profile"), Priority(2)] + [Summary("Display profile card of mentioned user. Syntax : !profile @user")] + public async Task DisplayProfile(IUser user) + { + try + { + await Context.Message.DeleteAsync(); + + var profileCard = await ProfileCardService.GenerateProfileCard(user); + if (string.IsNullOrEmpty(profileCard)) + { + await ReplyAsync("Failed to generate profile card.").DeleteAfterSeconds(seconds: 10); + return; + } + + var profile = await Context.Channel.SendFileAsync(profileCard); + await profile.DeleteAfterTime(minutes: 3); + } + catch (Exception e) + { + await LoggingService.LogAction($"Error while generating profile card for {user.Username}.\nEx:{e}", + ExtendedLogSeverity.LowWarning); + } + } +} diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs index d8bc3b65..1157362d 100644 --- a/DiscordBot/Modules/UserModule.cs +++ b/DiscordBot/Modules/UserModule.cs @@ -508,38 +508,6 @@ private async Task GenerateRankEmbedFromList(List<(ulong userID, int valu return embedBuilder.Build(); } - [Command("Profile"), Priority(2)] - [Summary("Display your profile card.")] - public async Task DisplayProfile() - { - await DisplayProfile(Context.Message.Author); - } - - [Command("Profile"), Priority(2)] - [Summary("Display profile card of mentioned user. Syntax : !profile @user")] - public async Task DisplayProfile(IUser user) - { - try - { - await Context.Message.DeleteAsync(); - - var profileCard = await UserService.GenerateProfileCard(user); - if (string.IsNullOrEmpty(profileCard)) - { - await ReplyAsync("Failed to generate profile card.").DeleteAfterSeconds(seconds: 10); - return; - } - - var profile = await Context.Channel.SendFileAsync(profileCard); - await profile.DeleteAfterTime(minutes: 3); - } - catch (Exception e) - { - await LoggingService.LogAction($"Error while generating profile card for {user.Username}.\nEx:{e}", - ExtendedLogSeverity.LowWarning); - } - } - [Command("JoinDate"), Priority(91)] [Summary("Display date you joined the server.")] public async Task JoinDate() diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 3b7aa5ca..1b65f2c9 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -95,6 +95,7 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/DiscordBot/Services/ProfileCardService.cs b/DiscordBot/Services/ProfileCardService.cs new file mode 100644 index 00000000..0b20d5e1 --- /dev/null +++ b/DiscordBot/Services/ProfileCardService.cs @@ -0,0 +1,154 @@ +using System.IO; +using System.Net.Http; +using DiscordBot.Domain; +using DiscordBot.Settings; +using DiscordBot.Skin; +using ImageMagick; +using Newtonsoft.Json; + +namespace DiscordBot.Services; + +public class ProfileCardService +{ + private const string ServiceName = "ProfileCardService"; + + private readonly DatabaseService _databaseService; + private readonly ILoggingService _loggingService; + private readonly IHttpClientFactory _httpClientFactory; + private readonly BotSettings _settings; + + public ProfileCardService(DatabaseService databaseService, ILoggingService loggingService, + IHttpClientFactory httpClientFactory, BotSettings settings) + { + _databaseService = databaseService; + _loggingService = loggingService; + _httpClientFactory = httpClientFactory; + _settings = settings; + } + + private SkinData GetSkinData() => + JsonConvert.DeserializeObject(File.ReadAllText($"{_settings.AssetsRootPath}/skins/skin.json"), + new SkinModuleJsonConverter()); + + private double GetXpLow(int level) => 70d - 139.5d * (level + 1d) + 69.5 * Math.Pow(level + 1d, 2d); + + private double GetXpHigh(int level) => 70d - 139.5d * (level + 2d) + 69.5 * Math.Pow(level + 2d, 2d); + + public async Task GenerateProfileCard(IUser user) + { + string profileCardPath = string.Empty; + + try + { + var dbRepo = _databaseService.Query; + if (dbRepo == null) + return profileCardPath; + + var userData = await dbRepo.GetUser(user.Id.ToString()); + + var xpTotal = userData.Exp; + var xpRank = await dbRepo.GetLevelRank(userData.UserID, userData.Level); + var karmaRank = await dbRepo.GetKarmaRank(userData.UserID, userData.Karma); + var karma = userData.Karma; + var level = userData.Level; + var xpLow = GetXpLow(level); + var xpHigh = GetXpHigh(level); + + var xpShown = (int)(xpTotal - xpLow); + var maxXpShown = (int)(xpHigh - xpLow); + + var percentage = (float)xpShown / maxXpShown; + + var u = (IGuildUser)user; + IRole mainRole = null; + foreach (var id in u.RoleIds) + { + var role = u.Guild.GetRole(id); + if (mainRole == null) + mainRole = u.Guild.GetRole(id); + else if (role.Position > mainRole.Position) mainRole = role; + } + + mainRole ??= u.Guild.EveryoneRole; + + using var profileCard = new MagickImageCollection(); + var skin = GetSkinData(); + var profile = new ProfileData + { + Karma = karma, + KarmaRank = karmaRank, + Level = level, + MainRoleColor = mainRole.Color, + MaxXpShown = maxXpShown, + Nickname = ((IGuildUser)user).Nickname, + UserId = ulong.Parse(userData.UserID), + Username = user.GetPreferredAndUsername(), + XpHigh = xpHigh, + XpLow = xpLow, + XpPercentage = percentage, + XpRank = xpRank, + XpShown = xpShown, + XpTotal = xpTotal + }; + + var background = new MagickImage($"{_settings.AssetsRootPath}/skins/{skin.Background}"); + + var avatarUrl = user.GetAvatarUrl(ImageFormat.Auto, 256); + if (string.IsNullOrEmpty(avatarUrl)) + profile.Picture = new MagickImage($"{_settings.AssetsRootPath}/images/default.png"); + else + try + { + Stream stream; + + using (var http = _httpClientFactory.CreateClient()) + { + stream = await http.GetStreamAsync(new Uri(avatarUrl)); + } + + profile.Picture = new MagickImage(stream); + } + catch (Exception e) + { + LoggingService.LogToConsole( + $"Failed to download user profile image for ProfileCard.\nEx:{e.Message}", + LogSeverity.Warning); + profile.Picture = new MagickImage($"{_settings.AssetsRootPath}/images/default.png"); + } + + profile.Picture.Resize(skin.AvatarSize, skin.AvatarSize); + profileCard.Add(background); + + foreach (var layer in skin.Layers) + { + if (layer.Image != null) + { + var image = layer.Image.ToLower() == "avatar" + ? profile.Picture + : new MagickImage($"{_settings.AssetsRootPath}/skins/{layer.Image}"); + + background.Composite(image, (int)layer.StartX, (int)layer.StartY, CompositeOperator.Over); + } + + var l = new MagickImage(MagickColors.Transparent, (int)layer.Width, (int)layer.Height); + foreach (var module in layer.Modules) module.GetDrawables(profile).Draw(l); + + background.Composite(l, (int)layer.StartX, (int)layer.StartY, CompositeOperator.Over); + } + + profileCardPath = $"{_settings.ServerRootPath}/images/profiles/{user.Username}-profile.png"; + + using var result = profileCard.Mosaic(); + result.Write(profileCardPath); + } + catch (Exception e) + { + await _loggingService.LogChannelAndFile($"Failed to generate profile card for {user.Username}.\nEx:{e.Message}", ExtendedLogSeverity.LowWarning); + } + + if (!string.IsNullOrEmpty(profileCardPath)) + await Task.Delay(100); + + return profileCardPath; + } +} diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index 66f953ba..48f6118a 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -1,15 +1,10 @@ using System.Globalization; using System.IO; -using System.Net.Http; using System.Text; using System.Text.RegularExpressions; using Discord.WebSocket; -using DiscordBot.Domain; using DiscordBot.Settings; -using DiscordBot.Skin; using DiscordBot.Data; -using ImageMagick; -using Newtonsoft.Json; namespace DiscordBot.Services; @@ -51,8 +46,6 @@ public class UserService private readonly UpdateService _updateService; - private readonly IHttpClientFactory _httpClientFactory; - private readonly Dictionary _xpCooldown; private readonly int _xpMaxCooldown; private readonly int _xpMaxPerMessage; @@ -70,7 +63,7 @@ public class UserService _welcomeNoticeUsers.Any() ? _welcomeNoticeUsers.Min(x => x.time) : DateTime.MaxValue; public UserService(DiscordSocketClient client, DatabaseService databaseService, ILoggingService loggingService, - UpdateService updateService, IHttpClientFactory httpClientFactory, + UpdateService updateService, BotSettings settings, UserSettings userSettings) { _client = client; @@ -78,7 +71,6 @@ public UserService(DiscordSocketClient client, DatabaseService databaseService, _databaseService = databaseService; _loggingService = loggingService; _updateService = updateService; - _httpClientFactory = httpClientFactory; _settings = settings; MutedUsers = new Dictionary(); _xpCooldown = new Dictionary(); @@ -314,133 +306,6 @@ private async Task LevelUp(SocketMessage messageParam, ulong userId) private double GetXpHigh(int level) => 70d - 139.5d * (level + 2d) + 69.5 * Math.Pow(level + 2d, 2d); - private SkinData GetSkinData() => - JsonConvert.DeserializeObject(File.ReadAllText($"{_settings.AssetsRootPath}/skins/skin.json"), - new SkinModuleJsonConverter()); - - /// - /// Generate the profile card for a given user and returns the generated image path - /// - /// - /// - public async Task GenerateProfileCard(IUser user) - { - string profileCardPath = string.Empty; - - try - { - var dbRepo = _databaseService.Query; - if (dbRepo == null) - return profileCardPath; - - var userData = await dbRepo.GetUser(user.Id.ToString()); - - var xpTotal = userData.Exp; - var xpRank = await dbRepo.GetLevelRank(userData.UserID, userData.Level); - var karmaRank = await dbRepo.GetKarmaRank(userData.UserID, userData.Karma); - var karma = userData.Karma; - var level = userData.Level; - var xpLow = GetXpLow(level); - var xpHigh = GetXpHigh(level); - - var xpShown = (int)(xpTotal - xpLow); - var maxXpShown = (int)(xpHigh - xpLow); - - var percentage = (float)xpShown / maxXpShown; - - var u = (IGuildUser)user; - IRole mainRole = null; - foreach (var id in u.RoleIds) - { - var role = u.Guild.GetRole(id); - if (mainRole == null) - mainRole = u.Guild.GetRole(id); - else if (role.Position > mainRole.Position) mainRole = role; - } - - mainRole ??= u.Guild.EveryoneRole; - - using var profileCard = new MagickImageCollection(); - var skin = GetSkinData(); - var profile = new ProfileData - { - Karma = karma, - KarmaRank = karmaRank, - Level = level, - MainRoleColor = mainRole.Color, - MaxXpShown = maxXpShown, - Nickname = ((IGuildUser)user).Nickname, - UserId = ulong.Parse(userData.UserID), - Username = user.GetPreferredAndUsername(), - XpHigh = xpHigh, - XpLow = xpLow, - XpPercentage = percentage, - XpRank = xpRank, - XpShown = xpShown, - XpTotal = xpTotal - }; - - var background = new MagickImage($"{_settings.AssetsRootPath}/skins/{skin.Background}"); - - var avatarUrl = user.GetAvatarUrl(ImageFormat.Auto, 256); - if (string.IsNullOrEmpty(avatarUrl)) - profile.Picture = new MagickImage($"{_settings.AssetsRootPath}/images/default.png"); - else - try - { - Stream stream; - - using (var http = _httpClientFactory.CreateClient()) - { - stream = await http.GetStreamAsync(new Uri(avatarUrl)); - } - - profile.Picture = new MagickImage(stream); - } - catch (Exception e) - { - LoggingService.LogToConsole( - $"Failed to download user profile image for ProfileCard.\nEx:{e.Message}", - LogSeverity.Warning); - profile.Picture = new MagickImage($"{_settings.AssetsRootPath}/images/default.png"); - } - - profile.Picture.Resize(skin.AvatarSize, skin.AvatarSize); - profileCard.Add(background); - - foreach (var layer in skin.Layers) - { - if (layer.Image != null) - { - var image = layer.Image.ToLower() == "avatar" - ? profile.Picture - : new MagickImage($"{_settings.AssetsRootPath}/skins/{layer.Image}"); - - background.Composite(image, (int)layer.StartX, (int)layer.StartY, CompositeOperator.Over); - } - - var l = new MagickImage(MagickColors.Transparent, (int)layer.Width, (int)layer.Height); - foreach (var module in layer.Modules) module.GetDrawables(profile).Draw(l); - - background.Composite(l, (int)layer.StartX, (int)layer.StartY, CompositeOperator.Over); - } - - profileCardPath = $"{_settings.ServerRootPath}/images/profiles/{user.Username}-profile.png"; - - using var result = profileCard.Mosaic(); - result.Write(profileCardPath); - } - catch (Exception e) - { - await _loggingService.LogChannelAndFile($"Failed to generate profile card for {user.Username}.\nEx:{e.Message}", ExtendedLogSeverity.LowWarning); - } - - if (!string.IsNullOrEmpty(profileCardPath)) - await Task.Delay(100); - - return profileCardPath; - } - public Embed WelcomeMessage(SocketGuildUser user) { string icon = user.GetAvatarUrl(); From 7d17cfc7e4a66b5ade4605d17cf53bb3e5db4486 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 6 Apr 2026 23:18:46 +0200 Subject: [PATCH 13/48] refactor(services): extract AuditLogService from ModerationService Extract message deleted/edited logging into a dedicated AuditLogService. This prepares for the removal of ModerationModule and ModerationService, which are being replaced by a separate moderation bot. - Create AuditLogService with MessageDeleted and MessageUpdated handlers - Register AuditLogService in DI, remove ModerationService and IntroductionWatcherService - Force-resolve AuditLogService in Ready callback for event subscription --- DiscordBot/Program.cs | 5 +- DiscordBot/Services/AuditLogService.cs | 110 +++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 DiscordBot/Services/AuditLogService.cs diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 1b65f2c9..4d9c51c5 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -72,8 +72,8 @@ private async Task MainAsync() _unityHelpService = _services.GetRequiredService(); _recruitService = _services.GetRequiredService(); - _services.GetRequiredService(); _services.GetRequiredService(); + _services.GetRequiredService(); _services.GetRequiredService(); return Task.CompletedTask; @@ -96,8 +96,7 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() - .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/DiscordBot/Services/AuditLogService.cs b/DiscordBot/Services/AuditLogService.cs new file mode 100644 index 00000000..750938ee --- /dev/null +++ b/DiscordBot/Services/AuditLogService.cs @@ -0,0 +1,110 @@ +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services; + +public class AuditLogService +{ + private readonly ILoggingService _loggingService; + + private const int MaxMessageLength = 800; + private static readonly Color DeletedMessageColor = new(200, 128, 128); + private static readonly Color EditedMessageColor = new(255, 255, 128); + + private readonly IMessageChannel _botAnnouncementChannel; + + public AuditLogService(DiscordSocketClient client, BotSettings settings, ILoggingService loggingService) + { + _loggingService = loggingService; + + client.MessageDeleted += EventGuard.Guarded, Cacheable>(MessageDeleted, nameof(MessageDeleted)); + client.MessageUpdated += EventGuard.Guarded, SocketMessage, ISocketMessageChannel>(MessageUpdated, nameof(MessageUpdated)); + + if (settings.BotAnnouncementChannel != null) + _botAnnouncementChannel = client.GetChannel(settings.BotAnnouncementChannel.Id) as IMessageChannel; + } + + private async Task MessageDeleted(Cacheable message, Cacheable channel) + { + if (message.HasValue == false) + { + await _loggingService.LogChannelAndFile($"An uncached Message snowflake:`{message.Id}` was deleted from channel <#{(await channel.GetOrDownloadAsync()).Id}>"); + return; + } + + if (message.Value.Author.IsBot || channel.Id == _botAnnouncementChannel?.Id) + return; + + var guildUser = message.Value.Author as SocketGuildUser; + if (guildUser == null) + return; + + var content = message.Value.Content; + if (content.Length > MaxMessageLength) + content = content[..MaxMessageLength]; + + var user = message.Value.Author; + var builder = new EmbedBuilder() + .WithColor(DeletedMessageColor) + .WithTimestamp(message.Value.Timestamp) + .FooterInChannel(message.Value.Channel) + .AddAuthorWithAction(user, "Deleted a message", true) + .AddField($"Deleted Message {(content.Length != message.Value.Content.Length ? "(truncated)" : "")}", + content); + var embed = builder.Build(); + + await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); + } + + private async Task MessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) + { + if (after.Author.IsBot || channel.Id == _botAnnouncementChannel?.Id) + return; + + bool isCached = true; + string content = ""; + var beforeMessage = await before.GetOrDownloadAsync(); + if (beforeMessage == null || beforeMessage.Content == after.Content) + isCached = false; + else + content = beforeMessage.Content; + + if (content == after.Content) + return; + if (content.Length == 0 && beforeMessage.Attachments.Count == 0) + return; + + bool isTruncated = false; + if (content.Length > MaxMessageLength) + { + content = content[..MaxMessageLength]; + isTruncated = true; + } + + var user = after.Author; + var builder = new EmbedBuilder() + .WithColor(EditedMessageColor) + .WithTimestamp(after.Timestamp) + .FooterInChannel(after.Channel) + .AddAuthorWithAction(user, "Updated a message", true); + if (isCached) + { + builder.AddField($"Previous message content {(isTruncated ? "(truncated)" : "")}", content); + if (beforeMessage.Attachments.Count > 0) + { + var attachments = beforeMessage.Attachments.Where(x => after.Attachments.All(y => y.Url != x.Url)); + var removedAttachments = attachments.ToList(); + if (removedAttachments.Any()) + { + var attachmentString = string.Join("\n", removedAttachments.Select(x => $"[{x.Filename}]({x.Url})")); + builder.AddField($"Previous attachments ({removedAttachments.Count()})", attachmentString); + } + } + } + + builder.WithDescription($"Message: [{after.Id}]({after.GetJumpUrl()})"); + var embed = builder.Build(); + + await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); + } +} From 1d4d83ed36bbfc3c2480914ca277ca2ac16c5f2f Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 6 Apr 2026 23:29:07 +0200 Subject: [PATCH 14/48] chore: remove moderation commands, role management, mute system, and message reporting Co-authored-by: Copilot --- DiscordBot/Modules/ModerationModule.cs | 514 ------------------ DiscordBot/Modules/UserModule.cs | 66 --- DiscordBot/Modules/UserSlashModule.cs | 134 ----- .../Moderation/IntroductionWatcherService.cs | 72 --- DiscordBot/Services/ModerationService.cs | 153 ------ DiscordBot/Services/UpdateService.cs | 33 -- DiscordBot/Services/UserService.cs | 20 - DiscordBot/Settings/Deserialized/Settings.cs | 18 - DiscordBot/Settings/Settings.example.json | 23 - k8s/dev/bot-config.yaml | 24 - k8s/prod/bot-config.yaml | 25 - 11 files changed, 1082 deletions(-) delete mode 100644 DiscordBot/Modules/ModerationModule.cs delete mode 100644 DiscordBot/Services/Moderation/IntroductionWatcherService.cs delete mode 100644 DiscordBot/Services/ModerationService.cs diff --git a/DiscordBot/Modules/ModerationModule.cs b/DiscordBot/Modules/ModerationModule.cs deleted file mode 100644 index 69c17141..00000000 --- a/DiscordBot/Modules/ModerationModule.cs +++ /dev/null @@ -1,514 +0,0 @@ -using System.IO; -using System.Text; -using Discord.Commands; -using Discord.WebSocket; -using DiscordBot.Services; -using DiscordBot.Settings; -using Pathoschild.NaturalTimeParser.Parser; -using DiscordBot.Attributes; -using DiscordBot.Utils; - -namespace DiscordBot.Modules; - -public class ModerationModule : ModuleBase -{ - #region Dependency Injection - - public CommandHandlingService CommandHandlingService { get; set; } - public DatabaseService DatabaseService { get; set; } - public ILoggingService LoggingService { get; set; } - public Rules Rules { get; set; } - public BotSettings Settings { get; set; } - public UserService UserService { get; set; } - public ModerationService ModerationService { get; set; } - - #endregion - - private async Task IsModerationEnabled() - { - if (Settings.ModeratorCommandsEnabled) return true; - if (await Context.Guild.GetChannelAsync(Settings.BotAnnouncementChannel.Id) is IMessageChannel botAnnouncementChannel) - { - var sentMessage = await botAnnouncementChannel.SendMessageAsync($"{Context.User.Mention} some moderation commands are disabled, try using Wick."); - await Context.Message.DeleteAsync(); - await sentMessage.DeleteAfterSeconds(seconds: 60); - } - return false; - } - - [Command("Mute")] - [Summary("Mute a user for a fixed duration.")] - [Alias("shutup", "stfu")] - [RequireModerator] - public async Task MuteUser(IUser user, uint arg) - { - if (!await IsModerationEnabled()) return; - - await Context.Message.DeleteAsync(); - - var u = user as IGuildUser; - if (u != null && u.RoleIds.Contains(Settings.MutedRoleId)) return; - - await u.AddRoleAsync(Context.Guild.GetRole(Settings.MutedRoleId)); - - var reply = await ReplyAsync($"User {user} has been muted for {Utils.Utils.FormatTime(arg)} ({arg} seconds)."); - await LoggingService.LogChannelAndFile( - $"{Context.User.Username} has muted {u.Username} ({u.Id}) for {Utils.Utils.FormatTime(arg)} ({arg} seconds)."); - - UserService.MutedUsers.AddCooldown(u.Id, (int)arg, ignoreExisting: true); - - await UserService.MutedUsers.AwaitCooldown(u.Id); - await reply.DeleteAsync(); - await UnmuteUser(user, true); - } - - [Command("Mute")] - [Summary("Mute a user for a fixed duration.")] - [Alias("shutup", "stfu")] - [RequireModerator] - public async Task MuteUser(IUser user, string duration, params string[] messages) - { - if (!await IsModerationEnabled()) return; - try - { - var dt = DateTime.Now.Offset(duration); - if (dt < DateTime.Now) - { - await ReplyAsync("Invalid DateTime specified."); - return; - } - - await MuteUser(user, (uint)Math.Round((dt - DateTime.Now).TotalSeconds), messages); - } - catch (Exception) - { - await ReplyAsync("Invalid DateTime specified."); - await Context.Message.DeleteAsync(); - } - } - - [Command("Mute")] - [Summary("Mute a user for a fixed duration.")] - [Alias("shutup", "stfu")] - [RequireModerator] - public async Task MuteUser(IUser user, uint seconds, params string[] messages) - { - if (!await IsModerationEnabled()) return; - var message = string.Join(' ', messages); - - await Context.Message.DeleteAsync(); - - var u = user as IGuildUser; - if (u != null && u.RoleIds.Contains(Settings.MutedRoleId)) return; - - await u.AddRoleAsync(Context.Guild.GetRole(Settings.MutedRoleId)); - - var reply = - await ReplyAsync($"User {user} has been muted for {Utils.Utils.FormatTime(seconds)} ({seconds} seconds). Reason : {message}"); - await LoggingService.LogChannelAndFile( - $"{Context.User.Username} has muted {u.Username} ({u.Id}) for {Utils.Utils.FormatTime(seconds)} ({seconds} seconds). Reason : {message}"); - - var dm = await user.CreateDMChannelAsync(new RequestOptions()); - if (!await dm.TrySendMessage( - $"You have been muted from UDC for **{Utils.Utils.FormatTime(seconds)}** for the following reason : **{message}**. " + - "This is not appealable and any tentative to avoid it will result in your permanent ban.")) - { - if (await Context.Guild.GetChannelAsync(Settings.BotCommandsChannel.Id) is ISocketMessageChannel botCommandChannel) - await botCommandChannel.SendMessageAsync( - $"I could not DM you {user.Mention}!\nYou have been muted from UDC for **{Utils.Utils.FormatTime(seconds)}** for the following reason : **{message}**. " + - "This is not appealable and any tentative to avoid it will result in your permanent ban."); - await LoggingService.Log(LogBehaviour.Channel, $"User {user.Username} has DM blocked and the mute reason couldn't be sent."); - } - - UserService.MutedUsers.AddCooldown(u.Id, (int)seconds, ignoreExisting: true); - await UserService.MutedUsers.AwaitCooldown(u.Id); - - await UnmuteUser(user, true); - if (reply != null) - await reply.DeleteAsync(); - } - - [Command("Unmute")] - [Summary("Unmute a muted user.")] - [RequireModerator] - public async Task UnmuteUser(IUser user, bool fromMute = false) - { - var u = user as IGuildUser; - - if (!fromMute && u == Context.Message.Author) - { - await ReplyAsync("You can't unmute yourself.").DeleteAfterSeconds(30); - return; - } - - if (!fromMute && Context != null && Context.Message != null) - await Context.Message.DeleteAsync(); - - UserService.MutedUsers.Remove(user.Id); - await u.RemoveRoleAsync(Context.Guild.GetRole(Settings.MutedRoleId)); - var reply = await ReplyAsync("User " + user + " has been unmuted."); - reply?.DeleteAfterSeconds(10d); - } - - [Command("AddRole")] - [Summary("Add a role to a user.")] - [Alias("roleadd")] - [RequireModerator] - public async Task AddRole(IRole role, IUser user) - { - var contextUser = Context.User as SocketGuildUser; - await Context.Message.DeleteAsync(); - - if (Settings.UserAssignableRoles.Roles.Contains(role.Name)) - { - var u = user as IGuildUser; - await u.AddRoleAsync(role); - await ReplyAsync("Role " + role + " has been added to " + user).DeleteAfterTime(minutes: 5); - await LoggingService.LogChannelAndFile($"{contextUser.Username} has added role {role} to {u.Username}"); - return; - } - - await ReplyAsync($"Bot cannot add {role.Name} role. Administrator must do it manually.").DeleteAfterSeconds(25); - } - - [Command("RemoveRole")] - [Summary("Remove a role from a user.")] - [Alias("roleremove")] - [RequireModerator] - public async Task RemoveRole(IRole role, IUser user) - { - var contextUser = Context.User as SocketGuildUser; - await Context.Message.DeleteAsync(); - - if (Settings.UserAssignableRoles.Roles.Contains(role.Name)) - { - var u = user as IGuildUser; - - await u.RemoveRoleAsync(role); - await ReplyAsync("Role " + role + " has been removed from " + user).DeleteAfterTime(minutes: 5); - await LoggingService.LogChannelAndFile($"{contextUser.Username} has removed role {role} from {u.Username}"); - return; - } - - await ReplyAsync($"Bot cannot remove {role.Name} role. Administrator must do it manually.").DeleteAfterSeconds(25); - } - - [Command("Clear")] - [Summary("Removes the last x messages from channel.")] - [Alias("clean", "nuke", "purge")] - [RequireModerator] - public async Task ClearMessages(int count) - { - var channel = Context.Channel as ITextChannel; - - var messages = await channel.GetMessagesAsync(count + 1).FlattenAsync(); - await channel.DeleteMessagesAsync(messages); - - await ReplyAsync("Messages deleted.").DeleteAfterSeconds(seconds: 5); - await LoggingService.LogChannelAndFile($"{Context.User.Username} has removed {count} messages from {Context.Channel.Name}"); - } - - [Command("Clear")] - [Summary("Removes messages until the message at the specified id.")] - [Alias("clean", "nuke", "purge")] - [RequireModerator] - public async Task ClearMessages(ulong messageId) - { - var channel = (ITextChannel)Context.Channel; - - var messages = await channel.GetMessagesAsync(messageId, Direction.After).FlattenAsync(); - var enumerable = messages.ToList(); - await channel.DeleteMessagesAsync(enumerable); - - await ReplyAsync("Messages deleted.").DeleteAfterSeconds(seconds: 5); - await LoggingService.LogChannelAndFile($"{Context.User.Username} has removed {enumerable.Count} messages from {Context.Channel.Name}"); - } - - [Command("Kick")] - [Summary("Kick a user.")] - [RequireUserPermission(GuildPermission.KickMembers)] - internal async Task KickUser(IUser user) - { - if (!await IsModerationEnabled()) return; - - var u = user as IGuildUser; - - await u.KickAsync(); - await LoggingService.LogChannelAndFile($"{Context.User.Username} has kicked {u.Username}"); - } - - [Command("Ban")] - [Summary("Ban an user")] - [RequireUserPermission(GuildPermission.BanMembers)] - public async Task BanUser(IUser user, params string[] reasons) - { - if (!await IsModerationEnabled()) return; - - var reason = string.Join(' ', reasons); - await Context.Guild.AddBanAsync(user, 7, reason, RequestOptions.Default); - await LoggingService.LogChannelAndFile($"{Context.User.Username} has banned {user.Username} with the reason \"{reasons}\""); - } - - [Command("Rules")] - [Summary("Display rules of the current channel.")] - [RequireModerator] - public async Task RulesCommand(int seconds = 60) - { - await RulesCommand(Context.Channel, seconds); - await Context.Message.DeleteAsync(); - } - - [Command("Rules")] - [Summary("Display rules of the mentioned channel.")] - [RequireModerator] - public async Task RulesCommand(IMessageChannel channel, int seconds = 60) - { - //Display rules of this channel for x seconds - var rule = Rules.Channel.First(x => x.Id == 0); - var m = await ReplyAsync( - $"{rule.Header}{(rule.Content.Length > 0 ? rule.Content : "There is no special rule for this channel.\nPlease follow global rules (you can get them by typing `!globalrules`)")}"); - - var deleteAsync = Context.Message?.DeleteAsync(); - if (deleteAsync != null) await deleteAsync; - - if (seconds == -1) - return; - await m.DeleteAfterSeconds(seconds: seconds); - } - - [Command("GlobalRules")] - [Summary("Display global rules in current channel.")] - [RequireModerator] - public async Task GlobalRules(int seconds = 60) - { - //Display rules of this channel for x seconds - var globalRules = Rules.Channel.First(x => x.Id == 0).Content; - var m = await ReplyAsync(globalRules); - await Context.Message.DeleteAsync(); - - if (seconds == -1) - return; - await m.DeleteAfterSeconds(seconds: seconds); - } - - [Command("Channels")] - [Summary("Get a description of the channels.")] - [RequireModerator] - public async Task ChannelsDescription(int seconds = 60) - { - //Display rules of this channel for x seconds - var channelData = Rules.Channel; - var sb = new StringBuilder(); - - foreach (var c in channelData) - sb.Append($"{(await Context.Guild.GetTextChannelAsync(c.Id))?.Mention} - {c.Header}\n"); - var text = sb.ToString(); - IUserMessage m; - IUserMessage m2 = null; - - if (sb.ToString().Length > 2000) - { - m = await ReplyAsync(text.Substring(0, 2000)); - m2 = await ReplyAsync(text.Substring(2000)); - } - else - m = await ReplyAsync(text); - - await Context.Message.DeleteAsync(); - - if (seconds == -1) - return; - await m.DeleteAfterSeconds(seconds: seconds); - var deleteAsync = m2?.DeleteAsync(); - if (deleteAsync != null) await deleteAsync; - } - - [Command("SlowMode")] - [Summary("Turn on slowmode.")] - [RequireModerator] - public async Task SlowMode(int time) - { - await Context.Message.DeleteAsync(); - await (Context.Channel as ITextChannel).ModifyAsync(p => p.SlowModeInterval = time); - await ReplyAsync($"Slowmode has been set to {time}s !").DeleteAfterSeconds(10); - } - - [Command("TagRole")] - [Summary("Tag a role and post a message.")] - [Alias("mentionrole", "pingrole", "rolemention", "roletag", "roleping")] - [RequireAdmin] - public async Task TagRole(IRole role, params string[] messages) - { - var message = string.Join(' ', messages); - var isMentionable = role.IsMentionable; - if (!isMentionable) await role.ModifyAsync(properties => { properties.Mentionable = true; }); - await role.ModifyAsync(properties => { properties.Mentionable = true; }); - await Context.Channel.SendMessageAsync($"{role.Mention}\n{message}"); - if (!isMentionable) await role.ModifyAsync(properties => { properties.Mentionable = false; }); - await Context.Message.DeleteAsync(); - } - - [Command("React")] - [Alias("reaction", "reactions", "addreactions", "addreaction")] - [Summary("Adds the requested reactions to a message.")] - [RequireAdmin] - public async Task React(ulong msgId, params string[] emojis) - { - var msg = (IUserMessage)await Context.Channel.GetMessageAsync(msgId); - await Context.Message.DeleteAsync(); - foreach (var emoji in emojis) - if (Emote.TryParse(emoji, out var emote)) - await msg.AddReactionAsync(emote); - else - await msg.AddReactionAsync(new Emoji(emoji)); - } - - [Command("React")] - [Alias("reaction", "reactions", "addreactions", "addreaction")] - [Summary("Adds the requested reactions to a message.")] - [RequireAdmin] - public async Task React(params string[] emojis) - { - var msg = (IUserMessage)(await Context.Channel.GetMessagesAsync(2).FlattenAsync()).Last(); - - await Context.Message.DeleteAsync(); - foreach (var emoji in emojis) - if (Emote.TryParse(emoji, out var emote)) - await msg.AddReactionAsync(emote); - else - await msg.AddReactionAsync(new Emoji(emoji)); - } - - [Command("ClosePoll")] - [Summary("Close a poll and append a message.")] - [Alias("pollclose")] - [RequireAdmin] - public async Task ClosePoll(IMessageChannel channel, ulong messageId, params string[] additionalNotes) - { - var additionalNote = string.Join(' ', additionalNotes); - var message = (IUserMessage)await channel.GetMessageAsync(messageId); - var reactions = message.Reactions; - - var reactionCount = string.Empty; - foreach (var reaction in reactions) - reactionCount += $" {reaction.Key.Name} ({reaction.Value.ReactionCount})"; - - await message.ModifyAsync(properties => - { - properties.Content = message.Content + - $"\n\nThe poll has been closed. Here's the vote results :{reactionCount}\nAdditional notes : {additionalNote}"; - }); - } - - [Command("CommandHistory")] - [Summary("Get a text file of the command history for bot.")] - [RequireModerator] - public async Task CommandHistory(int count = 20) - { - await Context.Message.DeleteAsync(); - await LoggingService.LogChannelAndFile("Command history requested by " + Context.User.Username, - ExtendedLogSeverity.Info); - - var response = await ModerationService.GetBotCommandHistory(count); - using var stream = new MemoryStream(); - using (var writer = new StreamWriter(stream)) - { - await writer.WriteAsync(response); - await writer.FlushAsync(); - stream.Position = 0; - - // Send the MemoryStream as a file - await Context.User.SendFileAsync(stream, "CommandHistory.txt"); - } - } - - [Command("DBSync")] - [Summary("Force add a user to the database.")] - [RequireAdmin] - public async Task DbSync(IUser user) - { - await DatabaseService.GetOrAddUser((SocketGuildUser)user); - } - - [Command("DBFullSync")] - [Summary("Inserts all missing users, and updates any tracked data.")] - [RequireAdmin] - public async Task FullSync() - { - await Context.Message.DeleteAsync(); - var tracker = await ReplyAsync("Updating user data: "); - await DatabaseService.FullDbSync(Context.Guild, tracker); - } - - #region General Utility Commands - - [Command("WelcomeMessageCount")] - [Summary("Returns a count of pending welcome messages.")] - [RequireModerator, HideFromHelp] - // Simple method to check if there are many welcome messages waiting, and when the next one is due. - public async Task WelcomeMessageCount() - { - var count = UserService.WaitingWelcomeMessagesCount; - // If there are more than 0 messages waiting, show when nearest one is - if (count > 0) - { - var next = UserService.NextWelcomeMessage.ToUnixTimestamp(); - await ReplyAsync($"There are {count} pending welcome messages. The next one is in ").DeleteAfterSeconds(seconds: 10); - } - else - { - await ReplyAsync("There are no pending welcome messages.").DeleteAfterSeconds(seconds: 10); - } - await Context.Message.DeleteAsync(); - } - - // Command to show the tags available for a specific channel, so the command needs to be run in a channel with tags or specific a channel id to check - [Command("ChannelTags")] - [Summary("Returns a list of tags for the current channel.")] - [RequireModerator, HideFromHelp] - public async Task ChannelTags(ulong channelId) - { - // Get the channel - var channel = await Context.Guild.GetChannelAsync(channelId); - - if (channel is not IForumChannel forumChannel) - { - await ReplyAsync($"<#{channelId}> is not a forum channel and has no tags.").DeleteAfterSeconds(seconds: 10); - return; - } - - var tags = forumChannel.Tags; - // If there are no tags, say so - if (tags.Count == 0) - { - await ReplyAsync($"<#{channelId}> has no tags.").DeleteAfterSeconds(seconds: 10); - return; - } - - // If there are tags, list them in an embed in format of (ID: `id` - Name: `name`) - var embed = new EmbedBuilder() - .WithTitle($"Tags for <#{channelId}>") - .WithDescription(string.Join("\n", tags.Select(tag => $"ID: `{tag.Id}` - Name: `{tag.Name}`")) + - $"\n\n{StringUtil.MessageSelfDestructIn(60)}") - .WithColor(Color.Blue) - .Build(); - - await Context.Message.DeleteAsync(); - await ReplyAsync(embed: embed).DeleteAfterSeconds(seconds: 60); - } - - #endregion - - #region CommandList - [RequireModerator] - [Summary("Does what you see now.")] - [Command("Mod Help")] - public async Task ModerationHelp() - { - foreach (var message in CommandHandlingService.GetCommandListMessages("ModerationModule", true, true, false)) - { - await ReplyAsync(message); - } - } - #endregion -} diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs index 1157362d..111d3335 100644 --- a/DiscordBot/Modules/UserModule.cs +++ b/DiscordBot/Modules/UserModule.cs @@ -257,72 +257,6 @@ public async Task UserCompleted(string message) await Context.Message.DeleteAsync(); } - [Group("Role"), BotCommandChannel] - public class RoleModule : ModuleBase - { - public BotSettings Settings { get; set; } - public ILoggingService LoggingService { get; set; } - - [Command("Add")] - [Summary("Add a role to yourself. Syntax: !role add rolename")] - public async Task AddRoleUser(IRole role) - { - if (!Settings.UserAssignableRoles.Roles.Contains(role.Name)) - { - await ReplyAsync("This role is not assignable."); - return; - } - - var u = Context.User as IGuildUser; - var uname = u.GetUserPreferredName(); - - await u.AddRoleAsync(role); - await ReplyAsync($"{uname}, you now have the `{role.Name}` role."); - await LoggingService.LogChannelAndFile($"{Context.User.Username} has added {role} to themself."); - } - - [Command("Remove")] - [Summary("Remove a role from yourself. Syntax: !role remove rolename")] - [Alias("delete")] - public async Task RemoveRoleUser(IRole role) - { - if (!Settings.UserAssignableRoles.Roles.Contains(role.Name)) - { - await ReplyAsync("This role is not assignable."); - return; - } - - var u = Context.User as IGuildUser; - var uname = u.GetUserPreferredName(); - - await u.RemoveRoleAsync(role); - await ReplyAsync($"{uname}, your `{role.Name}` role has been removed."); - await LoggingService.LogChannelAndFile($"{Context.User.Username} has removed role {role} from themself."); - } - - [Command("List")] - [Summary("List of available roles. Syntax: !role list")] - public async Task ListRole() - { - await ReplyAsync("**The following roles are available on this server** :\n" + - "We offer multiple roles to show what you specialize in, whether it's professionally or as a hobby, so if there's something you're good at, assign the corresponding role! \n" + - "You can assign as much roles as you want, but try to keep them for what you're good at :) \n"); - await ReplyAsync( - "```!role add/remove 2D-Artists - If you're good at drawing, painting, digital art, concept art or anything else that's flat. \n" + - "!role add/remove 3D-Artists - If you are a wizard with vertices or like to forge your models from mud. \n" + - "!role add/remove Animators - If you like to bring characters to life. \n" + - "!role add/remove Technical-Artists - If you write tools and shaders to bridge the gap between art and programming. \n" + - "!role add/remove Programmers - If you like typing away to make your dreams come true (or the code come to your dreams). \n" + - "!role add/remove Game-Designers - If you are good at designing games, mechanics and levels.\n" + - "!role add/remove Audio-Engineers - If you live life to the rhythm of your own music and sounds.\n" + - "!role add/remove Generalists - If you like to dabble in everything.\n" + - "!role add/remove Hobbyists - If you're using Unity as a hobby.\n" + - "!role add/remove Students - If you're currently studying in a game-dev related field. \n" + - "!role add/remove XR-Developers - If you're a VR, AR or MR sorcerer. \n" + - "!role add/remove Writers - If you like writing lore, scenarios, characters and stories. \n" + - "```"); - } - } #region All Rules [Command("Rules"), Priority(1)] diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs index 1d2febd2..88c59afa 100644 --- a/DiscordBot/Modules/UserSlashModule.cs +++ b/DiscordBot/Modules/UserSlashModule.cs @@ -121,140 +121,6 @@ public async Task ReturnInvite() await Context.Interaction.RespondAsync(text: BotSettings.Invite, ephemeral: true); } - #region Moderation - - [MessageCommand("Report Message")] - public async Task ReportMessage(IMessage reportedMessage) - { - if (reportedMessage.Author.Id == Context.User.Id) - { - await Context.Interaction.RespondAsync(text: "You can't report your own messages!", ephemeral: true); - return; - } - if (reportedMessage.Author.IsBot) // Don't report bots - { - await Context.Interaction.RespondAsync(text: "You can't report bot messages!", ephemeral: true); - return; - } - if (reportedMessage.Author.IsWebhook) // Don't report webhooks - { - await Context.Interaction.RespondAsync(text: "You can't report webhook messages!", ephemeral: true); - return; - } - await Context.Interaction.RespondWithModalAsync($"report_{reportedMessage.Id}"); - } - - // Defines the modal that will be sent. - public class ReportMessageModal : IModal - { - public string Title => "Report a message"; - - // Additional parameters can be specified to further customize the input. - [InputLabel("Reason")] - [ModalTextInput("report_reason", TextInputStyle.Paragraph, maxLength: 500)] - public string Reason { get; set; } - } - - // Responds to the modal. - [ModalInteraction("report_*")] - public async Task ModalResponse(ulong id, ReportMessageModal modal) - { - var reportedMessage = await Context.Channel.GetMessageAsync(id); - - var reportedMessageChannel = await Context.Guild.GetTextChannelAsync(BotSettings.ReportedMessageChannel.Id); - if (reportedMessageChannel == null) - return; - - var embed = new EmbedBuilder() - .WithColor(new Color(0xFF0000)) - .WithDescription(reportedMessage.Content) - .WithTimestamp(reportedMessage.Timestamp) - .WithFooter(footer => - { - footer - .WithText($"Reported by {Context.User.GetPreferredAndUsername()} • From channel {reportedMessage.Channel.Name}") - .WithIconUrl(Context.User.GetAvatarUrl()); - }) - .AddAuthor(reportedMessage.Author); - - embed.Description += $"\n\n***[Linkback]({reportedMessage.GetJumpUrl()})***"; - - if (reportedMessage.Attachments.Count > 0) - { - var attachments = reportedMessage.Attachments.Select(a => a.Url).ToList(); - string attachmentString = string.Empty; - for (int i = 0; i < attachments.Count; i++) - { - attachmentString += $"• {attachments[i]}"; - if (i < attachments.Count - 1) - attachmentString += "\n"; - } - embed.AddField("Attachments", attachmentString); - } - embed.AddField("Reason", modal.Reason); - - await reportedMessageChannel.SendMessageAsync(string.Empty, embed: embed.Build()); - await RespondAsync("Message has been reported.", ephemeral: true); - } - - #endregion // Moderation - - #region User Roles - - [SlashCommand("roles", "Give or Remove roles for yourself (Programmer, Artist, Designer, etc)")] - public async Task UserRoles() - { - await Context.Interaction.DeferAsync(ephemeral: true); - - ComponentBuilder builder = new(); - - foreach (var userRole in BotSettings.UserAssignableRoles.Roles) - { - builder.WithButton(userRole, $"user_role_add:{userRole}"); - } - - builder.Build(); - - await Context.Interaction.FollowupAsync(text: "Click any role that applies to you!", embed: null, - ephemeral: true, components: builder.Build()); - } - - [ComponentInteraction("user_role_add:*")] - public async Task UserRoleAdd(string role) - { - await Context.Interaction.DeferAsync(ephemeral: true); - - var user = Context.User as IGuildUser; - var guild = Context.Guild; - - // Try get the role from the guild - var roleObj = guild.Roles.FirstOrDefault(r => r.Name == role); - if (roleObj == null) - { - await Context.Interaction.ModifyOriginalResponseAsync(msg => - msg.Content = $"Failed to add role {role}, role not found."); - return; - } - // We make sure the role is in our UserAssignableRoles just in case - if (BotSettings.UserAssignableRoles.Roles.Contains(roleObj.Name)) - { - if (user.RoleIds.Contains(roleObj.Id)) - { - await user.RemoveRoleAsync(roleObj); - await Context.Interaction.ModifyOriginalResponseAsync(msg => - msg.Content = $"{roleObj.Name} has been removed!"); - } - else - { - await user.AddRoleAsync(roleObj); - await Context.Interaction.ModifyOriginalResponseAsync(msg => - msg.Content = $"You now have the {roleObj.Name} role!"); - } - } - } - - #endregion - #region Duel System private static readonly ConcurrentDictionary _activeDuels = new ConcurrentDictionary(); diff --git a/DiscordBot/Services/Moderation/IntroductionWatcherService.cs b/DiscordBot/Services/Moderation/IntroductionWatcherService.cs deleted file mode 100644 index 00196c59..00000000 --- a/DiscordBot/Services/Moderation/IntroductionWatcherService.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Discord.WebSocket; -using DiscordBot.Settings; -using DiscordBot.Services.UnityHelp; - -namespace DiscordBot.Services; - -// Small service to watch users posting new messages in introductions, keeping track of the last 500 messages and deleting any from the same user -public class IntroductionWatcherService -{ - private const string ServiceName = "IntroductionWatcherService"; - - private readonly DiscordSocketClient _client; - private readonly ILoggingService _loggingService; - private readonly SocketChannel _introductionChannel; - - private readonly HashSet _uniqueUsers = new HashSet(MaxMessagesToTrack + 1); - private readonly Queue _orderedUsers = new Queue(MaxMessagesToTrack + 1); - - private SocketRole ModeratorRole { get; set; } - - private const int MaxMessagesToTrack = 1000; - - public IntroductionWatcherService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings) - { - _client = client; - _loggingService = loggingService; - - ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.ModeratorRoleId); - - if (!settings.IntroductionWatcherServiceEnabled) - { - LoggingService.LogServiceDisabled(ServiceName, nameof(settings.IntroductionWatcherServiceEnabled)); - return; - } - - _introductionChannel = client.GetChannel(settings.IntroductionChannel.Id); - if (_introductionChannel == null) - { - _loggingService.LogAction($"[{ServiceName}] Error: Could not find introduction channel.", ExtendedLogSeverity.Warning); - return; - } - - _client.MessageReceived += EventGuard.Guarded(MessageReceived, nameof(MessageReceived)); - } - - private async Task MessageReceived(SocketMessage message) - { - // We only watch the introduction channel - if (_introductionChannel == null || message.Channel.Id != _introductionChannel.Id) - return; - - if (message.Author.HasRoleGroup(ModeratorRole)) - return; - - if (_uniqueUsers.Contains(message.Author.Id)) - { - await message.DeleteAsync(); - await _loggingService.LogChannelAndFile( - $"[{ServiceName}]: Duplicate introduction from {message.Author.GetUserLoggingString()} [Message deleted]"); - } - - _uniqueUsers.Add(message.Author.Id); - _orderedUsers.Enqueue(message.Author.Id); - if (_orderedUsers.Count > MaxMessagesToTrack) - { - var oldestUser = _orderedUsers.Dequeue(); - _uniqueUsers.Remove(oldestUser); - } - - await Task.CompletedTask; - } -} diff --git a/DiscordBot/Services/ModerationService.cs b/DiscordBot/Services/ModerationService.cs deleted file mode 100644 index 9338dd1a..00000000 --- a/DiscordBot/Services/ModerationService.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System.Text.RegularExpressions; -using Discord.WebSocket; -using DiscordBot.Settings; - -namespace DiscordBot.Services; - -public class ModerationService -{ - private readonly ILoggingService _loggingService; - private readonly DiscordSocketClient _client; - private readonly CommandHandlingService _commandHandlingService; - - private const int MaxMessageLength = 800; - private static readonly Color DeletedMessageColor = new(200, 128, 128); - private static readonly Color EditedMessageColor = new(255, 255, 128); - - private readonly IMessageChannel _botAnnouncementChannel; - private readonly IMessageChannel _memeChannel; - private readonly bool _moderatorNoInviteLinks; - - public ModerationService(DiscordSocketClient client, BotSettings settings, ILoggingService loggingService, - CommandHandlingService commandHandlingService) - { - _client = client; - _loggingService = loggingService; - _commandHandlingService = commandHandlingService; - - client.MessageDeleted += EventGuard.Guarded, Cacheable>(MessageDeleted, nameof(MessageDeleted)); - client.MessageUpdated += EventGuard.Guarded, SocketMessage, ISocketMessageChannel>(MessageUpdated, nameof(MessageUpdated)); - client.MessageReceived += EventGuard.Guarded(MessageReceived, nameof(MessageReceived)); - - if (settings.BotAnnouncementChannel != null) - _botAnnouncementChannel = _client.GetChannel(settings.BotAnnouncementChannel.Id) as IMessageChannel; - if (settings.MemeChannel != null) - _memeChannel = _client.GetChannel(settings.MemeChannel.Id) as IMessageChannel; - _moderatorNoInviteLinks = settings.ModeratorNoInviteLinks; - } - - private async Task MessageDeleted(Cacheable message, Cacheable channel) - { - if (message.HasValue == false) - { - await _loggingService.LogChannelAndFile($"An uncached Message snowflake:`{message.Id}` was deleted from channel <#{(await channel.GetOrDownloadAsync()).Id}>"); - return; - } - - if (message.Value.Author.IsBot || channel.Id == _botAnnouncementChannel.Id) - return; - // Check the author is even in the guild - var guildUser = message.Value.Author as SocketGuildUser; - if (guildUser == null) - return; - - var content = message.Value.Content; - if (content.Length > MaxMessageLength) - content = content[..MaxMessageLength]; - - var user = message.Value.Author; - var builder = new EmbedBuilder() - .WithColor(DeletedMessageColor) - .WithTimestamp(message.Value.Timestamp) - .FooterInChannel(message.Value.Channel) - .AddAuthorWithAction(user, "Deleted a message", true) - .AddField($"Deleted Message {(content.Length != message.Value.Content.Length ? "(truncated)" : "")}", - content); - var embed = builder.Build(); - - await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); - } - - private async Task MessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) - { - if (after.Author.IsBot || channel.Id == _botAnnouncementChannel.Id) - return; - - bool isCached = true; - string content = ""; - var beforeMessage = await before.GetOrDownloadAsync(); - if (beforeMessage == null || beforeMessage.Content == after.Content) - isCached = false; - else - content = beforeMessage.Content; - - // Check the message aren't the same - if (content == after.Content) - return; - if (content.Length == 0 && beforeMessage.Attachments.Count == 0) - return; - - bool isTruncated = false; - if (content.Length > MaxMessageLength) - { - content = content[..MaxMessageLength]; - isTruncated = true; - } - - var user = after.Author; - var builder = new EmbedBuilder() - .WithColor(EditedMessageColor) - .WithTimestamp(after.Timestamp) - .FooterInChannel(after.Channel) - .AddAuthorWithAction(user, "Updated a message", true); - if (isCached) - { - builder.AddField($"Previous message content {(isTruncated ? "(truncated)" : "")}", content); - // if any attachments that after does not, add a link to them and a count - if (beforeMessage.Attachments.Count > 0) - { - var attachments = beforeMessage.Attachments.Where(x => after.Attachments.All(y => y.Url != x.Url)); - var removedAttachments = attachments.ToList(); - if (removedAttachments.Any()) - { - var attachmentString = string.Join("\n", removedAttachments.Select(x => $"[{x.Filename}]({x.Url})")); - builder.AddField($"Previous attachments ({removedAttachments.Count()})", attachmentString); - } - } - } - - builder.WithDescription($"Message: [{after.Id}]({after.GetJumpUrl()})"); - var embed = builder.Build(); - - // TimeStamp for the Footer - - await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); - } - - // MessageReceived - private async Task MessageReceived(SocketMessage message) - { - if (message.Author.IsBot) - return; - - if (_moderatorNoInviteLinks == true) - { - if (_memeChannel.Id == message.Channel.Id) - { - if (message.ContainsInviteLink()) - { - await message.DeleteAsync(); - // Send a message in _botAnnouncementChannel about the deleted message, nothing fancy, name, userid, channel and message content - await _botAnnouncementChannel.SendMessageAsync( - $"{message.Author.Mention} tried to post an invite link in <#{message.Channel.Id}>: {message.Content}"); - return; - } - } - } - } - - public async Task GetBotCommandHistory(int count) - { - return await _commandHandlingService.GetCommandHistory(count); - } -} diff --git a/DiscordBot/Services/UpdateService.cs b/DiscordBot/Services/UpdateService.cs index d2d802a4..8f727dc7 100644 --- a/DiscordBot/Services/UpdateService.cs +++ b/DiscordBot/Services/UpdateService.cs @@ -18,11 +18,9 @@ public class UserData { public UserData() { - MutedUsers = new Dictionary(); CodeReminderCooldown = new Dictionary(); } - public Dictionary MutedUsers { get; set; } public Dictionary CodeReminderCooldown { get; set; } } @@ -90,37 +88,6 @@ private void ReadDataFromFile() _botData = SerializeUtil.DeserializeFile($"{_settings.ServerRootPath}/botdata.json"); _userData = SerializeUtil.DeserializeFile($"{_settings.ServerRootPath}/userdata.json"); - Task.Run( - async () => - { - while (_client.ConnectionState != ConnectionState.Connected || - _client.LoginState != LoginState.LoggedIn) - await Task.Delay(100, _token); - - await Task.Delay(10000, _token); - //Check if there are users still muted - foreach (var userId in _userData.MutedUsers) - { - if (!_userData.MutedUsers.HasUser(userId.Key, true)) continue; - - var guild = _client.Guilds.First(g => g.Id == _settings.GuildId); - var sgu = guild.GetUser(userId.Key); - if (sgu == null) continue; - - IGuildUser user = sgu; - - var mutedRole = user.Guild.GetRole(_settings.MutedRoleId); - //Make sure they have the muted role - if (!user.RoleIds.Contains(_settings.MutedRoleId)) await user.AddRoleAsync(mutedRole); - - //Setup delay to remove role when time is up. - await Task.Run(async () => - { - await _userData.MutedUsers.AwaitCooldown(user.Id); - await user.RemoveRoleAsync(mutedRole); - }, _token); - } - }, _token); _faqData = SerializeUtil.DeserializeFile>("Settings/FAQs.json"); _feedData = SerializeUtil.DeserializeFile($"{_settings.ServerRootPath}/feeds.json"); diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs index 48f6118a..192c7ef1 100644 --- a/DiscordBot/Services/UserService.cs +++ b/DiscordBot/Services/UserService.cs @@ -55,7 +55,6 @@ public class UserService private readonly Random _rand; - public Dictionary MutedUsers { get; private set; } private readonly Color _welcomeColour = new Color(7, 84, 53); public int WaitingWelcomeMessagesCount => _welcomeNoticeUsers.Count; @@ -72,7 +71,6 @@ public UserService(DiscordSocketClient client, DatabaseService databaseService, _loggingService = loggingService; _updateService = updateService; _settings = settings; - MutedUsers = new Dictionary(); _xpCooldown = new Dictionary(); _canEditThanks = new HashSet(32); _thanksCooldown = new Dictionary(); @@ -215,7 +213,6 @@ private async void UpdateLoop() private void LoadData() { var data = _updateService.GetUserData(); - MutedUsers = data.MutedUsers ?? new Dictionary(); CodeReminderCooldown = data.CodeReminderCooldown ?? new Dictionary(); } @@ -223,7 +220,6 @@ private void SaveData() { var data = new UserData { - MutedUsers = MutedUsers, CodeReminderCooldown = CodeReminderCooldown }; _updateService.SetUserData(data); @@ -553,22 +549,6 @@ private async Task UserJoined(SocketGuildUser user) var socketTextChannel = _client.GetChannel(_settings.GeneralChannel.Id) as SocketTextChannel; await _databaseService.GetOrAddUser(user); - // Check if moderator commands are enabled, and if so we check if they were previously muted. - if (_settings.ModeratorCommandsEnabled) - { - if (MutedUsers.HasUser(user.Id)) - { - await user.AddRoleAsync(socketTextChannel?.Guild.GetRole(_settings.MutedRoleId)); - await _loggingService.LogChannelAndFile( - $"Currently muted user rejoined - {user.Mention} - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}`"); - if (socketTextChannel != null) - await socketTextChannel.SendMessageAsync( - $"{user.Mention} tried to rejoin the server to avoid their mute. Mute time increased by 72 hours."); - MutedUsers.AddCooldown(user.Id, hours: 72); - return; - } - } - await _loggingService.LogChannelAndFile( $"User Joined - {user.Mention} - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}`"); diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index 9b6be99e..7f17d551 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -19,8 +19,6 @@ public class BotSettings #region Configuration public int WelcomeMessageDelaySeconds { get; set; } = 300; - public bool ModeratorCommandsEnabled { get; set; } - public bool ModeratorNoInviteLinks { get; set; } // How long between when the bot will scold a user for trying to ping everyone. Default 6 hours public ulong EveryoneScoldPeriodSeconds { get; set; } = 21600; @@ -42,7 +40,6 @@ public class BotSettings public bool RecruitmentServiceEnabled { get; set; } = false; public bool UnityHelpBabySitterEnabled { get; set; } = false; - public bool IntroductionWatcherServiceEnabled { get; set; } = false; #endregion // Service Enabling @@ -72,8 +69,6 @@ public class BotSettings public ChannelInfo RecruitmentChannel { get; set; } - public ChannelInfo ReportedMessageChannel { get; set; } - public ChannelInfo MemeChannel { get; set; } #region Complaint Channel @@ -89,8 +84,6 @@ public class BotSettings #region User Roles - public RoleGroup UserAssignableRoles { get; set; } - public ulong MutedRoleId { get; set; } public ulong SubsReleasesRoleId { get; set; } public ulong SubsNewsRoleId { get; set; } public ulong ModeratorRoleId { get; set; } @@ -157,17 +150,6 @@ public class BotSettings } -#region Role Group Collections - -// Classes used to hold information regarding a collection of role ids with a description. -public class RoleGroup -{ - public string Desc { get; set; } - public List Roles { get; set; } -} - -#endregion - #region Channel Information // Channel Information. Description and Channel ID diff --git a/DiscordBot/Settings/Settings.example.json b/DiscordBot/Settings/Settings.example.json index db00b571..417103c3 100644 --- a/DiscordBot/Settings/Settings.example.json +++ b/DiscordBot/Settings/Settings.example.json @@ -12,24 +12,6 @@ "prefix": "!", "ModeratorRoleId": "0", "guildId": "0", // Replace with your servers guild ID - /* All assignable roles as of 29/04/21 */ - "UserAssignableRoles": { - "desc": "All normal user assignable roles available", - "roles": [ - "Audio-Engineers", - "Technical-Artists", - "Animators", - "3D-Artists", - "2D-Artists", - "XR-Developers", - "Programmers", - "Writers", - "Game-Designers", - "Generalists", - "Hobbyists", - "Students" - ] - }, /* Channel IDs for certain channels. */ "generalChannel": { // Off-topic "desc": "General-Chat Channel", @@ -51,12 +33,7 @@ "desc": "Unity News Channel", "id": "0" }, - "ReportedMessageChannel": { - "desc": "Reported Message Channel", - "id": "0" - }, /* Role Ids */ - "mutedRoleID": "0", "SubsNewsRoleId": "0", "SubsReleasesRoleId": "0", /*Complaints Channels Stuff*/ diff --git a/k8s/dev/bot-config.yaml b/k8s/dev/bot-config.yaml index e702a26a..7af6183b 100644 --- a/k8s/dev/bot-config.yaml +++ b/k8s/dev/bot-config.yaml @@ -23,24 +23,6 @@ data: "Administrator": "838030241103478805", "ModeratorRoleId": "769010537119088690", "guildId": "566084539664039938", // Replace with your servers guild ID - /* All assignable roles as of 29/04/21 */ - "UserAssignableRoles": { - "desc": "All normal user assignable roles available", - "roles": [ - "Audio-Engineers", - "Technical-Artists", - "Animators", - "3D-Artists", - "2D-Artists", - "XR-Developers", - "Programmers", - "Writers", - "Game-Designers", - "Generalists", - "Hobbyists", - "Students" - ] - }, /* Channel IDs for certain channels. */ "generalChannel": { // Off-topic "desc": "General-Chat Channel", @@ -70,12 +52,7 @@ data: "desc": "The Rules", "id": "825932695698669618" }, - "ReportedMessageChannel": { - "desc": "Reported Message Channel", - "id": "567628191221547008" - }, /* Role Ids */ - "mutedRoleID": "682432235445682194", "SubsReleasesRoleId": "769870886743703584", "TipsUserRoleId": "603187742096228374", "TipsAuthorRoleId": "603187742096228374", @@ -110,7 +87,6 @@ data: "desc": "Introduction Channel", "id": "1198575542467838044" }, - "IntroductionWatcherServiceEnabled": true, "UserModuleSlapObjectsTable": "Settings/udc-slap.txt", "UserModuleSlapChoices": [ "developer manual", diff --git a/k8s/prod/bot-config.yaml b/k8s/prod/bot-config.yaml index 5354d590..2ca82987 100644 --- a/k8s/prod/bot-config.yaml +++ b/k8s/prod/bot-config.yaml @@ -23,26 +23,7 @@ data: "prefix": "!", "Administrator": "493514411026153482", "ModeratorRoleId": "493514490504019969", - "ModeratorCommandsEnabled": false, "guildId": "493510779866316801", // Replace with your servers guild ID - /* All assignable roles as of 29/04/21 */ - "UserAssignableRoles": { - "desc": "All normal user assignable roles available", - "roles": [ - "Audio-Engineers", - "Technical-Artists", - "Animators", - "3D-Artists", - "2D-Artists", - "XR-Developers", - "Programmers", - "Writers", - "Game-Designers", - "Generalists", - "Hobbyists", - "Students" - ] - }, /* Channel IDs for certain channels. */ "generalChannel": { // Off-topic "desc": "General-Chat Channel", @@ -64,12 +45,7 @@ data: "desc": "The Rules", "id": "519890141805019137" }, - "ReportedMessageChannel": { - "desc": "Reported Message Channel", - "id": "993446104790220840" - }, /* Role Ids */ - "mutedRoleID": "493514472942600202", "SubsNewsRoleId": "1209260621342707772", "SubsReleasesRoleId": "523205962279157771", "TipsUserRoleId": "493514563736698880", @@ -117,7 +93,6 @@ data: "desc": "Introduction Channel", "id": "768488410959708210" }, - "IntroductionWatcherServiceEnabled": true, "UserModuleSlapObjectsTable": "Settings/udc-slap.txt", "UserModuleSlapChoices": [ "trout", "duck", "truck", "paddle", "magikarp", "sausage", "student loan", From 02c482c0208c5850605b1f61d288073606a15cfe Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Mon, 6 Apr 2026 23:45:36 +0200 Subject: [PATCH 15/48] chore(user): remove Christmas event completion command Co-authored-by: Copilot --- DiscordBot/Modules/UserModule.cs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs index 111d3335..59a805a3 100644 --- a/DiscordBot/Modules/UserModule.cs +++ b/DiscordBot/Modules/UserModule.cs @@ -238,25 +238,6 @@ await ReplyAsync( $"We currently have {(await Context.Guild.GetUsersAsync()).Count - 1} members. Let's keep on growing as the strong community we are :muscle:"); } - [Command("ChristmasCompleted"), HideFromHelp] - [Summary("Reward for christmas event.")] - public async Task UserCompleted(string message) - { - //Make sure they're the santa bot - if (Context.Message.Author.Id != 514979161144557600L) return; - - if (!long.TryParse(message, out var userId)) - { - await ReplyAsync("Invalid user id"); - return; - } - - const int xpGain = 5000; - var userXp = await DatabaseService.Query.GetXp(userId.ToString()); - await DatabaseService.Query.UpdateXp(userId.ToString(), userXp + xpGain); - await Context.Message.DeleteAsync(); - } - #region All Rules [Command("Rules"), Priority(1)] From 16e0ed54bf6ab60ee69056862a07a5b5de8b80cc Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 00:21:36 +0200 Subject: [PATCH 16/48] refactor: split UserService and UserModule into focused services and modules Extract 6 services from UserService: - XpService (XP/level system) - KarmaService (thanks/karma tracking) - CodeCheckService (code format reminders) - EveryoneScoldService (@everyone scold) - MikuService (easter egg) - ServerService (gateway ping) Extract 9 modules from UserModule: - QuoteModule, RulesModule, RankModule, FunModule - SearchModule, BirthdayModule, ConvertModule - CodeTipModule, ServerModule Rename UserService to WelcomeService (welcome-only). Delete UserModule after full extraction. Remove unused UserService injections from UnityHelp modules. Fix: eagerly resolve WelcomeService in Ready handler. Archive completed split plan. --- DiscordBot/Modules/BirthdayModule.cs | 93 ++ DiscordBot/Modules/CodeTipModule.cs | 35 + DiscordBot/Modules/ConvertModule.cs | 102 ++ DiscordBot/Modules/FunModule.cs | 129 ++ DiscordBot/Modules/ProfileModule.cs | 19 + DiscordBot/Modules/QuoteModule.cs | 83 ++ DiscordBot/Modules/RankModule.cs | 111 ++ DiscordBot/Modules/RulesModule.cs | 188 +++ DiscordBot/Modules/SearchModule.cs | 222 ++++ DiscordBot/Modules/ServerModule.cs | 67 + .../Modules/UnityHelp/CannedResponseModule.cs | 41 +- .../Modules/UnityHelp/GeneralHelpModule.cs | 11 +- .../Modules/UnityHelp/UnityHelpModule.cs | 3 +- DiscordBot/Modules/UserModule.cs | 1139 ----------------- DiscordBot/Modules/UserSlashModule.cs | 7 +- DiscordBot/Modules/Weather/WeatherModule.cs | 38 + DiscordBot/Program.cs | 14 +- DiscordBot/Services/AuditLogService.cs | 34 + DiscordBot/Services/CodeCheckService.cs | 137 ++ DiscordBot/Services/EveryoneScoldService.cs | 36 + DiscordBot/Services/KarmaService.cs | 113 ++ DiscordBot/Services/MikuService.cs | 55 + DiscordBot/Services/ServerService.cs | 15 + DiscordBot/Services/UserService.cs | 694 ---------- DiscordBot/Services/WelcomeService.cs | 241 ++++ DiscordBot/Services/XpService.cs | 100 ++ docs/code-quality-audit.md | 2 +- .../done/userservice-usermodule-split.md | 207 +++ 28 files changed, 2069 insertions(+), 1867 deletions(-) create mode 100644 DiscordBot/Modules/BirthdayModule.cs create mode 100644 DiscordBot/Modules/CodeTipModule.cs create mode 100644 DiscordBot/Modules/ConvertModule.cs create mode 100644 DiscordBot/Modules/FunModule.cs create mode 100644 DiscordBot/Modules/QuoteModule.cs create mode 100644 DiscordBot/Modules/RankModule.cs create mode 100644 DiscordBot/Modules/RulesModule.cs create mode 100644 DiscordBot/Modules/SearchModule.cs create mode 100644 DiscordBot/Modules/ServerModule.cs delete mode 100644 DiscordBot/Modules/UserModule.cs create mode 100644 DiscordBot/Services/CodeCheckService.cs create mode 100644 DiscordBot/Services/EveryoneScoldService.cs create mode 100644 DiscordBot/Services/KarmaService.cs create mode 100644 DiscordBot/Services/MikuService.cs create mode 100644 DiscordBot/Services/ServerService.cs delete mode 100644 DiscordBot/Services/UserService.cs create mode 100644 DiscordBot/Services/WelcomeService.cs create mode 100644 DiscordBot/Services/XpService.cs create mode 100644 docs/plans/done/userservice-usermodule-split.md diff --git a/DiscordBot/Modules/BirthdayModule.cs b/DiscordBot/Modules/BirthdayModule.cs new file mode 100644 index 00000000..b389de0a --- /dev/null +++ b/DiscordBot/Modules/BirthdayModule.cs @@ -0,0 +1,93 @@ +using System.Globalization; +using Discord.Commands; +using DiscordBot.Attributes; +using DiscordBot.Utils; + +namespace DiscordBot.Modules; + +[Group("UserModule"), Alias("")] +public class BirthdayModule : ModuleBase +{ + [Command("Birthday"), HideFromHelp] + [Summary("Display next member birthday.")] + [Alias("bday")] + public async Task Birthday() + { + const string nextBirthday = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&range=C15:C15"; + + var tableText = await WebUtil.GetHtmlNodeInnerText(nextBirthday, "/html/body/table/tr[2]/td"); + var message = $"**{tableText}**"; + + await ReplyAsync(message).DeleteAfterTime(minutes: 3); + await Context.Message.DeleteAfterTime(minutes: 3); + } + + [Command("Birthday"), Priority(27)] + [Summary("Display birthday of mentioned user. Syntax : !birthday @user")] + [Alias("bday")] + public async Task Birthday(IUser user) + { + var searchName = user.Username; + const string birthdayTable = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&gid=318080247&range=B:D"; + var relevantNodes = await WebUtil.GetHtmlNodes(birthdayTable, "/html/body/table/tr"); + + var birthdate = default(DateTime); + + HtmlAgilityPack.HtmlNode matchedNode = null; + var matchedLength = int.MaxValue; + + foreach (var row in relevantNodes) + { + var nameNode = row.SelectSingleNode("td[2]"); + var name = nameNode.InnerText; + + if (!name.ToLower().Contains(searchName.ToLower()) || name.Length >= matchedLength) + continue; + + matchedNode = row; + matchedLength = name.Length; + if (name.Length == searchName.Length) break; + } + + if (matchedNode != null) + { + var dateNode = matchedNode.SelectSingleNode("td[1]"); + var yearNode = matchedNode.SelectSingleNode("td[3]"); + + var provider = CultureInfo.InvariantCulture; + var wrongFormat = "M/d/yyyy"; + + var dateString = dateNode.InnerText; + if (!yearNode.InnerText.Contains(" ")) dateString = dateString + "/" + yearNode.InnerText; + + dateString = dateString.Trim(); + + try + { + birthdate = DateTime.ParseExact(dateString, wrongFormat, provider); + } + catch (FormatException) + { + birthdate = DateTime.ParseExact(dateString, "M/d", provider); + } + } + + if (birthdate == default) + { + await ReplyAsync( + $"Sorry, I couldn't find **{searchName}**'s birthday date. They can add it at https://docs.google.com/forms/d/e/1FAIpQLSfUglZtJ3pyMwhRk5jApYpvqT3EtKmLBXijCXYNwHY-v-lKxQ/viewform !") + .DeleteAfterSeconds(30); + } + else + { + var date = birthdate.ToUnixTimestamp(); + var message = + $"**{searchName}**'s birthdate: __**{birthdate.ToString("dd MMMM yyyy", CultureInfo.InvariantCulture)}**__ " + + $"({(int)((DateTime.Now - birthdate).TotalDays / 365)}yo)"; + + await ReplyAsync(message).DeleteAfterTime(minutes: 3); + } + + await Context.Message.DeleteAfterTime(minutes: 3); + } +} diff --git a/DiscordBot/Modules/CodeTipModule.cs b/DiscordBot/Modules/CodeTipModule.cs new file mode 100644 index 00000000..13136a4d --- /dev/null +++ b/DiscordBot/Modules/CodeTipModule.cs @@ -0,0 +1,35 @@ +using Discord.Commands; +using DiscordBot.Services; + +namespace DiscordBot.Modules; + +[Group("UserModule"), Alias("")] +public class CodeTipModule : ModuleBase +{ + public CodeCheckService CodeCheckService { get; set; } + + [Command("CodeTip"), Priority(20)] + [Summary("Show code formatting example. Syntax: !codetip userToPing(optional)")] + [Alias("codetips")] + public async Task CodeTip(IUser user = null) + { + var message = user != null ? user.Mention + ", " : ""; + message += "When posting code, format it like so:" + Environment.NewLine; + message += CodeCheckService.CodeFormattingExample; + await Context.Message.DeleteAsync(); + await ReplyAsync(message).DeleteAfterSeconds(seconds: 60); + } + + [Command("DisableCodeTips"), Priority(91)] + [Summary("Stops code formatting reminders.")] + public async Task DisableCodeTips() + { + await Context.Message.DeleteAsync(); + if (!CodeCheckService.CodeReminderCooldown.IsPermanent(Context.User.Id)) + { + CodeCheckService.CodeReminderCooldown.SetPermanent(Context.User.Id, true); + var uname = Context.User.GetUserPreferredName(); + await ReplyAsync($"{uname}, you will no longer be reminded about correct code formatting.").DeleteAfterTime(20); + } + } +} diff --git a/DiscordBot/Modules/ConvertModule.cs b/DiscordBot/Modules/ConvertModule.cs new file mode 100644 index 00000000..0b7bdf74 --- /dev/null +++ b/DiscordBot/Modules/ConvertModule.cs @@ -0,0 +1,102 @@ +using Discord.Commands; +using DiscordBot.Attributes; +using DiscordBot.Services; +using DiscordBot.Utils; + +namespace DiscordBot.Modules; + +[Group("UserModule"), Alias("")] +public class ConvertModule : ModuleBase +{ + public CurrencyService CurrencyService { get; set; } + + [Command("FtoC"), Priority(28)] + [Summary("Converts a temperature in fahrenheit to celsius. Syntax : !ftoc temperature")] + public async Task FahrenheitToCelsius(float f) + { + await ReplyAsync($"{Context.User.Mention} {f}°F is {MathUtility.FahrenheitToCelsius(f)}°C."); + } + + [Command("CtoF"), Priority(28)] + [Summary("Converts a temperature in celsius to fahrenheit. Syntax : !ftoc temperature")] + public async Task CelsiusToFahrenheit(float c) + { + await ReplyAsync($"{Context.User.Mention} {c}°C is {MathUtility.CelsiusToFahrenheit(c)}°F"); + } + + [Command("Translate"), HideFromHelp] + [Summary("Translate a message. Syntax : !translate messageId language")] + public async Task Translate(ulong messageId, string language = "en") + { + await Translate((await Context.Channel.GetMessageAsync(messageId)).Content, language); + } + + [Command("Translate"), HideFromHelp] + [Summary("Translate a message. Syntax : !translate text language")] + public async Task Translate(string text, string language = "en") + { + var msg = await ReplyAsync($"Here: "); + await Context.Message.DeleteAfterSeconds(seconds: 1); + await msg.DeleteAfterSeconds(seconds: 20); + } + + [Command("CurrencyName"), Priority(29)] + [Summary("Get the name of a currency. Syntax : !currname USD")] + [Alias("currname")] + public async Task CurrencyName(string currency) + { + if (Context.HasAnyPingableMention()) + return; + var name = await CurrencyService.GetCurrencyName(currency); + if (name == string.Empty) + { + await Context.Message.ReplyAsync($"Sorry, I couldn't find the name of the currency **{currency}**."); + return; + } + await Context.Message.ReplyAsync($"The name of the currency **{currency.ToUpper()}** is **{name}**."); + } + + [Command("Currency"), HideFromHelp] + [Summary("Converts a currency. Syntax : !currency fromCurrency toCurrency")] + [Alias("curr")] + public async Task ConvertCurrency(string from, string to = "usd") + { + await ConvertCurrency(1, from, to); + } + + [Command("Currency"), Priority(29)] + [Summary("Converts a currency. Syntax : !currency amount fromCurrency toCurrency")] + [Alias("curr")] + public async Task ConvertCurrency(double amount, string from, string to = "usd") + { + if (Context.HasAnyPingableMention()) + { + if (!Context.IsReply()) + return; + if (!Context.IsOnlyReplyingToAuthor()) + return; + } + + from = from.ToLower(); + to = to.ToLower(); + + bool fromValid = await CurrencyService.IsCurrency(from.ToLower()); + bool toValid = await CurrencyService.IsCurrency(to.ToLower()); + + if (!fromValid || !toValid) + { + await Context.Message.ReplyAsync("One of the currencies provided is invalid."); + return; + } + + var response = await CurrencyService.GetConversion(to, from); + if (Math.Abs(response - (-1)) < 0.01) + { + await Context.Message.ReplyAsync("An error occured while converting the currency, the API may be down!"); + return; + } + + var totalAmount = Math.Round(amount * response, 2); + await Context.Message.ReplyAsync($"**{amount} {from.ToUpper()}** = **{totalAmount} {to.ToUpper()}**"); + } +} diff --git a/DiscordBot/Modules/FunModule.cs b/DiscordBot/Modules/FunModule.cs new file mode 100644 index 00000000..c3ada19b --- /dev/null +++ b/DiscordBot/Modules/FunModule.cs @@ -0,0 +1,129 @@ +using System.Text; +using Discord.Commands; +using DiscordBot.Services; +using DiscordBot.Settings; +using DiscordBot.Data; + +namespace DiscordBot.Modules; + +[Group("UserModule"), Alias("")] +public class FunModule : ModuleBase +{ + public ILoggingService LoggingService { get; set; } + public BotSettings Settings { get; set; } + + private readonly Random _random = new(); + private FuzzTable _slapObjects = new(); + private FuzzTable _slapFails = new(); + + [Command("Slap"), Priority(21)] + [Summary("Slap the specified user(s). Syntax : !slap @user1 [@user2 @user3...]")] + public async Task SlapUser(params IUser[] users) + { + try + { + if (_slapObjects.Count == 0) + _slapObjects.Load(Settings.UserModuleSlapObjectsTable); + } + catch (Exception e) + { + await LoggingService.LogChannelAndFile($"Error while loading '{Settings.UserModuleSlapObjectsTable}'.\nEx:{e}", + ExtendedLogSeverity.LowWarning); + return; + } + if (_slapObjects.Count == 0) + _slapObjects.Add(Settings.UserModuleSlapChoices); + if (_slapObjects.Count == 0) + _slapObjects.Add("fish|mallet"); + + if (_slapFails.Count == 0) + _slapFails.Add(Settings.UserModuleSlapFails); + if (_slapFails.Count == 0) + _slapFails.Add("hurting themselves"); + + var uname = Context.User.GetUserPreferredName(); + + if (users == null || users.Length == 0) + { + await Context.Channel.SendMessageAsync( + $"**{uname}** slaps away an invisible pest."); + await Context.Message.DeleteAfterSeconds(seconds: 1); + return; + } + + var sb = new StringBuilder(); + var mentions = users.ToMentionArray().ToCommaList(); + + bool fail = (_random.Next(1, 100) < 5); + if (fail) + { + sb.Append($"**{uname}** tries to slap {mentions} "); + sb.Append("around a bit with a large "); + sb.Append(_slapObjects.Pick(true)); + sb.Append(", but misses and ends up "); + sb.Append(_slapFails.Pick(true)); + sb.Append("."); + } + else + { + sb.Append($"**{uname}** slaps {mentions} "); + sb.Append("around a bit with a large "); + sb.Append(_slapObjects.Pick(true)); + sb.Append("."); + } + + await Context.Channel.SendMessageAsync(sb.ToString()); + await Context.Message.DeleteAfterSeconds(seconds: 1); + } + + [Command("CoinFlip"), Priority(22)] + [Summary("Flip a coin and see the result.")] + [Alias("flipcoin")] + public async Task CoinFlip() + { + var coin = new[] { "Heads", "Tails" }; + + var uname = Context.User.GetUserPreferredName(); + await ReplyAsync($"**{uname}** flipped a coin and got **{coin[_random.Next() % 2]}**!"); + await Context.Message.DeleteAfterSeconds(seconds: 1); + } + + [Command("Roll"), Priority(23)] + [Summary("Roll a dice. Syntax: !roll [sides]")] + public async Task RollDice(int sides = 20) + { + await RollDice(sides, 0); + } + + [Command("Roll"), Priority(23)] + [Summary("Roll a dice. Syntax: !roll [sides] [minimum]")] + public async Task RollDice(int sides, int number) + { + if (sides < 1 || sides > 1000) + { + await ReplyAsync("Invalid number of sides. Please choose a number between 1 and 1000.").DeleteAfterSeconds(seconds: 10); + await Context.Message.DeleteAsync(); + return; + } + + var uname = Context.User.GetUserPreferredName(); + var roll = _random.Next(1, sides + 1); + var message = $"**{uname}** rolled a D{sides} and got **{roll}**!"; + if (number < 1) + message = " :game_die: " + message; + else if (roll >= number) + message = " :white_check_mark: " + message + " [Needed: " + number + "]"; + else + message = " :x: " + message + " [Needed: " + number + "]"; + + await ReplyAsync(message); + await Context.Message.DeleteAfterSeconds(seconds: 1); + } + + [Command("D20"), Priority(23)] + [Summary("Roll a D20 dice. Syntax: !d20 [minimum]")] + public async Task RollD20(int number = 0) + { + await RollDice(20, number); + } +} diff --git a/DiscordBot/Modules/ProfileModule.cs b/DiscordBot/Modules/ProfileModule.cs index afca94b0..555aa025 100644 --- a/DiscordBot/Modules/ProfileModule.cs +++ b/DiscordBot/Modules/ProfileModule.cs @@ -9,6 +9,25 @@ public class ProfileModule : ModuleBase public ProfileCardService ProfileCardService { get; set; } public ILoggingService LoggingService { get; set; } + [Command("Karma"), Priority(95)] + [Summary("Description of what Karma is.")] + public async Task KarmaDescription(int seconds = 60) + { + var uname = Context.User.GetUserPreferredName(); + await ReplyAsync($"{uname}, Karma is tracked on your !profile which helps indicate how much you've helped others and provides a small increase in EXP gain."); + await Context.Message.DeleteAfterSeconds(seconds: seconds); + } + + [Command("JoinDate"), Priority(91)] + [Summary("Display date you joined the server.")] + public async Task JoinDate() + { + var userId = Context.User.Id; + var joinDate = ((IGuildUser)Context.User).JoinedAt; + await ReplyAsync($"{Context.User.Mention} you joined **{joinDate:dddd dd/MM/yyy HH:mm:ss}**"); + await Context.Message.DeleteAsync(); + } + [Command("Profile"), Priority(2)] [Summary("Display your profile card.")] public async Task DisplayProfile() diff --git a/DiscordBot/Modules/QuoteModule.cs b/DiscordBot/Modules/QuoteModule.cs new file mode 100644 index 00000000..d5fdd340 --- /dev/null +++ b/DiscordBot/Modules/QuoteModule.cs @@ -0,0 +1,83 @@ +using System.Text.RegularExpressions; +using Discord.Commands; +using DiscordBot.Attributes; + +namespace DiscordBot.Modules; + +[Group("UserModule"), Alias("")] +public class QuoteModule : ModuleBase +{ + [Command("Quote"), HideFromHelp] + public async Task QuoteMessageCommand(IMessageChannel channel, ulong messageId) + { + await QuoteMessage(messageId: messageId, channel: channel); + } + + [Command("Quote"), Priority(10)] + [Summary("Quote a message. Syntax : !quote messageid (#channel)")] + public async Task QuoteMessageCommand(ulong messageId, ulong channel) + { + IMessageChannel targetChannel = (IMessageChannel)await Context.Client.GetChannelAsync(channel) ?? (IMessageChannel)await Context.Client.GetChannelAsync(messageId); + if (targetChannel == null) + { + await ReplyAsync("Channel or MessageID does not exist").DeleteAfterSeconds(seconds: 5); + return; + } + + if (targetChannel.Id == channel) + await QuoteMessage(messageId, targetChannel); + else + await QuoteMessage(channel, targetChannel); + } + + [Command("Quote"), HideFromHelp] + [Summary("Quote a message. Syntax : !quote messageid (#channel)")] + public async Task QuoteMessage(ulong messageId, IMessageChannel channel = null) + { + channel ??= Context.Channel; + var message = await channel.GetMessageAsync(messageId); + if (message == null) + { + await Context.Message.DeleteAfterSeconds(seconds: 1); + await ReplyAsync("No message with that id found.").DeleteAfterSeconds(seconds: 4); + return; + } + if (message.Author.IsBot) + { + await Context.Message.DeleteAfterSeconds(seconds: 2); + return; + } + + var messageLink = "https://discordapp.com/channels/" + Context.Guild.Id + "/" + channel.Id + "/" + messageId; + + var msgContent = message.Content; + + if (msgContent != null) + { + msgContent = msgContent.Truncate(1020); + + var regex = new Regex(@"\[([^\[\]\(\)]*)\]\((.*?)\)"); + var matches = regex.Matches(msgContent); + + foreach (var match in matches as IEnumerable) + { + msgContent = msgContent.Replace(match.Value, $"\\{match.Value}"); + } + } + + var msgAttachment = string.Empty; + if (message.Attachments?.Count > 0) msgAttachment = "\t📸"; + var builder = new EmbedBuilder() + .WithColor(new Color(200, 128, 128)) + .WithTimestamp(message.Timestamp) + .FooterQuoteBy(Context.User, message.Channel) + .AddAuthor(message.Author); + if (msgContent == string.Empty && msgAttachment != string.Empty) msgContent = "📸"; + + msgContent += $"\n\n***[Linkback]({messageLink})***"; + builder.Description = msgContent; + + await ReplyAsync(embed: builder.Build()); + await Context.Message.DeleteAfterSeconds(1.0); + } +} diff --git a/DiscordBot/Modules/RankModule.cs b/DiscordBot/Modules/RankModule.cs new file mode 100644 index 00000000..4905f9e8 --- /dev/null +++ b/DiscordBot/Modules/RankModule.cs @@ -0,0 +1,111 @@ +using Discord.Commands; +using DiscordBot.Services; + +namespace DiscordBot.Modules; + +[Group("UserModule"), Alias("")] +public class RankModule : ModuleBase +{ + public DatabaseService DatabaseService { get; set; } + public ILoggingService LoggingService { get; set; } + + [Command("Top"), Priority(6)] + [Summary("Display top 10 users by level.")] + [Alias("toplevel", "ranking")] + public async Task TopLevel() + { + var users = await DatabaseService.Query.GetTopLevel(10); + var userList = users.Select(user => (ulong.Parse(user.UserID), user.Level)).ToList(); + + var embed = await GenerateRankEmbedFromList(userList, "Level"); + await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); + } + + [Command("TopKarma"), Priority(5)] + [Summary("Display top 10 users by karma.")] + [Alias("karmarank", "rankingkarma", "topk")] + public async Task TopKarma() + { + var users = await DatabaseService.Query.GetTopKarma(10); + var userList = users.Select(user => (ulong.Parse(user.UserID), user.Karma)).ToList(); + + var embed = await GenerateRankEmbedFromList(userList, "Karma"); + await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); + } + + [Command("TopKarmaWeekly"), Priority(5)] + [Summary("Display weekly top 10 users by karma.")] + [Alias("karmarankweekly", "rankingkarmaweekly", "topkw")] + public async Task TopKarmaWeekly() + { + var users = await DatabaseService.Query.GetTopKarmaWeekly(10); + var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaWeekly)).ToList(); + + var embed = await GenerateRankEmbedFromList(userList, "Weekly Karma"); + await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); + } + + [Command("TopKarmaMonthly"), Priority(5)] + [Summary("Display monthly top 10 users by karma.")] + [Alias("karmarankmonthly", "rankingkarmamonthly", "topkm")] + public async Task TopKarmaMonthly() + { + var users = await DatabaseService.Query.GetTopKarmaMonthly(10); + var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaMonthly)).ToList(); + + var embed = await GenerateRankEmbedFromList(userList, "Monthly Karma"); + await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); + } + + [Command("TopKarmaYearly"), Priority(5)] + [Summary("Display tearly top 10 users by karma.")] + [Alias("karmaranktearly", "rankingkarmayearly", "topky")] + public async Task TopKarmaYearly() + { + var users = await DatabaseService.Query.GetTopKarmaYearly(10); + var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaYearly)).ToList(); + + var embed = await GenerateRankEmbedFromList(userList, "Yearly Karma"); + await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); + } + + private async Task GenerateRankEmbedFromList(List<(ulong userID, int value)> data, string labelName) + { + var embedBuilder = new EmbedBuilder + { + Title = $"Top 10 Users by {labelName}", + Footer = new EmbedFooterBuilder + { + Text = $"The best of the best, by {labelName}." + } + }; + + try + { + var maxUsernameLength = data + .Select(async x => await Context.Guild.GetUserAsync(x.userID)) + .Select(x => x.Result) + .Max(x => (x?.Username ?? "Unknown User").Length); + + var str = ""; + for (var i = 0; i < data.Count; i++) + { + var user = await Context.Guild.GetUserAsync(data[i].userID); + var username = user?.Username ?? "Unknown User"; + int rankPadding = (int)Math.Floor(Math.Log10(data.Count)); + + str += + $"`{(i + 1).ToString().PadLeft(rankPadding + 1)}.` **`{username.PadRight(maxUsernameLength, '\u2000')}`** `{labelName}: {data[i].value}`\n"; + } + + embedBuilder.Description = str; + } + catch (Exception e) + { + await LoggingService.LogChannelAndFile($"Failed to generate top 10 embed.\n{e}", ExtendedLogSeverity.LowWarning); + embedBuilder.Description = "Failed to generate top 10 embed."; + } + + return embedBuilder.Build(); + } +} diff --git a/DiscordBot/Modules/RulesModule.cs b/DiscordBot/Modules/RulesModule.cs new file mode 100644 index 00000000..808dcbeb --- /dev/null +++ b/DiscordBot/Modules/RulesModule.cs @@ -0,0 +1,188 @@ +using System.Text; +using Discord.Commands; +using Discord.WebSocket; +using DiscordBot.Services; +using DiscordBot.Settings; +using DiscordBot.Attributes; + +namespace DiscordBot.Modules; + +[Group("UserModule"), Alias("")] +public class RulesModule : ModuleBase +{ + public WelcomeService WelcomeService { get; set; } + public UpdateService UpdateService { get; set; } + public Rules Rules { get; set; } + + [Command("Rules"), Priority(1)] + [Summary("Rules of current channel by DM.")] + public async Task RulesCommand() + { + await RulesCommand(Context.Channel); + await Context.Message.DeleteAsync(); + } + + [Command("Rules"), Priority(99)] + [Summary("Rules of the mentioned channel by DM. !rules #channel")] + [Alias("rule")] + public async Task RulesCommand(IMessageChannel channel) + { + var rule = Rules.Channel.First(x => x.Id == channel.Id); + var dm = await Context.User.CreateDMChannelAsync(); + bool sentMessage = false; + + sentMessage = await dm.TrySendMessage($"{rule.Header}{(rule.Content.Length > 0 ? rule.Content : $"There is no special rule for {channel.Name} channel.\nPlease follow global rules (you can get them by typing `!globalrules`)")}"); + if (!sentMessage) + await ReplyAsync("Could not send rules, your DMs are disabled.").DeleteAfterSeconds(seconds: 10); + } + + [Command("GlobalRules"), Priority(99)] + [Summary("Global Rules by DM.")] + public async Task GlobalRules(int seconds = 60) + { + var globalRules = Rules.Channel.First(x => x.Id == 0).Content; + var dm = await Context.User.CreateDMChannelAsync(); + await Context.Message.DeleteAsync(); + if (!await dm.TrySendMessage(globalRules)) + { + await ReplyAsync("Could not send rules, your DMs are disabled.").DeleteAfterSeconds(seconds: 10); + } + } + + [Command("Welcome"), Priority(1)] + [Summary("Condensed version of the rules and links to quality resources.")] + public async Task ServerWelcome() + { + if (!await WelcomeService.DMFormattedWelcome(Context.User as SocketGuildUser)) + { + await ReplyAsync("Could not send welcome, your DMs are disabled.").DeleteAfterSeconds(seconds: 2); + } + await Context.Message.DeleteAfterSeconds(seconds: 4); + } + + [Command("Channels"), Priority(92)] + [Summary("Description of the channels by DM.")] + public async Task ChannelsDescription() + { + var channelData = Rules.Channel; + var sb = new StringBuilder(); + foreach (var c in channelData) + sb.Append((await Context.Guild.GetTextChannelAsync(c.Id))?.Mention).Append(" - ").Append(c.Header).Append("\n"); + + var dm = await Context.User.CreateDMChannelAsync(); + + var messages = sb.ToString().MessageSplitToSize(); + await Context.Message.DeleteAsync(); + foreach (var message in messages) + { + if (!await dm.TrySendMessage(message)) + { + await ReplyAsync("Could not send channel descriptions, your DMs are disabled.").DeleteAfterSeconds(seconds: 10); + break; + } + } + } + + [Command("FAQ")] + [Summary("Searches UDC FAQs. Syntax : !faq \"query\"")] + public async Task SearchFaqs(params string[] queries) + { + var faqDataList = UpdateService.GetFaqData(); + + if (queries.Length == 1 && ParseNumber(queries[0]) > 0) + { + var id = ParseNumber(queries[0]) - 1; + if (id < faqDataList.Count) + await ReplyAsync(embed: GetFaqEmbed(faqDataList[id])); + else + await ReplyAsync("Invalid FAQ ID selected."); + } + else if (queries.Length > 0 && !(queries.Length == 1 && queries[0].Equals("list"))) + { + var minimumScore = double.MaxValue; + FaqData mostSimilarFaq = null; + var query = string.Join(" ", queries); + + foreach (var faq in faqDataList) + { + foreach (var keyword in faq.Keywords) + { + var curScore = CalculateScore(keyword, query); + if (curScore < minimumScore) + { + minimumScore = curScore; + mostSimilarFaq = faq; + } + } + } + + if (mostSimilarFaq != null) + await ReplyAsync(embed: GetFaqEmbed(mostSimilarFaq)); + else + await ReplyAsync("No FAQs Found."); + } + else + await ListFaqs(faqDataList); + } + + private async Task ListFaqs(List faqs) + { + var sb = new StringBuilder(faqs.Count); + var index = 1; + var keywordSb = new StringBuilder(); + foreach (var faq in faqs) + { + sb.Append(FormatFaq(index, faq) + "\n"); + keywordSb.Append("["); + for (var i = 0; i < faq.Keywords.Length; i++) + { + keywordSb.Append(faq.Keywords[i]); + keywordSb.Append(i < faq.Keywords.Length - 1 ? ", " : "]\n\n"); + } + + index++; + sb.Append(keywordSb); + keywordSb.Clear(); + } + + await ReplyAsync(sb.ToString()).DeleteAfterTime(minutes: 3); + } + + private Embed GetFaqEmbed(FaqData faq) + { + var builder = new EmbedBuilder() + .WithTitle($"{faq.Question}") + .WithDescription($"{faq.Answer}") + .WithColor(new Color(0x33CC00)); + return builder.Build(); + } + + private string FormatFaq(int id, FaqData faq) => $"{id}. **{faq.Question}** - {faq.Answer}"; + + private double CalculateScore(string s1, string s2) + { + double curScore = 0; + var i = 0; + + foreach (var q in s1.Split(' ')) + { + foreach (var x in s2.Split(' ')) + { + i++; + if (x.Equals(q)) + curScore -= 50; + else + curScore += x.CalculateLevenshteinDistance(q); + } + } + + curScore /= i; + return curScore; + } + + private int ParseNumber(string s) + { + if (int.TryParse(s, out int id)) return id; + return -1; + } +} diff --git a/DiscordBot/Modules/SearchModule.cs b/DiscordBot/Modules/SearchModule.cs new file mode 100644 index 00000000..fbbb6cc4 --- /dev/null +++ b/DiscordBot/Modules/SearchModule.cs @@ -0,0 +1,222 @@ +using System.Net; +using System.Text; +using Discord.Commands; +using DiscordBot.Services; +using DiscordBot.Settings; +using DiscordBot.Attributes; +using HtmlAgilityPack; + +namespace DiscordBot.Modules; + +[Group("UserModule"), Alias("")] +public class SearchModule : ModuleBase +{ + public ILoggingService LoggingService { get; set; } + public BotSettings Settings { get; set; } + public UpdateService UpdateService { get; set; } + + [Command("Search"), Priority(25)] + [Summary("Searches DuckDuckGo for results. Syntax: !search c# lambda help")] + [Alias("s", "ddg")] + public async Task SearchResults(params string[] messages) + { + StringBuilder sb = new(); + foreach (var msg in messages) + sb.Append(msg).Append(" "); + await SearchResults(sb.ToString()); + } + + [Command("Search"), HideFromHelp] + [Summary("Searches DuckDuckGo for web results. Syntax : !search \"query\" resNum site")] + [Alias("s", "ddg")] + public async Task SearchResults(string query, uint resNum = 3, string site = "") + { + resNum = resNum <= 5 ? resNum : 5; + var searchQuery = "https://duckduckgo.com/html/?q=" + query.Replace(' ', '+'); + + if (site != string.Empty) searchQuery += "+site:" + site; + + var doc = new HtmlWeb().Load(searchQuery); + var counter = 1; + + EmbedBuilder embedBuilder = new(); + embedBuilder.Title = $"Q: {WebUtility.UrlDecode(query)}"; + string resultTitle = string.Empty; + + foreach (var row in doc.DocumentNode.SelectNodes("/html/body/div[1]/div[3]/div/div/div[*]/div/h2/a")) + { + if (counter > resNum) break; + + row.Attributes["href"].Value = row.Attributes["href"].Value.Replace("//duckduckgo.com/l/?uddg=", string.Empty); + + if (counter <= resNum && IsValidResult(row)) + { + var url = WebUtility.UrlDecode(row.Attributes["href"].Value); + + int andCount = url.Count(c => c == '&'); + url = url.Substring(0, url.LastIndexOf('&')); + + resultTitle += $"{counter}. {(row.InnerText.Length > 60 ? $"{row.InnerText[..60]}.." : row.InnerText)}" + $" [__Read More..__{(andCount > 1 ? "~" : string.Empty)}]({url})\n"; + + counter++; + } + } + + embedBuilder.AddField("Search Query", searchQuery); + embedBuilder.AddField("Results", resultTitle, inline: false); + + embedBuilder.Color = new Color(81, 50, 169); + embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from DuckDuckGo."); + + var embed = embedBuilder.Build(); + await ReplyAsync(embed: embed); + } + + bool IsValidResult(HtmlNode node) + { + return (!node.Attributes["href"].Value.Contains("duckduckgo.com") && + !node.Attributes["href"].Value.Contains("duck.co")); + } + + [Command("Manual"), Priority(8)] + [Summary("Searches Unity3D manual for results. Syntax : !manual \"query\"")] + public async Task SearchManual(params string[] queries) + { + var minimumScore = double.MaxValue; + string[] mostSimilarPage = null; + var pages = await UpdateService.GetManualDatabase(); + var query = string.Join(" ", queries); + foreach (var p in pages) + { + var curScore = CalculateScore(p[1], query); + if (!(curScore < minimumScore)) continue; + + minimumScore = curScore; + mostSimilarPage = p; + } + + if (mostSimilarPage != null) + { + EmbedBuilder embedBuilder = new(); + embedBuilder.Title = $"Found {mostSimilarPage[0]}"; + embedBuilder.Description = $"**{mostSimilarPage[1]}** - [Read More..](https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html)"; + embedBuilder.Color = new Color(81, 50, 169); + embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs."); + var message = await ReplyAsync(embed: embedBuilder.Build()); + + var doc = new HtmlWeb().Load($"https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html"); + var descriptionNode = doc.DocumentNode.SelectSingleNode("//h1"); + if (descriptionNode == null) return; + descriptionNode = descriptionNode.SelectSingleNode("following-sibling::p"); + descriptionNode.Descendants().Where(n => n.GetAttributeValue("class", "").Contains("tooltip")).ToList().ForEach(n => n.Remove()); + var description = descriptionNode.InnerText; + + embedBuilder.WithDescription($"**Description:** {(description.Length > 500 ? $"{description[..500]}.." : description)}\n" + $"[Read More..](https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html)"); + await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); + } + else + await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10); + } + + [Command("Doc"), Priority(9)] + [Summary("Searches Unity3D API for results. Syntax : !api \"query\"")] + [Alias("ref", "reference", "api", "docs")] + public async Task SearchApi(params string[] queries) + { + var minimumScore = double.MaxValue; + string[] mostSimilarPage = null; + var pages = await UpdateService.GetApiDatabase(); + var query = string.Join(" ", queries); + foreach (var p in pages) + { + var curScore = CalculateScore(p[1], query); + if (!(curScore < minimumScore)) continue; + + minimumScore = curScore; + mostSimilarPage = p; + } + + if (mostSimilarPage != null) + { + EmbedBuilder embedBuilder = new(); + embedBuilder.Title = $"Found {mostSimilarPage[0]}"; + embedBuilder.Description = $"**{mostSimilarPage[1]}** - [Read More..](https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html)"; + embedBuilder.Color = new Color(81, 50, 169); + embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs."); + var message = await ReplyAsync(embed: embedBuilder.Build()); + + var doc = new HtmlWeb().Load($"https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html"); + var descriptionNode = doc.DocumentNode.SelectSingleNode("//h3[contains(text(), 'Description')]"); + + string descriptionString = ""; + string manualLinkString = ""; + if (descriptionNode != null) + { + var description = descriptionNode.SelectSingleNode("following-sibling::p").InnerText; + descriptionString = + $"**Description:** {(description.Length > 500 ? $"{description[..500]}.." : description)}\n" + + $"[Read More..](https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html)"; + } + + var manualLink = doc.DocumentNode.SelectSingleNode("//a[contains(@class, 'switch-link')]"); + if (manualLink != null && manualLink.Attributes.Contains("title")) + { + var manualLinkText = manualLink.GetAttributes("title").First().Value; + var manualLinkUrl = "https://docs.unity3d.com/" + manualLink.GetAttributeValue("href", ""); + manualLinkString = $"\n**Manual:** [{manualLinkText}]({manualLinkUrl})"; + } + + embedBuilder.WithDescription(descriptionString + manualLinkString); + await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); + } + else + await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10); + } + + [Command("Wiki"), Priority(26)] + [Summary("Searches Wikipedia. Syntax : !wiki \"query\"")] + [Alias("wikipedia")] + public async Task SearchWikipedia([Remainder] string query) + { + var article = await UpdateService.DownloadWikipediaArticle(query); + + if (article.url == null) + { + await ReplyAsync($"No Articles for \"{query}\" were found."); + return; + } + + await ReplyAsync(embed: GetWikipediaEmbed(article.name, article.extract, article.url)); + } + + private Embed GetWikipediaEmbed(string subject, string articleExtract, string articleUrl) + { + var builder = new EmbedBuilder() + .WithTitle($"Wikipedia | {subject}") + .WithDescription($"{articleExtract}") + .WithUrl(articleUrl) + .WithColor(new Color(0x33CC00)); + return builder.Build(); + } + + private double CalculateScore(string s1, string s2) + { + double curScore = 0; + var i = 0; + + foreach (var q in s1.Split(' ')) + { + foreach (var x in s2.Split(' ')) + { + i++; + if (x.Equals(q)) + curScore -= 50; + else + curScore += x.CalculateLevenshteinDistance(q); + } + } + + curScore /= i; + return curScore; + } +} diff --git a/DiscordBot/Modules/ServerModule.cs b/DiscordBot/Modules/ServerModule.cs new file mode 100644 index 00000000..b62862e7 --- /dev/null +++ b/DiscordBot/Modules/ServerModule.cs @@ -0,0 +1,67 @@ +using Discord.Commands; +using DiscordBot.Attributes; +using DiscordBot.Services; +using DiscordBot.Settings; + +namespace DiscordBot.Modules; + +[Group("UserModule"), Alias("")] +public class ServerModule : ModuleBase +{ + public CommandHandlingService CommandHandlingService { get; set; } + public ServerService ServerService { get; set; } + public BotSettings Settings { get; set; } + + [Command("Help"), Priority(100)] + [Summary("Does what you see now.")] + [Alias("command", "commands")] + public async Task DisplayHelp() + { + var commandMessages = CommandHandlingService.GetCommandListMessages("UserModule", false, true, false); + if (Context.Channel.Id != Settings.BotCommandsChannel.Id) + { + try + { + foreach (var message in commandMessages) + { + await Context.User.SendMessageAsync(message); + } + } + catch (Exception) + { + await ReplyAsync($"Your direct messages are disabled, please use <#{Settings.BotCommandsChannel.Id}> instead!").DeleteAfterSeconds(10); + } + } + else + { + foreach (var message in commandMessages) + { + await ReplyAsync(message); + } + } + await Context.Message.DeleteAsync(); + } + + [Command("Ping"), Priority(98)] + [Summary("Bot latency.")] + [Alias("pong")] + public async Task Ping() + { + var message = await ReplyAsync("Pong"); + var time = message.CreatedAt.Subtract(Context.Message.Timestamp); + await message.ModifyAsync(m => + m.Content = $"Pong (**{time.TotalMilliseconds}** *ms* / gateway **{ServerService.GetGatewayPing()}** *ms*)"); + await message.DeleteAfterTime(seconds: 10); + + await Context.Message.DeleteAfterTime(seconds: 5); + } + + [Command("Members"), Priority(90)] + [Summary("Current member count.")] + [Alias("MemberCount")] + public async Task MemberCount() + { + await ReplyAsync( + $"We currently have {(await Context.Guild.GetUsersAsync()).Count - 1} members. Let's keep on growing as the strong community we are :muscle:"); + } +} diff --git a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs b/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs index 6584e64d..6fc83d65 100644 --- a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs +++ b/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs @@ -10,123 +10,122 @@ namespace DiscordBot.Modules; public class CannedResponseModule : ModuleBase { #region Dependency Injection - - public UserService UserService { get; set; } + public BotSettings BotSettings { get; set; } public CannedResponseService CannedResponseService { get; set; } - + #endregion // Dependency Injection - + // The core command for the canned response module public async Task RespondWithCannedResponse(CannedResponseType type) { if (Context.User.IsUserBotOrWebhook()) return; - + var embed = CannedResponseService.GetCannedResponse(type, Context.User); await Context.Message.DeleteAsync(); - + await ReplyAsync(string.Empty, false, embed.Build()); } - + [Command("ask"), Alias("dontasktoask", "nohello")] [Summary("When someone asks to ask a question, respond with a link to the 'How to Ask' page.")] public async Task RespondWithHowToAsk() { await RespondWithCannedResponse(CannedResponseType.HowToAsk); } - + [Command("paste")] [Summary("When someone asks how to paste code, respond with a link to the 'How to Paste Code' page.")] public async Task RespondWithHowToPaste() { await RespondWithCannedResponse(CannedResponseType.Paste); } - + [Command("nocode")] [Summary("When someone asks for help with code, but doesn't provide any, respond with a link to the 'No Code Provided' page.")] public async Task RespondWithNoCode() { await RespondWithCannedResponse(CannedResponseType.NoCode); } - + [Command("xy")] [Summary("When someone is asking about their attempted solution rather than their actual problem, respond with a link to the 'XY Problem' page.")] public async Task RespondWithXYProblem() { await RespondWithCannedResponse(CannedResponseType.XYProblem); } - + [Command("biggame"), Alias("scope", "bigscope", "scopecreep")] [Summary("When someone is asking for help with a large project, respond with a link to the 'Game Too Big' page.")] public async Task RespondWithGameToBig() { await RespondWithCannedResponse(CannedResponseType.GameTooBig); } - + [Command("google"), Alias("search", "howtosearch")] [Summary("When someone asks a question that could have been answered by a quick search, respond with a link to the 'How to Google' page.")] public async Task RespondWithHowToGoogle() { await RespondWithCannedResponse(CannedResponseType.HowToGoogle); } - + [Command("debug")] [Summary("When someone asks for help debugging, respond with a link to the 'How to Debug' page.")] public async Task RespondWithHowToDebug() { await RespondWithCannedResponse(CannedResponseType.Debugging); } - + [Command("folder"), Alias("directory", "structure")] [Summary("When someone asks about folder structure, respond with a link to the 'Folder Structure' page.")] public async Task RespondWithFolderStructure() { await RespondWithCannedResponse(CannedResponseType.FolderStructure); } - + [Command("programming")] [Summary("When someone asks for programming resources, respond with a link to the 'Programming Resources' page.")] public async Task RespondWithProgrammingResources() { await RespondWithCannedResponse(CannedResponseType.Programming); } - + [Command("art")] [Summary("When someone asks for art resources, respond with a link to the 'Art Resources' page.")] public async Task RespondWithArtResources() { await RespondWithCannedResponse(CannedResponseType.Art); } - + [Command("3d"), Alias("3dmodeling", "3dassets")] [Summary("When someone asks for 3D modeling resources, respond with a link to the '3D Modeling Resources' page.")] public async Task RespondWith3DModelingResources() { await RespondWithCannedResponse(CannedResponseType.ThreeD); } - + [Command("2d"), Alias("2dmodeling", "2dassets")] [Summary("When someone asks for 2D modeling resources, respond with a link to the '2D Modeling Resources' page.")] public async Task RespondWith2DModelingResources() { await RespondWithCannedResponse(CannedResponseType.TwoD); } - + [Command("audio"), Alias("sound", "music")] [Summary("When someone asks for audio resources, respond with a link to the 'Audio Resources' page.")] public async Task RespondWithAudioResources() { await RespondWithCannedResponse(CannedResponseType.Audio); } - + [Command("design"), Alias("ui", "ux")] [Summary("When someone asks for design resources, respond with a link to the 'Design Resources' page.")] public async Task RespondWithDesignResources() { await RespondWithCannedResponse(CannedResponseType.Design); } - + [Command("delta"), Alias("deltatime", "fixedupdate")] [Summary("When someone asks about delta time, respond with a link to the 'Delta Time' page.")] public async Task RespondWithDeltaTime() diff --git a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs b/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs index d9da9385..60c74636 100644 --- a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs +++ b/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs @@ -9,19 +9,18 @@ namespace DiscordBot.Modules; public class GeneralHelpModule : ModuleBase { #region Dependency Injection - - public UserService UserService { get; set; } + public BotSettings BotSettings { get; set; } #endregion // Dependency Injection - + [Command("error")] [Summary("Uses a C# error code, or Unity error code and returns a link to appropriate documentation.")] public async Task RespondWithErrorDocumentation(string error) { if (Context.User.IsUserBotOrWebhook()) return; - + // If we're dealing with C# error if (error.StartsWith("CS")) { @@ -31,10 +30,10 @@ public async Task RespondWithErrorDocumentation(string error) "https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/", "https://docs.microsoft.com/en-us/dotnet/csharp/misc/" }; - + HtmlDocument errorPage = null; string usedUrl = string.Empty; - + foreach (var url in urls) { errorPage = await WebUtil.GetHtmlDocument($"{url}{error}"); diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs b/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs index 8e64f22a..a0522051 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs +++ b/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs @@ -11,7 +11,6 @@ public class UnityHelpModule : ModuleBase #region Dependency Injection public UnityHelpService HelpService { get; set; } - public UserService UserService { get; set; } public BotSettings BotSettings { get; set; } #endregion // Dependency Injection @@ -26,7 +25,7 @@ public async Task ResolveAsync() await Context.Message.DeleteAsync(); await HelpService.OnUserRequestChannelClose(Context.User, Context.Channel as SocketThreadChannel); } - + [Command("pending-questions")] [Summary("Moderation only command, announces the number of pending questions in the help channel.")] [RequireModerator, HideFromHelp, IgnoreBots] diff --git a/DiscordBot/Modules/UserModule.cs b/DiscordBot/Modules/UserModule.cs deleted file mode 100644 index 59a805a3..00000000 --- a/DiscordBot/Modules/UserModule.cs +++ /dev/null @@ -1,1139 +0,0 @@ -using System.Globalization; -using System.Net; -using System.Text; -using System.Text.RegularExpressions; -using Discord.Commands; -using Discord.WebSocket; -using DiscordBot.Services; -using DiscordBot.Settings; -using DiscordBot.Utils; -using HtmlAgilityPack; -using DiscordBot.Attributes; -using DiscordBot.Data; - -namespace DiscordBot.Modules; - -public class UserModule : ModuleBase -{ - #region Dependency Injection - - public UserService UserService { get; set; } - public ILoggingService LoggingService { get; set; } - public CurrencyService CurrencyService { get; set; } - public DatabaseService DatabaseService { get; set; } - public UpdateService UpdateService { get; set; } - public CommandHandlingService CommandHandlingService { get; set; } - public WeatherService WeatherService { get; set; } - public UserExtendedService UserExtendedService { get; set; } - public BotSettings Settings { get; set; } - public Rules Rules { get; set; } - - #endregion - - private readonly Random _random = new(); - private FuzzTable _slapObjects = new(); - private FuzzTable _slapFails = new(); - - [Command("Help"), Priority(100)] - [Summary("Does what you see now.")] - [Alias("command", "commands")] - public async Task DisplayHelp() - { - var commandMessages = CommandHandlingService.GetCommandListMessages("UserModule", false, true, false); - if (Context.Channel.Id != Settings.BotCommandsChannel.Id) - { - try - { - foreach (var message in commandMessages) - { - await Context.User.SendMessageAsync(message); - } - } - catch (Exception) - { - await ReplyAsync($"Your direct messages are disabled, please use <#{Settings.BotCommandsChannel.Id}> instead!").DeleteAfterSeconds(10); - } - } - else - { - foreach (var message in commandMessages) - { - await ReplyAsync(message); - } - } - await Context.Message.DeleteAsync(); - } - #region Quote - - [Command("Quote"), HideFromHelp] - public async Task QuoteMessageCommand(IMessageChannel channel, ulong messageId) - { - await QuoteMessage(messageId: messageId, channel: channel); - } - - [Command("Quote"), Priority(10)] - [Summary("Quote a message. Syntax : !quote messageid (#channel)")] - public async Task QuoteMessageCommand(ulong messageId, ulong channel) - { - // Get channel, if channel doesn't exist, we try get channel from messageID - IMessageChannel targetChannel = (IMessageChannel)await Context.Client.GetChannelAsync(channel) ?? (IMessageChannel)await Context.Client.GetChannelAsync(messageId); - if (targetChannel == null) - { - await ReplyAsync("Channel or MessageID does not exist").DeleteAfterSeconds(seconds: 5); - return; - } - - if (targetChannel.Id == channel) - await QuoteMessage(messageId, targetChannel); - else - await QuoteMessage(channel, targetChannel); - } - - [Command("Quote"), HideFromHelp] - [Summary("Quote a message. Syntax : !quote messageid (#channel)")] - public async Task QuoteMessage(ulong messageId, IMessageChannel channel = null) - { - // If channel is null use Context.Channel, else use the provided channel - channel ??= Context.Channel; - var message = await channel.GetMessageAsync(messageId); - if (message == null) - { - await Context.Message.DeleteAfterSeconds(seconds: 1); - await ReplyAsync("No message with that id found.").DeleteAfterSeconds(seconds: 4); - return; - } - if (message.Author.IsBot) // Can't imagine we need to quote the bots - { - await Context.Message.DeleteAfterSeconds(seconds: 2); - return; - } - - var messageLink = "https://discordapp.com/channels/" + Context.Guild.Id + "/" + channel.Id + "/" + messageId; - - var msgContent = message.Content; - - if (msgContent != null) - { - msgContent = msgContent.Truncate(1020); - - // Searches for embed links such as [Google](https://bing.com/) - var regex = new Regex(@"\[([^\[\]\(\)]*)\]\((.*?)\)"); - - var matches = regex.Matches(msgContent); - - foreach (var match in matches as IEnumerable) - { - msgContent = msgContent.Replace(match.Value, $"\\{match.Value}"); - } - } - - var msgAttachment = string.Empty; - if (message.Attachments?.Count > 0) msgAttachment = "\t📸"; - var builder = new EmbedBuilder() - .WithColor(new Color(200, 128, 128)) - .WithTimestamp(message.Timestamp) - .FooterQuoteBy(Context.User, message.Channel) - .AddAuthor(message.Author); - if (msgContent == string.Empty && msgAttachment != string.Empty) msgContent = "📸"; - - msgContent += $"\n\n***[Linkback]({messageLink})***"; - builder.Description = msgContent; - - await ReplyAsync(embed: builder.Build()); - await Context.Message.DeleteAfterSeconds(1.0); - } - #endregion - - /* Not really a required feature of the bot? - [Command("compile")] - [Summary("Try to compile a snippet of C# code. Be sure to escape your strings. Syntax : !compile \"Your code\"")] - [Alias("code", "compute", "assert")] - public async Task CompileCode(params string[] code) - { - var codeComplete = Resources.PaizaCodeTemplate.Replace("{code}", string.Join(" ", code)); - - var parameters = new Dictionary {{"source_code", codeComplete}, {"language", "csharp"}, {"api_key", "guest"}}; - - var content = new FormUrlEncodedContent(parameters); - - var message = await ReplyAsync( - $"Please wait a moment, trying to compile your code interpreted as\n {codeComplete.AsCodeBlock()}"); - - using (var client = new HttpClient()) - { - var httpResponse = await client.PostAsync("https://api.paiza.io/runners/create", content); - var response = JsonConvert.DeserializeObject>(await httpResponse.Content.ReadAsStringAsync()); - - var id = response["id"]; - string status; - var startTime = DateTime.Now; - const int maxTime = 30; - - do - { - httpResponse = await client.GetAsync($"http://api.paiza.io/runners/get_details?id={id}&api_key=guest"); - response = JsonConvert.DeserializeObject>(await httpResponse.Content.ReadAsStringAsync()); - status = response["status"]; - await Task.Delay(300); - } while (status != "completed" && (DateTime.Now - startTime).TotalSeconds < maxTime); - - string newMessage; - - if (status != "completed") - { - newMessage = (message.Content + "The code didn't compile in time.").Truncate(1990); - await message.ModifyAsync(m => m.Content = newMessage); - return; - } - - var buildStddout = response["build_stdout"]; - var stdout = response["stdout"]; - var stderr = response["stderr"]; - var buildStderr = response["build_stderr"]; - var result = response["build_result"]; - - string fullMessage; - if (result == "failure") - { - fullMessage = message.Content + "The code resulted in a failure.\n"; - fullMessage += buildStddout.Length > 0 ? buildStddout.AsCodeBlock() : string.Empty; - fullMessage += buildStderr.Length > 0 ? buildStderr.AsCodeBlock() : string.Empty; - } - else - { - fullMessage = message.Content + "Result : "; - fullMessage += stdout.Length > 0 ? stdout.AsCodeBlock() : string.Empty; - fullMessage += stderr.Length > 0 ? stderr.AsCodeBlock() : string.Empty; - } - - httpResponse = await client.PostAsync("https://hastebin.com/documents", new StringContent(fullMessage.Truncate(10000))); - response = JsonConvert.DeserializeObject>(await httpResponse.Content.ReadAsStringAsync()); - - newMessage = ($"\nFull result : https://hastebin.com/{response["key"]}\n" + fullMessage).Truncate(1990) + "```"; - await message.ModifyAsync(m => m.Content = newMessage); - } - } - */ - - [Command("Ping"), Priority(98)] - [Summary("Bot latency.")] - [Alias("pong")] - public async Task Ping() - { - var message = await ReplyAsync("Pong"); - var time = message.CreatedAt.Subtract(Context.Message.Timestamp); - await message.ModifyAsync(m => - m.Content = $"Pong (**{time.TotalMilliseconds}** *ms* / gateway **{UserService.GetGatewayPing()}** *ms*)"); - await message.DeleteAfterTime(seconds: 10); - - await Context.Message.DeleteAfterTime(seconds: 5); - } - - [Command("Members"), Priority(90)] - [Summary("Current member count.")] - [Alias("MemberCount")] - public async Task MemberCount() - { - await ReplyAsync( - $"We currently have {(await Context.Guild.GetUsersAsync()).Count - 1} members. Let's keep on growing as the strong community we are :muscle:"); - } - - #region All Rules - - [Command("Rules"), Priority(1)] - [Summary("Rules of current channel by DM.")] - public async Task RulesCommand() - { - await RulesCommand(Context.Channel); - await Context.Message.DeleteAsync(); - } - - [Command("Rules"), Priority(99)] - [Summary("Rules of the mentioned channel by DM. !rules #channel")] - [Alias("rule")] - public async Task RulesCommand(IMessageChannel channel) - { - var rule = Rules.Channel.First(x => x.Id == channel.Id); - var dm = await Context.User.CreateDMChannelAsync(); - bool sentMessage = false; - - sentMessage = await dm.TrySendMessage($"{rule.Header}{(rule.Content.Length > 0 ? rule.Content : $"There is no special rule for {channel.Name} channel.\nPlease follow global rules (you can get them by typing `!globalrules`)")}"); - if (!sentMessage) - await ReplyAsync("Could not send rules, your DMs are disabled.").DeleteAfterSeconds(seconds: 10); - } - - [Command("GlobalRules"), Priority(99)] - [Summary("Global Rules by DM.")] - public async Task GlobalRules(int seconds = 60) - { - var globalRules = Rules.Channel.First(x => x.Id == 0).Content; - var dm = await Context.User.CreateDMChannelAsync(); - await Context.Message.DeleteAsync(); - if (!await dm.TrySendMessage(globalRules)) - { - await ReplyAsync("Could not send rules, your DMs are disabled.").DeleteAfterSeconds(seconds: 10); - } - } - - [Command("Welcome"), Priority(1)] - [Summary("Condensed version of the rules and links to quality resources.")] - public async Task ServerWelcome() - { - if (!await UserService.DMFormattedWelcome(Context.User as SocketGuildUser)) - { - await ReplyAsync("Could not send welcome, your DMs are disabled.").DeleteAfterSeconds(seconds: 2); - } - await Context.Message.DeleteAfterSeconds(seconds: 4); - } - - [Command("Channels"), Priority(92)] - [Summary("Description of the channels by DM.")] - public async Task ChannelsDescription() - { - //Display rules of this channel for x seconds - var channelData = Rules.Channel; - var sb = new StringBuilder(); - foreach (var c in channelData) - sb.Append((await Context.Guild.GetTextChannelAsync(c.Id))?.Mention).Append(" - ").Append(c.Header).Append("\n"); - - var dm = await Context.User.CreateDMChannelAsync(); - - var messages = sb.ToString().MessageSplitToSize(); - await Context.Message.DeleteAsync(); - foreach (var message in messages) - { - if (!await dm.TrySendMessage(message)) - { - await ReplyAsync("Could not send channel descriptions, your DMs are disabled.").DeleteAfterSeconds(seconds: 10); - break; - } - } - } - - #endregion - - #region XP & Karma - - [Command("Karma"), Priority(95)] - [Summary("Description of what Karma is.")] - public async Task KarmaDescription(int seconds = 60) - { - var uname = Context.User.GetUserPreferredName(); - await ReplyAsync($"{uname}, Karma is tracked on your !profile which helps indicate how much you've helped others and provides a small increase in EXP gain."); - await Context.Message.DeleteAfterSeconds(seconds: seconds); - } - - [Command("Top"), Priority(6)] - [Summary("Display top 10 users by level.")] - [Alias("toplevel", "ranking")] - public async Task TopLevel() - { - var users = await DatabaseService.Query.GetTopLevel(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.Level)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Level"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); - } - - [Command("TopKarma"), Priority(5)] - [Summary("Display top 10 users by karma.")] - [Alias("karmarank", "rankingkarma", "topk")] - public async Task TopKarma() - { - var users = await DatabaseService.Query.GetTopKarma(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.Karma)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Karma"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); - } - - [Command("TopKarmaWeekly"), Priority(5)] - [Summary("Display weekly top 10 users by karma.")] - [Alias("karmarankweekly", "rankingkarmaweekly", "topkw")] - public async Task TopKarmaWeekly() - { - var users = await DatabaseService.Query.GetTopKarmaWeekly(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaWeekly)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Weekly Karma"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); - } - - [Command("TopKarmaMonthly"), Priority(5)] - [Summary("Display monthly top 10 users by karma.")] - [Alias("karmarankmonthly", "rankingkarmamonthly", "topkm")] - public async Task TopKarmaMonthly() - { - var users = await DatabaseService.Query.GetTopKarmaMonthly(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaMonthly)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Monthly Karma"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); - } - - [Command("TopKarmaYearly"), Priority(5)] - [Summary("Display tearly top 10 users by karma.")] - [Alias("karmaranktearly", "rankingkarmayearly", "topky")] - public async Task TopKarmaYearly() - { - var users = await DatabaseService.Query.GetTopKarmaYearly(10); - var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaYearly)).ToList(); - - var embed = await GenerateRankEmbedFromList(userList, "Yearly Karma"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); - } - - private async Task GenerateRankEmbedFromList(List<(ulong userID, int value)> data, string labelName) - { - var embedBuilder = new EmbedBuilder - { - Title = $"Top 10 Users by {labelName}", - Footer = new EmbedFooterBuilder - { - Text = $"The best of the best, by {labelName}." - } - }; - - try - { - var maxUsernameLength = data - .Select(async x => await Context.Guild.GetUserAsync(x.userID)) - .Select(x => x.Result) - .Max(x => (x?.Username ?? "Unknown User").Length); - - var str = ""; - for (var i = 0; i < data.Count; i++) - { - var user = await Context.Guild.GetUserAsync(data[i].userID); - var username = user?.Username ?? "Unknown User"; // For cases where the user has left the guild - int rankPadding = (int)Math.Floor(Math.Log10(data.Count)); - - str += - $"`{(i + 1).ToString().PadLeft(rankPadding + 1)}.` **`{username.PadRight(maxUsernameLength, '\u2000')}`** `{labelName}: {data[i].value}`\n"; - } - - embedBuilder.Description = str; - } - catch (Exception e) - { - await LoggingService.LogChannelAndFile($"Failed to generate top 10 embed.\n{e}", ExtendedLogSeverity.LowWarning); - embedBuilder.Description = "Failed to generate top 10 embed."; - } - - return embedBuilder.Build(); - } - - [Command("JoinDate"), Priority(91)] - [Summary("Display date you joined the server.")] - public async Task JoinDate() - { - var userId = Context.User.Id; - var joinDate = ((IGuildUser)Context.User).JoinedAt; - await ReplyAsync($"{Context.User.Mention} you joined **{joinDate:dddd dd/MM/yyy HH:mm:ss}**"); - await Context.Message.DeleteAsync(); - } - - [Command("SetCity"), Priority(100)] - [Alias("SetDefaultCity")] - [Summary("Set 'Default City' which can be used by various commands.")] - public async Task SetDefaultCity(params string[] city) - { - var uname = Context.User.GetUserPreferredName(); - var fullCityName = string.Join(" ", city); - var (exists, result) = await WeatherService.CityExists(fullCityName); - if (!exists) - { - await ReplyAsync($"Sorry, {uname}, but I couldn't find a city with that name.").DeleteAfterSeconds(30); - await Context.Message.DeleteAsync(); - return; - } - // Set default city - await UserExtendedService.SetUserDefaultCity(Context.User, result.name); - await ReplyAsync($"{uname}, your default city has been set to {result.name}."); - } - - [Command("RemoveCity"), Priority(100)] - [Alias("RemoveDefaultCity")] - [Summary("Remove 'Default City' which can be used by various commands.")] - public async Task RemoveDefaultCity() - { - var uname = Context.User.GetUserPreferredName(); - if (!await UserExtendedService.DoesUserHaveDefaultCity(Context.User)) - { - await ReplyAsync($"{uname}, you don't have a default city set.").DeleteAfterSeconds(30); - await Context.Message.DeleteAsync(); - return; - } - await UserExtendedService.RemoveUserDefaultCity(Context.User); - await ReplyAsync($"{uname}, your default city has been removed."); - } - - #endregion - - #region Codetips - - [Command("CodeTip"), Priority(20)] - [Summary("Show code formatting example. Syntax: !codetip userToPing(optional)")] - [Alias("codetips")] - public async Task CodeTip(IUser user = null) - { - var message = user != null ? user.Mention + ", " : ""; - message += "When posting code, format it like so:" + Environment.NewLine; - message += UserService.CodeFormattingExample; - await Context.Message.DeleteAsync(); - await ReplyAsync(message).DeleteAfterSeconds(seconds: 60); - } - - [Command("DisableCodeTips"), Priority(91)] - [Summary("Stops code formatting reminders.")] - public async Task DisableCodeTips() - { - await Context.Message.DeleteAsync(); - if (!UserService.CodeReminderCooldown.IsPermanent(Context.User.Id)) - { - UserService.CodeReminderCooldown.SetPermanent(Context.User.Id, true); - var uname = Context.User.GetUserPreferredName(); - await ReplyAsync($"{uname}, you will no longer be reminded about correct code formatting.").DeleteAfterTime(20); - } - } - - #endregion - - #region Fun - [Command("Slap"), Priority(21)] - [Summary("Slap the specified user(s). Syntax : !slap @user1 [@user2 @user3...]")] - public async Task SlapUser(params IUser[] users) - { - try - { - if (_slapObjects.Count == 0) - _slapObjects.Load(Settings.UserModuleSlapObjectsTable); - } - catch (Exception e) - { - await LoggingService.LogChannelAndFile($"Error while loading '{Settings.UserModuleSlapObjectsTable}'.\nEx:{e}", - ExtendedLogSeverity.LowWarning); - return; - } - if (_slapObjects.Count == 0) - _slapObjects.Add(Settings.UserModuleSlapChoices); - if (_slapObjects.Count == 0) - _slapObjects.Add("fish|mallet"); - - if (_slapFails.Count == 0) - _slapFails.Add(Settings.UserModuleSlapFails); - if (_slapFails.Count == 0) - _slapFails.Add("hurting themselves"); - - var uname = Context.User.GetUserPreferredName(); - - if (users == null || users.Length == 0) - { - await Context.Channel.SendMessageAsync( - $"**{uname}** slaps away an invisible pest."); - await Context.Message.DeleteAfterSeconds(seconds: 1); - return; - } - - var sb = new StringBuilder(); - var mentions = users.ToMentionArray().ToCommaList(); - - bool fail = (_random.Next(1, 100) < 5); - if (fail) - { - sb.Append($"**{uname}** tries to slap {mentions} "); - sb.Append("around a bit with a large "); - sb.Append(_slapObjects.Pick(true)); - sb.Append(", but misses and ends up "); - sb.Append(_slapFails.Pick(true)); - sb.Append("."); - } - else - { - sb.Append($"**{uname}** slaps {mentions} "); - sb.Append("around a bit with a large "); - sb.Append(_slapObjects.Pick(true)); - sb.Append("."); - } - - await Context.Channel.SendMessageAsync(sb.ToString()); - await Context.Message.DeleteAfterSeconds(seconds: 1); - } - - [Command("CoinFlip"), Priority(22)] - [Summary("Flip a coin and see the result.")] - [Alias("flipcoin")] - public async Task CoinFlip() - { - var coin = new[] { "Heads", "Tails" }; - - var uname = Context.User.GetUserPreferredName(); - await ReplyAsync($"**{uname}** flipped a coin and got **{coin[_random.Next() % 2]}**!"); - await Context.Message.DeleteAfterSeconds(seconds: 1); - } - - [Command("Roll"), Priority(23)] - [Summary("Roll a dice. Syntax: !roll [sides]")] - public async Task RollDice(int sides = 20) - { - await RollDice(sides, 0); - } - - [Command("Roll"), Priority(23)] - [Summary("Roll a dice. Syntax: !roll [sides] [minimum]")] - public async Task RollDice(int sides, int number) - { - if (sides < 1 || sides > 1000) - { - await ReplyAsync("Invalid number of sides. Please choose a number between 1 and 1000.").DeleteAfterSeconds(seconds: 10); - await Context.Message.DeleteAsync(); - return; - } - - var uname = Context.User.GetUserPreferredName(); - var roll = _random.Next(1, sides + 1); - var message = $"**{uname}** rolled a D{sides} and got **{roll}**!"; - if (number < 1) - message = " :game_die: " + message; - else if (roll >= number) - message = " :white_check_mark: " + message + " [Needed: " + number + "]"; - else - message = " :x: " + message + " [Needed: " + number + "]"; - - await ReplyAsync(message); - await Context.Message.DeleteAfterSeconds(seconds: 1); - } - - [Command("D20"), Priority(23)] - [Summary("Roll a D20 dice. Syntax: !d20 [minimum]")] - public async Task RollD20(int number = 0) - { - await RollDice(20, number); - } - - #endregion - - #region Search - [Command("Search"), Priority(25)] - [Summary("Searches DuckDuckGo for results. Syntax: !search c# lambda help")] - [Alias("s", "ddg")] - public async Task SearchResults(params string[] messages) - { - StringBuilder sb = new(); - foreach (var msg in messages) - sb.Append(msg).Append(" "); - await SearchResults(sb.ToString()); - } - - [Command("Search"), HideFromHelp] - [Summary("Searches DuckDuckGo for web results. Syntax : !search \"query\" resNum site")] - [Alias("s", "ddg")] - public async Task SearchResults(string query, uint resNum = 3, string site = "") - { - // Cleaning inputs from user (maybe we can ban certain domains or keywords) - resNum = resNum <= 5 ? resNum : 5; - var searchQuery = "https://duckduckgo.com/html/?q=" + query.Replace(' ', '+'); - - if (site != string.Empty) searchQuery += "+site:" + site; - - var doc = new HtmlWeb().Load(searchQuery); - var counter = 1; - - EmbedBuilder embedBuilder = new(); - embedBuilder.Title = $"Q: {WebUtility.UrlDecode(query)}"; - string resultTitle = string.Empty; - - // XPath for DuckDuckGo as of 10/05/2018, if results stop showing up, check this first! - // Still working (13/05/21) - foreach (var row in doc.DocumentNode.SelectNodes("/html/body/div[1]/div[3]/div/div/div[*]/div/h2/a")) - { - if (counter > resNum) break; - - // Seems to be some weird additional data attached to links. Fix added (13/05/21) - row.Attributes["href"].Value = row.Attributes["href"].Value.Replace("//duckduckgo.com/l/?uddg=", string.Empty); - - // Check if we are within the allowed number of results and if the result is valid (i.e. no evil ads) - if (counter <= resNum && IsValidResult(row)) // && IsValidResult(row)) - { - var url = WebUtility.UrlDecode(row.Attributes["href"].Value); // .Replace("/l/?kh=-1&uddg=", "")); <- no longer works (14/05/21) - - // We count how many & there are, as links with multiple may be broken, so we include a ~ just to try give a bit more info if there is more than 1. - int andCount = url.Count(c => c == '&'); - url = url.Substring(0, url.LastIndexOf('&')); - - resultTitle += $"{counter}. {(row.InnerText.Length > 60 ? $"{row.InnerText[..60]}.." : row.InnerText)}" + $" [__Read More..__{(andCount > 1 ? "~" : string.Empty)}]({url})\n"; - - counter++; - } - } - - embedBuilder.AddField("Search Query", searchQuery); - embedBuilder.AddField("Results", resultTitle, inline: false); - - embedBuilder.Color = new Color(81, 50, 169); - embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from DuckDuckGo."); - - var embed = embedBuilder.Build(); - await ReplyAsync(embed: embed); - } - - // Utility function for avoiding evil ads from DuckDuckGo - bool IsValidResult(HtmlNode node) - { - return (!node.Attributes["href"].Value.Contains("duckduckgo.com") && - !node.Attributes["href"].Value.Contains("duck.co")); - } - - [Command("Manual"), Priority(8)] - [Summary("Searches Unity3D manual for results. Syntax : !manual \"query\"")] - public async Task SearchManual(params string[] queries) - { - // Download Unity3D Documentation Database (lol) - - // Calculate the closest match to the input query - var minimumScore = double.MaxValue; - string[] mostSimilarPage = null; - var pages = await UpdateService.GetManualDatabase(); - var query = string.Join(" ", queries); - foreach (var p in pages) - { - var curScore = CalculateScore(p[1], query); - if (!(curScore < minimumScore)) continue; - - minimumScore = curScore; - mostSimilarPage = p; - } - - // If a page has been found (should be), return the message, else return information - if (mostSimilarPage != null) - { - EmbedBuilder embedBuilder = new(); - embedBuilder.Title = $"Found {mostSimilarPage[0]}"; - embedBuilder.Description = $"**{mostSimilarPage[1]}** - [Read More..](https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html)"; - embedBuilder.Color = new Color(81, 50, 169); - embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs."); - var message = await ReplyAsync(embed: embedBuilder.Build()); - - var doc = new HtmlWeb().Load($"https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html"); - // Get first Header as this'll contain the main part we need - var descriptionNode = doc.DocumentNode.SelectSingleNode("//h1"); - if (descriptionNode == null) return; - // Description is in next

, but we need to strip out tooltips - descriptionNode = descriptionNode.SelectSingleNode("following-sibling::p"); - descriptionNode.Descendants().Where(n => n.GetAttributeValue("class", "").Contains("tooltip")).ToList().ForEach(n => n.Remove()); - var description = descriptionNode.InnerText; - - embedBuilder.WithDescription($"**Description:** {(description.Length > 500 ? $"{description[..500]}.." : description)}\n" + $"[Read More..](https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html)"); - await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); - } - else - await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10); - } - - [Command("Doc"), Priority(9)] - [Summary("Searches Unity3D API for results. Syntax : !api \"query\"")] - [Alias("ref", "reference", "api", "docs")] - public async Task SearchApi(params string[] queries) - { - // Download Unity3D Documentation Database (lol) - - // Calculate the closest match to the input query - var minimumScore = double.MaxValue; - string[] mostSimilarPage = null; - var pages = await UpdateService.GetApiDatabase(); - var query = string.Join(" ", queries); - foreach (var p in pages) - { - var curScore = CalculateScore(p[1], query); - if (!(curScore < minimumScore)) continue; - - minimumScore = curScore; - mostSimilarPage = p; - } - - // If a page has been found (should be), return the message, else return information - if (mostSimilarPage != null) - { - EmbedBuilder embedBuilder = new(); - embedBuilder.Title = $"Found {mostSimilarPage[0]}"; - embedBuilder.Description = $"**{mostSimilarPage[1]}** - [Read More..](https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html)"; - embedBuilder.Color = new Color(81, 50, 169); - embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs."); - var message = await ReplyAsync(embed: embedBuilder.Build()); - - // Load the page, and look for a

Description

tag, and then get the next

tag - var doc = new HtmlWeb().Load($"https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html"); - var descriptionNode = doc.DocumentNode.SelectSingleNode("//h3[contains(text(), 'Description')]"); - - string descriptionString = ""; - string manualLinkString = ""; - if (descriptionNode != null) - { - var description = descriptionNode.SelectSingleNode("following-sibling::p").InnerText; - descriptionString = - $"**Description:** {(description.Length > 500 ? $"{description[..500]}.." : description)}\n" + - $"[Read More..](https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html)"; - - } - - // We check the page for the first "switch-link" class, which will be a link to a Manual page - var manualLink = doc.DocumentNode.SelectSingleNode("//a[contains(@class, 'switch-link')]"); - if (manualLink != null && manualLink.Attributes.Contains("title")) - { - var manualLinkText = manualLink.GetAttributes("title").First().Value; - var manualLinkUrl = "https://docs.unity3d.com/" + manualLink.GetAttributeValue("href", ""); - manualLinkString = $"\n**Manual:** [{manualLinkText}]({manualLinkUrl})"; - } - - embedBuilder.WithDescription(descriptionString + manualLinkString); - await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); - } - else - await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10); - } - - private double CalculateScore(string s1, string s2) - { - double curScore = 0; - var i = 0; - - foreach (var q in s1.Split(' ')) - { - foreach (var x in s2.Split(' ')) - { - i++; - if (x.Equals(q)) - curScore -= 50; - else - curScore += x.CalculateLevenshteinDistance(q); - } - } - - curScore /= i; - return curScore; - } - - [Command("FAQ")] - [Summary("Searches UDC FAQs. Syntax : !faq \"query\"")] - public async Task SearchFaqs(params string[] queries) - { - var faqDataList = UpdateService.GetFaqData(); - - // Check if query is faq ID (e.g. "!faq 1") - if (queries.Length == 1 && ParseNumber(queries[0]) > 0) - { - var id = ParseNumber(queries[0]) - 1; - if (id < faqDataList.Count) - await ReplyAsync(embed: GetFaqEmbed(faqDataList[id])); - else - await ReplyAsync("Invalid FAQ ID selected."); - } - // Check if query contains "list" command (i.e. "!faq list") - else if (queries.Length > 0 && !(queries.Length == 1 && queries[0].Equals("list"))) - { - // Calculate the closest match to the input query - var minimumScore = double.MaxValue; - FaqData mostSimilarFaq = null; - var query = string.Join(" ", queries); - - // Go through each FAQ in the list and check the most similar - foreach (var faq in faqDataList) - { - foreach (var keyword in faq.Keywords) - { - var curScore = CalculateScore(keyword, query); - if (curScore < minimumScore) - { - minimumScore = curScore; - mostSimilarFaq = faq; - } - } - } - - // If an FAQ has been found (should be), return the FAQ, else return information msg - if (mostSimilarFaq != null) - await ReplyAsync(embed: GetFaqEmbed(mostSimilarFaq)); - else - await ReplyAsync("No FAQs Found."); - } - else - // List all the FAQs available - await ListFaqs(faqDataList); - } - - private async Task ListFaqs(List faqs) - { - var sb = new StringBuilder(faqs.Count); - var index = 1; - var keywordSb = new StringBuilder(); - foreach (var faq in faqs) - { - sb.Append(FormatFaq(index, faq) + "\n"); - keywordSb.Append("["); - for (var i = 0; i < faq.Keywords.Length; i++) - { - keywordSb.Append(faq.Keywords[i]); - keywordSb.Append(i < faq.Keywords.Length - 1 ? ", " : "]\n\n"); - } - - index++; - sb.Append(keywordSb); - keywordSb.Clear(); - } - - await ReplyAsync(sb.ToString()).DeleteAfterTime(minutes: 3); - } - - private Embed GetFaqEmbed(FaqData faq) - { - var builder = new EmbedBuilder() - .WithTitle($"{faq.Question}") - .WithDescription($"{faq.Answer}") - .WithColor(new Color(0x33CC00)); - return builder.Build(); - } - - private string FormatFaq(int id, FaqData faq) => $"{id}. **{faq.Question}** - {faq.Answer}"; - - [Command("Wiki"), Priority(26)] - [Summary("Searches Wikipedia. Syntax : !wiki \"query\"")] - [Alias("wikipedia")] - public async Task SearchWikipedia([Remainder] string query) - { - var article = await UpdateService.DownloadWikipediaArticle(query); - - // If an article is found return it, else return error message - if (article.url == null) - { - await ReplyAsync($"No Articles for \"{query}\" were found."); - return; - } - - await ReplyAsync(embed: GetWikipediaEmbed(article.name, article.extract, article.url)); - } - - private Embed GetWikipediaEmbed(string subject, string articleExtract, string articleUrl) - { - var builder = new EmbedBuilder() - .WithTitle($"Wikipedia | {subject}") - .WithDescription($"{articleExtract}") - .WithUrl(articleUrl) - .WithColor(new Color(0x33CC00)); - return builder.Build(); - } - - private int ParseNumber(string s) - { - int id; - if (int.TryParse(s, out id)) return id; - - return -1; - } - - #endregion - - #region Birthday - - [Command("Birthday"), HideFromHelp] - [Summary("Display next member birthday.")] - [Alias("bday")] - public async Task Birthday() - { - // URL to cell C15/"Next birthday" cell from Corn's google sheet - const string nextBirthday = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&range=C15:C15"; - - var tableText = await WebUtil.GetHtmlNodeInnerText(nextBirthday, "/html/body/table/tr[2]/td"); - var message = $"**{tableText}**"; - - await ReplyAsync(message).DeleteAfterTime(minutes: 3); - await Context.Message.DeleteAfterTime(minutes: 3); - } - - [Command("Birthday"), Priority(27)] - [Summary("Display birthday of mentioned user. Syntax : !birthday @user")] - [Alias("bday")] - public async Task Birthday(IUser user) - { - var searchName = user.Username; - // URL to columns B to D of Corn's google sheet - const string birthdayTable = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&gid=318080247&range=B:D"; - var relevantNodes = await WebUtil.GetHtmlNodes(birthdayTable, "/html/body/table/tr"); - - var birthdate = default(DateTime); - - HtmlNode matchedNode = null; - var matchedLength = int.MaxValue; - - // XPath to each table row - foreach (var row in relevantNodes) - { - // XPath to the name column (C) - var nameNode = row.SelectSingleNode("td[2]"); - var name = nameNode.InnerText; - - if (!name.ToLower().Contains(searchName.ToLower()) || name.Length >= matchedLength) - continue; - - // Check for a "Closer" match - matchedNode = row; - matchedLength = name.Length; - // Nothing will match "Better" so we may as well break out - if (name.Length == searchName.Length) break; - } - - if (matchedNode != null) - { - // XPath to the date column (B) - var dateNode = matchedNode.SelectSingleNode("td[1]"); - // XPath to the year column (D) - var yearNode = matchedNode.SelectSingleNode("td[3]"); - - var provider = CultureInfo.InvariantCulture; - var wrongFormat = "M/d/yyyy"; - //string rightFormat = "dd-MMMM-yyyy"; - - var dateString = dateNode.InnerText; - if (!yearNode.InnerText.Contains(" ")) dateString = dateString + "/" + yearNode.InnerText; - - dateString = dateString.Trim(); - - try - { - // Converting the birthdate from the wrong format to the right format WITH year - birthdate = DateTime.ParseExact(dateString, wrongFormat, provider); - } - catch (FormatException) - { - // Converting the birthdate from the wrong format to the right format WITHOUT year - birthdate = DateTime.ParseExact(dateString, "M/d", provider); - } - } - - // Business as usual - if (birthdate == default) - { - await ReplyAsync( - $"Sorry, I couldn't find **{searchName}**'s birthday date. They can add it at https://docs.google.com/forms/d/e/1FAIpQLSfUglZtJ3pyMwhRk5jApYpvqT3EtKmLBXijCXYNwHY-v-lKxQ/viewform !") - .DeleteAfterSeconds(30); - } - else - { - var date = birthdate.ToUnixTimestamp(); - var message = - $"**{searchName}**'s birthdate: __**{birthdate.ToString("dd MMMM yyyy", CultureInfo.InvariantCulture)}**__ " + - $"({(int)((DateTime.Now - birthdate).TotalDays / 365)}yo)"; - - await ReplyAsync(message).DeleteAfterTime(minutes: 3); - } - - await Context.Message.DeleteAfterTime(minutes: 3); - } - - #endregion - - #region Temperatures - - [Command("FtoC"), Priority(28)] - [Summary("Converts a temperature in fahrenheit to celsius. Syntax : !ftoc temperature")] - public async Task FahrenheitToCelsius(float f) - { - await ReplyAsync($"{Context.User.Mention} {f}°F is {MathUtility.FahrenheitToCelsius(f)}°C."); - } - - [Command("CtoF"), Priority(28)] - [Summary("Converts a temperature in celsius to fahrenheit. Syntax : !ftoc temperature")] - public async Task CelsiusToFahrenheit(float c) - { - await ReplyAsync($"{Context.User.Mention} {c}°C is {MathUtility.CelsiusToFahrenheit(c)}°F"); - } - - #endregion - - #region Translate - - [Command("Translate"), HideFromHelp] - [Summary("Translate a message. Syntax : !translate messageId language")] - public async Task Translate(ulong messageId, string language = "en") - { - await Translate((await Context.Channel.GetMessageAsync(messageId)).Content, language); - } - - [Command("Translate"), HideFromHelp] - [Summary("Translate a message. Syntax : !translate text language")] - public async Task Translate(string text, string language = "en") - { - var msg = await ReplyAsync($"Here: "); - await Context.Message.DeleteAfterSeconds(seconds: 1); - await msg.DeleteAfterSeconds(seconds: 20); - } - - #endregion - - #region Currency - - [Command("CurrencyName"), Priority(29)] - [Summary("Get the name of a currency. Syntax : !currname USD")] - [Alias("currname")] - public async Task CurrencyName(string currency) - { - if (Context.HasAnyPingableMention()) - return; - var name = await CurrencyService.GetCurrencyName(currency); - if (name == string.Empty) - { - await Context.Message.ReplyAsync($"Sorry, I couldn't find the name of the currency **{currency}**."); - return; - } - await Context.Message.ReplyAsync($"The name of the currency **{currency.ToUpper()}** is **{name}**."); - } - - [Command("Currency"), HideFromHelp] - [Summary("Converts a currency. Syntax : !currency fromCurrency toCurrency")] - [Alias("curr")] - public async Task ConvertCurrency(string from, string to = "usd") - { - await ConvertCurrency(1, from, to); - } - - [Command("Currency"), Priority(29)] - [Summary("Converts a currency. Syntax : !currency amount fromCurrency toCurrency")] - [Alias("curr")] - public async Task ConvertCurrency(double amount, string from, string to = "usd") - { - if (Context.HasAnyPingableMention()) - { - // Only continue command if the user is replying to a message - if (!Context.IsReply()) - return; - // And that mention is only the author of the replied message - if (!Context.IsOnlyReplyingToAuthor()) - return; - } - - from = from.ToLower(); - to = to.ToLower(); - - // We check if both currencies are valid - bool fromValid = await CurrencyService.IsCurrency(from.ToLower()); - bool toValid = await CurrencyService.IsCurrency(to.ToLower()); - - // Check if valid - if (!fromValid || !toValid) - { - await Context.Message.ReplyAsync("One of the currencies provided is invalid."); - return; - } - - var response = await CurrencyService.GetConversion(to, from); - if (Math.Abs(response - (-1)) < 0.01) - { - await Context.Message.ReplyAsync("An error occured while converting the currency, the API may be down!"); - return; - } - - var totalAmount = Math.Round(amount * response, 2); - await Context.Message.ReplyAsync($"**{amount} {from.ToUpper()}** = **{totalAmount} {to.ToUpper()}**"); - } - - #endregion -} diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs index 88c59afa..a778afd4 100644 --- a/DiscordBot/Modules/UserSlashModule.cs +++ b/DiscordBot/Modules/UserSlashModule.cs @@ -12,7 +12,8 @@ public class UserSlashModule : InteractionModuleBase #region Dependency Injection public CommandHandlingService CommandHandlingService { get; set; } - public UserService UserService { get; set; } + public WelcomeService WelcomeService { get; set; } + public ServerService ServerService { get; set; } public BotSettings BotSettings { get; set; } public ILoggingService LoggingService { get; set; } @@ -104,7 +105,7 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => public async Task SlashWelcome() { await Context.Interaction.RespondAsync(string.Empty, - embed: UserService.GetWelcomeEmbed(Context.User.Username), ephemeral: true); + embed: WelcomeService.GetWelcomeEmbed(Context.User.Username), ephemeral: true); } [SlashCommand("ping", "Bot latency")] @@ -112,7 +113,7 @@ public async Task Ping() { await Context.Interaction.RespondAsync("Bot latency: ...", ephemeral: true); await Context.Interaction.ModifyOriginalResponseAsync(m => - m.Content = $"Bot latency: {UserService.GetGatewayPing().ToString()}ms"); + m.Content = $"Bot latency: {ServerService.GetGatewayPing().ToString()}ms"); } [SlashCommand("invite", "Returns the invite link for the server.")] diff --git a/DiscordBot/Modules/Weather/WeatherModule.cs b/DiscordBot/Modules/Weather/WeatherModule.cs index 061f1b2d..800fbb42 100644 --- a/DiscordBot/Modules/Weather/WeatherModule.cs +++ b/DiscordBot/Modules/Weather/WeatherModule.cs @@ -347,4 +347,42 @@ private static string GetWindDirection(float windDeg) } #endregion Utility Methods + + #region City Settings + + [Command("SetCity"), Priority(100)] + [Alias("SetDefaultCity")] + [Summary("Set 'Default City' which can be used by various commands.")] + public async Task SetDefaultCity(params string[] city) + { + var uname = Context.User.GetUserPreferredName(); + var fullCityName = string.Join(" ", city); + var (exists, result) = await WeatherService.CityExists(fullCityName); + if (!exists) + { + await ReplyAsync($"Sorry, {uname}, but I couldn't find a city with that name.").DeleteAfterSeconds(30); + await Context.Message.DeleteAsync(); + return; + } + await UserExtendedService.SetUserDefaultCity(Context.User, result.name); + await ReplyAsync($"{uname}, your default city has been set to {result.name}."); + } + + [Command("RemoveCity"), Priority(100)] + [Alias("RemoveDefaultCity")] + [Summary("Remove 'Default City' which can be used by various commands.")] + public async Task RemoveDefaultCity() + { + var uname = Context.User.GetUserPreferredName(); + if (!await UserExtendedService.DoesUserHaveDefaultCity(Context.User)) + { + await ReplyAsync($"{uname}, you don't have a default city set.").DeleteAfterSeconds(30); + await Context.Message.DeleteAsync(); + return; + } + await UserExtendedService.RemoveUserDefaultCity(Context.User); + await ReplyAsync($"{uname}, your default city has been removed."); + } + + #endregion City Settings } diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 4d9c51c5..46813081 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -74,6 +74,12 @@ private async Task MainAsync() _recruitService = _services.GetRequiredService(); _services.GetRequiredService(); _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); _services.GetRequiredService(); return Task.CompletedTask; @@ -94,7 +100,13 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/DiscordBot/Services/AuditLogService.cs b/DiscordBot/Services/AuditLogService.cs index 750938ee..10bd2fb7 100644 --- a/DiscordBot/Services/AuditLogService.cs +++ b/DiscordBot/Services/AuditLogService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Discord.WebSocket; using DiscordBot.Settings; @@ -19,6 +20,8 @@ public AuditLogService(DiscordSocketClient client, BotSettings settings, ILoggin client.MessageDeleted += EventGuard.Guarded, Cacheable>(MessageDeleted, nameof(MessageDeleted)); client.MessageUpdated += EventGuard.Guarded, SocketMessage, ISocketMessageChannel>(MessageUpdated, nameof(MessageUpdated)); + client.UserLeft += EventGuard.Guarded(UserLeft, nameof(UserLeft)); + client.GuildMemberUpdated += EventGuard.Guarded, SocketGuildUser>(GuildMemberUpdated, nameof(GuildMemberUpdated)); if (settings.BotAnnouncementChannel != null) _botAnnouncementChannel = client.GetChannel(settings.BotAnnouncementChannel.Id) as IMessageChannel; @@ -107,4 +110,35 @@ private async Task MessageUpdated(Cacheable before, SocketMessa await _loggingService.Log(LogBehaviour.Channel, string.Empty, ExtendedLogSeverity.Info, embed); } + + private async Task UserLeft(SocketGuild guild, SocketUser user) + { + if (user.IsBot) return; + + var guildUser = guild.GetUser(user.Id); + if (guildUser?.JoinedAt != null) + { + var joinDate = guildUser.JoinedAt.Value.Date; + var timeStayed = DateTime.Now - joinDate; + await _loggingService.LogChannelAndFile( + $"User Left - After {(timeStayed.Days > 1 ? Math.Floor((double)timeStayed.Days) + " days" : " ")}" + + $" {Math.Floor((double)timeStayed.Hours).ToString(CultureInfo.InvariantCulture)} hours {user.Mention} - `{guildUser.GetPreferredAndUsername()}` - ID : `{user.Id}`"); + } + else + { + await _loggingService.LogChannelAndFile( + $"User Left - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}` - Left at {DateTime.Now}"); + } + } + + private async Task GuildMemberUpdated(Cacheable oldUserCached, SocketGuildUser user) + { + var oldUser = await oldUserCached.GetOrDownloadAsync(); + if (oldUser.Nickname != user.Nickname) + { + await _loggingService.LogChannelAndFile( + $"User {oldUser.GetUserPreferredName()} changed his " + + $"username to {user.GetUserPreferredName()}"); + } + } } diff --git a/DiscordBot/Services/CodeCheckService.cs b/DiscordBot/Services/CodeCheckService.cs new file mode 100644 index 00000000..eb3b78a5 --- /dev/null +++ b/DiscordBot/Services/CodeCheckService.cs @@ -0,0 +1,137 @@ +using System.Text.RegularExpressions; +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services; + +public class CodeCheckService +{ + private readonly DiscordSocketClient _client; + private readonly BotSettings _settings; + private readonly UpdateService _updateService; + + private readonly Regex _x3CodeBlock = + new("^(?`{3}((?\\w*?$)|$).+?({.+?}).+?`{3})", RegexOptions.Multiline | RegexOptions.Singleline); + + private readonly Regex _x2CodeBlock = new("^(`{2})[^`].+?([^`]`{2})$", RegexOptions.Multiline); + private readonly List _codeBlockWarnPatterns; + private readonly short _maxCodeBlockLengthWarning = 800; + + public readonly string CodeFormattingExample; + private readonly string _codeReminderFormattingExample; + public Dictionary CodeReminderCooldown { get; private set; } + + public CodeCheckService(DiscordSocketClient client, BotSettings settings, + UpdateService updateService) + { + _client = client; + _settings = settings; + _updateService = updateService; + + CodeReminderCooldown = new Dictionary(); + + CodeFormattingExample = @"\`\`\`cs" + Environment.NewLine + + "Write your code on new line here." + Environment.NewLine + + @"\`\`\`" + Environment.NewLine; + + _codeReminderFormattingExample = CodeFormattingExample + "*To disable these reminders use \"!disablecodetips\"*"; + + _codeBlockWarnPatterns = new List + { + new(".*?({.+?}).*?", RegexOptions.Singleline), + new("(if|else\\sif).?\\(.+\\).?($|\\/{2}|\\s?)", RegexOptions.Multiline), + new("^(\\w*.\\w*)\\(\\w*?\\);($|.?($|.*?\\/{2}))", RegexOptions.Multiline), + new("^.+? =.+?($|.*?\\/\\/)", RegexOptions.Multiline) + }; + + _client.MessageReceived += EventGuard.Guarded(CodeCheck, nameof(CodeCheck)); + + LoadData(); + UpdateLoop(); + } + + private async void UpdateLoop() + { + while (true) + { + try + { + await Task.Delay(10000); + SaveData(); + } + catch (Exception e) + { + LoggingService.LogToConsole($"[CodeCheckService.UpdateLoop] Unhandled exception: {e}", LogSeverity.Error); + } + } + } + + private void LoadData() + { + var data = _updateService.GetUserData(); + CodeReminderCooldown = data.CodeReminderCooldown ?? new Dictionary(); + } + + private void SaveData() + { + var data = new UserData + { + CodeReminderCooldown = CodeReminderCooldown + }; + _updateService.SetUserData(data); + } + + public async Task CodeCheck(SocketMessage messageParam) + { + if (messageParam.Author.IsBot || messageParam.Channel.Id == _settings.GeneralChannel.Id) + return; + + if (messageParam.Content.Length < 200) + return; + + var userId = messageParam.Author.Id; + + if (!CodeReminderCooldown.HasUser(userId)) + { + var content = messageParam.Content; + + var foundTrippleCodeBlock = _x3CodeBlock.Match(content); + if (foundTrippleCodeBlock.Groups["CS"].Length > 0) + return; + if (foundTrippleCodeBlock.Groups["CodeBlock"].Success) + { + await messageParam.Channel.SendMessageAsync( + $"{messageParam.Author.Mention} when using code blocks remember to use the ***syntax highlights*** to improve readability.\n{_codeReminderFormattingExample}") + .DeleteAfterSeconds(seconds: 60); + return; + } + + var foundDoubleCodeBlock = _x2CodeBlock.Match(content).Success; + + int hits = 0; + foreach (var regex in _codeBlockWarnPatterns) + { + hits += regex.Match(content).Captures.Count; + } + + if (!foundDoubleCodeBlock && hits >= 3) + { + await messageParam.Channel.SendMessageAsync( + $"{messageParam.Author.Mention} are you sharing C# scripts? Remember to use codeblocks to help readability!\n{_codeReminderFormattingExample}") + .DeleteAfterSeconds(seconds: 60); + if (content.Length > _maxCodeBlockLengthWarning) + { + await messageParam.Channel.SendMessageAsync( + "The code you're sharing is quite long, maybe use a free service like and share the link here instead.") + .DeleteAfterSeconds(seconds: 60); + } + } + else if (foundDoubleCodeBlock && hits > 0) + { + await messageParam.Channel.SendMessageAsync( + $"{messageParam.Author.Mention} when using code blocks remember to use \\`\\`\\`cs as this will help improve readability for C# scripts.\n{_codeReminderFormattingExample}") + .DeleteAfterSeconds(seconds: 60); + } + } + } +} diff --git a/DiscordBot/Services/EveryoneScoldService.cs b/DiscordBot/Services/EveryoneScoldService.cs new file mode 100644 index 00000000..a514dba6 --- /dev/null +++ b/DiscordBot/Services/EveryoneScoldService.cs @@ -0,0 +1,36 @@ +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services; + +public class EveryoneScoldService +{ + private readonly BotSettings _settings; + private readonly Dictionary _everyoneScoldCooldown = new(); + + public EveryoneScoldService(DiscordSocketClient client, BotSettings settings) + { + _settings = settings; + client.MessageReceived += EventGuard.Guarded(ScoldForAtEveryoneUsage, nameof(ScoldForAtEveryoneUsage)); + } + + private async Task ScoldForAtEveryoneUsage(SocketMessage messageParam) + { + if (messageParam.Author.IsBot || ((IGuildUser)messageParam.Author).GuildPermissions.MentionEveryone) + return; + var content = messageParam.Content; + if (content.Contains("@everyone") || content.Contains("@here")) + { + if (_everyoneScoldCooldown.ContainsKey(messageParam.Author.Id) && + _everyoneScoldCooldown[messageParam.Author.Id] > DateTime.Now) + return; + _everyoneScoldCooldown[messageParam.Author.Id] = + DateTime.Now.AddSeconds(_settings.EveryoneScoldPeriodSeconds); + + await messageParam.Channel.SendMessageAsync( + $"Please don't try to alert **everyone** on the server, {messageParam.Author.Mention}!\n" + + "If you are asking a question, people will help you when they have time.") + .DeleteAfterTime(minutes: 2); + } + } +} diff --git a/DiscordBot/Services/KarmaService.cs b/DiscordBot/Services/KarmaService.cs new file mode 100644 index 00000000..030f24a8 --- /dev/null +++ b/DiscordBot/Services/KarmaService.cs @@ -0,0 +1,113 @@ +using System.Text; +using System.Text.RegularExpressions; +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services; + +public class KarmaService +{ + private readonly DatabaseService _databaseService; + private readonly ILoggingService _loggingService; + private readonly BotSettings _settings; + + private readonly HashSet _canEditThanks; + private readonly Dictionary _thanksCooldown; + private readonly string _thanksRegex; + private readonly int _thanksCooldownTime; + private readonly int _thanksMinJoinTime; + + public KarmaService(DiscordSocketClient client, DatabaseService databaseService, ILoggingService loggingService, + BotSettings settings, UserSettings userSettings) + { + _databaseService = databaseService; + _loggingService = loggingService; + _settings = settings; + _canEditThanks = new HashSet(32); + _thanksCooldown = new Dictionary(); + + var sbThanks = new StringBuilder(); + var thx = userSettings.Thanks; + sbThanks.Append(@"(?i)(?(Thanks, nameof(Thanks)); + client.MessageUpdated += EventGuard.Guarded, SocketMessage, ISocketMessageChannel>(ThanksEdited, nameof(ThanksEdited)); + } + + private async Task ThanksEdited(Cacheable cachedMessage, SocketMessage messageParam, + ISocketMessageChannel socketMessageChannel) + { + if (_canEditThanks.Contains(messageParam.Id)) await Thanks(messageParam); + } + + private async Task Thanks(SocketMessage messageParam) + { + var channel = (SocketGuildChannel)messageParam.Channel; + var guildId = channel.Guild.Id; + + if (guildId != _settings.GuildId) return; + + if (messageParam.Author.IsBot) + return; + var match = Regex.Match(messageParam.Content, _thanksRegex); + if (!match.Success) + return; + + var userId = messageParam.Author.Id; + var mentions = messageParam.MentionedUsers; + mentions = mentions.Distinct().Where(who => !who.IsBot && who.Id != userId).ToList(); + + const int defaultDelTime = 120; + if (mentions.Count > 0) + { + if (_thanksCooldown.HasUser(userId)) + { + await messageParam.Channel.SendMessageAsync( + $"{messageParam.Author.Mention} you must wait " + + $"{DateTime.Now - _thanksCooldown[userId]:ss} " + + "seconds before giving another karma point." + Environment.NewLine + + "(In the future, if you are trying to thank multiple people, include all their names in the thanks message.)") + .DeleteAfterTime(defaultDelTime); + return; + } + + var joinDate = ((IGuildUser)messageParam.Author).JoinedAt; + var j = joinDate + TimeSpan.FromSeconds(_thanksMinJoinTime); + if (j > DateTime.Now) + { + return; + } + + var sb = new StringBuilder(); + sb.Append(messageParam.Author.GetUserPreferredName().ToBold()); + sb.Append(" gave karma to "); + sb.Append(mentions.ToArray().ToUserPreferredNameArray().ToBoldArray().ToCommaList()); + foreach (var mention in mentions) + await _databaseService.Query.IncrementKarma(mention.Id.ToString()); + + var authorKarmaGiven = await _databaseService.Query.GetKarmaGiven(messageParam.Author.Id.ToString()); + await _databaseService.Query.UpdateKarmaGiven(messageParam.Author.Id.ToString(), authorKarmaGiven + 1); + + sb.Append("."); + + _canEditThanks.Remove(messageParam.Id); + _thanksCooldown.AddCooldown(userId, _thanksCooldownTime); + + await messageParam.Channel.SendMessageAsync(sb.ToString()); + await _loggingService.LogChannelAndFile(sb + " in channel " + messageParam.Channel.Name); + } + + if (mentions.Count == 0 && _canEditThanks.Add(messageParam.Id)) + { + var _ = _canEditThanks.RemoveAfterSeconds(messageParam.Id, 240); + } + } +} diff --git a/DiscordBot/Services/MikuService.cs b/DiscordBot/Services/MikuService.cs new file mode 100644 index 00000000..958e873c --- /dev/null +++ b/DiscordBot/Services/MikuService.cs @@ -0,0 +1,55 @@ +using System.Text.RegularExpressions; +using Discord.WebSocket; +using DiscordBot.Data; +using DiscordBot.Settings; + +namespace DiscordBot.Services; + +public class MikuService +{ + private readonly BotSettings _settings; + + private DateTime _mikuMentioned; + private readonly TimeSpan _mikuCooldownTime; + private readonly string _mikuRegex; + private readonly string _mikuReply; + + public MikuService(DiscordSocketClient client, BotSettings settings) + { + _settings = settings; + + _mikuCooldownTime = new TimeSpan(0, 39, 0); // 39min + _mikuMentioned = DateTime.Now - _mikuCooldownTime; + _mikuRegex = @"(?i)\b(miku|hatsune|初音ミク|初音|ミク)\b"; + _mikuReply = + "(:three: :nine:|:microphone:|:notes:|:musical_note:|:musical_keyboard:|:mirror_ball:) " + + "(Oi, mite, mite,|Heya,|Hey, look,|Did someone mention Miku?) " + + "<@358915848515354626> (-chan|)!"; + + // Subscription commented out — enable when ready + //_client.MessageReceived += EventGuard.Guarded(MikuCheck, nameof(MikuCheck)); + } + + public async Task MikuCheck(SocketMessage messageParam) + { + var channel = (SocketGuildChannel)messageParam.Channel; + var guildId = channel.Guild.Id; + + if (guildId != _settings.GuildId) return; + + if (messageParam.Author.IsBot) + return; + + var now = DateTime.Now; + if ((DateTime.Now - _mikuMentioned) < _mikuCooldownTime) + return; + + var match = Regex.Match(messageParam.Content, _mikuRegex); + if (!match.Success) + return; + + _mikuMentioned = now; + var reply = FuzzTable.Evaluate(_mikuReply); + await messageParam.Channel.SendMessageAsync(reply); + } +} diff --git a/DiscordBot/Services/ServerService.cs b/DiscordBot/Services/ServerService.cs new file mode 100644 index 00000000..bdcd8919 --- /dev/null +++ b/DiscordBot/Services/ServerService.cs @@ -0,0 +1,15 @@ +using Discord.WebSocket; + +namespace DiscordBot.Services; + +public class ServerService +{ + private readonly DiscordSocketClient _client; + + public ServerService(DiscordSocketClient client) + { + _client = client; + } + + public int GetGatewayPing() => _client.Latency; +} diff --git a/DiscordBot/Services/UserService.cs b/DiscordBot/Services/UserService.cs deleted file mode 100644 index 192c7ef1..00000000 --- a/DiscordBot/Services/UserService.cs +++ /dev/null @@ -1,694 +0,0 @@ -using System.Globalization; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; -using Discord.WebSocket; -using DiscordBot.Settings; -using DiscordBot.Data; - -namespace DiscordBot.Services; - -public class UserService -{ - private const string ServiceName = "UserService"; - - private readonly HashSet _canEditThanks; //Doesn't need to be saved - private readonly DiscordSocketClient _client; - public readonly string CodeFormattingExample; - private readonly int _codeReminderCooldownTime; - private readonly string CodeReminderFormattingExample; - private readonly DatabaseService _databaseService; - private readonly ILoggingService _loggingService; - - private readonly Regex _x3CodeBlock = -new("^(?`{3}((?\\w*?$)|$).+?({.+?}).+?`{3})", RegexOptions.Multiline | RegexOptions.Singleline); - - private readonly Regex _x2CodeBlock = new("^(`{2})[^`].+?([^`]`{2})$", RegexOptions.Multiline); - private readonly List _codeBlockWarnPatterns; - private readonly short _maxCodeBlockLengthWarning = 800; - - private readonly List _noXpChannels; - - private readonly BotSettings _settings; - private readonly Dictionary _thanksCooldown; - private readonly Dictionary _everyoneScoldCooldown = new(); - - private readonly List<(ulong id, DateTime time)> _welcomeNoticeUsers = new(); - - private readonly int _thanksCooldownTime; - private readonly int _thanksMinJoinTime; - private readonly string _thanksRegex; - - private DateTime _mikuMentioned; - private readonly TimeSpan _mikuCooldownTime; - private readonly string _mikuRegex; - private readonly string _mikuReply; - - private readonly UpdateService _updateService; - - private readonly Dictionary _xpCooldown; - private readonly int _xpMaxCooldown; - private readonly int _xpMaxPerMessage; - private readonly int _xpMinCooldown; - - private readonly int _xpMinPerMessage; - - private readonly Random _rand; - - private readonly Color _welcomeColour = new Color(7, 84, 53); - public int WaitingWelcomeMessagesCount => _welcomeNoticeUsers.Count; - - public DateTime NextWelcomeMessage => - _welcomeNoticeUsers.Any() ? _welcomeNoticeUsers.Min(x => x.time) : DateTime.MaxValue; - - public UserService(DiscordSocketClient client, DatabaseService databaseService, ILoggingService loggingService, - UpdateService updateService, - BotSettings settings, UserSettings userSettings) - { - _client = client; - _rand = new Random(); - _databaseService = databaseService; - _loggingService = loggingService; - _updateService = updateService; - _settings = settings; - _xpCooldown = new Dictionary(); - _canEditThanks = new HashSet(32); - _thanksCooldown = new Dictionary(); - CodeReminderCooldown = new Dictionary(); - - //TODO We should make this into a config file that we can confiure during runtime. - _noXpChannels = new List - { - _settings.BotCommandsChannel.Id - }; - - /* - Init XP - */ - _xpMinPerMessage = userSettings.XpMinPerMessage; - _xpMaxPerMessage = userSettings.XpMaxPerMessage; - _xpMinCooldown = userSettings.XpMinCooldown; - _xpMaxCooldown = userSettings.XpMaxCooldown; - - /* - Init thanks - */ - var sbThanks = new StringBuilder(); - var thx = userSettings.Thanks; - sbThanks.Append(@"(?i)(? (-chan|)!"; - //_mikuReply = "Oi, mite, mite, <@427306565184389132> ! :three: :nine:"; // test - - /* - Init Code analysis - */ - _codeReminderCooldownTime = userSettings.CodeReminderCooldown; - CodeFormattingExample = @"\`\`\`cs" + Environment.NewLine + - "Write your code on new line here." + Environment.NewLine + - @"\`\`\`" + Environment.NewLine; - - CodeReminderFormattingExample = CodeFormattingExample + "*To disable these reminders use \"!disablecodetips\"*"; - - //TODO Detect double code block and tell them to use 3? Seems kinda pointless since all it provides is highlights - - _codeBlockWarnPatterns = new List(); - // Checks if there is { } in the message - _codeBlockWarnPatterns.Add(new Regex(".*?({.+?}).*?", RegexOptions.Singleline)); - // We look for (if, else if) followed by ( and ) somewhere after. We also check that the ) is end of the line, or followed by comments // - _codeBlockWarnPatterns.Add(new Regex("(if|else\\sif).?\\(.+\\).?($|\\/{2}|\\s?)", RegexOptions.Multiline)); - // Check for a method from start of line (since discord would ignore tab) and if any comments after - _codeBlockWarnPatterns.Add(new Regex("^(\\w*.\\w*)\\(\\w*?\\);($|.?($|.*?\\/{2}))", RegexOptions.Multiline)); - // Check for some collection of characters being set to some other collection of characters and check if end of line or comment. - _codeBlockWarnPatterns.Add(new Regex("^.+? =.+?($|.*?\\/\\/)", RegexOptions.Multiline)); - - /* Make sure folders we require exist */ - if (!Directory.Exists($"{_settings.ServerRootPath}/images/profiles/")) - { - Directory.CreateDirectory($"{_settings.ServerRootPath}/images/profiles/"); - } - - /* - Event subscriptions - */ - _client.MessageReceived += EventGuard.Guarded(UpdateXp, nameof(UpdateXp)); - _client.MessageReceived += EventGuard.Guarded(Thanks, nameof(Thanks)); - _client.MessageUpdated += EventGuard.Guarded, SocketMessage, ISocketMessageChannel>(ThanksEdited, nameof(ThanksEdited)); - //_client.MessageReceived += MikuCheck; - _client.MessageReceived += EventGuard.Guarded(CodeCheck, nameof(CodeCheck)); - _client.MessageReceived += EventGuard.Guarded(ScoldForAtEveryoneUsage, nameof(ScoldForAtEveryoneUsage)); - _client.UserJoined += EventGuard.Guarded(UserJoined, nameof(UserJoined)); - _client.GuildMemberUpdated += EventGuard.Guarded, SocketGuildUser>(UserUpdated, nameof(UserUpdated)); - _client.UserLeft += EventGuard.Guarded(UserLeft, nameof(UserLeft)); - - _client.MessageReceived += EventGuard.Guarded(CheckForWelcomeMessage, nameof(CheckForWelcomeMessage)); - _client.UserIsTyping += EventGuard.Guarded, Cacheable>(UserIsTyping, nameof(UserIsTyping)); - - LoadData(); - UpdateLoop(); - - Task.Run(DelayedWelcomeService); - } - - private async Task UserLeft(SocketGuild guild, SocketUser user) - { - if (user.IsBot) return; - // Try get user, may not exist anymore since they've "left" - var guildUser = guild.GetUser(user.Id); - if (guildUser?.JoinedAt != null) - { - var joinDate = guildUser.JoinedAt.Value.Date; - - var timeStayed = DateTime.Now - joinDate; - await _loggingService.LogChannelAndFile( - $"User Left - After {(timeStayed.Days > 1 ? Math.Floor((double)timeStayed.Days) + " days" : " ")}" + - $" {Math.Floor((double)timeStayed.Hours).ToString(CultureInfo.InvariantCulture)} hours {user.Mention} - `{guildUser.GetPreferredAndUsername()}` - ID : `{user.Id}`"); - } - // If bot is to slow to get user info, we just say they left at current time. - else - { - await _loggingService.LogChannelAndFile( - $"User Left - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}` - Left at {DateTime.Now}"); - } - } - - public Dictionary CodeReminderCooldown { get; private set; } - - private async void UpdateLoop() - { - while (true) - { - try - { - await Task.Delay(10000); - SaveData(); - } - catch (Exception e) - { - LoggingService.LogToConsole($"[UpdateLoop] Unhandled exception: {e}", LogSeverity.Error); - } - } - // ReSharper disable once FunctionNeverReturns - } - - private void LoadData() - { - var data = _updateService.GetUserData(); - CodeReminderCooldown = data.CodeReminderCooldown ?? new Dictionary(); - } - - private void SaveData() - { - var data = new UserData - { - CodeReminderCooldown = CodeReminderCooldown - }; - _updateService.SetUserData(data); - } - - public async Task UpdateXp(SocketMessage messageParam) - { - if (messageParam.Author.IsBot) - return; - - if (_noXpChannels.Contains(messageParam.Channel.Id)) - return; - - var userId = messageParam.Author.Id; - if (_xpCooldown.HasUser(userId)) - return; - - var waitTime = _rand.Next(_xpMinCooldown, _xpMaxCooldown); - float baseXp = _rand.Next(_xpMinPerMessage, _xpMaxPerMessage); - float bonusXp = 0; - - // Add Delay and delay action by 200ms to avoid some weird database collision? - _xpCooldown.AddCooldown(userId, waitTime); - Task.Run(async () => - { - var user = await _databaseService.GetOrAddUser((SocketGuildUser)messageParam.Author); - if (user == null) - return; - - bonusXp += baseXp * (1f + user.Karma / 100f); - - //Reduce XP for members with no role - if (((IGuildUser)messageParam.Author).RoleIds.Count < 2) - baseXp *= .9f; - - //Lower xp for difference between level and karma - var reduceXp = 1f; - if (user.Karma < user.Level) reduceXp = 1 - Math.Min(.9f, (user.Level - user.Karma) * .05f); - - var xpGain = (int)Math.Round((baseXp + bonusXp) * reduceXp); - - await _databaseService.Query.UpdateXp(userId.ToString(), user.Exp + (long)xpGain); - - _loggingService.LogXp(messageParam.Channel.Name, messageParam.Author.Username, baseXp, bonusXp, reduceXp, - xpGain); - - await LevelUp(messageParam, userId); - }); - } - - ///

- /// Show level up message - /// - /// - /// - /// - private async Task LevelUp(SocketMessage messageParam, ulong userId) - { - var level = await _databaseService.Query.GetLevel(userId.ToString()); - var xp = await _databaseService.Query.GetXp(userId.ToString()); - - var xpHigh = GetXpHigh(level); - - if (xp < xpHigh) - return; - - await _databaseService.Query.UpdateLevel(userId.ToString(), level + 1); - - // First few levels are only a couple messages, - // so we hide them to avoid scaring people away and give them slightly longer to naturally see these in the server. - if (level <= 3) - return; - - var msg = messageParam.Author.GetUserPreferredName().ToBold() + " has leveled up!"; - await messageParam.Channel.SendMessageAsync(msg).DeleteAfterTime(60); - //TODO Add level up card - } - - private double GetXpLow(int level) => 70d - 139.5d * (level + 1d) + 69.5 * Math.Pow(level + 1d, 2d); - - private double GetXpHigh(int level) => 70d - 139.5d * (level + 2d) + 69.5 * Math.Pow(level + 2d, 2d); - - public Embed WelcomeMessage(SocketGuildUser user) - { - string icon = user.GetAvatarUrl(); - icon = string.IsNullOrEmpty(icon) ? "https://cdn.discordapp.com/embed/avatars/0.png" : icon; - - string welcomeString = $"Welcome to Unity Developer Community, {user.GetPreferredAndUsername()}!"; - var builder = new EmbedBuilder() - .WithDescription(welcomeString) - .WithColor(_welcomeColour) - .WithAuthor(user.GetUserPreferredName(), icon); - - var embed = builder.Build(); - return embed; - } - - public int GetGatewayPing() => _client.Latency; - - #region Events - - // Message Edited Thanks - public async Task ThanksEdited(Cacheable cachedMessage, SocketMessage messageParam, - ISocketMessageChannel socketMessageChannel) - { - if (_canEditThanks.Contains(messageParam.Id)) await Thanks(messageParam); - } - - public async Task Thanks(SocketMessage messageParam) - { - //Get guild id - var channel = (SocketGuildChannel)messageParam.Channel; - var guildId = channel.Guild.Id; - - //Make sure its in the UDC server - if (guildId != _settings.GuildId) return; - - if (messageParam.Author.IsBot) - return; - var match = Regex.Match(messageParam.Content, _thanksRegex); - if (!match.Success) - return; - - var userId = messageParam.Author.Id; - var mentions = messageParam.MentionedUsers; - mentions = mentions.Distinct().Where(who => !who.IsBot && who.Id != userId).ToList(); - - const int defaultDelTime = 120; - if (mentions.Count > 0) - { - if (_thanksCooldown.HasUser(userId)) - { - await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.Mention} you must wait " + - $"{DateTime.Now - _thanksCooldown[userId]:ss} " + - "seconds before giving another karma point." + Environment.NewLine + - "(In the future, if you are trying to thank multiple people, include all their names in the thanks message.)") - .DeleteAfterTime(defaultDelTime); - return; - } - - var joinDate = ((IGuildUser)messageParam.Author).JoinedAt; - var j = joinDate + TimeSpan.FromSeconds(_thanksMinJoinTime); - if (j > DateTime.Now) - { - return; - } - - var sb = new StringBuilder(); - sb.Append(messageParam.Author.GetUserPreferredName().ToBold()); - sb.Append(" gave karma to "); - sb.Append(mentions.ToArray().ToUserPreferredNameArray().ToBoldArray().ToCommaList()); - foreach (var mention in mentions) - await _databaseService.Query.IncrementKarma(mention.Id.ToString()); - - // Even if a user gives multiple karma in one message, we only give one credit. - var authorKarmaGiven = await _databaseService.Query.GetKarmaGiven(messageParam.Author.Id.ToString()); - await _databaseService.Query.UpdateKarmaGiven(messageParam.Author.Id.ToString(), authorKarmaGiven + 1); - - sb.Append("."); - - _canEditThanks.Remove(messageParam.Id); - _thanksCooldown.AddCooldown(userId, _thanksCooldownTime); - - await messageParam.Channel.SendMessageAsync(sb.ToString()); - await _loggingService.LogChannelAndFile(sb + " in channel " + messageParam.Channel.Name); - } - - if (mentions.Count == 0 && _canEditThanks.Add(messageParam.Id)) - { - var _ = _canEditThanks.RemoveAfterSeconds(messageParam.Id, 240); - } - } - - public async Task MikuCheck(SocketMessage messageParam) - { - //Get guild id - var channel = (SocketGuildChannel)messageParam.Channel; - var guildId = channel.Guild.Id; - - //Make sure its in the UDC server - if (guildId != _settings.GuildId) return; - - if (messageParam.Author.IsBot) - return; - - var now = DateTime.Now; - if ((DateTime.Now - _mikuMentioned) < _mikuCooldownTime) - return; - - var match = Regex.Match(messageParam.Content, _mikuRegex); - if (!match.Success) - return; - - _mikuMentioned = now; - var reply = FuzzTable.Evaluate(_mikuReply); - await messageParam.Channel.SendMessageAsync(reply); - } - - public async Task CodeCheck(SocketMessage messageParam) - { - // Don't correct a Bot, don't correct in off-topic - if (messageParam.Author.IsBot || messageParam.Channel.Id == _settings.GeneralChannel.Id) - return; - - // We just ignore anything if it is under 200 characters - if (messageParam.Content.Length < 200) - return; - - var userId = messageParam.Author.Id; - - //Simple check to cover most large code posting cases without being an issue for most non-code messages - // TODO Perhaps work out a more advanced Regex based check at a later time - if (!CodeReminderCooldown.HasUser(userId)) - { - var content = messageParam.Content; - - // We have a smart cookie using ```cs so we assume they're all knowing and abort early to save cpu - var foundTrippleCodeBlock = _x3CodeBlock.Match(content); - if (foundTrippleCodeBlock.Groups["CS"].Length > 0) - return; - if (foundTrippleCodeBlock.Groups["CodeBlock"].Success) - { - // A ``` codeblock was found, but no CS, let 'em know - await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.Mention} when using code blocks remember to use the ***syntax highlights*** to improve readability.\n{CodeReminderFormattingExample}") - .DeleteAfterSeconds(seconds: 60); - return; - } - - // Checks get a bit more expensive from here - var foundDoubleCodeBlock = _x2CodeBlock.Match(content).Success; - - int hits = 0; - foreach (var regex in _codeBlockWarnPatterns) - { - hits += regex.Match(content).Captures.Count; - } - - // Some arbitary condition, this means 3 regex captures would be required which should easy enough to trigger without much chance for a false positive. - if (!foundDoubleCodeBlock && hits >= 3) - { - //! CodeReminderCooldown.AddCooldown(userId, _codeReminderCooldownTime); - await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.Mention} are you sharing C# scripts? Remember to use codeblocks to help readability!\n{CodeReminderFormattingExample}") - .DeleteAfterSeconds(seconds: 60); - if (content.Length > _maxCodeBlockLengthWarning) - { - await messageParam.Channel.SendMessageAsync( -"The code you're sharing is quite long, maybe use a free service like and share the link here instead.") - .DeleteAfterSeconds(seconds: 60); - } - } - // If we know there is a codeblock - else if (foundDoubleCodeBlock && hits > 0) - { - //! CodeReminderCooldown.AddCooldown(userId, _codeReminderCooldownTime); - await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.Mention} when using code blocks remember to use \\`\\`\\`cs as this will help improve readability for C# scripts.\n{CodeReminderFormattingExample}") - .DeleteAfterSeconds(seconds: 60); - } - } - } - - - private async Task ScoldForAtEveryoneUsage(SocketMessage messageParam) - { - if (messageParam.Author.IsBot || ((IGuildUser)messageParam.Author).GuildPermissions.MentionEveryone) - return; - var content = messageParam.Content; - if (content.Contains("@everyone") || content.Contains("@here")) - { - if (_everyoneScoldCooldown.ContainsKey(messageParam.Author.Id) && - _everyoneScoldCooldown[messageParam.Author.Id] > DateTime.Now) - return; - // We add to dictionary with the time it must be passed before they'll be notified again. - _everyoneScoldCooldown[messageParam.Author.Id] = - DateTime.Now.AddSeconds(_settings.EveryoneScoldPeriodSeconds); - - await messageParam.Channel.SendMessageAsync( - $"Please don't try to alert **everyone** on the server, {messageParam.Author.Mention}!\n" + - "If you are asking a question, people will help you when they have time.") - .DeleteAfterTime(minutes: 2); - } - } - - // Anything relevant to the first time someone connects to the server - - #region Welcome Service - - // If a user talks before they've been welcomed, we welcome them and remove them from the welcome list so they're not welcomes a second time. - private async Task UserIsTyping(Cacheable user, Cacheable channel) - { - if (_welcomeNoticeUsers.Count == 0) - return; - if (user.Value.IsBot) - return; - - if (_welcomeNoticeUsers.Exists(u => u.id == user.Id)) - { - _welcomeNoticeUsers.RemoveAll(u => u.id == user.Id); - await ProcessWelcomeUser(user.Id, user.Value); - } - } - - private async Task CheckForWelcomeMessage(SocketMessage messageParam) - { - if (_welcomeNoticeUsers.Count == 0) - return; - - var user = messageParam.Author; - if (user.IsBot) - return; - - if (_welcomeNoticeUsers.Exists(u => u.id == user.Id)) - { - _welcomeNoticeUsers.RemoveAll(u => u.id == user.Id); - await ProcessWelcomeUser(user.Id, user); - } - } - - private async Task UserJoined(SocketGuildUser user) - { - // Send them the Welcome DM first. - await DMFormattedWelcome(user); - - var socketTextChannel = _client.GetChannel(_settings.GeneralChannel.Id) as SocketTextChannel; - await _databaseService.GetOrAddUser(user); - - await _loggingService.LogChannelAndFile( - $"User Joined - {user.Mention} - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}`"); - - // We check if they're already in the welcome list, if they are we don't add them again to avoid double posts - if (_welcomeNoticeUsers.Count == 0 || !_welcomeNoticeUsers.Exists(u => u.id == user.Id)) - { - _welcomeNoticeUsers.Add((user.Id, DateTime.Now.AddSeconds(_settings.WelcomeMessageDelaySeconds))); - } - } - - // Welcomes users to the server after they've been connected for over x number of seconds. - private async Task DelayedWelcomeService() - { - ulong currentlyProcessedUserId = 0; - bool firstRun = true; - await Task.Delay(10000); - try - { - List toRemove = new(); - while (true) - { - var now = DateTime.Now; - // This could be optimized, however the users in this list won't ever really be large enough to matter. - // We loop through our list, anyone that has been in the list for more than x seconds is welcomed. - foreach (var userData in _welcomeNoticeUsers.Where(u => u.time < now)) - { - currentlyProcessedUserId = userData.id; - await ProcessWelcomeUser(userData.id, null); - - toRemove.Add(userData.id); - } - - // Remove all the users we've welcomed from the list - if (toRemove.Count > 0) - { - _welcomeNoticeUsers.RemoveAll(u => toRemove.Contains(u.id)); - toRemove.Clear(); - // Prevent the list from growing too large, not that it really matters. - if (toRemove.Capacity > 20) - { - toRemove.Capacity = 20; - } - } - - if (firstRun) - firstRun = false; - await Task.Delay(10000); - } - } - catch (Exception e) - { - // Catch and show exception - await _loggingService.LogChannelAndFile($"{ServiceName} Exception during welcome message `{currentlyProcessedUserId}`.\n{e.Message}.", ExtendedLogSeverity.Warning); - - // Remove the offending user from the dictionary and run the service again. - _welcomeNoticeUsers.RemoveAll(u => u.id == currentlyProcessedUserId); - if (_welcomeNoticeUsers.Count > 200) - { - _welcomeNoticeUsers.Clear(); - await _loggingService.LogAction($"{ServiceName}: Welcome list cleared due to size (+200), this should not happen.", ExtendedLogSeverity.Error); - } - - if (firstRun) - await _loggingService.LogAction($"{ServiceName}: Welcome service failed on first run!? This should not happen.", ExtendedLogSeverity.Error); - - // Run the service again. - Task.Run(DelayedWelcomeService); - } - } - - private async Task ProcessWelcomeUser(ulong userID, IUser user = null) - { - if (_welcomeNoticeUsers.Exists(u => u.id == userID)) - // If we didn't get the user passed in, we try grab it - user ??= await _client.GetUserAsync(userID); - // if they're null, they've likely left, so we just remove them from the list. - if (user == null) - return; - - var offTopic = await _client.GetChannelAsync(_settings.GeneralChannel.Id) as SocketTextChannel; - if (user is not SocketGuildUser guildUser) - return; - var em = WelcomeMessage(guildUser); - if (offTopic != null && em != null) - await offTopic.SendMessageAsync(string.Empty, false, em); - } - - - public async Task DMFormattedWelcome(SocketGuildUser user) - { - var dm = await user.CreateDMChannelAsync(); - return await dm.TrySendMessage(embed: GetWelcomeEmbed(user.Username)); - } - - public Embed GetWelcomeEmbed(string username = "") - { - //TODO Generate this using Settings or some other config, hardcoded isn't ideal. - var em = new EmbedBuilder() - .WithColor(new Color(0x12D687)) - .AddField("Hello " + username, - "Welcome to Unity Developer Community!\nPlease read and respect the rules to keep the community friendly!\n*When asking questions, remember to ask your question, [don't ask to ask](https://dontasktoask.com/).*") - .AddField("__RULES__", - ":white_small_square: Be polite and respectful.\n" + - ":white_small_square: No Direct Messages to users without permission.\n" + - ":white_small_square: Do not post the same question in multiple channels.\n" + - ":white_small_square: Only post links to your games in the appropriate channels.\n" + - ":white_small_square: Some channels have additional rules, please check pinned messages.\n" + - $":white_small_square: A more inclusive list of rules can be found in {(_settings.RulesChannel is null || _settings.RulesChannel.Id == 0 ? "#rules" : $"<#{_settings.RulesChannel.Id.ToString()}>")}" - ) - .AddField("__PROGRAMMING RESOURCES__", - ":white_small_square: Official Unity [Manual](https://docs.unity3d.com/Manual/index.html)\n" + - ":white_small_square: Official Unity [Script API](https://docs.unity3d.com/ScriptReference/index.html)\n" + - ":white_small_square: Introductory Tutorials: [Official Unity Tutorials](https://unity3d.com/learn/tutorials)\n" + - ":white_small_square: Intermediate Tutorials: [CatLikeCoding](https://catlikecoding.com/unity/tutorials/)\n" - ) - .AddField("__ART RESOURCES__", - ":white_small_square: Blender Beginner Tutorial [Blender Guru Donut](https://www.youtube.com/watch?v=TPrnSACiTJ4&list=PLjEaoINr3zgEq0u2MzVgAaHEBt--xLB6U&index=2)\n" + - ":white_small_square: Free Simple Assets [Kenney](https://www.kenney.nl/assets)\n" + - ":white_small_square: Game Assets [itch.io](https://itch.io/game-assets/free)" - ) - .AddField("__GAME DESIGN RESOURCES__", - ":white_small_square: How to write a Game Design Document (GDD) [Gamasutra](https://www.gamasutra.com/blogs/LeandroGonzalez/20160726/277928/How_to_Write_a_Game_Design_Document.php)\n" + - ":white_small_square: How to start building video games [CGSpectrum](https://www.cgspectrum.com/blog/game-design-basics-how-to-start-building-video-games)\n" + - ":white_small_square: Keep Things Clear: Don't Confuse Your Players [TutsPlus](https://gamedevelopment.tutsplus.com/articles/keep-things-clear-dont-confuse-your-players--cms-22780)" - ); - return (em.Build()); - } - - #endregion - - private async Task UserUpdated(Cacheable oldUserCached, SocketGuildUser user) - { - var oldUser = await oldUserCached.GetOrDownloadAsync(); - if (oldUser.Nickname != user.Nickname) - { - await _loggingService.LogChannelAndFile( - $"User {oldUser.GetUserPreferredName()} changed his " + - $"username to {user.GetUserPreferredName()}"); - } - } - - #endregion -} diff --git a/DiscordBot/Services/WelcomeService.cs b/DiscordBot/Services/WelcomeService.cs new file mode 100644 index 00000000..72ba2434 --- /dev/null +++ b/DiscordBot/Services/WelcomeService.cs @@ -0,0 +1,241 @@ +using System.IO; +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services; + +public class WelcomeService +{ + private const string ServiceName = "WelcomeService"; + + private readonly DiscordSocketClient _client; + private readonly DatabaseService _databaseService; + private readonly ILoggingService _loggingService; + + private readonly BotSettings _settings; + + private readonly List<(ulong id, DateTime time)> _welcomeNoticeUsers = new(); + + private readonly Color _welcomeColour = new Color(7, 84, 53); + public int WaitingWelcomeMessagesCount => _welcomeNoticeUsers.Count; + + public DateTime NextWelcomeMessage => + _welcomeNoticeUsers.Any() ? _welcomeNoticeUsers.Min(x => x.time) : DateTime.MaxValue; + + public WelcomeService(DiscordSocketClient client, DatabaseService databaseService, ILoggingService loggingService, + BotSettings settings) + { + _client = client; + _databaseService = databaseService; + _loggingService = loggingService; + _settings = settings; + + /* Make sure folders we require exist */ + if (!Directory.Exists($"{_settings.ServerRootPath}/images/profiles/")) + { + Directory.CreateDirectory($"{_settings.ServerRootPath}/images/profiles/"); + } + + /* + Event subscriptions + */ + _client.UserJoined += EventGuard.Guarded(UserJoined, nameof(UserJoined)); + + _client.MessageReceived += EventGuard.Guarded(CheckForWelcomeMessage, nameof(CheckForWelcomeMessage)); + _client.UserIsTyping += EventGuard.Guarded, Cacheable>(UserIsTyping, nameof(UserIsTyping)); + + Task.Run(DelayedWelcomeService); + } + + public Embed WelcomeMessage(SocketGuildUser user) + { + string icon = user.GetAvatarUrl(); + icon = string.IsNullOrEmpty(icon) ? "https://cdn.discordapp.com/embed/avatars/0.png" : icon; + + string welcomeString = $"Welcome to Unity Developer Community, {user.GetPreferredAndUsername()}!"; + var builder = new EmbedBuilder() + .WithDescription(welcomeString) + .WithColor(_welcomeColour) + .WithAuthor(user.GetUserPreferredName(), icon); + + var embed = builder.Build(); + return embed; + } + + #region Events + + // Anything relevant to the first time someone connects to the server + + #region Welcome Service + + // If a user talks before they've been welcomed, we welcome them and remove them from the welcome list so they're not welcomes a second time. + private async Task UserIsTyping(Cacheable user, Cacheable channel) + { + if (_welcomeNoticeUsers.Count == 0) + return; + if (user.Value.IsBot) + return; + + if (_welcomeNoticeUsers.Exists(u => u.id == user.Id)) + { + _welcomeNoticeUsers.RemoveAll(u => u.id == user.Id); + await ProcessWelcomeUser(user.Id, user.Value); + } + } + + private async Task CheckForWelcomeMessage(SocketMessage messageParam) + { + if (_welcomeNoticeUsers.Count == 0) + return; + + var user = messageParam.Author; + if (user.IsBot) + return; + + if (_welcomeNoticeUsers.Exists(u => u.id == user.Id)) + { + _welcomeNoticeUsers.RemoveAll(u => u.id == user.Id); + await ProcessWelcomeUser(user.Id, user); + } + } + + private async Task UserJoined(SocketGuildUser user) + { + // Send them the Welcome DM first. + await DMFormattedWelcome(user); + + var socketTextChannel = _client.GetChannel(_settings.GeneralChannel.Id) as SocketTextChannel; + await _databaseService.GetOrAddUser(user); + + await _loggingService.LogChannelAndFile( + $"User Joined - {user.Mention} - `{user.GetPreferredAndUsername()}` - ID : `{user.Id}`"); + + // We check if they're already in the welcome list, if they are we don't add them again to avoid double posts + if (_welcomeNoticeUsers.Count == 0 || !_welcomeNoticeUsers.Exists(u => u.id == user.Id)) + { + _welcomeNoticeUsers.Add((user.Id, DateTime.Now.AddSeconds(_settings.WelcomeMessageDelaySeconds))); + } + } + + // Welcomes users to the server after they've been connected for over x number of seconds. + private async Task DelayedWelcomeService() + { + ulong currentlyProcessedUserId = 0; + bool firstRun = true; + await Task.Delay(10000); + try + { + List toRemove = new(); + while (true) + { + var now = DateTime.Now; + // This could be optimized, however the users in this list won't ever really be large enough to matter. + // We loop through our list, anyone that has been in the list for more than x seconds is welcomed. + foreach (var userData in _welcomeNoticeUsers.Where(u => u.time < now)) + { + currentlyProcessedUserId = userData.id; + await ProcessWelcomeUser(userData.id, null); + + toRemove.Add(userData.id); + } + + // Remove all the users we've welcomed from the list + if (toRemove.Count > 0) + { + _welcomeNoticeUsers.RemoveAll(u => toRemove.Contains(u.id)); + toRemove.Clear(); + // Prevent the list from growing too large, not that it really matters. + if (toRemove.Capacity > 20) + { + toRemove.Capacity = 20; + } + } + + if (firstRun) + firstRun = false; + await Task.Delay(10000); + } + } + catch (Exception e) + { + // Catch and show exception + await _loggingService.LogChannelAndFile($"{ServiceName} Exception during welcome message `{currentlyProcessedUserId}`.\n{e.Message}.", ExtendedLogSeverity.Warning); + + // Remove the offending user from the dictionary and run the service again. + _welcomeNoticeUsers.RemoveAll(u => u.id == currentlyProcessedUserId); + if (_welcomeNoticeUsers.Count > 200) + { + _welcomeNoticeUsers.Clear(); + await _loggingService.LogAction($"{ServiceName}: Welcome list cleared due to size (+200), this should not happen.", ExtendedLogSeverity.Error); + } + + if (firstRun) + await _loggingService.LogAction($"{ServiceName}: Welcome service failed on first run!? This should not happen.", ExtendedLogSeverity.Error); + + // Run the service again. + Task.Run(DelayedWelcomeService); + } + } + + private async Task ProcessWelcomeUser(ulong userID, IUser user = null) + { + if (_welcomeNoticeUsers.Exists(u => u.id == userID)) + // If we didn't get the user passed in, we try grab it + user ??= await _client.GetUserAsync(userID); + // if they're null, they've likely left, so we just remove them from the list. + if (user == null) + return; + + var offTopic = await _client.GetChannelAsync(_settings.GeneralChannel.Id) as SocketTextChannel; + if (user is not SocketGuildUser guildUser) + return; + var em = WelcomeMessage(guildUser); + if (offTopic != null && em != null) + await offTopic.SendMessageAsync(string.Empty, false, em); + } + + + public async Task DMFormattedWelcome(SocketGuildUser user) + { + var dm = await user.CreateDMChannelAsync(); + return await dm.TrySendMessage(embed: GetWelcomeEmbed(user.Username)); + } + + public Embed GetWelcomeEmbed(string username = "") + { + //TODO Generate this using Settings or some other config, hardcoded isn't ideal. + var em = new EmbedBuilder() + .WithColor(new Color(0x12D687)) + .AddField("Hello " + username, + "Welcome to Unity Developer Community!\nPlease read and respect the rules to keep the community friendly!\n*When asking questions, remember to ask your question, [don't ask to ask](https://dontasktoask.com/).*") + .AddField("__RULES__", + ":white_small_square: Be polite and respectful.\n" + + ":white_small_square: No Direct Messages to users without permission.\n" + + ":white_small_square: Do not post the same question in multiple channels.\n" + + ":white_small_square: Only post links to your games in the appropriate channels.\n" + + ":white_small_square: Some channels have additional rules, please check pinned messages.\n" + + $":white_small_square: A more inclusive list of rules can be found in {(_settings.RulesChannel is null || _settings.RulesChannel.Id == 0 ? "#rules" : $"<#{_settings.RulesChannel.Id.ToString()}>")}" + ) + .AddField("__PROGRAMMING RESOURCES__", + ":white_small_square: Official Unity [Manual](https://docs.unity3d.com/Manual/index.html)\n" + + ":white_small_square: Official Unity [Script API](https://docs.unity3d.com/ScriptReference/index.html)\n" + + ":white_small_square: Introductory Tutorials: [Official Unity Tutorials](https://unity3d.com/learn/tutorials)\n" + + ":white_small_square: Intermediate Tutorials: [CatLikeCoding](https://catlikecoding.com/unity/tutorials/)\n" + ) + .AddField("__ART RESOURCES__", + ":white_small_square: Blender Beginner Tutorial [Blender Guru Donut](https://www.youtube.com/watch?v=TPrnSACiTJ4&list=PLjEaoINr3zgEq0u2MzVgAaHEBt--xLB6U&index=2)\n" + + ":white_small_square: Free Simple Assets [Kenney](https://www.kenney.nl/assets)\n" + + ":white_small_square: Game Assets [itch.io](https://itch.io/game-assets/free)" + ) + .AddField("__GAME DESIGN RESOURCES__", + ":white_small_square: How to write a Game Design Document (GDD) [Gamasutra](https://www.gamasutra.com/blogs/LeandroGonzalez/20160726/277928/How_to_Write_a_Game_Design_Document.php)\n" + + ":white_small_square: How to start building video games [CGSpectrum](https://www.cgspectrum.com/blog/game-design-basics-how-to-start-building-video-games)\n" + + ":white_small_square: Keep Things Clear: Don't Confuse Your Players [TutsPlus](https://gamedevelopment.tutsplus.com/articles/keep-things-clear-dont-confuse-your-players--cms-22780)" + ); + return (em.Build()); + } + + #endregion + + #endregion +} diff --git a/DiscordBot/Services/XpService.cs b/DiscordBot/Services/XpService.cs new file mode 100644 index 00000000..606cb675 --- /dev/null +++ b/DiscordBot/Services/XpService.cs @@ -0,0 +1,100 @@ +using Discord.WebSocket; +using DiscordBot.Settings; + +namespace DiscordBot.Services; + +public class XpService +{ + private readonly DatabaseService _databaseService; + private readonly ILoggingService _loggingService; + + private readonly Dictionary _xpCooldown; + private readonly List _noXpChannels; + private readonly Random _rand; + + private readonly int _xpMinPerMessage; + private readonly int _xpMaxPerMessage; + private readonly int _xpMinCooldown; + private readonly int _xpMaxCooldown; + + public XpService(DiscordSocketClient client, DatabaseService databaseService, ILoggingService loggingService, + BotSettings settings, UserSettings userSettings) + { + _databaseService = databaseService; + _loggingService = loggingService; + _rand = new Random(); + _xpCooldown = new Dictionary(); + + _xpMinPerMessage = userSettings.XpMinPerMessage; + _xpMaxPerMessage = userSettings.XpMaxPerMessage; + _xpMinCooldown = userSettings.XpMinCooldown; + _xpMaxCooldown = userSettings.XpMaxCooldown; + + _noXpChannels = new List { settings.BotCommandsChannel.Id }; + + client.MessageReceived += EventGuard.Guarded(UpdateXp, nameof(UpdateXp)); + } + + private async Task UpdateXp(SocketMessage messageParam) + { + if (messageParam.Author.IsBot) + return; + + if (_noXpChannels.Contains(messageParam.Channel.Id)) + return; + + var userId = messageParam.Author.Id; + if (_xpCooldown.HasUser(userId)) + return; + + var waitTime = _rand.Next(_xpMinCooldown, _xpMaxCooldown); + float baseXp = _rand.Next(_xpMinPerMessage, _xpMaxPerMessage); + float bonusXp = 0; + + _xpCooldown.AddCooldown(userId, waitTime); + Task.Run(async () => + { + var user = await _databaseService.GetOrAddUser((SocketGuildUser)messageParam.Author); + if (user == null) + return; + + bonusXp += baseXp * (1f + user.Karma / 100f); + + if (((IGuildUser)messageParam.Author).RoleIds.Count < 2) + baseXp *= .9f; + + var reduceXp = 1f; + if (user.Karma < user.Level) reduceXp = 1 - Math.Min(.9f, (user.Level - user.Karma) * .05f); + + var xpGain = (int)Math.Round((baseXp + bonusXp) * reduceXp); + + await _databaseService.Query.UpdateXp(userId.ToString(), user.Exp + (long)xpGain); + + _loggingService.LogXp(messageParam.Channel.Name, messageParam.Author.Username, baseXp, bonusXp, reduceXp, + xpGain); + + await LevelUp(messageParam, userId); + }); + } + + private async Task LevelUp(SocketMessage messageParam, ulong userId) + { + var level = await _databaseService.Query.GetLevel(userId.ToString()); + var xp = await _databaseService.Query.GetXp(userId.ToString()); + + var xpHigh = GetXpHigh(level); + + if (xp < xpHigh) + return; + + await _databaseService.Query.UpdateLevel(userId.ToString(), level + 1); + + if (level <= 3) + return; + + var msg = messageParam.Author.GetUserPreferredName().ToBold() + " has leveled up!"; + await messageParam.Channel.SendMessageAsync(msg).DeleteAfterTime(60); + } + + private double GetXpHigh(int level) => 70d - 139.5d * (level + 2d) + 69.5 * Math.Pow(level + 2d, 2d); +} diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md index 34d8c163..f3d524c5 100644 --- a/docs/code-quality-audit.md +++ b/docs/code-quality-audit.md @@ -370,7 +370,7 @@ no `.cs` files and no test framework configured. ### Short-term (Architecture) -1. Split `UserService` into focused services +1. ~~Split `UserService` into focused services~~ ✅ 2. Split `BotSettings` into domain-specific config classes 3. Add `BotSettings.Validate()` post-deserialization 4. Extract business logic from command handlers into services diff --git a/docs/plans/done/userservice-usermodule-split.md b/docs/plans/done/userservice-usermodule-split.md new file mode 100644 index 00000000..1c328c64 --- /dev/null +++ b/docs/plans/done/userservice-usermodule-split.md @@ -0,0 +1,207 @@ +--- +post_title: "UserService & UserModule Split Plan" +author1: "Copilot" +post_slug: "userservice-usermodule-split" +microsoft_alias: "" +featured_image: "" +categories: [] +tags: ["refactor", "architecture"] +ai_note: "AI-generated plan" +summary: "Detailed plan for extracting focused services from UserService and focused modules from UserModule" +post_date: "2026-04-06" +--- + +## Overview + +Split `UserService` (god service) and `UserModule` (god module) into focused, +single-responsibility classes. Each service extraction is paired with its +corresponding module extraction where applicable. + +## Services to Extract from UserService + +### S1. XpService + +- **State**: `_xpCooldown`, `_xpMin/MaxPerMessage`, `_xpMin/MaxCooldown`, + `_noXpChannels`, `_rand` +- **Events**: `MessageReceived → UpdateXp` +- **Methods**: `UpdateXp()`, `LevelUp()`, `GetXpLow()`, `GetXpHigh()` +- **Dependencies**: `DatabaseService`, `ILoggingService`, `BotSettings`, + `UserSettings` +- **Note**: `GetXpLow/High` already duplicated in `ProfileCardService` — will + remain duplicated for now (different ownership) + +### S2. KarmaService + +- **State**: `_thanksCooldown`, `_canEditThanks`, `_thanksRegex`, + `_thanksCooldownTime`, `_thanksMinJoinTime` +- **Events**: `MessageReceived → Thanks`, + `MessageUpdated → ThanksEdited` +- **Methods**: `Thanks()`, `ThanksEdited()` +- **Dependencies**: `DatabaseService`, `ILoggingService`, `BotSettings`, + `UserSettings` + +### S3. CodeCheckService + +- **State**: `CodeReminderCooldown`, `_codeBlockWarnPatterns`, `_x3CodeBlock`, + `_x2CodeBlock`, `_codeReminderCooldownTime`, `_maxCodeBlockLengthWarning`, + `CodeFormattingExample`, `CodeReminderFormattingExample` +- **Events**: `MessageReceived → CodeCheck` +- **Methods**: `CodeCheck()` +- **Dependencies**: `ILoggingService`, `BotSettings`, `UserSettings`, + `UpdateService` +- **Public API**: `CodeFormattingExample` (used by `CodeTipModule`), + `CodeReminderCooldown` (used by `CodeTipModule`) +- **Persistence**: `UpdateLoop()`, `SaveData()`, `LoadData()` move here — + only `CodeReminderCooldown` is persisted + +### S4. EveryoneScoldService + +- **State**: `_everyoneScoldCooldown` +- **Events**: `MessageReceived → ScoldForAtEveryoneUsage` +- **Methods**: `ScoldForAtEveryoneUsage()` +- **Dependencies**: `BotSettings` +- **Tiny service** — could be inlined into a message filter, but extracting + keeps UserService clean + +### S5. MikuService + +- **State**: `_mikuMentioned`, `_mikuCooldownTime`, `_mikuRegex`, `_mikuReply` +- **Events**: `MessageReceived → MikuCheck` (currently commented out) +- **Methods**: `MikuCheck()` +- **Dependencies**: None (standalone easter egg) +- **Note**: Currently disabled. Will extract as-is with the event subscription + commented out + +### What Stays in UserService (→ renamed to WelcomeService) + +- Welcome block (`UserJoined`, `DelayedWelcomeService`, `ProcessWelcomeUser`, + `WelcomeMessage`, `DMFormattedWelcome`, `GetWelcomeEmbed`, + `CheckForWelcomeMessage`, `UserIsTyping`) + +`UserLeft` and `UserUpdated` move to `AuditLogService` (step 1, pre-split). + +After extraction, `UserService` is renamed to **WelcomeService** (step 17). + +## Modules to Extract from UserModule + +All new modules use `[Group("UserModule"), Alias("")]` to stay visible in +`!help`. + +### M1. ProfileModule — ALREADY DONE + +- Commands: `!profile` (2 overloads) +- Dependencies: `ProfileCardService`, `ILoggingService` + +### M2. QuoteModule + +- Commands: `!quote` (3 overloads) +- Dependencies: None beyond `Context` +- Self-contained, no service dependency + +### M3. RulesModule + +- Commands: `!rules` (2 overloads), `!globalrules`, `!welcome`, `!channels`, + `!faq` +- Dependencies: `Rules`, `UserService` (DMFormattedWelcome), `UpdateService` + (GetFaqData) +- FAQ is server info/guidance, fits with rules thematically +- Includes helper methods: `SearchFaqs`, `ListFaqs`, `GetFaqEmbed`, + `FormatFaq`, `CalculateScore`, `ParseNumber` + +### M4. RankModule + +- Commands: `!top`, `!topkarma`, `!topkarmaweekly`, `!topkarmamonthly`, + `!topkarmayearly` +- Dependencies: `DatabaseService`, `ILoggingService` +- Includes helper: `GenerateRankEmbedFromList()` + +### M5. ProfileModule update + +- Add `!karma` and `!joindate` to existing `ProfileModule` +- Dependencies already satisfied (`DatabaseService` to add) + +### M6. CodeTipModule + +- Commands: `!codetip`, `!disablecodetips` +- Dependencies: `CodeCheckService` (new, replaces `UserService` for + `CodeFormattingExample` and `CodeReminderCooldown`) + +### M7. FunModule + +- Commands: `!slap`, `!coinflip`, `!roll` (2 overloads), `!d20` +- Dependencies: `BotSettings` (slap tables) +- State: `_random`, `_slapObjects`, `_slapFails` + +### M8. SearchModule + +- Commands: `!search` (2 overloads), `!manual`, `!doc`, `!wiki` +- Dependencies: `BotSettings` (API URLs), `ILoggingService` +- Uses `HtmlAgilityPack`, `UnityAPI` +- Includes helpers: various HTML scraping methods + +### M9. BirthdayModule + +- Commands: `!birthday` (2 overloads) +- Dependencies: `UserExtendedService`, `DatabaseService`, `ILoggingService` +- Includes helper: `GenerateBirthdayCard()` + +### M10. ConvertModule + +- Commands: `!ftoc`, `!ctof`, `!translate` (2 overloads), `!currency` + (2 overloads), `!currencyname` +- Dependencies: `CurrencyService` +- Groups temperature, translation, and currency conversion — all + "convert/translate" commands + +### M11. WeatherModule update + +- Move `!setcity` and `!removecity` from UserModule into existing + `WeatherModule` (they're only used by weather) +- Dependencies: `WeatherService`, `UserExtendedService` (already in + WeatherModule) + +### M12. ServerModule + ServerService + +- **ServerService**: `GetGatewayPing()` extracted from UserService +- Commands: `!ping`, `!members`, `!help` +- Dependencies: `ServerService`, `CommandHandlingService` +- `!help` moves here as the final extraction from UserModule + +### What Stays in UserModule + +Nothing — **UserModule is deleted** once all commands are extracted (step 16). + +## Execution Order + +Paired service+module commits where applicable. +Each commit includes DI registration in `Program.cs`. + +| Step | Service | Module | Commit message | +|------|---------|--------|----------------| +| 0 | AuditLogService update | — | `refactor(services): move UserLeft and UserUpdated to AuditLogService` | +| 1 | XpService | — | `refactor(services): extract XpService from UserService` | +| 2 | KarmaService | — | `refactor(services): extract KarmaService from UserService` | +| 3 | CodeCheckService | CodeTipModule | `refactor: extract CodeCheckService and CodeTipModule` | +| 4 | EveryoneScoldService | — | `refactor(services): extract EveryoneScoldService from UserService` | +| 5 | MikuService | — | `refactor(services): extract MikuService from UserService` | +| 6 | — | QuoteModule | `refactor(modules): extract QuoteModule from UserModule` | +| 7 | — | RulesModule (+FAQ) | `refactor(modules): extract RulesModule from UserModule` | +| 8 | — | RankModule | `refactor(modules): extract RankModule from UserModule` | +| 9 | — | ProfileModule update | `refactor(modules): move karma and joindate to ProfileModule` | +| 10 | — | FunModule | `refactor(modules): extract FunModule from UserModule` | +| 11 | — | SearchModule | `refactor(modules): extract SearchModule from UserModule` | +| 12 | — | BirthdayModule | `refactor(modules): extract BirthdayModule from UserModule` | +| 13 | — | ConvertModule | `refactor(modules): extract ConvertModule from UserModule` | +| 14 | — | WeatherModule update | `refactor(modules): move city commands to WeatherModule` | +| 15 | ServerService | ServerModule (+!help) | `refactor: extract ServerService and ServerModule` | +| 16 | — | delete UserModule | `refactor: delete UserModule after full extraction` | +| 17 | rename UserService→WelcomeService | — | `refactor: rename UserService to WelcomeService` | + +## Notes + +- Every new module gets `[Group("UserModule"), Alias("")]` for `!help` + compatibility +- Every new service registered as singleton in `Program.cs` +- Build verified after every step +- Peer review at the end (or after each major batch) +- Audit doc S1 checkmark added on the final cleanup commit From 97e95d28fe8e6847caaef9ddcbc494045deb7101 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 00:26:42 +0200 Subject: [PATCH 17/48] refactor: consolidate XP formula into XpService Move GetXpLow/GetXpHigh to XpService as public methods. ProfileCardService now consumes them via DI instead of maintaining its own duplicate copies. --- DiscordBot/Services/ProfileCardService.cs | 12 +++++------- DiscordBot/Services/XpService.cs | 4 +++- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/DiscordBot/Services/ProfileCardService.cs b/DiscordBot/Services/ProfileCardService.cs index 0b20d5e1..a65a71f2 100644 --- a/DiscordBot/Services/ProfileCardService.cs +++ b/DiscordBot/Services/ProfileCardService.cs @@ -16,24 +16,22 @@ public class ProfileCardService private readonly ILoggingService _loggingService; private readonly IHttpClientFactory _httpClientFactory; private readonly BotSettings _settings; + private readonly XpService _xpService; public ProfileCardService(DatabaseService databaseService, ILoggingService loggingService, - IHttpClientFactory httpClientFactory, BotSettings settings) + IHttpClientFactory httpClientFactory, BotSettings settings, XpService xpService) { _databaseService = databaseService; _loggingService = loggingService; _httpClientFactory = httpClientFactory; _settings = settings; + _xpService = xpService; } private SkinData GetSkinData() => JsonConvert.DeserializeObject(File.ReadAllText($"{_settings.AssetsRootPath}/skins/skin.json"), new SkinModuleJsonConverter()); - private double GetXpLow(int level) => 70d - 139.5d * (level + 1d) + 69.5 * Math.Pow(level + 1d, 2d); - - private double GetXpHigh(int level) => 70d - 139.5d * (level + 2d) + 69.5 * Math.Pow(level + 2d, 2d); - public async Task GenerateProfileCard(IUser user) { string profileCardPath = string.Empty; @@ -51,8 +49,8 @@ public async Task GenerateProfileCard(IUser user) var karmaRank = await dbRepo.GetKarmaRank(userData.UserID, userData.Karma); var karma = userData.Karma; var level = userData.Level; - var xpLow = GetXpLow(level); - var xpHigh = GetXpHigh(level); + var xpLow = _xpService.GetXpLow(level); + var xpHigh = _xpService.GetXpHigh(level); var xpShown = (int)(xpTotal - xpLow); var maxXpShown = (int)(xpHigh - xpLow); diff --git a/DiscordBot/Services/XpService.cs b/DiscordBot/Services/XpService.cs index 606cb675..37c937bc 100644 --- a/DiscordBot/Services/XpService.cs +++ b/DiscordBot/Services/XpService.cs @@ -96,5 +96,7 @@ private async Task LevelUp(SocketMessage messageParam, ulong userId) await messageParam.Channel.SendMessageAsync(msg).DeleteAfterTime(60); } - private double GetXpHigh(int level) => 70d - 139.5d * (level + 2d) + 69.5 * Math.Pow(level + 2d, 2d); + public double GetXpLow(int level) => 70d - 139.5d * (level + 1d) + 69.5 * Math.Pow(level + 1d, 2d); + + public double GetXpHigh(int level) => 70d - 139.5d * (level + 2d) + 69.5 * Math.Pow(level + 2d, 2d); } From 0367949332948ed4c91afd2206f4cb72436193ad Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 00:36:12 +0200 Subject: [PATCH 18/48] refactor: move duel state from UserSlashModule to DuelService Extract _activeDuels, _random, and win messages into DuelService. UserSlashModule now delegates all duel state management to the service via TryStartDuel/TryRemoveDuel/GetDuel/ChallengerWins. Mark audit item S7 as complete. --- DiscordBot/Modules/UserSlashModule.cs | 59 +++++++-------------------- DiscordBot/Program.cs | 1 + DiscordBot/Services/DuelService.cs | 54 ++++++++++++++++++++++++ docs/code-quality-audit.md | 2 +- 4 files changed, 71 insertions(+), 45 deletions(-) create mode 100644 DiscordBot/Services/DuelService.cs diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/UserSlashModule.cs index a778afd4..8ed57e93 100644 --- a/DiscordBot/Modules/UserSlashModule.cs +++ b/DiscordBot/Modules/UserSlashModule.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using Discord.Interactions; using DiscordBot.Services; using DiscordBot.Settings; @@ -14,6 +13,7 @@ public class UserSlashModule : InteractionModuleBase public CommandHandlingService CommandHandlingService { get; set; } public WelcomeService WelcomeService { get; set; } public ServerService ServerService { get; set; } + public DuelService DuelService { get; set; } public BotSettings BotSettings { get; set; } public ILoggingService LoggingService { get; set; } @@ -124,21 +124,6 @@ public async Task ReturnInvite() #region Duel System - private static readonly ConcurrentDictionary _activeDuels = new ConcurrentDictionary(); - private static readonly Random _random = new Random(); - - private static readonly string[] _normalWinMessages = - { - "{winner} lands a solid hit on {loser} and wins the duel!", - "{winner} uses their sword to attack {loser}, but {loser} fails to dodge and {winner} wins!", - "{winner} outmaneuvers {loser} with a swift strike and claims victory!", - "{winner} blocks {loser}'s attack and counters with a decisive blow!", - "{winner} dodges {loser}'s clumsy swing and delivers the winning hit!", - "{winner} parries {loser}'s blade and strikes back to win the duel!", - "{winner} feints left, strikes right, and defeats {loser}!", - "{winner} overwhelms {loser} with superior technique and emerges victorious!" - }; - [SlashCommand("duel", "Challenge another user to a duel!")] public async Task Duel( [Summary(description: "The user you want to duel")] IUser opponent, @@ -163,17 +148,13 @@ public async Task Duel( // Check for active duel string duelKey = $"{Context.User.Id}_{opponent.Id}"; - string reverseDuelKey = $"{opponent.Id}_{Context.User.Id}"; - if (_activeDuels.ContainsKey(duelKey) || _activeDuels.ContainsKey(reverseDuelKey)) + if (!DuelService.TryStartDuel(duelKey, Context.User.Id, opponent.Id)) { await Context.Interaction.RespondAsync("There's already an active duel between you two!", ephemeral: true); return; } - // Store the duel with both user IDs for timeout tracking - _activeDuels[duelKey] = (Context.User.Id, opponent.Id); - var embed = new EmbedBuilder() .WithColor(Color.Orange) .WithTitle("⚔️ Duel Challenge!") @@ -200,15 +181,15 @@ public async Task Duel( _ = Task.Run(async () => { await Task.Delay(60000); // 60 seconds - if (_activeDuels.ContainsKey(duelKey)) + var duel = DuelService.GetDuel(duelKey); + if (duel != null) { - var (challengerId, opponentId) = _activeDuels[duelKey]; - _activeDuels.TryRemove(duelKey, out _); + DuelService.TryRemoveDuel(duelKey, out _); try { - var challenger = await Context.Guild.GetUserAsync(challengerId); - var challengedUser = await Context.Guild.GetUserAsync(opponentId); + var challenger = await Context.Guild.GetUserAsync(duel.Value.challengerId); + var challengedUser = await Context.Guild.GetUserAsync(duel.Value.opponentId); string timeoutMessage = challengedUser != null ? $"⏰ Duel challenge to {challengedUser.Mention} expired." @@ -250,16 +231,13 @@ public async Task DuelAccept(string duelKey, string type) return; } - // Check if duel is still active - if (!_activeDuels.ContainsKey(duelKey)) + // Check if duel is still active and remove it + if (!DuelService.TryRemoveDuel(duelKey, out _)) { await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); return; } - // Remove from active duels - _activeDuels.TryRemove(duelKey, out _); - await Context.Interaction.DeferAsync(); // Get users @@ -273,7 +251,7 @@ public async Task DuelAccept(string duelKey, string type) } // Randomly select winner (50/50) - bool challengerWins = _random.Next(2) == 0; + bool challengerWins = DuelService.ChallengerWins(); var winner = challengerWins ? challenger : opponent; var loser = challengerWins ? opponent : challenger; if (type == "mute") @@ -288,8 +266,7 @@ public async Task DuelAccept(string duelKey, string type) } // Generate flavor message - string flavorMessage = _normalWinMessages[_random.Next(_normalWinMessages.Length)]; - flavorMessage = flavorMessage.Replace("{winner}", winner.Mention).Replace("{loser}", loser.Mention); + string flavorMessage = DuelService.GetWinMessage(winner.Mention, loser.Mention); var resultEmbed = new EmbedBuilder() .WithColor(Color.Gold) @@ -343,16 +320,13 @@ public async Task DuelRefuse(string duelKey) return; } - // Check if duel is still active - if (!_activeDuels.ContainsKey(duelKey)) + // Check if duel is still active and remove it + if (!DuelService.TryRemoveDuel(duelKey, out _)) { await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); return; } - // Remove from active duels - _activeDuels.TryRemove(duelKey, out _); - // Edit the embed to show refusal instead of deleting await Context.Interaction.DeferAsync(); await Context.Interaction.ModifyOriginalResponseAsync(msg => @@ -384,16 +358,13 @@ public async Task DuelCancel(string duelKey) return; } - // Check if duel is still active - if (!_activeDuels.ContainsKey(duelKey)) + // Check if duel is still active and remove it + if (!DuelService.TryRemoveDuel(duelKey, out _)) { await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); return; } - // Remove from active duels - _activeDuels.TryRemove(duelKey, out _); - // Edit the embed to show cancellation await Context.Interaction.DeferAsync(); await Context.Interaction.ModifyOriginalResponseAsync(msg => diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 46813081..77b50062 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -107,6 +107,7 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/DiscordBot/Services/DuelService.cs b/DiscordBot/Services/DuelService.cs new file mode 100644 index 00000000..f6ef4121 --- /dev/null +++ b/DiscordBot/Services/DuelService.cs @@ -0,0 +1,54 @@ +using System.Collections.Concurrent; +using Discord.WebSocket; + +namespace DiscordBot.Services; + +public class DuelService +{ + private readonly ConcurrentDictionary _activeDuels = new(); + private readonly Random _random = new(); + private readonly ILoggingService _loggingService; + + private static readonly string[] NormalWinMessages = + { + "{winner} lands a solid hit on {loser} and wins the duel!", + "{winner} uses their sword to attack {loser}, but {loser} fails to dodge and {winner} wins!", + "{winner} outmaneuvers {loser} with a swift strike and claims victory!", + "{winner} blocks {loser}'s attack and counters with a decisive blow!", + "{winner} dodges {loser}'s clumsy swing and delivers the winning hit!", + "{winner} parries {loser}'s blade and strikes back to win the duel!", + "{winner} feints left, strikes right, and defeats {loser}!", + "{winner} overwhelms {loser} with superior technique and emerges victorious!" + }; + + public DuelService(ILoggingService loggingService) + { + _loggingService = loggingService; + } + + public bool IsDuelActive(string duelKey) => _activeDuels.ContainsKey(duelKey); + + public bool TryStartDuel(string duelKey, ulong challengerId, ulong opponentId) + { + string reverseKey = $"{opponentId}_{challengerId}"; + if (_activeDuels.ContainsKey(duelKey) || _activeDuels.ContainsKey(reverseKey)) + return false; + + _activeDuels[duelKey] = (challengerId, opponentId); + return true; + } + + public bool TryRemoveDuel(string duelKey, out (ulong challengerId, ulong opponentId) duel) + => _activeDuels.TryRemove(duelKey, out duel); + + public (ulong challengerId, ulong opponentId)? GetDuel(string duelKey) + => _activeDuels.TryGetValue(duelKey, out var duel) ? duel : null; + + public bool ChallengerWins() => _random.Next(2) == 0; + + public string GetWinMessage(string winnerMention, string loserMention) + { + var message = NormalWinMessages[_random.Next(NormalWinMessages.Length)]; + return message.Replace("{winner}", winnerMention).Replace("{loser}", loserMention); + } +} diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md index f3d524c5..1372dca3 100644 --- a/docs/code-quality-audit.md +++ b/docs/code-quality-audit.md @@ -376,7 +376,7 @@ no `.cs` files and no test framework configured. 4. Extract business logic from command handlers into services 5. ~~Register `IHttpClientFactory` in DI; remove manual `HttpClient` creation~~ ✅ 6. Add graceful shutdown support with `CancellationToken` -7. Move static module state (`_activeDuels`) to services +7. ~~Move static module state (`_activeDuels`) to services~~ ✅ ### Medium-term (Quality) From a7f70218eda8663febcef23d2bf310a86c47e646 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 01:38:07 +0200 Subject: [PATCH 19/48] refactor(modules): split UserSlashModule into ServerSlashModule and DuelSlashModule --- ...{UserSlashModule.cs => DuelSlashModule.cs} | 144 +----------------- DiscordBot/Modules/ServerSlashModule.cs | 114 ++++++++++++++ 2 files changed, 116 insertions(+), 142 deletions(-) rename DiscordBot/Modules/{UserSlashModule.cs => DuelSlashModule.cs} (62%) create mode 100644 DiscordBot/Modules/ServerSlashModule.cs diff --git a/DiscordBot/Modules/UserSlashModule.cs b/DiscordBot/Modules/DuelSlashModule.cs similarity index 62% rename from DiscordBot/Modules/UserSlashModule.cs rename to DiscordBot/Modules/DuelSlashModule.cs index 8ed57e93..5ec2f15c 100644 --- a/DiscordBot/Modules/UserSlashModule.cs +++ b/DiscordBot/Modules/DuelSlashModule.cs @@ -1,129 +1,13 @@ using Discord.Interactions; using DiscordBot.Services; -using DiscordBot.Settings; namespace DiscordBot.Modules; -// For commands that only require a single interaction, these can be done automatically and don't require complex setup or configuration. -// ie; A command that might just return the result of a service method such as Ping, or Welcome -public class UserSlashModule : InteractionModuleBase +public class DuelSlashModule : InteractionModuleBase { - #region Dependency Injection - - public CommandHandlingService CommandHandlingService { get; set; } - public WelcomeService WelcomeService { get; set; } - public ServerService ServerService { get; set; } public DuelService DuelService { get; set; } - public BotSettings BotSettings { get; set; } public ILoggingService LoggingService { get; set; } - #endregion - - #region Help - - [SlashCommand("help", "Shows available commands")] - private async Task Help(string search = "") - { - await Context.Interaction.DeferAsync(ephemeral: true); - - var helpEmbed = HelpEmbed(0, search); - if (helpEmbed.Item1 >= 0) - { - ComponentBuilder builder = new(); - builder.WithButton("Next Page", $"user_module_help_next:{0}"); - - await Context.Interaction.FollowupAsync(embed: helpEmbed.Item2, ephemeral: true, - components: builder.Build()); - } - else - { - await Context.Interaction.FollowupAsync(embed: helpEmbed.Item2, ephemeral: true); - } - } - - [ComponentInteraction("user_module_help_next:*")] - private async Task InteractionHelp(string pageString) - { - await Context.Interaction.DeferAsync(ephemeral: true); - - int page = int.Parse(pageString); - - var helpEmbed = HelpEmbed(page + 1); - ComponentBuilder builder = new(); - builder.WithButton("Next Page", $"user_module_help_next:{helpEmbed.Item1}"); - - await Context.Interaction.ModifyOriginalResponseAsync(msg => - { - msg.Components = builder.Build(); - msg.Embed = helpEmbed.Item2; - }); - } - - // Returns an embed with the help text for a module, if the page is outside the bounds (high) it will return to the first page. - private (int, Embed) HelpEmbed(int page, string search = "") - { - EmbedBuilder embedBuilder = new(); - embedBuilder.Title = "User Module Commands"; - embedBuilder.Color = Color.LighterGrey; - - List helpMessages = null; - if (search == string.Empty) - { - helpMessages = CommandHandlingService.GetCommandListMessages("UserModule", false, true, false); - - if (page >= helpMessages.Count) - page = 0; - else if (page < 0) - page = helpMessages.Count - 1; - - embedBuilder.WithFooter(text: $"Page {page + 1} of {helpMessages.Count}"); - embedBuilder.Description = helpMessages[page]; - } - else - { - // We need search results which we don't cache, so we don't want to provide a page number - page = -1; - helpMessages = CommandHandlingService.SearchForCommand(("UserModule", false, true, false), search); - if (helpMessages[0].Length > 0) - { - embedBuilder.WithFooter(text: $"Search results for {search}"); - embedBuilder.Description = helpMessages[0]; - } - else - { - embedBuilder.WithFooter(text: $"No results for {search}"); - embedBuilder.Description = "No commands found"; - } - } - - return (page, embedBuilder.Build()); - } - - #endregion - - [SlashCommand("welcome", "An introduction to the server!")] - public async Task SlashWelcome() - { - await Context.Interaction.RespondAsync(string.Empty, - embed: WelcomeService.GetWelcomeEmbed(Context.User.Username), ephemeral: true); - } - - [SlashCommand("ping", "Bot latency")] - public async Task Ping() - { - await Context.Interaction.RespondAsync("Bot latency: ...", ephemeral: true); - await Context.Interaction.ModifyOriginalResponseAsync(m => - m.Content = $"Bot latency: {ServerService.GetGatewayPing().ToString()}ms"); - } - - [SlashCommand("invite", "Returns the invite link for the server.")] - public async Task ReturnInvite() - { - await Context.Interaction.RespondAsync(text: BotSettings.Invite, ephemeral: true); - } - - #region Duel System - [SlashCommand("duel", "Challenge another user to a duel!")] public async Task Duel( [Summary(description: "The user you want to duel")] IUser opponent, @@ -132,21 +16,18 @@ public async Task Duel( [Choice("Mute", "mute")] string type = "normal") { - // Prevent self-dueling if (opponent.Id == Context.User.Id) { await Context.Interaction.RespondAsync("You cannot duel yourself!", ephemeral: true); return; } - // Prevent dueling bots if (opponent.IsBot) { await Context.Interaction.RespondAsync("You cannot duel a bot!", ephemeral: true); return; } - // Check for active duel string duelKey = $"{Context.User.Id}_{opponent.Id}"; if (!DuelService.TryStartDuel(duelKey, Context.User.Id, opponent.Id)) @@ -174,13 +55,11 @@ public async Task Duel( await Context.Interaction.RespondAsync(embed: embed.Build(), components: components); - // Store the message reference for timeout var originalResponse = await Context.Interaction.GetOriginalResponseAsync(); - // Auto-timeout after 60 seconds _ = Task.Run(async () => { - await Task.Delay(60000); // 60 seconds + await Task.Delay(60000); var duel = DuelService.GetDuel(duelKey); if (duel != null) { @@ -216,7 +95,6 @@ await originalResponse.ModifyAsync(msg => [ComponentInteraction("duel_accept:*:*")] public async Task DuelAccept(string duelKey, string type) { - // Extract user IDs from the duel key var userIds = duelKey.Split('_'); if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) { @@ -224,14 +102,12 @@ public async Task DuelAccept(string duelKey, string type) return; } - // Only the challenged user can accept if (Context.User.Id != opponentId) { await Context.Interaction.RespondAsync("Only the challenged user can accept this duel!", ephemeral: true); return; } - // Check if duel is still active and remove it if (!DuelService.TryRemoveDuel(duelKey, out _)) { await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); @@ -240,7 +116,6 @@ public async Task DuelAccept(string duelKey, string type) await Context.Interaction.DeferAsync(); - // Get users var challenger = await Context.Guild.GetUserAsync(challengerId); var opponent = await Context.Guild.GetUserAsync(opponentId); @@ -250,7 +125,6 @@ public async Task DuelAccept(string duelKey, string type) return; } - // Randomly select winner (50/50) bool challengerWins = DuelService.ChallengerWins(); var winner = challengerWins ? challenger : opponent; var loser = challengerWins ? opponent : challenger; @@ -260,12 +134,10 @@ public async Task DuelAccept(string duelKey, string type) var isOpponentAdmin = opponent.GuildPermissions.Has(GuildPermission.Administrator); if (isChallengerAdmin || isOpponentAdmin) { - // Unfair advantages are unfair. Also, bot can't mute admins. Remove the stakes. type = "friendly"; } } - // Generate flavor message string flavorMessage = DuelService.GetWinMessage(winner.Mention, loser.Mention); var resultEmbed = new EmbedBuilder() @@ -281,7 +153,6 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => msg.Components = new ComponentBuilder().Build(); }); - // Handle mute duel using Discord timeout if (type == "mute") { try @@ -289,7 +160,6 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => var guildLoser = loser as IGuildUser; if (guildLoser != null) { - // Use Discord's timeout feature for 5 minutes await guildLoser.SetTimeOutAsync(TimeSpan.FromMinutes(5), new RequestOptions { AuditLogReason = "Lost /duel" }); await Context.Interaction.FollowupAsync($"💀 {loser.Mention} has been timed out for 5 minutes as the duel loser!", ephemeral: false); } @@ -305,7 +175,6 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => [ComponentInteraction("duel_refuse:*")] public async Task DuelRefuse(string duelKey) { - // Extract user IDs from the duel key var userIds = duelKey.Split('_'); if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) { @@ -313,21 +182,18 @@ public async Task DuelRefuse(string duelKey) return; } - // Only the challenged user can refuse if (Context.User.Id != opponentId) { await Context.Interaction.RespondAsync("Only the challenged user can refuse this duel!", ephemeral: true); return; } - // Check if duel is still active and remove it if (!DuelService.TryRemoveDuel(duelKey, out _)) { await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); return; } - // Edit the embed to show refusal instead of deleting await Context.Interaction.DeferAsync(); await Context.Interaction.ModifyOriginalResponseAsync(msg => { @@ -343,7 +209,6 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => [ComponentInteraction("duel_cancel:*")] public async Task DuelCancel(string duelKey) { - // Extract user IDs from the duel key var userIds = duelKey.Split('_'); if (userIds.Length != 2 || !ulong.TryParse(userIds[0], out var challengerId) || !ulong.TryParse(userIds[1], out var opponentId)) { @@ -351,21 +216,18 @@ public async Task DuelCancel(string duelKey) return; } - // Only the challenger can cancel if (Context.User.Id != challengerId) { await Context.Interaction.RespondAsync("Only the challenger can cancel this duel!", ephemeral: true); return; } - // Check if duel is still active and remove it if (!DuelService.TryRemoveDuel(duelKey, out _)) { await Context.Interaction.RespondAsync("This duel is no longer active!", ephemeral: true); return; } - // Edit the embed to show cancellation await Context.Interaction.DeferAsync(); await Context.Interaction.ModifyOriginalResponseAsync(msg => { @@ -377,6 +239,4 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => msg.Components = new ComponentBuilder().Build(); }); } - - #endregion } diff --git a/DiscordBot/Modules/ServerSlashModule.cs b/DiscordBot/Modules/ServerSlashModule.cs new file mode 100644 index 00000000..a1f3c21d --- /dev/null +++ b/DiscordBot/Modules/ServerSlashModule.cs @@ -0,0 +1,114 @@ +using Discord.Interactions; +using DiscordBot.Services; +using DiscordBot.Settings; + +namespace DiscordBot.Modules; + +public class ServerSlashModule : InteractionModuleBase +{ + public CommandHandlingService CommandHandlingService { get; set; } + public WelcomeService WelcomeService { get; set; } + public ServerService ServerService { get; set; } + public BotSettings BotSettings { get; set; } + + #region Help + + [SlashCommand("help", "Shows available commands")] + private async Task Help(string search = "") + { + await Context.Interaction.DeferAsync(ephemeral: true); + + var helpEmbed = HelpEmbed(0, search); + if (helpEmbed.Item1 >= 0) + { + ComponentBuilder builder = new(); + builder.WithButton("Next Page", $"user_module_help_next:{0}"); + + await Context.Interaction.FollowupAsync(embed: helpEmbed.Item2, ephemeral: true, + components: builder.Build()); + } + else + { + await Context.Interaction.FollowupAsync(embed: helpEmbed.Item2, ephemeral: true); + } + } + + [ComponentInteraction("user_module_help_next:*")] + private async Task InteractionHelp(string pageString) + { + await Context.Interaction.DeferAsync(ephemeral: true); + + int page = int.Parse(pageString); + + var helpEmbed = HelpEmbed(page + 1); + ComponentBuilder builder = new(); + builder.WithButton("Next Page", $"user_module_help_next:{helpEmbed.Item1}"); + + await Context.Interaction.ModifyOriginalResponseAsync(msg => + { + msg.Components = builder.Build(); + msg.Embed = helpEmbed.Item2; + }); + } + + private (int, Embed) HelpEmbed(int page, string search = "") + { + EmbedBuilder embedBuilder = new(); + embedBuilder.Title = "User Module Commands"; + embedBuilder.Color = Color.LighterGrey; + + List helpMessages = null; + if (search == string.Empty) + { + helpMessages = CommandHandlingService.GetCommandListMessages("UserModule", false, true, false); + + if (page >= helpMessages.Count) + page = 0; + else if (page < 0) + page = helpMessages.Count - 1; + + embedBuilder.WithFooter(text: $"Page {page + 1} of {helpMessages.Count}"); + embedBuilder.Description = helpMessages[page]; + } + else + { + page = -1; + helpMessages = CommandHandlingService.SearchForCommand(("UserModule", false, true, false), search); + if (helpMessages[0].Length > 0) + { + embedBuilder.WithFooter(text: $"Search results for {search}"); + embedBuilder.Description = helpMessages[0]; + } + else + { + embedBuilder.WithFooter(text: $"No results for {search}"); + embedBuilder.Description = "No commands found"; + } + } + + return (page, embedBuilder.Build()); + } + + #endregion + + [SlashCommand("welcome", "An introduction to the server!")] + public async Task SlashWelcome() + { + await Context.Interaction.RespondAsync(string.Empty, + embed: WelcomeService.GetWelcomeEmbed(Context.User.Username), ephemeral: true); + } + + [SlashCommand("ping", "Bot latency")] + public async Task Ping() + { + await Context.Interaction.RespondAsync("Bot latency: ...", ephemeral: true); + await Context.Interaction.ModifyOriginalResponseAsync(m => + m.Content = $"Bot latency: {ServerService.GetGatewayPing().ToString()}ms"); + } + + [SlashCommand("invite", "Returns the invite link for the server.")] + public async Task ReturnInvite() + { + await Context.Interaction.RespondAsync(text: BotSettings.Invite, ephemeral: true); + } +} From 48fd81d160a8c2451aaf79c5c40ecd920b4fe827 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 01:44:19 +0200 Subject: [PATCH 20/48] chore: remove dead ContainsInviteLink overloads --- DiscordBot/Extensions/MessageExtensions.cs | 31 +--------------------- docs/code-quality-audit.md | 4 +-- 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/DiscordBot/Extensions/MessageExtensions.cs b/DiscordBot/Extensions/MessageExtensions.cs index fc039d1e..22d741ff 100644 --- a/DiscordBot/Extensions/MessageExtensions.cs +++ b/DiscordBot/Extensions/MessageExtensions.cs @@ -1,11 +1,7 @@ -using System.Text.RegularExpressions; - namespace DiscordBot.Extensions; public static class MessageExtensions { - private const string InviteLinkPattern = @"(https?:\/\/)?(www\.)?(discord\.gg\/[a-zA-Z0-9]+)"; - public static async Task TrySendMessage(this IDMChannel channel, string message = "", Embed embed = null) { try @@ -18,7 +14,7 @@ public static async Task TrySendMessage(this IDMChannel channel, string me } return true; } - + /// /// Returns true if the message includes any RoleID's, UserID's or Mentions Everyone /// @@ -26,29 +22,4 @@ public static bool HasAnyPingableMention(this IUserMessage message) { return message.MentionedUserIds.Count > 0 || message.MentionedRoleIds.Count > 0 || message.MentionedEveryone; } - - /// - /// Returns true if the message contains any discord invite links, ie; discord.gg/invite - /// - public static bool ContainsInviteLink(this IUserMessage message) - { - return Regex.IsMatch(message.Content, InviteLinkPattern, RegexOptions.IgnoreCase); - } - - /// - /// Returns true if the message contains any discord invite links, ie; discord.gg/invite - /// - public static bool ContainsInviteLink(this string message) - { - return Regex.IsMatch(message, InviteLinkPattern, RegexOptions.IgnoreCase); - } - - /// - /// Returns true if the message contains any discord invite links, ie; discord.gg/invite - /// - public static bool ContainsInviteLink(this IMessage message) - { - return Regex.IsMatch(message.Content, InviteLinkPattern, RegexOptions.IgnoreCase); - } - } \ No newline at end of file diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md index 1372dca3..040d8f54 100644 --- a/docs/code-quality-audit.md +++ b/docs/code-quality-audit.md @@ -93,7 +93,7 @@ Task.Run(() => ...); **Fix:** Create a `SafeFireAndForget()` extension that logs exceptions. -#### 2e. `ContainsInviteLink()` — three identical overloads +#### ~~2e. `ContainsInviteLink()` — three identical overloads~~ (removed — dead code, no callers) File: `MessageExtensions.cs` — same regex for `IUserMessage`, `string`, and `IMessage`. Should be a single implementation on `string` with the others @@ -382,7 +382,7 @@ no `.cs` files and no test framework configured. 1. Create `EmbedFactory` to reduce embed construction duplication 2. Create `SafeFireAndForget()` extension to replace `#pragma` + `Task.Run` -3. Consolidate `ContainsInviteLink()` overloads +3. ~~Consolidate `ContainsInviteLink()` overloads~~ ✅ removed (dead code) 4. Add configuration validation for all settings 5. Audit service lifetimes — consider `Scoped` for interaction-scoped services 6. Remove all dead/commented-out code From 0a1d83fd921077cfb9ab06e6708e213f1c15c310 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 02:47:33 +0200 Subject: [PATCH 21/48] refactor(modules): extract business logic from command handlers into services - SearchModule -> SearchService (DuckDuckGo search, fuzzy matching, HTML fetching) - EmbedModule -> EmbedParsingService (JSON embed parsing, URL validation) - FeedService -> ReleaseNotesParser (Unity release notes HTML parsing) - UpdateService -> UnityDocParser (JS-to-array static utility) - CasinoSlashModule -> TransactionFormatter (transaction display formatting) 5 new service files, 6 existing files slimmed (~510 lines removed) --- .../Modules/Casino/CasinoSlashModule.cs | 79 +------- DiscordBot/Modules/EmbedModule.cs | 158 +--------------- DiscordBot/Modules/SearchModule.cs | 149 ++++----------- DiscordBot/Program.cs | 4 + .../Services/Casino/TransactionFormatter.cs | 86 +++++++++ DiscordBot/Services/EmbedParsingService.cs | 134 ++++++++++++++ DiscordBot/Services/FeedService.cs | 170 +++--------------- DiscordBot/Services/ReleaseNotesParser.cs | 120 +++++++++++++ DiscordBot/Services/SearchService.cs | 117 ++++++++++++ DiscordBot/Services/UnityDocParser.cs | 31 ++++ DiscordBot/Services/UpdateService.cs | 29 +-- docs/code-quality-audit.md | 2 +- 12 files changed, 569 insertions(+), 510 deletions(-) create mode 100644 DiscordBot/Services/Casino/TransactionFormatter.cs create mode 100644 DiscordBot/Services/EmbedParsingService.cs create mode 100644 DiscordBot/Services/ReleaseNotesParser.cs create mode 100644 DiscordBot/Services/SearchService.cs create mode 100644 DiscordBot/Services/UnityDocParser.cs diff --git a/DiscordBot/Modules/Casino/CasinoSlashModule.cs b/DiscordBot/Modules/Casino/CasinoSlashModule.cs index 4d30b28e..1dceaf6c 100644 --- a/DiscordBot/Modules/Casino/CasinoSlashModule.cs +++ b/DiscordBot/Modules/Casino/CasinoSlashModule.cs @@ -41,6 +41,7 @@ public class TokenCommands : InteractionModuleBase public CasinoService CasinoService { get; set; } public ILoggingService LoggingService { get; set; } public BotSettings BotSettings { get; set; } + public TransactionFormatter TransactionFormatter { get; set; } private async Task CheckChannelPermissions() { @@ -301,7 +302,7 @@ private async Task DisplayTransactionHistory(string userId = null, int page = 1, foreach (var transaction in transactions) { var amountText = transaction.Amount >= 0 ? $"+{transaction.Amount}" : transaction.Amount.ToString(); - var (emoji, transactionTitle, transactionDescription) = FormatTransactionDisplay(transaction, isAllUsersRequest); + var (emoji, transactionTitle, transactionDescription) = TransactionFormatter.Format(transaction, Context.Guild, isAllUsersRequest); embed.AddField($"{emoji} {transactionTitle}", $"{amountText} tokens - *{TimestampTag.FromDateTime(transaction.CreatedAt)}*\n{transactionDescription}", @@ -378,82 +379,6 @@ public async Task NavigateHistory(string userId, string pageStr, string requestT await DisplayTransactionHistory(userId: userId == "all" ? null : userId, page: page, targetUser: targetUser, isInitialCall: false); } - private (string emoji, string title, string description) FormatTransactionDisplay(TokenTransaction transaction, bool showUserInfo = false) - { - var (emoji, title, description) = transaction.Kind switch - { - TransactionKind.TokenInitialisation => ("🎯", "Account Created", ""), - TransactionKind.DailyReward => ("📅", "Daily Reward", ""), - TransactionKind.Gift => GetGiftDisplay(transaction), - TransactionKind.Game => GetGameDisplay(transaction), - TransactionKind.Admin => GetAdminDisplay(transaction), - _ => ("❓", transaction.TransactionType, "") - }; - - // If showing user info (for all-users view), prepend user name to title - if (showUserInfo) - { - var user = Context.Guild.GetUser(ulong.Parse(transaction.UserID)); - var username = user?.DisplayName ?? "Unknown User"; - return (emoji, $"{username}: {title}", description); - } - - return (emoji, title, description); - } - - private (string emoji, string title, string description) GetGiftDisplay(TokenTransaction transaction) - { - SocketGuildUser? user = null; - var userId = transaction.Details?.GetValueOrDefault(transaction.Amount >= 0 ? "from" : "to"); - if (userId != null) user = Context.Guild.GetUser(ulong.Parse(userId)); - - string title = transaction.Amount > 0 ? "Gift Received" : "Gift Sent"; - if (user != null) title = transaction.Amount > 0 ? $"Gift from {user.DisplayName}" : $"Gift to {user.DisplayName}"; - - return ("🎁", title, ""); - } - - private (string emoji, string title, string description) GetGameDisplay(TokenTransaction transaction) - { - var gameName = transaction.Details?.GetValueOrDefault("game"); - - string emoji = transaction.Amount >= 0 ? "📈" : "📉"; - string title = transaction.Amount >= 0 ? "Won" : "Lost"; - if (gameName != null) title += $" {CapitalizeFirst(gameName)}"; - - return (emoji, title, ""); - } - - private (string emoji, string title, string description) GetAdminDisplay(TokenTransaction transaction) - { - var adminId = transaction.Details?.GetValueOrDefault("admin"); - var action = transaction.Details?.GetValueOrDefault("action"); - SocketGuildUser? admin = null; - if (adminId != null) admin = Context.Guild.GetUser(ulong.Parse(adminId)); - - string title = action switch - { - "add" => "Tokens Added", - "set" => "Tokens Set", - _ => $"UNKNOWN ACTION: {action}" - }; - string description = action switch - { - "set" => "This overrides past transactions", - _ => "" - }; - - if (admin != null) title += $" by Admin {admin.DisplayName}"; - - return ("⚙️", title, description); - } - - private string CapitalizeFirst(string input) - { - if (string.IsNullOrEmpty(input)) - return input; - return char.ToUpper(input[0]) + input.Substring(1).ToLower(); - } #region Admin Commands diff --git a/DiscordBot/Modules/EmbedModule.cs b/DiscordBot/Modules/EmbedModule.cs index 4a4a705b..2da616ae 100644 --- a/DiscordBot/Modules/EmbedModule.cs +++ b/DiscordBot/Modules/EmbedModule.cs @@ -1,8 +1,6 @@ -using System.Net.Http; -using System.Text; using Discord.Commands; using DiscordBot.Attributes; -using Newtonsoft.Json; +using DiscordBot.Services; // ReSharper disable all UnusedMember.Local namespace DiscordBot.Modules; @@ -10,53 +8,7 @@ namespace DiscordBot.Modules; [RequireAdmin] public class EmbedModule : ModuleBase { - public IHttpClientFactory HttpClientFactory { get; set; } - -#pragma warning disable 0649 - private class Embed - { - public class Footer - { - public string icon_url; - public string text; - } - - public class Thumbnail - { - public string url; - } - - public class Image - { - public string url; - } - - public class Author - { - public string name; - public string url; - public string icon_url; - } - - public class Field - { - public string name; - public string value; - public bool? inline; - } - - public string title; - public string description; - public string url; - public uint? color; - public DateTimeOffset? timestamp; - public Footer footer; - public Thumbnail thumbnail; - public Image image; - public Author author; - public Field[] fields; - } -#pragma warning restore 0649 + public EmbedParsingService EmbedParsingService { get; set; } /// /// Generate an embed @@ -74,7 +26,7 @@ public async Task EmbedCommand(IMessageChannel channel = null, ulong messageId = return; } var attachment = Context.Message.Attachments.ElementAt(0); - var embed = await BuildEmbedFromUrl(attachment.Url); + var embed = await EmbedParsingService.BuildEmbedFromUrl(attachment.Url); await SendEmbedToChannel(embed, channel, messageId); } @@ -88,24 +40,22 @@ public async Task EmbedCommand(string url, IMessageChannel channel = null, ulong await SendEmbedToChannel(builtEmbed, channel, messageId); } - // Checks if the the argument is a url and if the host is supported. If so it will try to return a built embeded object. Returns null if invalid. private async Task TryGetEmbedFromUrl(string url) { - Uri uriResult; - bool result = Uri.TryCreate(url, UriKind.Absolute, out uriResult) + bool result = Uri.TryCreate(url, UriKind.Absolute, out var uriResult) && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); if (!result) { await ReplyAsync($"{Context.User.Mention}, the parameter is not a valid URL.").DeleteAfterSeconds(5); return null; } - if (!IsValidHost(uriResult.Host)) + if (!EmbedParsingService.IsValidHost(uriResult.Host)) { await ReplyAsync($"{Context.User.Mention}, supported URLs: [https://hastebin.com, https://pastebin.com, https://gdl.space, https://hastepaste.com, http://pastie.org].").DeleteAfterSeconds(5); return null; } - string download_url = GetDownUrlFromUri(uriResult); - var builtEmbed = await BuildEmbedFromUrl(download_url); + string downloadUrl = EmbedParsingService.GetDownloadUrl(uriResult); + var builtEmbed = await EmbedParsingService.BuildEmbedFromUrl(downloadUrl); if (builtEmbed.Length == 0) { await ReplyAsync("Failed to generate embed from url.").DeleteAfterSeconds(seconds: 10f); @@ -114,100 +64,6 @@ public async Task EmbedCommand(string url, IMessageChannel channel = null, ulong return builtEmbed; } - private async Task BuildEmbedFromUrl(string url) - { - using var client = HttpClientFactory.CreateClient(); - var buffer = await client.GetByteArrayAsync(url); - string json = Encoding.UTF8.GetString(buffer); - - return BuildEmbed(json); - } - - private bool IsValidHost(string url) - { - switch (url) - { - case "hastebin.com": - case "gdl.space": - case "hastepaste.com": - case "pastebin.com": - case "pastie.org": - return true; - default: - return false; - } - } - - private string GetDownUrlFromUri(Uri uri) - { - switch (uri.Host) - { - case "hastebin.com": - case "gdl.space": - return $"https://{uri.Host}/raw{uri.AbsolutePath}"; - case "hastepaste.com": - return $"https://hastepaste.com/raw{uri.AbsolutePath.Substring(5)}"; - case "pastebin.com": - return $"https://pastebin.com/raw{uri.AbsolutePath}"; - case "pastie.org": - return $"{uri.OriginalString}/raw"; - } - return string.Empty; - } - - private Discord.Embed BuildEmbed(string json) - { - try - { - var embed_data = JsonConvert.DeserializeObject(json); - var embedBuilder = new EmbedBuilder(); - if (!String.IsNullOrEmpty(embed_data.title)) embedBuilder.Title = embed_data.title; - if (!String.IsNullOrEmpty(embed_data.description)) embedBuilder.Description = embed_data.description; - if (!String.IsNullOrEmpty(embed_data.url)) embedBuilder.Url = embed_data.url; - if (embed_data.color.HasValue) embedBuilder.Color = new Color(embed_data.color.Value); - if (embed_data.timestamp.HasValue) embedBuilder.Timestamp = embed_data.timestamp.Value; - - if (embed_data.footer != null) - { - embedBuilder.Footer = new EmbedFooterBuilder(); - if (!String.IsNullOrEmpty(embed_data.footer.icon_url)) embedBuilder.Footer.IconUrl = embed_data.footer.icon_url; - if (!String.IsNullOrEmpty(embed_data.footer.text)) embedBuilder.Footer.Text = embed_data.footer.text; - } - - if (embed_data.thumbnail != null && !String.IsNullOrEmpty(embed_data.thumbnail.url)) embedBuilder.ThumbnailUrl = embed_data.thumbnail.url; - if (embed_data.image != null && !String.IsNullOrEmpty(embed_data.image.url)) embedBuilder.ImageUrl = embed_data.image.url; - - if (embed_data.author != null) - { - embedBuilder.Author = new EmbedAuthorBuilder(); - if (!String.IsNullOrEmpty(embed_data.author.icon_url)) embedBuilder.Author.IconUrl = embed_data.author.icon_url; - if (!String.IsNullOrEmpty(embed_data.author.name)) embedBuilder.Author.Name = embed_data.author.name; - if (!String.IsNullOrEmpty(embed_data.author.url)) embedBuilder.Author.Url = embed_data.author.url; - } - - if (embed_data.fields != null) - { - foreach (var field in embed_data.fields) - { - var f = new EmbedFieldBuilder(); - if (!String.IsNullOrEmpty(field.name)) f.Name = field.name; - if (!String.IsNullOrEmpty(field.value)) f.Value = field.value; - if (field.inline.HasValue) f.IsInline = field.inline.Value; - embedBuilder.AddField(f); - } - } - - return embedBuilder.Build(); - } - catch (Exception e) - { - Console.Error.WriteLine(e); - ReplyAsync($"{Context.User.Mention}, the provided JSON is invalid.").DeleteAfterSeconds(5); - } - - return null; - } - private readonly IEmote _thumbUpEmote = new Emoji("👍"); private async Task SendEmbedToChannel(Discord.Embed embed, IMessageChannel channel, ulong messageId = 0) diff --git a/DiscordBot/Modules/SearchModule.cs b/DiscordBot/Modules/SearchModule.cs index fbbb6cc4..84786183 100644 --- a/DiscordBot/Modules/SearchModule.cs +++ b/DiscordBot/Modules/SearchModule.cs @@ -4,7 +4,6 @@ using DiscordBot.Services; using DiscordBot.Settings; using DiscordBot.Attributes; -using HtmlAgilityPack; namespace DiscordBot.Modules; @@ -14,6 +13,7 @@ public class SearchModule : ModuleBase public ILoggingService LoggingService { get; set; } public BotSettings Settings { get; set; } public UpdateService UpdateService { get; set; } + public SearchService SearchService { get; set; } [Command("Search"), Priority(25)] [Summary("Searches DuckDuckGo for results. Syntax: !search c# lambda help")] @@ -31,88 +31,52 @@ public async Task SearchResults(params string[] messages) [Alias("s", "ddg")] public async Task SearchResults(string query, uint resNum = 3, string site = "") { - resNum = resNum <= 5 ? resNum : 5; - var searchQuery = "https://duckduckgo.com/html/?q=" + query.Replace(' ', '+'); + var results = SearchService.SearchDuckDuckGo(query, resNum, site); - if (site != string.Empty) searchQuery += "+site:" + site; + var resultTitle = string.Empty; + for (int i = 0; i < results.Count; i++) + { + resultTitle += $"{i + 1}. {results[i].Title} [__Read More__]({results[i].Url})\n"; + } - var doc = new HtmlWeb().Load(searchQuery); - var counter = 1; + var searchQuery = "https://duckduckgo.com/html/?q=" + query.Replace(' ', '+'); + if (site != string.Empty) searchQuery += "+site:" + site; EmbedBuilder embedBuilder = new(); embedBuilder.Title = $"Q: {WebUtility.UrlDecode(query)}"; - string resultTitle = string.Empty; - - foreach (var row in doc.DocumentNode.SelectNodes("/html/body/div[1]/div[3]/div/div/div[*]/div/h2/a")) - { - if (counter > resNum) break; - - row.Attributes["href"].Value = row.Attributes["href"].Value.Replace("//duckduckgo.com/l/?uddg=", string.Empty); - - if (counter <= resNum && IsValidResult(row)) - { - var url = WebUtility.UrlDecode(row.Attributes["href"].Value); - - int andCount = url.Count(c => c == '&'); - url = url.Substring(0, url.LastIndexOf('&')); - - resultTitle += $"{counter}. {(row.InnerText.Length > 60 ? $"{row.InnerText[..60]}.." : row.InnerText)}" + $" [__Read More..__{(andCount > 1 ? "~" : string.Empty)}]({url})\n"; - - counter++; - } - } - embedBuilder.AddField("Search Query", searchQuery); - embedBuilder.AddField("Results", resultTitle, inline: false); - + embedBuilder.AddField("Results", resultTitle.Length > 0 ? resultTitle : "No results found.", inline: false); embedBuilder.Color = new Color(81, 50, 169); embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from DuckDuckGo."); - var embed = embedBuilder.Build(); - await ReplyAsync(embed: embed); - } - - bool IsValidResult(HtmlNode node) - { - return (!node.Attributes["href"].Value.Contains("duckduckgo.com") && - !node.Attributes["href"].Value.Contains("duck.co")); + await ReplyAsync(embed: embedBuilder.Build()); } [Command("Manual"), Priority(8)] [Summary("Searches Unity3D manual for results. Syntax : !manual \"query\"")] public async Task SearchManual(params string[] queries) { - var minimumScore = double.MaxValue; - string[] mostSimilarPage = null; var pages = await UpdateService.GetManualDatabase(); var query = string.Join(" ", queries); - foreach (var p in pages) - { - var curScore = CalculateScore(p[1], query); - if (!(curScore < minimumScore)) continue; - - minimumScore = curScore; - mostSimilarPage = p; - } + var match = SearchService.FindBestMatch(query, pages, "https://docs.unity3d.com/Manual"); - if (mostSimilarPage != null) + if (match != null) { + var url = $"{match.BaseUrl}/{match.PageName}.html"; + EmbedBuilder embedBuilder = new(); - embedBuilder.Title = $"Found {mostSimilarPage[0]}"; - embedBuilder.Description = $"**{mostSimilarPage[1]}** - [Read More..](https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html)"; + embedBuilder.Title = $"Found {match.PageName}"; + embedBuilder.Description = $"**{match.Title}** - [Read More..]({url})"; embedBuilder.Color = new Color(81, 50, 169); embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs."); var message = await ReplyAsync(embed: embedBuilder.Build()); - var doc = new HtmlWeb().Load($"https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html"); - var descriptionNode = doc.DocumentNode.SelectSingleNode("//h1"); - if (descriptionNode == null) return; - descriptionNode = descriptionNode.SelectSingleNode("following-sibling::p"); - descriptionNode.Descendants().Where(n => n.GetAttributeValue("class", "").Contains("tooltip")).ToList().ForEach(n => n.Remove()); - var description = descriptionNode.InnerText; - - embedBuilder.WithDescription($"**Description:** {(description.Length > 500 ? $"{description[..500]}.." : description)}\n" + $"[Read More..](https://docs.unity3d.com/Manual/{mostSimilarPage[0]}.html)"); - await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); + var description = SearchService.FetchPageDescription(url, "//h1", "following-sibling::p"); + if (description != null) + { + embedBuilder.WithDescription($"**Description:** {description}\n[Read More..]({url})"); + await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); + } } else await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10); @@ -123,48 +87,30 @@ public async Task SearchManual(params string[] queries) [Alias("ref", "reference", "api", "docs")] public async Task SearchApi(params string[] queries) { - var minimumScore = double.MaxValue; - string[] mostSimilarPage = null; var pages = await UpdateService.GetApiDatabase(); var query = string.Join(" ", queries); - foreach (var p in pages) - { - var curScore = CalculateScore(p[1], query); - if (!(curScore < minimumScore)) continue; + var match = SearchService.FindBestMatch(query, pages, "https://docs.unity3d.com/ScriptReference"); - minimumScore = curScore; - mostSimilarPage = p; - } - - if (mostSimilarPage != null) + if (match != null) { + var url = $"{match.BaseUrl}/{match.PageName}.html"; + EmbedBuilder embedBuilder = new(); - embedBuilder.Title = $"Found {mostSimilarPage[0]}"; - embedBuilder.Description = $"**{mostSimilarPage[1]}** - [Read More..](https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html)"; + embedBuilder.Title = $"Found {match.PageName}"; + embedBuilder.Description = $"**{match.Title}** - [Read More..]({url})"; embedBuilder.Color = new Color(81, 50, 169); embedBuilder.Footer = new EmbedFooterBuilder().WithText("Results sourced from Unity3D Docs."); var message = await ReplyAsync(embed: embedBuilder.Build()); - var doc = new HtmlWeb().Load($"https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html"); - var descriptionNode = doc.DocumentNode.SelectSingleNode("//h3[contains(text(), 'Description')]"); + var description = SearchService.FetchPageDescription(url, "//h3[contains(text(), 'Description')]", "following-sibling::p"); + var manualLink = SearchService.FetchManualLink(url); - string descriptionString = ""; - string manualLinkString = ""; - if (descriptionNode != null) - { - var description = descriptionNode.SelectSingleNode("following-sibling::p").InnerText; - descriptionString = - $"**Description:** {(description.Length > 500 ? $"{description[..500]}.." : description)}\n" + - $"[Read More..](https://docs.unity3d.com/ScriptReference/{mostSimilarPage[0]}.html)"; - } - - var manualLink = doc.DocumentNode.SelectSingleNode("//a[contains(@class, 'switch-link')]"); - if (manualLink != null && manualLink.Attributes.Contains("title")) - { - var manualLinkText = manualLink.GetAttributes("title").First().Value; - var manualLinkUrl = "https://docs.unity3d.com/" + manualLink.GetAttributeValue("href", ""); - manualLinkString = $"\n**Manual:** [{manualLinkText}]({manualLinkUrl})"; - } + string descriptionString = description != null + ? $"**Description:** {description}\n[Read More..]({url})" + : string.Empty; + string manualLinkString = manualLink != null + ? $"\n**Manual:** {manualLink}" + : string.Empty; embedBuilder.WithDescription(descriptionString + manualLinkString); await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); @@ -198,25 +144,4 @@ private Embed GetWikipediaEmbed(string subject, string articleExtract, string ar .WithColor(new Color(0x33CC00)); return builder.Build(); } - - private double CalculateScore(string s1, string s2) - { - double curScore = 0; - var i = 0; - - foreach (var q in s1.Split(' ')) - { - foreach (var x in s2.Split(' ')) - { - i++; - if (x.Equals(q)) - curScore -= 50; - else - curScore += x.CalculateLevenshteinDistance(q); - } - } - - curScore /= i; - return curScore; - } } diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 77b50062..233a6aff 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -110,10 +110,13 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -123,6 +126,7 @@ private IServiceProvider ConfigureServices() => .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .BuildServiceProvider(); diff --git a/DiscordBot/Services/Casino/TransactionFormatter.cs b/DiscordBot/Services/Casino/TransactionFormatter.cs new file mode 100644 index 00000000..fcb6470f --- /dev/null +++ b/DiscordBot/Services/Casino/TransactionFormatter.cs @@ -0,0 +1,86 @@ +using Discord.WebSocket; +using DiscordBot.Domain; + +namespace DiscordBot.Services; + +public class TransactionFormatter +{ + public (string emoji, string title, string description) Format( + TokenTransaction transaction, SocketGuild guild, bool showUserInfo = false) + { + var (emoji, title, description) = transaction.Kind switch + { + TransactionKind.TokenInitialisation => ("🎯", "Account Created", ""), + TransactionKind.DailyReward => ("📅", "Daily Reward", ""), + TransactionKind.Gift => FormatGift(transaction, guild), + TransactionKind.Game => FormatGame(transaction), + TransactionKind.Admin => FormatAdmin(transaction, guild), + _ => ("❓", transaction.TransactionType, "") + }; + + if (showUserInfo) + { + var user = guild.GetUser(ulong.Parse(transaction.UserID)); + var username = user?.DisplayName ?? "Unknown User"; + return (emoji, $"{username}: {title}", description); + } + + return (emoji, title, description); + } + + private static (string emoji, string title, string description) FormatGift( + TokenTransaction transaction, SocketGuild guild) + { + SocketGuildUser user = null; + var userId = transaction.Details?.GetValueOrDefault(transaction.Amount >= 0 ? "from" : "to"); + if (userId != null) user = guild.GetUser(ulong.Parse(userId)); + + string title = transaction.Amount > 0 ? "Gift Received" : "Gift Sent"; + if (user != null) title = transaction.Amount > 0 ? $"Gift from {user.DisplayName}" : $"Gift to {user.DisplayName}"; + + return ("🎁", title, ""); + } + + private static (string emoji, string title, string description) FormatGame(TokenTransaction transaction) + { + var gameName = transaction.Details?.GetValueOrDefault("game"); + + string emoji = transaction.Amount >= 0 ? "📈" : "📉"; + string title = transaction.Amount >= 0 ? "Won" : "Lost"; + if (gameName != null) title += $" {CapitalizeFirst(gameName)}"; + + return (emoji, title, ""); + } + + private static (string emoji, string title, string description) FormatAdmin( + TokenTransaction transaction, SocketGuild guild) + { + var adminId = transaction.Details?.GetValueOrDefault("admin"); + var action = transaction.Details?.GetValueOrDefault("action"); + SocketGuildUser admin = null; + if (adminId != null) admin = guild.GetUser(ulong.Parse(adminId)); + + string title = action switch + { + "add" => "Tokens Added", + "set" => "Tokens Set", + _ => $"UNKNOWN ACTION: {action}" + }; + string description = action switch + { + "set" => "This overrides past transactions", + _ => "" + }; + + if (admin != null) title += $" by Admin {admin.DisplayName}"; + + return ("⚙️", title, description); + } + + private static string CapitalizeFirst(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + return char.ToUpper(input[0]) + input[1..].ToLower(); + } +} diff --git a/DiscordBot/Services/EmbedParsingService.cs b/DiscordBot/Services/EmbedParsingService.cs new file mode 100644 index 00000000..6b34bdda --- /dev/null +++ b/DiscordBot/Services/EmbedParsingService.cs @@ -0,0 +1,134 @@ +using System.Net.Http; +using System.Text; +using Newtonsoft.Json; + +namespace DiscordBot.Services; + +public class EmbedParsingService +{ + private readonly IHttpClientFactory _httpClientFactory; + + public EmbedParsingService(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + +#pragma warning disable 0649 + private class EmbedData + { + public class Footer + { + public string icon_url; + public string text; + } + + public class Thumbnail + { + public string url; + } + + public class Image + { + public string url; + } + + public class Author + { + public string name; + public string url; + public string icon_url; + } + + public class Field + { + public string name; + public string value; + public bool? inline; + } + + public string title; + public string description; + public string url; + public uint? color; + public DateTimeOffset? timestamp; + public Footer footer; + public Thumbnail thumbnail; + public Image image; + public Author author; + public Field[] fields; + } +#pragma warning restore 0649 + + private static readonly string[] ValidHosts = + { + "hastebin.com", "gdl.space", "hastepaste.com", "pastebin.com", "pastie.org" + }; + + public bool IsValidHost(string host) => ValidHosts.Contains(host); + + public string GetDownloadUrl(Uri uri) + { + return uri.Host switch + { + "hastebin.com" or "gdl.space" => $"https://{uri.Host}/raw{uri.AbsolutePath}", + "hastepaste.com" => $"https://hastepaste.com/raw{uri.AbsolutePath[5..]}", + "pastebin.com" => $"https://pastebin.com/raw{uri.AbsolutePath}", + "pastie.org" => $"{uri.OriginalString}/raw", + _ => string.Empty + }; + } + + public async Task BuildEmbedFromUrl(string url) + { + using var client = _httpClientFactory.CreateClient(); + var buffer = await client.GetByteArrayAsync(url); + string json = Encoding.UTF8.GetString(buffer); + return BuildEmbed(json); + } + + public Discord.Embed BuildEmbed(string json) + { + var embedData = JsonConvert.DeserializeObject(json); + var builder = new Discord.EmbedBuilder(); + + if (!string.IsNullOrEmpty(embedData.title)) builder.Title = embedData.title; + if (!string.IsNullOrEmpty(embedData.description)) builder.Description = embedData.description; + if (!string.IsNullOrEmpty(embedData.url)) builder.Url = embedData.url; + if (embedData.color.HasValue) builder.Color = new Discord.Color(embedData.color.Value); + if (embedData.timestamp.HasValue) builder.Timestamp = embedData.timestamp.Value; + + if (embedData.footer != null) + { + builder.Footer = new Discord.EmbedFooterBuilder(); + if (!string.IsNullOrEmpty(embedData.footer.icon_url)) builder.Footer.IconUrl = embedData.footer.icon_url; + if (!string.IsNullOrEmpty(embedData.footer.text)) builder.Footer.Text = embedData.footer.text; + } + + if (embedData.thumbnail != null && !string.IsNullOrEmpty(embedData.thumbnail.url)) + builder.ThumbnailUrl = embedData.thumbnail.url; + if (embedData.image != null && !string.IsNullOrEmpty(embedData.image.url)) + builder.ImageUrl = embedData.image.url; + + if (embedData.author != null) + { + builder.Author = new Discord.EmbedAuthorBuilder(); + if (!string.IsNullOrEmpty(embedData.author.icon_url)) builder.Author.IconUrl = embedData.author.icon_url; + if (!string.IsNullOrEmpty(embedData.author.name)) builder.Author.Name = embedData.author.name; + if (!string.IsNullOrEmpty(embedData.author.url)) builder.Author.Url = embedData.author.url; + } + + if (embedData.fields != null) + { + foreach (var field in embedData.fields) + { + var f = new Discord.EmbedFieldBuilder(); + if (!string.IsNullOrEmpty(field.name)) f.Name = field.name; + if (!string.IsNullOrEmpty(field.value)) f.Value = field.value; + if (field.inline.HasValue) f.IsInline = field.inline.Value; + builder.AddField(f); + } + } + + return builder.Build(); + } +} diff --git a/DiscordBot/Services/FeedService.cs b/DiscordBot/Services/FeedService.cs index ebabf0be..b8c411ab 100644 --- a/DiscordBot/Services/FeedService.cs +++ b/DiscordBot/Services/FeedService.cs @@ -4,7 +4,6 @@ using Discord.WebSocket; using DiscordBot.Settings; using DiscordBot.Utils; -using HtmlAgilityPack; namespace DiscordBot.Services; @@ -12,9 +11,10 @@ public class FeedService { private const string ServiceName = "FeedService"; private readonly DiscordSocketClient _client; - + private readonly BotSettings _settings; private readonly ILoggingService _logging; + private readonly ReleaseNotesParser _releaseNotesParser; #region Configurable Settings @@ -33,14 +33,14 @@ private class ForumNewsFeed { TitleFormat = "Beta Release - {0}", Url = "https://unity3d.com/unity/beta/latest.xml", - IncludeTags = new(){ "Beta Update" }, + IncludeTags = new() { "Beta Update" }, IsRelease = true }; private readonly ForumNewsFeed _releaseNews = new() { TitleFormat = "New Release - {0}", Url = "https://unity3d.com/unity/releases.xml", - IncludeTags = new(){"New Release"}, + IncludeTags = new() { "New Release" }, IsRelease = true }; private readonly ForumNewsFeed _blogNews = new() @@ -50,25 +50,26 @@ private class ForumNewsFeed IncludeTags = new() { "Unity Blog" }, IsRelease = false }; - + #endregion // News Feed Config - + // We store the title of the last 40 posts, and check against them to prevent duplicate posts private const int MaxHistoryCheck = 40; - private readonly List _postedFeeds = new( MaxHistoryCheck ); - + private readonly List _postedFeeds = new(MaxHistoryCheck); + private const int MaximumCheck = 3; private const ThreadArchiveDuration ForumArchiveDuration = ThreadArchiveDuration.OneWeek; #endregion // Configurable Settings - - public FeedService(DiscordSocketClient client, BotSettings settings, ILoggingService logging) + + public FeedService(DiscordSocketClient client, BotSettings settings, ILoggingService logging, ReleaseNotesParser releaseNotesParser) { _client = client; _settings = settings; _logging = logging; + _releaseNotesParser = releaseNotesParser; } - + private async Task GetFeedData(string url) { SyndicationFeed feed = null; @@ -80,7 +81,7 @@ private async Task GetFeedData(string url) } catch (Exception e) { - LoggingService.LogToConsole( $"[{ServiceName} Feed failure: {e.ToString()}", ExtendedLogSeverity.LowWarning); + LoggingService.LogToConsole($"[{ServiceName} Feed failure: {e.ToString()}", ExtendedLogSeverity.LowWarning); } // Return the feed, empty feed if null to prevent additional checks for null on return @@ -109,7 +110,7 @@ private async Task HandleFeed(FeedData feedData, ForumNewsFeed newsFeed, ulong c var newsTitle = string.Format(newsFeed.TitleFormat, item.Title.Text); if (newsTitle.Length > 90) newsTitle = newsTitle[..90] + "..."; - + // Confirm we haven't posted this title before if (_postedFeeds.Contains(newsTitle)) continue; @@ -124,10 +125,18 @@ private async Task HandleFeed(FeedData feedData, ForumNewsFeed newsFeed, ulong c newsContent = GetSummary(newsFeed, item); else { - releaseNotes = GetReleaseNotes(item); + try + { + releaseNotes = _releaseNotesParser.Parse(item.Summary.Text); + } + catch (Exception e) + { + _logging.LogChannelAndFile($"[{ServiceName}] Error generating release notes: {e}\nLikely updated format.", ExtendedLogSeverity.Warning); + releaseNotes = new List { "No release notes found" }; + } newsContent = releaseNotes[0]; } - + // If a role is provided we add to end of title to ping the role var role = _client.GetGuild(_settings.GuildId).GetRole(roleId ?? 0); if (role != null) @@ -137,14 +146,14 @@ private async Task HandleFeed(FeedData feedData, ForumNewsFeed newsFeed, ulong c newsContent += $"\n\n**__Source__**\n{item.Links[0].Uri}"; newsContent = newsContent.SanitizeEveryoneHereMentions(); - + // The Post var post = await channel.CreatePostAsync(newsTitle, ForumArchiveDuration, null, newsContent, null, null, AllowedMentions.All); await AddTagsToPost(channel, post, newsFeed.IncludeTags); if (releaseNotes.Count == 1) continue; - + // post a new message for each release note after the first for (int i = 1; i < releaseNotes.Count; i++) { @@ -159,12 +168,12 @@ private async Task HandleFeed(FeedData feedData, ForumNewsFeed newsFeed, ulong c await _logging.LogAction($"[{ServiceName}] Error: {e}", ExtendedLogSeverity.Error); } } - + private async Task AddTagsToPost(IForumChannel channel, IThreadChannel post, List tags) { if (tags.Count <= 0) return; - + var includedTags = new List(); foreach (var tag in tags) { @@ -187,129 +196,6 @@ private string GetSummary(ForumNewsFeed feed, SyndicationItem item) return summary; } - private List GetReleaseNotes(SyndicationItem item) - { - List releaseNotes = new(); - var summary = string.Empty; - - var htmlDoc = new HtmlDocument(); - var summaryText = item.Summary.Text; - - summaryText = summaryText.Replace("→", "->"); - // TODO : (James) Likely other entities we need to replace - - htmlDoc.LoadHtml(summaryText); - - // Find "release-notes" - var summaryNode = htmlDoc.DocumentNode.SelectSingleNode("//div[@class='release-notes']"); - if (summaryNode == null) - return new List() { "No release notes found" }; - - try - { - // Find "Known Issues" - var knownIssueNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains("Known Issues"))?.NextSibling; - var entriesSinceNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains("Entries since")); - - // Find the features node which will be a h4 heading with content "Features" - var featuresNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Features")?.NextSibling; - var improvementsNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Improvements")?.NextSibling; - var apiChangesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "API Changes")?.NextSibling; - var changesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Changes")?.NextSibling; - var fixesNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText == "Fixes")?.NextSibling; - var packagesUpdatedNode = summaryNode.ChildNodes.FirstOrDefault(x => x.Name == "h4" && x.InnerText.ToLower().Contains("package changes"))?.NextSibling.NextSibling.NextSibling; - - // Need to construct the summary which is just a stats summary - summary += $"**Summary**\n"; - summary += GetNodeLiCountString("Known Issues", knownIssueNode?.NextSibling); - - if (entriesSinceNode != null) - summary += $"__{entriesSinceNode.InnerText}__\n\n"; - - // Construct Stat Summary - summary += GetNodeLiCountString("Features", featuresNode?.NextSibling); - summary += GetNodeLiCountString("Improvements", improvementsNode?.NextSibling); - summary += GetNodeLiCountString("API Changes", apiChangesNode?.NextSibling); - summary += GetNodeLiCountString("Changes", changesNode?.NextSibling); - summary += GetNodeLiCountString("Fixes", fixesNode?.NextSibling); - summary += GetNodeLiCountString("Packages Updated", packagesUpdatedNode?.NextSibling); - - // Add Package Updates to Summary - releaseNotes.Add(BuildReleaseNote("Packages Updated", packagesUpdatedNode, summary)); - - // Features, Improvements - releaseNotes.Add(BuildReleaseNote("Features", featuresNode)); - releaseNotes.Add(BuildReleaseNote("Improvements", improvementsNode, "", 1000)); - // API Changes, Changes + Fixes - releaseNotes.Add(BuildReleaseNote("API Changes", apiChangesNode)); - releaseNotes.Add(BuildReleaseNote("Changes", changesNode)); - releaseNotes.Add(BuildReleaseNote("Fixes", fixesNode, "")); - - // Known Issues - releaseNotes.Add(BuildReleaseNote("Known Issues", knownIssueNode, "", 1200)); - - return releaseNotes; - } - catch (Exception e) - { - _logging.LogChannelAndFile($"[{ServiceName}] Error generating release notes: {e}\nLikely updated format.", ExtendedLogSeverity.Warning); - // We ignore anything we've generated and return a "No release notes found" to maintain appearance - return new List() { "No release notes found" }; - } - } - - private string BuildReleaseNote(string title, HtmlNode node, string contents = "", int maxLength = Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer) - { - if (node == null) - return string.Empty; - - // If we pass in contents, we prepend it to the summary - var summary = $"{(contents.Length > 0 ? $"{contents}\n" : string.Empty)}**{node.PreviousSibling.InnerText}**\n"; - - bool needsExtraProcessing = title is "Fixes" or "Known Issues" or "API Changes"; - - foreach (var feature in node.NextSibling.ChildNodes.Where(x => x.Name == "li")) - { - var extraText = string.Empty; - if (needsExtraProcessing) - { - var nodeContents = feature.ChildNodes[0]; - // Remove \n if any - nodeContents.InnerHtml = nodeContents.InnerHtml.Replace("\n", " "); - - var linkNode = nodeContents.SelectSingleNode("a"); - if (linkNode != null) - { - nodeContents = nodeContents.RemoveChild(linkNode); - // Need to remove () - feature.InnerHtml = feature.InnerHtml.Replace("()", ""); - - // Add link to extraText, but use the InnerText as the text, and format so discord will use it as link - extraText = $" ([{linkNode.InnerText}](<{linkNode.Attributes["href"].Value}>))"; - } - } - - summary += $"- {feature.InnerText}{extraText}\n"; - if (summary.Length > maxLength) - { - // Trim down to the last full line, that is less than limits - var lastLine = summary[..maxLength].LastIndexOf('\n'); - summary = summary[..lastLine] + $"\n{title} truncated...\n"; - return summary; - } - } - return summary; - } - - private string GetNodeLiCountString(string title, HtmlNode node) - { - if (node == null) - return string.Empty; - - var count = node.ChildNodes.Count(x => x.Name == "li"); - return $"{title}: {count}\n"; - } - #endregion // Feed Handlers #region Public Feed Actions diff --git a/DiscordBot/Services/ReleaseNotesParser.cs b/DiscordBot/Services/ReleaseNotesParser.cs new file mode 100644 index 00000000..3291f36d --- /dev/null +++ b/DiscordBot/Services/ReleaseNotesParser.cs @@ -0,0 +1,120 @@ +using HtmlAgilityPack; + +namespace DiscordBot.Services; + +public class ReleaseNotesParser +{ + private const int MaxFeedLengthBuffer = 400; + + public List Parse(string summaryHtml) + { + var htmlDoc = new HtmlDocument(); + summaryHtml = summaryHtml.Replace("→", "->"); + htmlDoc.LoadHtml(summaryHtml); + + var summaryNode = htmlDoc.DocumentNode.SelectSingleNode("//div[@class='release-notes']"); + if (summaryNode == null) + return new List { "No release notes found" }; + + var knownIssueNode = FindH3Sibling(summaryNode, "Known Issues"); + var entriesSinceNode = summaryNode.ChildNodes + .FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains("Entries since")); + + var featuresNode = FindH4Sibling(summaryNode, "Features"); + var improvementsNode = FindH4Sibling(summaryNode, "Improvements"); + var apiChangesNode = FindH4Sibling(summaryNode, "API Changes"); + var changesNode = FindH4Sibling(summaryNode, "Changes"); + var fixesNode = FindH4Sibling(summaryNode, "Fixes"); + var packagesUpdatedNode = summaryNode.ChildNodes + .FirstOrDefault(x => x.Name == "h4" && x.InnerText.ToLower().Contains("package changes")) + ?.NextSibling?.NextSibling?.NextSibling; + + var summary = "**Summary**\n"; + summary += GetNodeLiCountString("Known Issues", knownIssueNode?.NextSibling); + + if (entriesSinceNode != null) + summary += $"__{entriesSinceNode.InnerText}__\n\n"; + + summary += GetNodeLiCountString("Features", featuresNode?.NextSibling); + summary += GetNodeLiCountString("Improvements", improvementsNode?.NextSibling); + summary += GetNodeLiCountString("API Changes", apiChangesNode?.NextSibling); + summary += GetNodeLiCountString("Changes", changesNode?.NextSibling); + summary += GetNodeLiCountString("Fixes", fixesNode?.NextSibling); + summary += GetNodeLiCountString("Packages Updated", packagesUpdatedNode?.NextSibling); + + var releaseNotes = new List + { + BuildSection("Packages Updated", packagesUpdatedNode, summary), + BuildSection("Features", featuresNode), + BuildSection("Improvements", improvementsNode, "", 1000), + BuildSection("API Changes", apiChangesNode), + BuildSection("Changes", changesNode), + BuildSection("Fixes", fixesNode, ""), + BuildSection("Known Issues", knownIssueNode, "", 1200) + }; + + return releaseNotes; + } + + private static HtmlNode FindH3Sibling(HtmlNode parent, string text) + { + return parent.ChildNodes + .FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains(text)) + ?.NextSibling; + } + + private static HtmlNode FindH4Sibling(HtmlNode parent, string text) + { + return parent.ChildNodes + .FirstOrDefault(x => x.Name == "h4" && x.InnerText == text) + ?.NextSibling; + } + + private string BuildSection(string title, HtmlNode node, string contents = "", + int maxLength = Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer) + { + if (node == null) + return string.Empty; + + var summary = $"{(contents.Length > 0 ? $"{contents}\n" : string.Empty)}**{node.PreviousSibling.InnerText}**\n"; + + bool needsExtraProcessing = title is "Fixes" or "Known Issues" or "API Changes"; + + foreach (var feature in node.NextSibling.ChildNodes.Where(x => x.Name == "li")) + { + var extraText = string.Empty; + if (needsExtraProcessing) + { + var nodeContents = feature.ChildNodes[0]; + nodeContents.InnerHtml = nodeContents.InnerHtml.Replace("\n", " "); + + var linkNode = nodeContents.SelectSingleNode("a"); + if (linkNode != null) + { + nodeContents = nodeContents.RemoveChild(linkNode); + feature.InnerHtml = feature.InnerHtml.Replace("()", ""); + extraText = $" ([{linkNode.InnerText}](<{linkNode.Attributes["href"].Value}>))"; + } + } + + summary += $"- {feature.InnerText}{extraText}\n"; + if (summary.Length > maxLength) + { + var lastLine = summary[..maxLength].LastIndexOf('\n'); + summary = summary[..lastLine] + $"\n{title} truncated...\n"; + return summary; + } + } + + return summary; + } + + private static string GetNodeLiCountString(string title, HtmlNode node) + { + if (node == null) + return string.Empty; + + var count = node.ChildNodes.Count(x => x.Name == "li"); + return $"{title}: {count}\n"; + } +} diff --git a/DiscordBot/Services/SearchService.cs b/DiscordBot/Services/SearchService.cs new file mode 100644 index 00000000..0e51b4ad --- /dev/null +++ b/DiscordBot/Services/SearchService.cs @@ -0,0 +1,117 @@ +using System.Net; +using HtmlAgilityPack; + +namespace DiscordBot.Services; + +public class SearchService +{ + public record SearchResult(string Title, string Url); + + public record DocSearchResult(string PageName, string Title, string BaseUrl, string Description = null); + + public List SearchDuckDuckGo(string query, uint maxResults = 3, string site = "") + { + maxResults = maxResults <= 5 ? maxResults : 5; + var searchQuery = "https://duckduckgo.com/html/?q=" + query.Replace(' ', '+'); + if (site != string.Empty) searchQuery += "+site:" + site; + + var doc = new HtmlWeb().Load(searchQuery); + var results = new List(); + + var nodes = doc.DocumentNode.SelectNodes("/html/body/div[1]/div[3]/div/div/div[*]/div/h2/a"); + if (nodes == null) return results; + + foreach (var row in nodes) + { + if (results.Count >= maxResults) break; + + row.Attributes["href"].Value = row.Attributes["href"].Value + .Replace("//duckduckgo.com/l/?uddg=", string.Empty); + + if (row.Attributes["href"].Value.Contains("duckduckgo.com") || + row.Attributes["href"].Value.Contains("duck.co")) + continue; + + var url = WebUtility.UrlDecode(row.Attributes["href"].Value); + int andCount = url.Count(c => c == '&'); + url = url[..url.LastIndexOf('&')]; + + var title = row.InnerText.Length > 60 ? $"{row.InnerText[..60]}.." : row.InnerText; + results.Add(new SearchResult(title, url + (andCount > 1 ? "~" : string.Empty))); + } + + return results; + } + + public DocSearchResult FindBestMatch(string query, string[][] database, string baseUrl) + { + var minimumScore = double.MaxValue; + string[] mostSimilarPage = null; + + foreach (var p in database) + { + var curScore = CalculateScore(p[1], query); + if (curScore < minimumScore) + { + minimumScore = curScore; + mostSimilarPage = p; + } + } + + if (mostSimilarPage == null) return null; + return new DocSearchResult(mostSimilarPage[0], mostSimilarPage[1], baseUrl); + } + + public string FetchPageDescription(string url, string descriptionXPath, string nextSiblingFilter = null) + { + var doc = new HtmlWeb().Load(url); + var node = doc.DocumentNode.SelectSingleNode(descriptionXPath); + if (node == null) return null; + + if (nextSiblingFilter != null) + node = node.SelectSingleNode(nextSiblingFilter); + + node?.Descendants() + .Where(n => n.GetAttributeValue("class", "").Contains("tooltip")) + .ToList() + .ForEach(n => n.Remove()); + + var text = node?.InnerText; + if (text != null && text.Length > 500) + text = $"{text[..500]}.."; + + return text; + } + + public string FetchManualLink(string url) + { + var doc = new HtmlWeb().Load(url); + var manualLink = doc.DocumentNode.SelectSingleNode("//a[contains(@class, 'switch-link')]"); + if (manualLink == null || !manualLink.Attributes.Contains("title")) return null; + + var text = manualLink.GetAttributes("title").First().Value; + var linkUrl = "https://docs.unity3d.com/" + manualLink.GetAttributeValue("href", ""); + return $"[{text}]({linkUrl})"; + } + + private double CalculateScore(string s1, string s2) + { + double curScore = 0; + var i = 0; + + foreach (var q in s1.Split(' ')) + { + foreach (var x in s2.Split(' ')) + { + i++; + if (x.Equals(q)) + curScore -= 50; + else + curScore += x.CalculateLevenshteinDistance(q); + } + } + + curScore /= i; + return curScore; + } +} diff --git a/DiscordBot/Services/UnityDocParser.cs b/DiscordBot/Services/UnityDocParser.cs new file mode 100644 index 00000000..72929408 --- /dev/null +++ b/DiscordBot/Services/UnityDocParser.cs @@ -0,0 +1,31 @@ +using HtmlAgilityPack; + +namespace DiscordBot.Services; + +public static class UnityDocParser +{ + public static string[][] ConvertJsToArray(string data, bool isManual) + { + var list = new List(); + string pagesInput; + + if (isManual) + { + pagesInput = data.Split("info = [")[0].Split("pages=")[1]; + pagesInput = pagesInput[2..^2]; + } + else + { + pagesInput = data.Split("info =")[0]; + pagesInput = pagesInput[63..^2]; + } + + foreach (var s in pagesInput.Split("],[")) + { + var ps = s.Split(","); + list.Add(new[] { ps[0].Replace("\"", ""), ps[1].Replace("\"", "") }); + } + + return list.ToArray(); + } +} diff --git a/DiscordBot/Services/UpdateService.cs b/DiscordBot/Services/UpdateService.cs index 8f727dc7..09856b27 100644 --- a/DiscordBot/Services/UpdateService.cs +++ b/DiscordBot/Services/UpdateService.cs @@ -149,38 +149,13 @@ private async Task DownloadDocDatabase() var api = await htmlWeb.LoadFromWebAsync("https://docs.unity3d.com/ScriptReference/docdata/index.js"); var apiInput = api.DocumentNode.OuterHtml; - _manualDatabase = ConvertJsToArray(manualInput, true); - _apiDatabase = ConvertJsToArray(apiInput, false); + _manualDatabase = UnityDocParser.ConvertJsToArray(manualInput, true); + _apiDatabase = UnityDocParser.ConvertJsToArray(apiInput, false); if (!SerializeUtil.SerializeFile($"{_settings.ServerRootPath}/unitymanual.json", _manualDatabase)) await _loggingService.Log(LogBehaviour.ConsoleChannelAndFile, $"{ServiceName}: Failed to save unitymanual.json", ExtendedLogSeverity.Warning); if (!SerializeUtil.SerializeFile($"{_settings.ServerRootPath}/unityapi.json", _apiDatabase)) await _loggingService.Log(LogBehaviour.ConsoleChannelAndFile, $"{ServiceName}: Failed to save unityapi.json", ExtendedLogSeverity.Warning); - - string[][] ConvertJsToArray(string data, bool isManual) - { - var list = new List(); - string pagesInput; - if (isManual) - { - pagesInput = data.Split("info = [")[0].Split("pages=")[1]; - pagesInput = pagesInput.Substring(2, pagesInput.Length - 4); - } - else - { - pagesInput = data.Split("info =")[0]; - pagesInput = pagesInput.Substring(63, pagesInput.Length - 65); - } - - foreach (var s in pagesInput.Split("],[")) - { - var ps = s.Split(","); - list.Add(new[] { ps[0].Replace("\"", ""), ps[1].Replace("\"", "") }); - //Console.WriteLine(ps[0].Replace("\"", "") + "," + ps[1].Replace("\"", "")); - } - - return list.ToArray(); - } } catch (Exception e) { diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md index 040d8f54..268db133 100644 --- a/docs/code-quality-audit.md +++ b/docs/code-quality-audit.md @@ -373,7 +373,7 @@ no `.cs` files and no test framework configured. 1. ~~Split `UserService` into focused services~~ ✅ 2. Split `BotSettings` into domain-specific config classes 3. Add `BotSettings.Validate()` post-deserialization -4. Extract business logic from command handlers into services +4. ~~Extract business logic from command handlers into services~~ ✅ 5. ~~Register `IHttpClientFactory` in DI; remove manual `HttpClient` creation~~ ✅ 6. Add graceful shutdown support with `CancellationToken` 7. ~~Move static module state (`_activeDuels`) to services~~ ✅ From 10a53d1bf7fc204120a80ea3acc2ba5c4855dabd Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 03:20:23 +0200 Subject: [PATCH 22/48] feat(shutdown): add graceful shutdown with CancellationToken - Program.cs: CTS wired to SIGINT/SIGTERM, registered in DI, 10s stop timeout - BirthdayAnnouncementService: cancellable polling loop - CodeCheckService: cancellable loop, saves data on shutdown - ReminderService: cancellable loop, saves pending reminders on shutdown - KarmaResetService: cancellable startup delay and hourly loop - WelcomeService: cancellable loop, no recursive restart during shutdown - UpdateService: replaced no-op CancellationToken with real DI-injected token --- DiscordBot/Program.cs | 18 ++++- .../Services/BirthdayAnnouncementService.cs | 10 ++- DiscordBot/Services/CodeCheckService.cs | 19 +++-- DiscordBot/Services/KarmaResetService.cs | 22 +++--- DiscordBot/Services/ReminderService.cs | 15 +++- DiscordBot/Services/UpdateService.cs | 76 +++++++++++-------- DiscordBot/Services/WelcomeService.cs | 16 ++-- docs/code-quality-audit.md | 2 +- 8 files changed, 114 insertions(+), 64 deletions(-) diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 233a6aff..ed9b4d65 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -29,11 +29,16 @@ public class Program private UnityHelpService _unityHelpService; private RecruitService _recruitService; + private readonly CancellationTokenSource _cts = new(); + public static void Main(string[] args) => new Program().MainAsync().GetAwaiter().GetResult(); private async Task MainAsync() { + Console.CancelKeyPress += (_, e) => { e.Cancel = true; _cts.Cancel(); }; + AppDomain.CurrentDomain.ProcessExit += (_, _) => _cts.Cancel(); + DeserializeSettings(); _client = new DiscordSocketClient(new DiscordSocketConfig @@ -85,12 +90,23 @@ private async Task MainAsync() return Task.CompletedTask; }; - await Task.Delay(-1); + try + { + await Task.Delay(Timeout.Infinite, _cts.Token); + } + catch (TaskCanceledException) { } + + LoggingService.LogToConsole("Shutdown signal received, stopping...", ExtendedLogSeverity.Warning); + using var shutdownTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + try { await _client.StopAsync().WaitAsync(shutdownTimeout.Token); } + catch (OperationCanceledException) { LoggingService.LogToConsole("Client stop timed out.", ExtendedLogSeverity.Warning); } + LoggingService.LogToConsole("Bot stopped.", ExtendedLogSeverity.Positive); } private IServiceProvider ConfigureServices() => new ServiceCollection() .AddHttpClient() + .AddSingleton(_cts) .AddSingleton(_settings) .AddSingleton(_rules) .AddSingleton(_userSettings) diff --git a/DiscordBot/Services/BirthdayAnnouncementService.cs b/DiscordBot/Services/BirthdayAnnouncementService.cs index b1eafbf8..f4e0fd08 100644 --- a/DiscordBot/Services/BirthdayAnnouncementService.cs +++ b/DiscordBot/Services/BirthdayAnnouncementService.cs @@ -15,6 +15,7 @@ public class BirthdayAnnouncementService private readonly DiscordSocketClient _client; private readonly ILoggingService _loggingService; private readonly BotSettings _settings; + private readonly CancellationToken _shutdownToken; // Track birthdays that have been announced today to avoid spam private readonly HashSet _announcedToday = new(); @@ -24,11 +25,13 @@ public class BirthdayAnnouncementService private const string NextBirthdayUrl = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&range=C15:C15"; private const string BirthdayTableUrl = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&gid=318080247&range=B:D"; - public BirthdayAnnouncementService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings) + public BirthdayAnnouncementService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings, + CancellationTokenSource cts) { _client = client; _loggingService = loggingService; _settings = settings; + _shutdownToken = cts.Token; Initialize(); } @@ -58,7 +61,7 @@ private async Task CheckBirthdaysLoop() { try { - while (IsRunning) + while (!_shutdownToken.IsCancellationRequested) { // Check if it's a new day and reset announced birthdays if (DateTime.Today > _lastAnnouncementDate) @@ -72,9 +75,10 @@ private async Task CheckBirthdaysLoop() // Wait for the configured interval var intervalMs = _settings.BirthdayCheckIntervalMinutes * 60 * 1000; - await Task.Delay(intervalMs); + await Task.Delay(intervalMs, _shutdownToken); } } + catch (OperationCanceledException) { } catch (Exception e) { await _loggingService.LogChannelAndFile($"[{ServiceName}] Birthday announcement service has crashed.\nException: {e.Message}", ExtendedLogSeverity.Warning); diff --git a/DiscordBot/Services/CodeCheckService.cs b/DiscordBot/Services/CodeCheckService.cs index eb3b78a5..cff549aa 100644 --- a/DiscordBot/Services/CodeCheckService.cs +++ b/DiscordBot/Services/CodeCheckService.cs @@ -9,6 +9,7 @@ public class CodeCheckService private readonly DiscordSocketClient _client; private readonly BotSettings _settings; private readonly UpdateService _updateService; + private readonly CancellationToken _shutdownToken; private readonly Regex _x3CodeBlock = new("^(?`{3}((?\\w*?$)|$).+?({.+?}).+?`{3})", RegexOptions.Multiline | RegexOptions.Singleline); @@ -22,11 +23,12 @@ public class CodeCheckService public Dictionary CodeReminderCooldown { get; private set; } public CodeCheckService(DiscordSocketClient client, BotSettings settings, - UpdateService updateService) + UpdateService updateService, CancellationTokenSource cts) { _client = client; _settings = settings; _updateService = updateService; + _shutdownToken = cts.Token; CodeReminderCooldown = new Dictionary(); @@ -52,17 +54,18 @@ public CodeCheckService(DiscordSocketClient client, BotSettings settings, private async void UpdateLoop() { - while (true) + try { - try + while (!_shutdownToken.IsCancellationRequested) { - await Task.Delay(10000); + await Task.Delay(10000, _shutdownToken); SaveData(); } - catch (Exception e) - { - LoggingService.LogToConsole($"[CodeCheckService.UpdateLoop] Unhandled exception: {e}", LogSeverity.Error); - } + } + catch (OperationCanceledException) { SaveData(); } + catch (Exception e) + { + LoggingService.LogToConsole($"[CodeCheckService.UpdateLoop] Unhandled exception: {e}", LogSeverity.Error); } } diff --git a/DiscordBot/Services/KarmaResetService.cs b/DiscordBot/Services/KarmaResetService.cs index 69f8d3ea..d47084ef 100644 --- a/DiscordBot/Services/KarmaResetService.cs +++ b/DiscordBot/Services/KarmaResetService.cs @@ -14,11 +14,13 @@ public class KarmaResetService private readonly ILoggingService _logging; private readonly string _connectionString; + private readonly CancellationToken _shutdownToken; - public KarmaResetService(ILoggingService logging, BotSettings settings) + public KarmaResetService(ILoggingService logging, BotSettings settings, CancellationTokenSource cts) { _logging = logging; _connectionString = settings.DbConnectionString; + _shutdownToken = cts.Token; Task.Run(RunLoop); } @@ -26,23 +28,24 @@ public KarmaResetService(ILoggingService logging, BotSettings settings) private async Task RunLoop() { // Wait for DatabaseService to finish table creation - await Task.Delay(TimeSpan.FromSeconds(10)); + await Task.Delay(TimeSpan.FromSeconds(10), _shutdownToken); try { await EnsureMetaTable(); await CatchUpMissedResets(); } + catch (OperationCanceledException) { return; } catch (Exception e) { await _logging.LogChannelAndFile($"KarmaResetService: Failed during startup: {e.Message}", ExtendedLogSeverity.Warning); } - while (true) + try { - try + while (!_shutdownToken.IsCancellationRequested) { - await Task.Delay(TimeSpan.FromHours(1)); + await Task.Delay(TimeSpan.FromHours(1), _shutdownToken); var now = DateTime.UtcNow; @@ -57,10 +60,11 @@ private async Task RunLoop() await TryReset("yearly", UserProps.KarmaYearly); } } - catch (Exception e) - { - await _logging.LogChannelAndFile($"KarmaResetService: Error during reset check: {e.Message}", ExtendedLogSeverity.Warning); - } + } + catch (OperationCanceledException) { } + catch (Exception e) + { + await _logging.LogChannelAndFile($"KarmaResetService: Error during reset check: {e.Message}", ExtendedLogSeverity.Warning); } } diff --git a/DiscordBot/Services/ReminderService.cs b/DiscordBot/Services/ReminderService.cs index 6c25ad32..c0757a8d 100644 --- a/DiscordBot/Services/ReminderService.cs +++ b/DiscordBot/Services/ReminderService.cs @@ -31,15 +31,18 @@ public class ReminderService private readonly ChannelInfo _botCommandsChannel; private readonly string _serverRootPath; private bool _hasChangedSinceLastSave = false; + private readonly CancellationToken _shutdownToken; private const int _maxUserReminders = 10; - public ReminderService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings) + public ReminderService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings, + CancellationTokenSource cts) { _client = client; _loggingService = loggingService; _botCommandsChannel = settings.BotCommandsChannel; _serverRootPath = settings.ServerRootPath; + _shutdownToken = cts.Token; Initialize(); } @@ -112,7 +115,7 @@ private async Task CheckReminders() { try { - while (true) + while (!_shutdownToken.IsCancellationRequested) { // We check if there has been a change to the reminders list since the last update. if (_hasChangedSinceLastSave) @@ -121,7 +124,7 @@ private async Task CheckReminders() _hasChangedSinceLastSave = false; } - await Task.Delay(1000); + await Task.Delay(1000, _shutdownToken); var now = DateTime.Now; // We wait until we know at least one reminder needs to be checked @@ -179,6 +182,12 @@ await channel.SendMessageAsync( _nearestReminder = _reminders.Min(x => x.When); } } + catch (OperationCanceledException) + { + // Save any pending changes on shutdown + if (_hasChangedSinceLastSave) + SaveReminders(); + } catch (Exception e) { // Catch and show exception diff --git a/DiscordBot/Services/UpdateService.cs b/DiscordBot/Services/UpdateService.cs index 09856b27..e08984c1 100644 --- a/DiscordBot/Services/UpdateService.cs +++ b/DiscordBot/Services/UpdateService.cs @@ -62,14 +62,15 @@ public class UpdateService private UserData _userData; public UpdateService(DiscordSocketClient client, - DatabaseService databaseService, BotSettings settings, FeedService feedService, ILoggingService loggingService) + DatabaseService databaseService, BotSettings settings, FeedService feedService, ILoggingService loggingService, + CancellationTokenSource cts) { _client = client; _feedService = feedService; _loggingService = loggingService as LoggingService; _settings = settings; - _token = new CancellationToken(); + _token = cts.Token; UpdateLoop(); } @@ -96,14 +97,17 @@ private void ReadDataFromFile() // Saves data to file private async Task SaveDataToFile() { - while (true) + try { - await SerializeUtil.SerializeFileAsync($"{_settings.ServerRootPath}/botdata.json", _botData); - await SerializeUtil.SerializeFileAsync($"{_settings.ServerRootPath}/userdata.json", _userData); - await SerializeUtil.SerializeFileAsync($"{_settings.ServerRootPath}/feeds.json", _feedData); - await Task.Delay(TimeSpan.FromSeconds(20d), _token); + while (!_token.IsCancellationRequested) + { + await SerializeUtil.SerializeFileAsync($"{_settings.ServerRootPath}/botdata.json", _botData); + await SerializeUtil.SerializeFileAsync($"{_settings.ServerRootPath}/userdata.json", _userData); + await SerializeUtil.SerializeFileAsync($"{_settings.ServerRootPath}/feeds.json", _feedData); + await Task.Delay(TimeSpan.FromSeconds(20d), _token); + } } - // ReSharper disable once FunctionNeverReturns + catch (OperationCanceledException) { } } public async Task GetManualDatabase() @@ -165,49 +169,55 @@ private async Task DownloadDocDatabase() private async Task UpdateDocDatabase() { - while (true) + try { - if (_botData.LastUnityDocDatabaseUpdate < DateTime.Now - TimeSpan.FromDays(1d)) - await DownloadDocDatabase(); + while (!_token.IsCancellationRequested) + { + if (_botData.LastUnityDocDatabaseUpdate < DateTime.Now - TimeSpan.FromDays(1d)) + await DownloadDocDatabase(); - await Task.Delay(TimeSpan.FromHours(1), _token); + await Task.Delay(TimeSpan.FromHours(1), _token); + } } - // ReSharper disable once FunctionNeverReturns + catch (OperationCanceledException) { } } private async Task UpdateRssFeeds() { - await Task.Delay(TimeSpan.FromSeconds(30d), _token); - while (true) + try { - try + await Task.Delay(TimeSpan.FromSeconds(30d), _token); + while (!_token.IsCancellationRequested) { - if (_feedData != null) + try { - if (_feedData.LastUnityReleaseCheck < DateTime.Now - TimeSpan.FromMinutes(5)) + if (_feedData != null) { - _feedData.LastUnityReleaseCheck = DateTime.Now; + if (_feedData.LastUnityReleaseCheck < DateTime.Now - TimeSpan.FromMinutes(5)) + { + _feedData.LastUnityReleaseCheck = DateTime.Now; - await _feedService.CheckUnityBetasAsync(_feedData); - await _feedService.CheckUnityReleasesAsync(_feedData); - } + await _feedService.CheckUnityBetasAsync(_feedData); + await _feedService.CheckUnityReleasesAsync(_feedData); + } - if (_feedData.LastUnityBlogCheck < DateTime.Now - TimeSpan.FromMinutes(10)) - { - _feedData.LastUnityBlogCheck = DateTime.Now; + if (_feedData.LastUnityBlogCheck < DateTime.Now - TimeSpan.FromMinutes(10)) + { + _feedData.LastUnityBlogCheck = DateTime.Now; - await _feedService.CheckUnityBlogAsync(_feedData); + await _feedService.CheckUnityBlogAsync(_feedData); + } } } - } - catch (Exception e) - { - await _loggingService.Log(LogBehaviour.ConsoleChannelAndFile, $"{ServiceName}: Failed to update RSS feeds, attempting to continue.", ExtendedLogSeverity.Error); - } + catch (Exception e) when (e is not OperationCanceledException) + { + await _loggingService.Log(LogBehaviour.ConsoleChannelAndFile, $"{ServiceName}: Failed to update RSS feeds, attempting to continue.", ExtendedLogSeverity.Error); + } - await Task.Delay(TimeSpan.FromSeconds(30d), _token); + await Task.Delay(TimeSpan.FromSeconds(30d), _token); + } } - // ReSharper disable once FunctionNeverReturns + catch (OperationCanceledException) { } } public async Task<(string name, string extract, string url)> DownloadWikipediaArticle(string searchQuery) diff --git a/DiscordBot/Services/WelcomeService.cs b/DiscordBot/Services/WelcomeService.cs index 72ba2434..d93323f9 100644 --- a/DiscordBot/Services/WelcomeService.cs +++ b/DiscordBot/Services/WelcomeService.cs @@ -13,6 +13,7 @@ public class WelcomeService private readonly ILoggingService _loggingService; private readonly BotSettings _settings; + private readonly CancellationToken _shutdownToken; private readonly List<(ulong id, DateTime time)> _welcomeNoticeUsers = new(); @@ -23,12 +24,13 @@ public class WelcomeService _welcomeNoticeUsers.Any() ? _welcomeNoticeUsers.Min(x => x.time) : DateTime.MaxValue; public WelcomeService(DiscordSocketClient client, DatabaseService databaseService, ILoggingService loggingService, - BotSettings settings) + BotSettings settings, CancellationTokenSource cts) { _client = client; _databaseService = databaseService; _loggingService = loggingService; _settings = settings; + _shutdownToken = cts.Token; /* Make sure folders we require exist */ if (!Directory.Exists($"{_settings.ServerRootPath}/images/profiles/")) @@ -122,11 +124,11 @@ private async Task DelayedWelcomeService() { ulong currentlyProcessedUserId = 0; bool firstRun = true; - await Task.Delay(10000); + await Task.Delay(10000, _shutdownToken); try { List toRemove = new(); - while (true) + while (!_shutdownToken.IsCancellationRequested) { var now = DateTime.Now; // This could be optimized, however the users in this list won't ever really be large enough to matter. @@ -153,9 +155,10 @@ private async Task DelayedWelcomeService() if (firstRun) firstRun = false; - await Task.Delay(10000); + await Task.Delay(10000, _shutdownToken); } } + catch (OperationCanceledException) { } catch (Exception e) { // Catch and show exception @@ -172,8 +175,9 @@ private async Task DelayedWelcomeService() if (firstRun) await _loggingService.LogAction($"{ServiceName}: Welcome service failed on first run!? This should not happen.", ExtendedLogSeverity.Error); - // Run the service again. - Task.Run(DelayedWelcomeService); + // Restart unless shutdown was requested + if (!_shutdownToken.IsCancellationRequested) + Task.Run(DelayedWelcomeService); } } diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md index 268db133..4e421dc0 100644 --- a/docs/code-quality-audit.md +++ b/docs/code-quality-audit.md @@ -375,7 +375,7 @@ no `.cs` files and no test framework configured. 3. Add `BotSettings.Validate()` post-deserialization 4. ~~Extract business logic from command handlers into services~~ ✅ 5. ~~Register `IHttpClientFactory` in DI; remove manual `HttpClient` creation~~ ✅ -6. Add graceful shutdown support with `CancellationToken` +6. ~~Add graceful shutdown support with `CancellationToken`~~ ✅ 7. ~~Move static module state (`_activeDuels`) to services~~ ✅ ### Medium-term (Quality) From 5c28bb5fc16647fea8fa6c39ace9388b31124cdf Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 03:21:48 +0200 Subject: [PATCH 23/48] style: apply dotnet format across codebase --- DiscordBot/Data/FuzzTable.cs | 178 +++---- DiscordBot/Extensions/ChannelExtensions.cs | 4 +- DiscordBot/Extensions/ContextExtension.cs | 6 +- .../Extensions/EmbedBuilderExtension.cs | 16 +- DiscordBot/Extensions/StringExtensions.cs | 44 +- DiscordBot/Extensions/UserExtensions.cs | 6 +- DiscordBot/Modules/AirportModule.cs | 24 +- DiscordBot/Modules/ReminderModule.cs | 2 +- DiscordBot/Modules/TicketModule.cs | 2 +- DiscordBot/Modules/TipModule.cs | 474 +++++++++--------- .../UnityHelp/CannedInteractiveModule.cs | 6 +- .../UnityHelp/UnityHelpInteractiveModule.cs | 12 +- .../Modules/Weather/WeatherContainers.cs | 214 ++++---- DiscordBot/Services/AirportService.cs | 60 +-- .../Services/BirthdayAnnouncementService.cs | 86 ++-- DiscordBot/Services/CurrencyService.cs | 22 +- DiscordBot/Services/LoggingService.cs | 51 +- .../Services/Recruitment/RecruitService.cs | 70 +-- DiscordBot/Services/Tips/Components/Tip.cs | 12 +- .../UnityHelp/CannedResponseService.cs | 52 +- .../UnityHelp/Components/HelpBotMessage.cs | 2 +- .../UnityHelp/Components/ThreadContainer.cs | 8 +- .../Services/UnityHelp/UnityHelpService.cs | 132 ++--- DiscordBot/Services/UserExtendedService.cs | 14 +- DiscordBot/Services/WeatherService.cs | 8 +- DiscordBot/Utils/StringUtil.cs | 12 +- 26 files changed, 759 insertions(+), 758 deletions(-) diff --git a/DiscordBot/Data/FuzzTable.cs b/DiscordBot/Data/FuzzTable.cs index 8ac2ff14..66c65a48 100644 --- a/DiscordBot/Data/FuzzTable.cs +++ b/DiscordBot/Data/FuzzTable.cs @@ -21,89 +21,89 @@ public class FuzzTable { private static Random random = new(); private static Regex parenContents = null; - private static TimeSpan timeout = new(10*10000/*x10nanoseconds*/); + private static TimeSpan timeout = new(10 * 10000/*x10nanoseconds*/); - private List choices = new(); - private Queue recent = new(); + private List choices = new(); + private Queue recent = new(); - public void Clear() - { - choices.Clear(); - recent.Clear(); - } + public void Clear() + { + choices.Clear(); + recent.Clear(); + } - public int Count => choices.Count + recent.Count; + public int Count => choices.Count + recent.Count; - // Add a string as a valid choice from which to pick. - // Note that empty strings or whitespace can be added manually as valid choices. - // Duplicate choices are also allowed for weighting. - // - public void Add(string choice) - { - choices.Add(choice); - } + // Add a string as a valid choice from which to pick. + // Note that empty strings or whitespace can be added manually as valid choices. + // Duplicate choices are also allowed for weighting. + // + public void Add(string choice) + { + choices.Add(choice); + } - // Add a collection of choice strings all at once. - // - public void Add(IEnumerable stream) - { - if (stream == null) - return; - foreach (var choice in stream) - Add(choice); - } + // Add a collection of choice strings all at once. + // + public void Add(IEnumerable stream) + { + if (stream == null) + return; + foreach (var choice in stream) + Add(choice); + } - // Load a file of string choices. - // Lines starting with a '#' character are ignored, as are blank lines. - // Each remaining line of the file is trimmed of leading and trailing whitespace. - // Each line is added as a new choice, and duplicates are allowed for weighting. - // If the file is missing, nothing is done, but any other exception is thrown. - // - public void Load(string filename) - { - if (!File.Exists(filename)) - return; - foreach (string line in File.ReadLines(filename)) - { - string choice = line.Trim(); - if (choice.StartsWith('#')) - continue; - Add(choice); - } - } + // Load a file of string choices. + // Lines starting with a '#' character are ignored, as are blank lines. + // Each remaining line of the file is trimmed of leading and trailing whitespace. + // Each line is added as a new choice, and duplicates are allowed for weighting. + // If the file is missing, nothing is done, but any other exception is thrown. + // + public void Load(string filename) + { + if (!File.Exists(filename)) + return; + foreach (string line in File.ReadLines(filename)) + { + string choice = line.Trim(); + if (choice.StartsWith('#')) + continue; + Add(choice); + } + } - // Pick one of the active choices. - // This choice is transferred to the MRU so it's not picked again too soon. - // If the evaluate flag is given, further Evaluate() it as a fuzz string. - // Returns the chosen results, or the empty string if no choices available. - // - public string Pick(bool evaluate=false) - { - Recycle(); - if (choices.Count == 0) - return ""; + // Pick one of the active choices. + // This choice is transferred to the MRU so it's not picked again too soon. + // If the evaluate flag is given, further Evaluate() it as a fuzz string. + // Returns the chosen results, or the empty string if no choices available. + // + public string Pick(bool evaluate = false) + { + Recycle(); + if (choices.Count == 0) + return ""; int pick = random.Next(0, choices.Count); - string chosen = choices[pick]; - choices.RemoveAt(pick); - recent.Enqueue(chosen); - if (evaluate) - return Evaluate(chosen); - return chosen; - } + string chosen = choices[pick]; + choices.RemoveAt(pick); + recent.Enqueue(chosen); + if (evaluate) + return Evaluate(chosen); + return chosen; + } + + // When the MRU gets too long, return the oldest MRU choice(s) back + // to the active list of choices. + // + private void Recycle() + { + // Caps the MRU at half of total choices. + while (recent.Count > choices.Count) + { + string choice = recent.Dequeue(); + choices.Add(choice); + } + } - // When the MRU gets too long, return the oldest MRU choice(s) back - // to the active list of choices. - // - private void Recycle() - { - // Caps the MRU at half of total choices. - while (recent.Count > choices.Count) - { - string choice = recent.Dequeue(); - choices.Add(choice); - } - } - // Evaluate a single fuzz string. // Replace any parenthetical phrase with one of its choices at random. // Allows for nesting of choices. There's currently no way to escape @@ -111,31 +111,31 @@ private void Recycle() // Returns one permutation from all choice alternatives given. // There is no MRU of individual permutations given. // - public static string Evaluate(string fuzz) + public static string Evaluate(string fuzz) { if (string.IsNullOrEmpty(fuzz)) return ""; - if (parenContents == null) - parenContents = - new(@"\( ( [^(]*? ) \)", - RegexOptions.IgnorePatternWhitespace | - RegexOptions.Compiled, - timeout); - string before = null; - while (fuzz != before) - { - before = fuzz; + if (parenContents == null) + parenContents = + new(@"\( ( [^(]*? ) \)", + RegexOptions.IgnorePatternWhitespace | + RegexOptions.Compiled, + timeout); + string before = null; + while (fuzz != before) + { + before = fuzz; try { - fuzz = parenContents.Replace(fuzz, - (m) => PickAlternate(m.Groups[1].ToString())); + fuzz = parenContents.Replace(fuzz, + (m) => PickAlternate(m.Groups[1].ToString())); } catch (RegexMatchTimeoutException) { break; } - } - return fuzz; + } + return fuzz; } private static string PickAlternate(string fuzz) diff --git a/DiscordBot/Extensions/ChannelExtensions.cs b/DiscordBot/Extensions/ChannelExtensions.cs index 74229afc..29db1132 100644 --- a/DiscordBot/Extensions/ChannelExtensions.cs +++ b/DiscordBot/Extensions/ChannelExtensions.cs @@ -12,14 +12,14 @@ public static bool IsThreadInForumChannel(this IMessageChannel channel) return false; return true; } - + public static bool IsThreadInChannel(this IMessageChannel channel, ulong channelId) { if (!channel.IsThreadInForumChannel()) return false; return ((SocketThreadChannel)channel).ParentChannel.Id == channelId; } - + public static bool IsPinned(this IThreadChannel channel) { return channel.Flags.HasFlag(ChannelFlags.Pinned); diff --git a/DiscordBot/Extensions/ContextExtension.cs b/DiscordBot/Extensions/ContextExtension.cs index 4f25d1c9..e624bd1a 100644 --- a/DiscordBot/Extensions/ContextExtension.cs +++ b/DiscordBot/Extensions/ContextExtension.cs @@ -12,7 +12,7 @@ public static bool HasRoleOrEveryoneMention(this ICommandContext context) { return context.Message.MentionedRoleIds.Count != 0 || context.Message.MentionedEveryone; } - + /// /// True if the context includes a RoleID, UserID or Mentions Everyone (Should include @here, unsure) /// @@ -21,7 +21,7 @@ public static bool HasAnyPingableMention(this ICommandContext context) { return context.Message.MentionedUserIds.Count > 0 || context.HasRoleOrEveryoneMention(); } - + /// /// True if the Context contains a message that is a reply and only mentions the user that sent the message. /// ie; the message is a reply to the user but doesn't contain any other mentions. @@ -34,7 +34,7 @@ public static bool IsOnlyReplyingToAuthor(this ICommandContext context) return false; return context.Message.MentionedUserIds.First() == context.Message.ReferencedMessage.Author.Id; } - + /// /// Returns true if the Context has a reference to another message. /// ie; the message is a reply to another message. diff --git a/DiscordBot/Extensions/EmbedBuilderExtension.cs b/DiscordBot/Extensions/EmbedBuilderExtension.cs index 25296cc2..127fe16c 100644 --- a/DiscordBot/Extensions/EmbedBuilderExtension.cs +++ b/DiscordBot/Extensions/EmbedBuilderExtension.cs @@ -2,30 +2,30 @@ namespace DiscordBot.Extensions; public static class EmbedBuilderExtension { - + public static EmbedBuilder FooterRequestedBy(this EmbedBuilder builder, IUser requestor) { builder.WithFooter( - $"Requested by {requestor.GetUserPreferredName()}", + $"Requested by {requestor.GetUserPreferredName()}", requestor.GetAvatarUrl()); return builder; } - + public static EmbedBuilder FooterQuoteBy(this EmbedBuilder builder, IUser requestor, IChannel channel) { builder.WithFooter( - $"Quoted by {requestor.GetUserPreferredName()}, • From channel #{channel.Name}", + $"Quoted by {requestor.GetUserPreferredName()}, • From channel #{channel.Name}", requestor.GetAvatarUrl()); return builder; } - + public static EmbedBuilder FooterInChannel(this EmbedBuilder builder, IChannel channel) { builder.WithFooter( $"In channel #{channel.Name}", null); return builder; } - + public static EmbedBuilder AddAuthor(this EmbedBuilder builder, IUser user, bool includeAvatar = true) { builder.WithAuthor( @@ -33,7 +33,7 @@ public static EmbedBuilder AddAuthor(this EmbedBuilder builder, IUser user, bool includeAvatar ? user.GetAvatarUrl() : null); return builder; } - + public static EmbedBuilder AddAuthorWithAction(this EmbedBuilder builder, IUser user, string action, bool includeAvatar = true) { builder.WithAuthor( @@ -41,5 +41,5 @@ public static EmbedBuilder AddAuthorWithAction(this EmbedBuilder builder, IUser includeAvatar ? user.GetAvatarUrl() : null); return builder; } - + } \ No newline at end of file diff --git a/DiscordBot/Extensions/StringExtensions.cs b/DiscordBot/Extensions/StringExtensions.cs index 1f0ef267..43e46f58 100644 --- a/DiscordBot/Extensions/StringExtensions.cs +++ b/DiscordBot/Extensions/StringExtensions.cs @@ -118,7 +118,7 @@ public static string GetSha256(this string input) // Return the hexadecimal string. return sb.ToString(); } - + /// /// Returns true if the string contains only upper case characters, including spaces and all punctuation ie; "I NEED HELP!?!?!?!#$?!" will return true /// @@ -126,7 +126,7 @@ public static bool IsAllCaps(this string str) { return Regex.IsMatch(str, @"^[A-Z\s\p{P}]+$"); } - + public static string ToCapitalizeFirstLetter(this string str) { if (string.IsNullOrEmpty(str)) @@ -142,31 +142,31 @@ public static string ToCapitalizeFirstLetter(this string str) /// /// array or list of element phrases to be listed /// final conjunction; defaults to "and" if not given - public static string ToCommaList(this string[] nouns, string conj=null) - { - if (conj == null) - conj = "and"; - var sb = new StringBuilder(); - for (int i = 0; i < nouns.Length; i++) - { - if (i > 0) - { - if (nouns.Length > 2) - sb.Append(','); - sb.Append(' '); - if (i == nouns.Length-1) - sb.Append(conj).Append(' '); - } - sb.Append(nouns[i]); - } - return sb.ToString(); - } + public static string ToCommaList(this string[] nouns, string conj = null) + { + if (conj == null) + conj = "and"; + var sb = new StringBuilder(); + for (int i = 0; i < nouns.Length; i++) + { + if (i > 0) + { + if (nouns.Length > 2) + sb.Append(','); + sb.Append(' '); + if (i == nouns.Length - 1) + sb.Append(conj).Append(' '); + } + sb.Append(nouns[i]); + } + return sb.ToString(); + } public static string ToBold(this string text) { return $"**{text}**"; } - + public static string[] ToBoldArray(this string[] texts) { var bolds = new string[texts.Length]; diff --git a/DiscordBot/Extensions/UserExtensions.cs b/DiscordBot/Extensions/UserExtensions.cs index 76ed2e0b..2c641ed0 100644 --- a/DiscordBot/Extensions/UserExtensions.cs +++ b/DiscordBot/Extensions/UserExtensions.cs @@ -8,8 +8,8 @@ public static bool IsUserBotOrWebhook(this IUser user) { return user.IsBot || user.IsWebhook; } - - public static bool HasRoleGroup(this IUser user, SocketRole role) + + public static bool HasRoleGroup(this IUser user, SocketRole role) { return HasRoleGroup(user, role.Id); } @@ -27,7 +27,7 @@ public static string GetUserPreferredName(this IUser user) var guildUser = user as SocketGuildUser; return guildUser?.DisplayName ?? user.Username; } - + public static string GetPreferredAndUsername(this IUser user) { var guildUser = user as SocketGuildUser; diff --git a/DiscordBot/Modules/AirportModule.cs b/DiscordBot/Modules/AirportModule.cs index 5a35ae31..e2025662 100644 --- a/DiscordBot/Modules/AirportModule.cs +++ b/DiscordBot/Modules/AirportModule.cs @@ -19,7 +19,7 @@ public class AirportModule : ModuleBase #endregion // Dependency Injection #region API Results - + public class FlightResults { public string iata { get; set; } @@ -47,13 +47,13 @@ public async Task FlyTo(string from, string to) await Context.Message.DeleteAfterSeconds(2f); return; } - + EmbedBuilder embed = new(); embed.Title = "Flight Finder"; embed.Description = "Finding cities"; var msg = await ReplyAsync(string.Empty, false, embed.Build()); - + // Use Weather API to get lon/lat of cities var fromCity = await GetCity(from, embed, msg); if (fromCity == null) @@ -61,7 +61,7 @@ public async Task FlyTo(string from, string to) var toCity = await GetCity(to, embed, msg); if (toCity == null) return; - + // Find closest Airport using AirLabs API embed.Description = "Finding airports"; await msg.ModifyAsync(x => x.Embed = embed.Build()); @@ -72,11 +72,11 @@ public async Task FlyTo(string from, string to) var toAirport = await GetAirport(toCity, embed, msg); if (toAirport == null) return; - + // Find cheapest flight using GetFlightInfo embed.Description = $"Searching {fromAirport.name} to {toAirport.name}"; await msg.ModifyAsync(x => x.Embed = embed.Build()); - + var daysUntilTuesday = (int)DateTime.Now.DayOfWeek - 2; if (daysUntilTuesday < 0) daysUntilTuesday += 7; @@ -91,7 +91,7 @@ public async Task FlyTo(string from, string to) } var flight = flights[0]; - + var itinerary = flight.itineraries.First(); var numberOfStops = itinerary.segments.Count - 1; var departTime = itinerary.segments.First().departure; @@ -106,7 +106,7 @@ public async Task FlyTo(string from, string to) // embed.Description += // $"\nSeats remaining: {flight.numberOfBookableSeats}, Bags: {(flight.pricingOptions.includedCheckedBagsOnly ? "Y" : "N")}, OneWay: {(flight.oneWay ? "Y" : "N")}"; embed.Description += $"\nDepart: {departTime.at:dd/MM/yy HH:MM}, Arrive: {arriveTime.at:dd/MM/yy HH:MM}"; - + // string price = $"Base: {flight.price.@base}"; // foreach (var fee in flight.price.fees) // { @@ -121,9 +121,9 @@ public async Task FlyTo(string from, string to) } #endregion // Commands - + #region Utility Methods - + private async Task GetCity(string city, EmbedBuilder embed, IUserMessage msg) { var cityResult = await WeatherService.GetWeather(city); @@ -136,7 +136,7 @@ public async Task FlyTo(string from, string to) } return cityResult; } - + private async Task GetAirport(WeatherContainer.Result weather, EmbedBuilder embed, IUserMessage msg) { var airportResult = await AirportService.GetClosestAirport(weather.coord.Lat, weather.coord.Lon); @@ -151,5 +151,5 @@ public async Task FlyTo(string from, string to) } #endregion // Utility Methods - + } \ No newline at end of file diff --git a/DiscordBot/Modules/ReminderModule.cs b/DiscordBot/Modules/ReminderModule.cs index dba0abda..90a06a55 100644 --- a/DiscordBot/Modules/ReminderModule.cs +++ b/DiscordBot/Modules/ReminderModule.cs @@ -153,7 +153,7 @@ public async Task Reminders(IUser user) $"#{index++} | {Utils.Utils.FormatTime((uint)(reminder.When - DateTime.Now).TotalSeconds)}", $"[Link]({msgLink}) \"{reminder.Message}\""); } - if (await Context.Guild.GetChannelAsync(Settings.BotCommandsChannel.Id)is IMessageChannel botCommands) + if (await Context.Guild.GetChannelAsync(Settings.BotCommandsChannel.Id) is IMessageChannel botCommands) await botCommands .SendMessageAsync(Context.User.Mention, false, embed.Build()) .DeleteAfterSeconds(seconds: 30); diff --git a/DiscordBot/Modules/TicketModule.cs b/DiscordBot/Modules/TicketModule.cs index 67fb8f71..f08087e7 100644 --- a/DiscordBot/Modules/TicketModule.cs +++ b/DiscordBot/Modules/TicketModule.cs @@ -12,7 +12,7 @@ public class TicketModule : ModuleBase public CommandHandlingService CommandHandlingService { get; set; } public BotSettings Settings { get; set; } - + #endregion /// diff --git a/DiscordBot/Modules/TipModule.cs b/DiscordBot/Modules/TipModule.cs index 16136d74..ce93f0a2 100644 --- a/DiscordBot/Modules/TipModule.cs +++ b/DiscordBot/Modules/TipModule.cs @@ -11,241 +11,241 @@ namespace DiscordBot.Modules; public class TipModule : ModuleBase { - #region Dependency Injection - - public CommandHandlingService CommandHandlingService { get; set; } - public BotSettings Settings { get; set; } - public TipService TipService { get; set; } - - #endregion - - private bool IsAuthorized(IUser user) - { - if (user.HasRoleGroup(Settings.ModeratorRoleId)) - return true; - if (user.HasRoleGroup(Settings.TipsUserRoleId)) - return true; - - return false; - } - - [Command("Tip")] - [Summary("Find and provide pre-authored tips (images or text) by their keywords.")] - /* removing [RequireModerator] for custom check */ - public async Task Tip(params string[] keywords) - { - var user = Context.Message.Author; - if (!IsAuthorized(user)) - return; - - var terms = string.Join(",", keywords); - var tips = TipService.GetTips(terms); - if (tips.Count == 0) - { - await ReplyAsync("No tips for the keywords provided were found.").DeleteAfterSeconds(5); - return; - } - - foreach (var tip in tips) - tip.Requests++; - - var isAnyTextTips = tips.Any(tip => !string.IsNullOrEmpty(tip.Content)); - var builder = new EmbedBuilder(); - if (isAnyTextTips) - { - // Loop through tips in order, have dot point list of the .Content property in an embed - builder - .WithTitle("Tip List") - .WithDescription("Here are the tips for your keywords:"); - foreach (var tip in tips) - { - builder.AddField(tip.Keywords.Count == 1 ? tip.Keywords[0] : "Multiple Keywords", tip.Content); - } - } - - var attachments = tips - .Where(tip => tip.ImagePaths != null && tip.ImagePaths.Any()) - .SelectMany(tip => tip.ImagePaths) - .Select(imagePath => new FileAttachment(TipService.GetTipPath(imagePath))) - .ToList(); - - if (attachments.Count > 0) - { - if (isAnyTextTips) - { - await Context.Channel.SendFilesAsync(attachments, embed: builder.Build()); - } - else - { - await Context.Channel.SendFilesAsync(attachments); - } - } - else - { - await ReplyAsync(embed: builder.Build()); - } - - var ids = string.Join(" ", tips.Select(t => t.Id.ToString()).ToArray()); - await ReplyAsync($"-# Tip ID {ids}"); - await Context.Message.DeleteAsync(); - await TipService.CommitTipDatabase(); - } - - [Command("AddTip")] - [Summary("Add a tip to the database.")] - [RequireModerator] - public async Task AddTip(string keywords, string content = "") - { - await TipService.AddTip(Context.Message, keywords, content); - } - - [Command("RemoveTip")] - [Summary("Remove a tip from the database.")] - [RequireModerator] - public async Task RemoveTip(ulong tipId) - { - Tip tip = TipService.GetTip(tipId); - if (tip == null) - { - await Context.Channel.SendMessageAsync("No such tip found to be removed.").DeleteAfterSeconds(5); - return; - } - - await TipService.RemoveTip(Context.Message, tip); - } - - [Command("ReplaceTip")] - [Summary("Replace image content of an existing tip in the database.")] - [RequireModerator] - public async Task ReplaceTip(ulong tipId, string content = "") - { - Tip tip = TipService.GetTip(tipId); - if (tip == null) - { - await Context.Channel.SendMessageAsync("No such tip found to be replaced.").DeleteAfterSeconds(5); - return; - } - - await TipService.ReplaceTip(Context.Message, tip, content); - } - - [Command("ReloadTips")] - [Summary("Reload the database of tips.")] - [RequireModerator] - public async Task ReloadTipDatabase() - { - // rare usage, but in case someone with a shell decides - // to edit the json for debugging/expansion reasons... - await TipService.ReloadTipDatabase(); - await ReplyAsync("Tip index reloaded."); - } - - [Command("ListTips")] - [Summary("List available tips by their keywords.")] - /* removing [RequireModerator] for custom check */ - public async Task ListTips(params string[] keywords) - { - var user = Context.Message.Author; - if (!IsAuthorized(user)) - return; - - int floodCount = 20; - - List tips = null; - if (keywords?.Length > 0) - { - var terms = string.Join(",", keywords); - tips = TipService.GetTips(terms); - if (tips.Count == 0) - { - await ReplyAsync("No tips for the keywords provided were found.").DeleteAfterSeconds(5); - return; - } - if (tips.Count >= floodCount) - { - await ReplyAsync($"Total of {tips.Count} tips found for the keywords provided; refine your search.").DeleteAfterSeconds(5); - return; - } - } - else - { - tips = TipService.GetAllTips().OrderBy(t => t.Id).ToList(); - if (tips.Count >= floodCount) - { - var terms = new HashSet(); - foreach (var tip in tips) - foreach (var term in tip.Keywords) - terms.Add(term); - await ReplyAsync($"Total of {tips.Count} tips found, add one or more keywords to narrow the search."); - var termList = new List(); - foreach (var tip in terms.OrderBy(k => k)) - termList.Add(tip); - floodCount = 150; - while (termList.Count > 0) - { - int count = termList.Count; - if (count > floodCount) - count = floodCount-10; - string keywordList = "Keywords: "; - for (int i = 0; i < count; i++) - { - keywordList += $"`{termList[0]}`, "; - termList.RemoveAt(0); - } - keywordList = keywordList.Substring(0, keywordList.Length-2); - await ReplyAsync(keywordList); - if (termList.Count > 0) - await Task.Delay(500); - } - return; - } - } - - int chunkCount = 10; - int chunkTime = 1500; - bool first = true; - - while (tips.Count > 0) - { - var builder = new EmbedBuilder(); - if (first) - { - builder - .WithTitle("List of Tips") - .WithDescription("Tips available for the following keywords:"); - first = false; - } - - int chunk = 0; - while (tips.Count > 0 && chunk < chunkCount) - { - string keywordlist = string.Join("`, `", tips[0].Keywords.OrderBy(k => k)); - string images = String.Concat( - Enumerable.Repeat(" :frame_photo:", - tips[0].ImagePaths.Count).ToArray()); - builder.AddField($"ID: {tips[0].Id} {images}", $"`{keywordlist}`"); - tips.RemoveAt(0); - chunk++; - } - - await ReplyAsync(embed: builder.Build()); - if (tips.Count > 0) - await Task.Delay(chunkTime); - } - } - - #region CommandList - [Command("TipHelp")] - [Alias("TipsHelp")] - [Summary("Shows available tip database commands.")] - public async Task TipHelp() - { - // NOTE: skips the RequireModerator commands, so nearly an empty list - foreach (var message in CommandHandlingService.GetCommandListMessages("TipModule", true, true, false)) - { - await ReplyAsync(message); - } - } - #endregion - + #region Dependency Injection + + public CommandHandlingService CommandHandlingService { get; set; } + public BotSettings Settings { get; set; } + public TipService TipService { get; set; } + + #endregion + + private bool IsAuthorized(IUser user) + { + if (user.HasRoleGroup(Settings.ModeratorRoleId)) + return true; + if (user.HasRoleGroup(Settings.TipsUserRoleId)) + return true; + + return false; + } + + [Command("Tip")] + [Summary("Find and provide pre-authored tips (images or text) by their keywords.")] + /* removing [RequireModerator] for custom check */ + public async Task Tip(params string[] keywords) + { + var user = Context.Message.Author; + if (!IsAuthorized(user)) + return; + + var terms = string.Join(",", keywords); + var tips = TipService.GetTips(terms); + if (tips.Count == 0) + { + await ReplyAsync("No tips for the keywords provided were found.").DeleteAfterSeconds(5); + return; + } + + foreach (var tip in tips) + tip.Requests++; + + var isAnyTextTips = tips.Any(tip => !string.IsNullOrEmpty(tip.Content)); + var builder = new EmbedBuilder(); + if (isAnyTextTips) + { + // Loop through tips in order, have dot point list of the .Content property in an embed + builder + .WithTitle("Tip List") + .WithDescription("Here are the tips for your keywords:"); + foreach (var tip in tips) + { + builder.AddField(tip.Keywords.Count == 1 ? tip.Keywords[0] : "Multiple Keywords", tip.Content); + } + } + + var attachments = tips + .Where(tip => tip.ImagePaths != null && tip.ImagePaths.Any()) + .SelectMany(tip => tip.ImagePaths) + .Select(imagePath => new FileAttachment(TipService.GetTipPath(imagePath))) + .ToList(); + + if (attachments.Count > 0) + { + if (isAnyTextTips) + { + await Context.Channel.SendFilesAsync(attachments, embed: builder.Build()); + } + else + { + await Context.Channel.SendFilesAsync(attachments); + } + } + else + { + await ReplyAsync(embed: builder.Build()); + } + + var ids = string.Join(" ", tips.Select(t => t.Id.ToString()).ToArray()); + await ReplyAsync($"-# Tip ID {ids}"); + await Context.Message.DeleteAsync(); + await TipService.CommitTipDatabase(); + } + + [Command("AddTip")] + [Summary("Add a tip to the database.")] + [RequireModerator] + public async Task AddTip(string keywords, string content = "") + { + await TipService.AddTip(Context.Message, keywords, content); + } + + [Command("RemoveTip")] + [Summary("Remove a tip from the database.")] + [RequireModerator] + public async Task RemoveTip(ulong tipId) + { + Tip tip = TipService.GetTip(tipId); + if (tip == null) + { + await Context.Channel.SendMessageAsync("No such tip found to be removed.").DeleteAfterSeconds(5); + return; + } + + await TipService.RemoveTip(Context.Message, tip); + } + + [Command("ReplaceTip")] + [Summary("Replace image content of an existing tip in the database.")] + [RequireModerator] + public async Task ReplaceTip(ulong tipId, string content = "") + { + Tip tip = TipService.GetTip(tipId); + if (tip == null) + { + await Context.Channel.SendMessageAsync("No such tip found to be replaced.").DeleteAfterSeconds(5); + return; + } + + await TipService.ReplaceTip(Context.Message, tip, content); + } + + [Command("ReloadTips")] + [Summary("Reload the database of tips.")] + [RequireModerator] + public async Task ReloadTipDatabase() + { + // rare usage, but in case someone with a shell decides + // to edit the json for debugging/expansion reasons... + await TipService.ReloadTipDatabase(); + await ReplyAsync("Tip index reloaded."); + } + + [Command("ListTips")] + [Summary("List available tips by their keywords.")] + /* removing [RequireModerator] for custom check */ + public async Task ListTips(params string[] keywords) + { + var user = Context.Message.Author; + if (!IsAuthorized(user)) + return; + + int floodCount = 20; + + List tips = null; + if (keywords?.Length > 0) + { + var terms = string.Join(",", keywords); + tips = TipService.GetTips(terms); + if (tips.Count == 0) + { + await ReplyAsync("No tips for the keywords provided were found.").DeleteAfterSeconds(5); + return; + } + if (tips.Count >= floodCount) + { + await ReplyAsync($"Total of {tips.Count} tips found for the keywords provided; refine your search.").DeleteAfterSeconds(5); + return; + } + } + else + { + tips = TipService.GetAllTips().OrderBy(t => t.Id).ToList(); + if (tips.Count >= floodCount) + { + var terms = new HashSet(); + foreach (var tip in tips) + foreach (var term in tip.Keywords) + terms.Add(term); + await ReplyAsync($"Total of {tips.Count} tips found, add one or more keywords to narrow the search."); + var termList = new List(); + foreach (var tip in terms.OrderBy(k => k)) + termList.Add(tip); + floodCount = 150; + while (termList.Count > 0) + { + int count = termList.Count; + if (count > floodCount) + count = floodCount - 10; + string keywordList = "Keywords: "; + for (int i = 0; i < count; i++) + { + keywordList += $"`{termList[0]}`, "; + termList.RemoveAt(0); + } + keywordList = keywordList.Substring(0, keywordList.Length - 2); + await ReplyAsync(keywordList); + if (termList.Count > 0) + await Task.Delay(500); + } + return; + } + } + + int chunkCount = 10; + int chunkTime = 1500; + bool first = true; + + while (tips.Count > 0) + { + var builder = new EmbedBuilder(); + if (first) + { + builder + .WithTitle("List of Tips") + .WithDescription("Tips available for the following keywords:"); + first = false; + } + + int chunk = 0; + while (tips.Count > 0 && chunk < chunkCount) + { + string keywordlist = string.Join("`, `", tips[0].Keywords.OrderBy(k => k)); + string images = String.Concat( + Enumerable.Repeat(" :frame_photo:", + tips[0].ImagePaths.Count).ToArray()); + builder.AddField($"ID: {tips[0].Id} {images}", $"`{keywordlist}`"); + tips.RemoveAt(0); + chunk++; + } + + await ReplyAsync(embed: builder.Build()); + if (tips.Count > 0) + await Task.Delay(chunkTime); + } + } + + #region CommandList + [Command("TipHelp")] + [Alias("TipsHelp")] + [Summary("Shows available tip database commands.")] + public async Task TipHelp() + { + // NOTE: skips the RequireModerator commands, so nearly an empty list + foreach (var message in CommandHandlingService.GetCommandListMessages("TipModule", true, true, false)) + { + await ReplyAsync(message); + } + } + #endregion + } diff --git a/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs b/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs index fe745810..a085975b 100644 --- a/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs +++ b/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs @@ -15,9 +15,9 @@ public class CannedInteractiveModule : InteractionModuleBase public UnityHelpService HelpService { get; set; } public BotSettings BotSettings { get; set; } public CannedResponseService CannedResponseService { get; set; } - + #endregion // Dependency Injection - + // Responses are any of the CannedResponseType enum [SlashCommand("faq", "Prepared responses to help answer common questions")] public async Task CannedResponses(CannedHelp type) @@ -28,7 +28,7 @@ public async Task CannedResponses(CannedHelp type) var embed = CannedResponseService.GetCannedResponse((CannedResponseType)type); await Context.Interaction.RespondAsync(string.Empty, embed: embed.Build()); } - + [SlashCommand("resources", "Links to resources to help answer common questions")] public async Task Resources(CannedResources type) { diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs b/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs index 8e24e8bb..253fbd9e 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs +++ b/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs @@ -11,7 +11,7 @@ public class UnityHelpInteractiveModule : InteractionModuleBase public UnityHelpService HelpService { get; set; } public BotSettings BotSettings { get; set; } - + #endregion // Dependency Injection [SlashCommand("resolve-question", "If in unity-help forum channel, resolve the thread")] @@ -19,7 +19,7 @@ public async Task ResolveQuestion() { if (!BotSettings.UnityHelpBabySitterEnabled) return; - + await Context.Interaction.DeferAsync(ephemeral: true); if (!IsValidUser()) @@ -47,7 +47,7 @@ public async Task MarkResponseAnswer(IMessage targetResponse) { if (!BotSettings.UnityHelpBabySitterEnabled) return; - + await Context.Interaction.DeferAsync(ephemeral: true); if (!IsValidUser()) { @@ -68,14 +68,14 @@ await Context.Interaction.FollowupAsync( } var response = await HelpService.MarkResponseAsAnswer(Context.User, targetResponse); - await Context.Interaction.FollowupAsync( response, ephemeral: true); + await Context.Interaction.FollowupAsync(response, ephemeral: true); } #endregion // Context Commands - + #region Utility - + private bool IsInHelpChannel() => Context.Channel.IsThreadInChannel(BotSettings.GenericHelpChannel.Id); private bool IsValidUser() => !Context.User.IsUserBotOrWebhook(); diff --git a/DiscordBot/Modules/Weather/WeatherContainers.cs b/DiscordBot/Modules/Weather/WeatherContainers.cs index 1f5d351c..2294950e 100644 --- a/DiscordBot/Modules/Weather/WeatherContainers.cs +++ b/DiscordBot/Modules/Weather/WeatherContainers.cs @@ -2,128 +2,128 @@ namespace DiscordBot.Modules.Weather; - #region Weather Results +#region Weather Results #pragma warning disable 0649 - // ReSharper disable InconsistentNaming - public class WeatherContainer +// ReSharper disable InconsistentNaming +public class WeatherContainer +{ + public class Coord { - public class Coord - { - public double Lon { get; set; } - public double Lat { get; set; } - } + public double Lon { get; set; } + public double Lat { get; set; } + } - public class Weather - { - public int id { get; set; } - [JsonProperty("main")] public string Name { get; set; } - public string Description { get; set; } - public string Icon { get; set; } - } + public class Weather + { + public int id { get; set; } + [JsonProperty("main")] public string Name { get; set; } + public string Description { get; set; } + public string Icon { get; set; } + } - public class Main - { - public float Temp { get; set; } - [JsonProperty("feels_like")] public double Feels { get; set; } - [JsonProperty("temp_min")] public double Min { get; set; } - [JsonProperty("temp_max")] public double Max { get; set; } - public int Pressure { get; set; } - public int Humidity { get; set; } - } + public class Main + { + public float Temp { get; set; } + [JsonProperty("feels_like")] public double Feels { get; set; } + [JsonProperty("temp_min")] public double Min { get; set; } + [JsonProperty("temp_max")] public double Max { get; set; } + public int Pressure { get; set; } + public int Humidity { get; set; } + } - public class Wind - { - public double Speed { get; set; } - public int Deg { get; set; } - } + public class Wind + { + public double Speed { get; set; } + public int Deg { get; set; } + } - public class Clouds - { - public int all { get; set; } - } + public class Clouds + { + public int all { get; set; } + } - public class Rain - { - [JsonProperty("1h")] public double Rain1h { get; set; } - [JsonProperty("3h")] public double Rain3h { get; set; } - } - - public class Snow - { - [JsonProperty("1h")] public double Snow1h { get; set; } - [JsonProperty("3h")] public double Snow3h { get; set; } - } + public class Rain + { + [JsonProperty("1h")] public double Rain1h { get; set; } + [JsonProperty("3h")] public double Rain3h { get; set; } + } - public class Sys - { - public int type { get; set; } - public int id { get; set; } - public double message { get; set; } - public string country { get; set; } - public int sunrise { get; set; } - public int sunset { get; set; } - } + public class Snow + { + [JsonProperty("1h")] public double Snow1h { get; set; } + [JsonProperty("3h")] public double Snow3h { get; set; } + } - public class Result - { - public Coord coord { get; set; } - public List weather { get; set; } - public string @base { get; set; } - public Main main { get; set; } - public int visibility { get; set; } - public Wind wind { get; set; } - public Clouds clouds { get; set; } - public Rain rain { get; set; } - public Snow snow { get; set; } - public int dt { get; set; } - public Sys sys { get; set; } - public int timezone { get; set; } - public int id { get; set; } - public string name { get; set; } - public int cod { get; set; } - } + public class Sys + { + public int type { get; set; } + public int id { get; set; } + public double message { get; set; } + public string country { get; set; } + public int sunrise { get; set; } + public int sunset { get; set; } + } + + public class Result + { + public Coord coord { get; set; } + public List weather { get; set; } + public string @base { get; set; } + public Main main { get; set; } + public int visibility { get; set; } + public Wind wind { get; set; } + public Clouds clouds { get; set; } + public Rain rain { get; set; } + public Snow snow { get; set; } + public int dt { get; set; } + public Sys sys { get; set; } + public int timezone { get; set; } + public int id { get; set; } + public string name { get; set; } + public int cod { get; set; } } +} - #endregion - #region Pollution Results +#endregion +#region Pollution Results - public class PollutionContainer +public class PollutionContainer +{ + public class Coord + { + public double lon { get; set; } + public double lat { get; set; } + } + public class Main { - public class Coord - { - public double lon { get; set; } - public double lat { get; set; } - } - public class Main - { - public int aqi { get; set; } - } - public class Components - { - [JsonProperty("co")] public double CarbonMonoxide { get; set; } - [JsonProperty("no")] public double NitrogenMonoxide { get; set; } - [JsonProperty("no2")] public double NitrogenDioxide { get; set; } - [JsonProperty("o3")] public double Ozone { get; set; } - [JsonProperty("so2")] public double SulphurDioxide { get; set; } - [JsonProperty("pm2_5")] public double FineParticles { get; set; } - [JsonProperty("pm10")] public double CoarseParticulate { get; set; } - [JsonProperty("nh3")] public double Ammonia { get; set; } - } + public int aqi { get; set; } + } + public class Components + { + [JsonProperty("co")] public double CarbonMonoxide { get; set; } + [JsonProperty("no")] public double NitrogenMonoxide { get; set; } + [JsonProperty("no2")] public double NitrogenDioxide { get; set; } + [JsonProperty("o3")] public double Ozone { get; set; } + [JsonProperty("so2")] public double SulphurDioxide { get; set; } + [JsonProperty("pm2_5")] public double FineParticles { get; set; } + [JsonProperty("pm10")] public double CoarseParticulate { get; set; } + [JsonProperty("nh3")] public double Ammonia { get; set; } + } - public class List - { - public Main main { get; set; } - public Components components { get; set; } - public int dt { get; set; } - } - public class Result - { - public Coord coord { get; set; } - public List list { get; set; } - } + public class List + { + public Main main { get; set; } + public Components components { get; set; } + public int dt { get; set; } + } + public class Result + { + public Coord coord { get; set; } + public List list { get; set; } } +} - // ReSharper restore InconsistentNaming +// ReSharper restore InconsistentNaming #pragma warning restore 0649 - #endregion \ No newline at end of file +#endregion \ No newline at end of file diff --git a/DiscordBot/Services/AirportService.cs b/DiscordBot/Services/AirportService.cs index 18f14281..6f301161 100644 --- a/DiscordBot/Services/AirportService.cs +++ b/DiscordBot/Services/AirportService.cs @@ -12,7 +12,7 @@ public class AirportService { private readonly DiscordSocketClient _client; private readonly ILoggingService _loggingService; - + #region Amadeus private readonly string _flightApiKey; @@ -26,12 +26,12 @@ public class AirportService private const string CheapestRouteParam = "?originLocationCode={0}&destinationLocationCode={1}&departureDate={2}&adults=1&nonStop=false&max=5¤cyCode=USD"; #region Return Results - + public class AmadeusRoot { public List data { get; set; } } - + public class FlightInfo { public string type { get; set; } @@ -48,18 +48,18 @@ public class FlightInfo public List validatingAirlineCodes { get; set; } // public List travelerPricings { get; set; } } - + public class PricingOptions { public List fareType { get; set; } public bool includedCheckedBagsOnly { get; set; } } - + public class Price { public string currency { get; set; } public string total { get; set; } - public string @base { get; set; } + public string @base { get; set; } public List fees { get; set; } public string grandTotal { get; set; } @@ -68,19 +68,19 @@ public double GrandTotalNumber() return double.TryParse(grandTotal, out double result) ? result : double.MinValue; } } - + public class Fee { public string amount { get; set; } public string type { get; set; } } - + public class Itinerary { public string duration { get; set; } public List segments { get; set; } } - + public class Segment { public FlightDetails departure { get; set; } @@ -94,13 +94,13 @@ public class Segment public int numberOfStops { get; set; } public bool blacklistedInEU { get; set; } } - + public class FlightDetails { public string iataCode { get; set; } public DateTime at { get; set; } } - + public class AmadeusAuthRoot { public string type { get; set; } @@ -115,18 +115,18 @@ public class AmadeusAuthRoot } #endregion // Return Results - + #endregion // Amadeus #region AirLabs - + private string _airLabsNearbyCityRoute = "https://airlabs.co/api/v9/nearby?lat={0}&lng={1}&distance=100"; private string _airLabsAPIInclude = "&api_key={0}"; private string _airLabsAPIRequiredFields = "&_fields=iata_code"; - + #region Return Results - + public class AirLabsAirport { public string icao_code { get; set; } @@ -168,7 +168,7 @@ public class AirLabsSuperRoot } #endregion // Return Results - + #endregion // AirLabs public AirportService(DiscordSocketClient client, ILoggingService loggingService, BotSettings botSettings) @@ -177,7 +177,7 @@ public AirportService(DiscordSocketClient client, ILoggingService loggingService _loggingService = loggingService; _flightApiKey = botSettings.FlightAPIKey; _flightSecret = botSettings.FlightAPISecret; - + _airLabsAPIInclude = string.Format(_airLabsAPIInclude, botSettings.AirLabAPIKey); _airLabsNearbyCityRoute += _airLabsAPIInclude + _airLabsAPIRequiredFields; } @@ -187,16 +187,16 @@ public async Task GetClosestAirport(double lat, double lng) var url = string.Format(_airLabsNearbyCityRoute, lat, lng); var result = await SerializeUtil.LoadUrlDeserializeResult(url); - + // Sort by popularity result.response.airports.Sort((a, b) => b.popularity.CompareTo(a.popularity)); // Return first Airport that has a IATA code return result.response.airports.FirstOrDefault(a => !string.IsNullOrEmpty(a.iata_code)); } - + public async Task GetFlightTickets(string from, string to) { - + return null; } @@ -206,42 +206,42 @@ public async Task GetValidationToken() { if (_amadeusTokenExpiration > DateTime.Now) return true; - + var url = "https://test.api.amadeus.com/v1/security/oauth2/token"; var data = "grant_type=client_credentials&client_id=" + _flightApiKey + "&client_secret=" + _flightSecret; - + HttpClient client = new(); var response = await client.PostAsync(url, new StringContent(data, Encoding.UTF8, "application/x-www-form-urlencoded")); if (!response.IsSuccessStatusCode) return false; - + var result = await response.Content.ReadAsStringAsync(); var authRoot = JsonConvert.DeserializeObject(result); if (authRoot == null) return false; - + _amadeusToken = authRoot.access_token; _amadeusTokenExpiration = DateTime.Now.AddSeconds(authRoot.expires_in - 1); return true; } - + public async Task> GetFlightInfo(string from, string to, int daysFromNow = 2) { if (!await GetValidationToken()) return null; - + var url = BaseRoute + FindCheapestRoute + string.Format(CheapestRouteParam, from, to, DateTime.Now.AddDays(daysFromNow).ToString("yyyy-MM-dd")); - + HttpClient client = new(); HttpRequestHeaders headers = client.DefaultRequestHeaders; headers.Add("Authorization", "Bearer " + _amadeusToken); - + var response = await client.GetAsync(url); if (!response.IsSuccessStatusCode) return null; - + var result = await response.Content.ReadAsStringAsync(); var root = JsonConvert.DeserializeObject(result); if (root == null) return null; - + root.data.Sort((a, b) => b.price.GrandTotalNumber().CompareTo(a.price.GrandTotalNumber())); return root.data; } diff --git a/DiscordBot/Services/BirthdayAnnouncementService.cs b/DiscordBot/Services/BirthdayAnnouncementService.cs index f4e0fd08..0b556c2a 100644 --- a/DiscordBot/Services/BirthdayAnnouncementService.cs +++ b/DiscordBot/Services/BirthdayAnnouncementService.cs @@ -9,22 +9,22 @@ namespace DiscordBot.Services; public class BirthdayAnnouncementService { private const string ServiceName = "BirthdayAnnouncementService"; - + public bool IsRunning { get; private set; } - + private readonly DiscordSocketClient _client; private readonly ILoggingService _loggingService; private readonly BotSettings _settings; private readonly CancellationToken _shutdownToken; - + // Track birthdays that have been announced today to avoid spam private readonly HashSet _announcedToday = new(); private DateTime _lastAnnouncementDate = DateTime.Today; - + // URLs for birthday data from the existing !bday command private const string NextBirthdayUrl = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&range=C15:C15"; private const string BirthdayTableUrl = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&gid=318080247&range=B:D"; - + public BirthdayAnnouncementService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings, CancellationTokenSource cts) { @@ -32,31 +32,31 @@ public BirthdayAnnouncementService(DiscordSocketClient client, ILoggingService l _loggingService = loggingService; _settings = settings; _shutdownToken = cts.Token; - + Initialize(); } - + private void Initialize() { if (IsRunning) return; - + if (!_settings.BirthdayAnnouncementEnabled) { _loggingService.LogAction($"[{ServiceName}] Birthday announcement service is disabled in settings.", ExtendedLogSeverity.Info); return; } - + if (_settings.BirthdayAnnouncementChannel?.Id == 0) { _loggingService.LogAction($"[{ServiceName}] Birthday announcement channel not configured.", ExtendedLogSeverity.Warning); return; } - + IsRunning = true; _loggingService.LogAction($"[{ServiceName}] Starting birthday announcement service with {_settings.BirthdayCheckIntervalMinutes} minute intervals.", ExtendedLogSeverity.Info); Task.Run(CheckBirthdaysLoop); } - + private async Task CheckBirthdaysLoop() { try @@ -70,9 +70,9 @@ private async Task CheckBirthdaysLoop() _lastAnnouncementDate = DateTime.Today; _loggingService.LogAction($"[{ServiceName}] New day detected, reset announced birthdays list.", ExtendedLogSeverity.Info); } - + await CheckAndAnnounceBirthdays(); - + // Wait for the configured interval var intervalMs = _settings.BirthdayCheckIntervalMinutes * 60 * 1000; await Task.Delay(intervalMs, _shutdownToken); @@ -85,37 +85,37 @@ private async Task CheckBirthdaysLoop() IsRunning = false; } } - + private async Task CheckAndAnnounceBirthdays() { try { var todaysBirthdays = await GetTodaysBirthdays(); - + if (todaysBirthdays.Count == 0) { return; // No birthdays today } - + var channel = _client.GetChannel(_settings.BirthdayAnnouncementChannel.Id) as SocketTextChannel; if (channel == null) { _loggingService.LogAction($"[{ServiceName}] Could not find birthday announcement channel with ID {_settings.BirthdayAnnouncementChannel.Id}", ExtendedLogSeverity.Warning); return; } - + foreach (var birthday in todaysBirthdays) { var announcementKey = $"{birthday.Name}-{DateTime.Today:yyyy-MM-dd}"; - + if (_announcedToday.Contains(announcementKey)) { continue; // Already announced this birthday today } - + var message = FormatBirthdayAnnouncement(birthday); await channel.SendMessageAsync(message); - + _announcedToday.Add(announcementKey); _loggingService.LogAction($"[{ServiceName}] Announced birthday for {birthday.Name}", ExtendedLogSeverity.Info); } @@ -125,11 +125,11 @@ private async Task CheckAndAnnounceBirthdays() _loggingService.LogAction($"[{ServiceName}] Error checking birthdays: {e.Message}", ExtendedLogSeverity.LowWarning); } } - + private async Task> GetTodaysBirthdays() { var birthdays = new List(); - + try { var relevantNodes = await WebUtil.GetHtmlNodes(BirthdayTableUrl, "/html/body/table/tr"); @@ -137,23 +137,23 @@ private async Task> GetTodaysBirthdays() { return birthdays; } - + var today = DateTime.Today; - + foreach (var row in relevantNodes) { var nameNode = row.SelectSingleNode("td[2]"); var dateNode = row.SelectSingleNode("td[1]"); var yearNode = row.SelectSingleNode("td[3]"); - + if (nameNode == null || dateNode == null) continue; - + var name = nameNode.InnerText?.Trim(); if (string.IsNullOrEmpty(name)) continue; - + var dateString = dateNode.InnerText?.Trim(); if (string.IsNullOrEmpty(dateString)) continue; - + // Try to parse the birthday date if (TryParseBirthdayDate(dateString, yearNode?.InnerText, out var birthDate)) { @@ -170,18 +170,18 @@ private async Task> GetTodaysBirthdays() { _loggingService.LogAction($"[{ServiceName}] Error fetching birthday data: {e.Message}", ExtendedLogSeverity.LowWarning); } - + return birthdays; } - + private bool TryParseBirthdayDate(string dateString, string yearString, out DateTime birthDate) { birthDate = default; - + try { var provider = CultureInfo.InvariantCulture; - + // Add year if available and not empty if (!string.IsNullOrEmpty(yearString) && !yearString.Contains(" ")) { @@ -194,7 +194,7 @@ private bool TryParseBirthdayDate(string dateString, string yearString, out Date var tempDate = DateTime.ParseExact(dateString, "M/d", provider); birthDate = new DateTime(DateTime.Today.Year, tempDate.Month, tempDate.Day); } - + return true; } catch (FormatException) @@ -202,27 +202,27 @@ private bool TryParseBirthdayDate(string dateString, string yearString, out Date return false; } } - + private int? CalculateAge(DateTime birthDate, DateTime today) { if (birthDate.Year == today.Year) { return null; // No year information available } - + var age = today.Year - birthDate.Year; if (today.Month < birthDate.Month || (today.Month == birthDate.Month && today.Day < birthDate.Day)) { age--; } - + return age; } - + private string FormatBirthdayAnnouncement(BirthdayInfo birthday) { var message = $"🎉 **Happy Birthday {birthday.Name}!** 🎂"; - + if (birthday.Age.HasValue) { message += $" Hope you have a wonderful {GetAgeOrdinal(birthday.Age.Value)} birthday!"; @@ -231,10 +231,10 @@ private string FormatBirthdayAnnouncement(BirthdayInfo birthday) { message += " Hope you have a wonderful day!"; } - + return message; } - + private string GetAgeOrdinal(int age) { // Handle special cases for 11th, 12th, 13th regardless of tens digit @@ -243,17 +243,17 @@ private string GetAgeOrdinal(int age) { return $"{age}th"; } - + var lastDigit = age % 10; return lastDigit switch { 1 => $"{age}st", - 2 => $"{age}nd", + 2 => $"{age}nd", 3 => $"{age}rd", _ => $"{age}th" }; } - + public async Task RestartService() { IsRunning = false; diff --git a/DiscordBot/Services/CurrencyService.cs b/DiscordBot/Services/CurrencyService.cs index d274f5e2..dc36726c 100644 --- a/DiscordBot/Services/CurrencyService.cs +++ b/DiscordBot/Services/CurrencyService.cs @@ -6,14 +6,14 @@ namespace DiscordBot.Services; public class CurrencyService { private const string ServiceName = "CurrencyService"; - + #region Configuration private const int ApiVersion = 1; private const string TargetDate = "latest"; private const string ValidCurrenciesEndpoint = "currencies.min.json"; private const string ExchangeRatesEndpoint = "currencies"; - + private class Currency { public string Name { get; set; } @@ -21,7 +21,7 @@ private class Currency } #endregion // Configuration - + private readonly Dictionary _currencies = new(); private static readonly string ApiUrl = $"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{TargetDate}/v{ApiVersion}/"; @@ -30,22 +30,22 @@ public async Task GetConversion(string toCurrency, string fromCurrency = { toCurrency = toCurrency.ToLower(); fromCurrency = fromCurrency.ToLower(); - + var url = $"{ApiUrl}{ExchangeRatesEndpoint}/{fromCurrency.ToLower()}.min.json"; - + // Check if success var (success, response) = await WebUtil.TryGetObjectFromJson(url); if (!success) return -1; - + // json[fromCurrency][toCurrency] var value = response.SelectToken($"{fromCurrency}.{toCurrency}"); if (value == null) return -1; - + return value.Value(); } - + #region Public Methods public async Task GetCurrencyName(string currency) @@ -59,7 +59,7 @@ public async Task GetCurrencyName(string currency) // Checks if a provided currency is valid, it also checks is we have a list of currencies to check against and rebuilds it if not. (If the API was down when bot started) public async Task IsCurrency(string currency) { - if (_currencies.Count <= 1) + if (_currencies.Count <= 1) await BuildCurrencyList(); return _currencies.ContainsKey(currency); } @@ -72,7 +72,7 @@ private async Task BuildCurrencyList() { var url = ApiUrl + ValidCurrenciesEndpoint; var currencies = await WebUtil.GetObjectFromJson>(url); - + // Json is weird format of `Code: Name` each in dependant ie; {"1inch":"1inch Network","aave":"Aave"} foreach (var currency in currencies) { @@ -82,7 +82,7 @@ private async Task BuildCurrencyList() Short = currency.Key }); } - + LoggingService.LogToConsole($"[{ServiceName}] Built currency list with {_currencies.Count} currencies.", ExtendedLogSeverity.Positive); } diff --git a/DiscordBot/Services/LoggingService.cs b/DiscordBot/Services/LoggingService.cs index d184c22d..4624da63 100644 --- a/DiscordBot/Services/LoggingService.cs +++ b/DiscordBot/Services/LoggingService.cs @@ -53,12 +53,12 @@ public static LogSeverity ToLogSeverity(this ExtendedLogSeverity severity) _ => (LogSeverity)severity }; } - + public static ExtendedLogSeverity ToExtended(this LogSeverity severity) { return (ExtendedLogSeverity)severity; } - + } #endregion // Extended Log Severity @@ -66,17 +66,17 @@ public static ExtendedLogSeverity ToExtended(this LogSeverity severity) public class LoggingService : ILoggingService { private const string ServiceName = "LoggingService"; - + private readonly ISocketMessageChannel _logChannel; - + // Configuration private const long MaxLogSize = 1024 * 1024 * 2; // 2MB private const long FileCheckInterval = 1000 * 60 * 60 * 1; // 1 Hour private readonly bool _logCommandExecutions; - + // Where backup files go private readonly string _backupLogFilePath; - + private readonly string _logFilePath; // Normal Logs private readonly string _logXpFilePath; // XP Logs @@ -85,7 +85,7 @@ public class LoggingService : ILoggingService public LoggingService(DiscordSocketClient client, BotSettings settings) { _logCommandExecutions = settings.LogCommandExecutions; - + // Paths _backupLogFilePath = settings.ServerRootPath + @"/log_backups/"; _logFilePath = settings.ServerRootPath + @"/log.txt"; @@ -109,7 +109,7 @@ public LoggingService(DiscordSocketClient client, BotSettings settings) LogToConsole($"[{ServiceName}] Error: Logging Channel {settings.BotAnnouncementChannel.Id} not found", LogSeverity.Error); } } - + public async Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) { if (behaviour.HasFlag(LogBehaviour.Console)) @@ -121,21 +121,21 @@ public async Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverit if (_logCommandExecutions && behaviour.HasFlag(LogBehaviour.CommandFile)) await LogToFile(message, severity); } - + public async Task LogToChannel(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) { if (_logChannel == null) return; await _logChannel.SendMessageAsync(message, false, embed); } - + public async Task LogToFile(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info) - { + { PrepareLogFile(_logFilePath); await File.AppendAllTextAsync(_logFilePath, $"[{ConsistentDateTimeFormat()}] - [{severity}] - {message} {Environment.NewLine}"); } - + public void LogXp(string channel, string user, float baseXp, float bonusXp, float xpReduce, int totalXp) { PrepareLogFile(_logXpFilePath); @@ -160,7 +160,7 @@ private void PrepareLogFile(string path) { if (DateTime.Now - _lastFileCheck < TimeSpan.FromMilliseconds(FileCheckInterval)) return; - + _lastFileCheck = DateTime.Now; if (new FileInfo(path).Length > MaxLogSize) { @@ -169,7 +169,7 @@ private void PrepareLogFile(string path) File.Move(path, backupPath); LogToConsole($"[{ServiceName}] Log file was backed up to {backupPath}", ExtendedLogSeverity.Info); } - + if (!File.Exists(path)) { File.Create(path).Dispose(); @@ -177,14 +177,15 @@ private void PrepareLogFile(string path) LogToConsole($"[{ServiceName}] Log file was started", ExtendedLogSeverity.Info); } } - + #region Console Messages // Logs message to console without changing the colour - public static void LogConsole(string message) { + public static void LogConsole(string message) + { Console.WriteLine($"[{ConsistentDateTimeFormat()}] {message}"); } - public static void LogToConsole(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info) + public static void LogToConsole(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info) { ConsoleColor restoreColour = Console.ForegroundColor; SetConsoleColour(severity); @@ -193,14 +194,14 @@ public static void LogToConsole(string message, ExtendedLogSeverity severity = E Console.ForegroundColor = restoreColour; } - + public static void LogToConsole(string message, LogSeverity severity) => LogToConsole(message, severity.ToExtended()); - + public static void LogServiceDisabled(string service, string varName) { LogToConsole($"Service \"{service}\" is Disabled, {varName} is false in settings.json", ExtendedLogSeverity.LowWarning); } - + public static void LogServiceEnabled(string service) { LogToConsole($"Service \"{service}\" is Enabled", ExtendedLogSeverity.Info); @@ -246,7 +247,7 @@ private static void SetConsoleColour(ExtendedLogSeverity severity) } } #endregion -} +} /// /// Interface for the LoggingService, this is only really required if you want to use DI. @@ -258,7 +259,7 @@ private static void SetConsoleColour(ExtendedLogSeverity severity) public interface ILoggingService { void LogXp(string channel, string user, float baseXp, float bonusXp, float xpReduce, int totalXp); - + /// /// Standard logging, this will log to console, channel and file depending on the behaviour. /// @@ -272,13 +273,13 @@ public interface ILoggingService /// 'Short hand' for logging to all CURRENT supported behaviours, console, channel and file. /// Same as calling `Log(LogBehaviour.ConsoleChannelAndFile, message, severity, embed);` /// - Task LogAction(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) => + Task LogAction(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) => Log(LogBehaviour.ConsoleChannelAndFile, message, severity, embed); - + /// /// 'Short hand' for logging to channel and file. /// Same as calling `Log(LogBehaviour.ChannelAndFile, message, severity, embed);` /// - Task LogChannelAndFile(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) => + Task LogChannelAndFile(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) => Log(LogBehaviour.ChannelAndFile, message, severity, embed); } \ No newline at end of file diff --git a/DiscordBot/Services/Recruitment/RecruitService.cs b/DiscordBot/Services/Recruitment/RecruitService.cs index 8a18e95d..1c4a9482 100644 --- a/DiscordBot/Services/Recruitment/RecruitService.cs +++ b/DiscordBot/Services/Recruitment/RecruitService.cs @@ -7,13 +7,13 @@ namespace DiscordBot.Services; public class RecruitService { private const string ServiceName = "RecruitmentService"; - + private readonly DiscordSocketClient _client; private readonly ILoggingService _logging; private SocketRole ModeratorRole { get; set; } #region Extra Details - + private readonly ForumTag _tagIsHiring; private readonly ForumTag _tagWantsWork; private readonly ForumTag _tagUnpaidCollab; @@ -22,16 +22,16 @@ public class RecruitService private readonly IForumChannel _recruitChannel; #endregion // Extra Details - + #region Configuration - private static Color DeletedMessageColor => new (255, 50, 50); - private static Color WarningMessageColor => new (255, 255, 100); - private static Color EditedMessageColor => new (100, 255, 100); + private static Color DeletedMessageColor => new(255, 50, 50); + private static Color WarningMessageColor => new(255, 255, 100); + private static Color EditedMessageColor => new(100, 255, 100); private const int TimeBeforeDeletingForumInSec = 60; private const string MessageToBeDeleted = "Your thread will be deleted in %s because it did not follow the expected guidelines. Try again after the slow mode period has passed."; - + private const int MinimumLengthMessage = 120; private const int ShortMessageNoticeDurationInSec = 30 * 4; @@ -49,7 +49,7 @@ public class RecruitService Dictionary _botSanityCheck = new Dictionary(); #endregion // Configuration - + public RecruitService(DiscordSocketClient client, ILoggingService logging, BotSettings settings) { _client = client; @@ -62,7 +62,7 @@ public RecruitService(DiscordSocketClient client, ILoggingService logging, BotSe return; } _editTimePermissionInMin = settings.EditPermissionAccessTimeMin; - + // Get target channel _recruitChannel = _client.GetChannel(settings.RecruitmentChannel.Id) as IForumChannel; if (_recruitChannel == null) @@ -70,20 +70,20 @@ public RecruitService(DiscordSocketClient client, ILoggingService logging, BotSe LoggingService.LogToConsole("[{ServiceName}] Recruitment channel not found.", LogSeverity.Error); return; } - + try { var lookingToHire = ulong.Parse(settings.TagLookingToHire); var lookingForWork = ulong.Parse(settings.TagLookingForWork); var unpaidCollab = ulong.Parse(settings.TagUnpaidCollab); var positionFilled = ulong.Parse(settings.TagPositionFilled); - + var availableTags = _recruitChannel.Tags; _tagIsHiring = availableTags.First(x => x.Id == lookingToHire); _tagWantsWork = availableTags.First(x => x.Id == lookingForWork); _tagUnpaidCollab = availableTags.First(x => x.Id == unpaidCollab); _tagPosFilled = availableTags.First(x => x.Id == positionFilled); - + // If any tags are null we print a logging warning if (_tagIsHiring == null) StartUpTagMissing(lookingToHire, nameof(settings.TagLookingToHire)); if (_tagWantsWork == null) StartUpTagMissing(lookingForWork, nameof(settings.TagLookingForWork)); @@ -100,10 +100,10 @@ public RecruitService(DiscordSocketClient client, ILoggingService logging, BotSe _client.MessageReceived += GatewayOnMessageReceived; ConstructEmbeds(); - + LoggingService.LogServiceEnabled(ServiceName); } - + #region Thread Creation private async Task GatewayOnThreadCreated(SocketThreadChannel thread) @@ -127,7 +127,7 @@ private async Task GatewayOnThreadCreated(SocketThreadChannel thread) _botSanityCheck.Clear(); _botSanityCheck.Add(thread.Id, true); #endregion // Sanity Check - + LoggingService.DebugLog($"[{ServiceName}] New Thread Created: {thread.Id} - {thread.Name}", LogSeverity.Debug); var message = (await thread.GetMessagesAsync(1).FlattenAsync()).FirstOrDefault(); @@ -163,7 +163,7 @@ private async Task GatewayOnThreadCreated(SocketThreadChannel thread) } await ThreadHandleRevShare(thread, message); } - + // Any Notices that we can recommend the user for improvement if (message.Content.Length < MinimumLengthMessage) { @@ -184,7 +184,7 @@ private async Task GatewayOnThreadCreated(SocketThreadChannel thread) var threadMessage = await (channel.GetMessageAsync(thread.Id)); if (threadMessage == null) return; - + // We do one last check to make sure the thread is still valid if (isPaidWork && !threadMessage.Content.ContainsCurrencySymbol()) { @@ -192,14 +192,14 @@ private async Task GatewayOnThreadCreated(SocketThreadChannel thread) } }); } - + private async Task GatewayOnMessageReceived(SocketMessage message) { var thread = message.Channel as SocketThreadChannel; // check if channel is a thread in a forum if (thread == null) return; - + if (!thread.IsThreadInChannel(_recruitChannel.Id)) return; if (message.Author.IsUserBotOrWebhook()) @@ -234,19 +234,19 @@ private async Task ThreadHandleRevShare(SocketThreadChannel thread, IMessage mes await thread.SendMessageAsync(embed: _userRevShareMentioned); } } - + private async Task ThreadHandleMoreThanOneTag(SocketThreadChannel thread) { await thread.SendMessageAsync(embed: _userMoreThanOneTagUsed); await DeleteThread(thread); } - + private async Task ThreadHandleNoTags(SocketThreadChannel thread) { await thread.SendMessageAsync(embed: _userDidntUseTags); await DeleteThread(thread); } - + private async Task ThreadHandleShortMessage(SocketThreadChannel thread, IMessage message) { if (message.Content.Length < MinimumLengthMessage) @@ -255,21 +255,21 @@ private async Task ThreadHandleShortMessage(SocketThreadChannel thread, IMessage await ourResponse.DeleteAfterSeconds(ShortMessageNoticeDurationInSec); } } - + private async Task GrantEditPermissions(SocketThreadChannel thread) { var parentChannel = thread.ParentChannel; var message = await thread.SendMessageAsync(embed: GetEditPermMessageEmbed()); await parentChannel.AddPermissionOverwriteAsync(thread.Owner, new OverwritePermissions(sendMessages: PermValue.Allow)); - + // We give them a bit of time to edit their post, then remove the permission await message.DeleteAfterSeconds((_editTimePermissionInMin * 60) + 2); await parentChannel.RemovePermissionOverwriteAsync(thread.Owner); - + // Lock the thread so anyone else can't post even when they have edit permissions await thread.ModifyAsync(x => x.Locked = true); } - + #endregion // Basic Handlers for posts #region Basic Logging Assisst @@ -291,14 +291,14 @@ private void ConstructEmbeds() $"You have used the `{_tagIsHiring.Name}` tag but have not specified a price of any kind.\n\nPost **must** include a currency symbol or word, e.g. $, dollars, USD, £, pounds, €, EUR, euro, euros, GBP.") .WithColor(DeletedMessageColor) .Build(); - + _userWantsWorkButNoPrice = new EmbedBuilder() .WithTitle("No payment price detected") .WithDescription( $"You have used the `{_tagWantsWork.Name}` tag but have not specified a price of any kind.\n\nPost **must** include a currency symbol or word, e.g. $, dollars, USD, £, pounds, €, EUR, euro, euros, GBP.") .WithColor(DeletedMessageColor) .Build(); - + _userRevShareMentioned = new EmbedBuilder() .WithTitle("Notice: Rev-Share mentioned") .WithDescription( @@ -306,7 +306,7 @@ private void ConstructEmbeds() $"Consider using the `{_tagUnpaidCollab.Name}` tag instead if you intend to use rev-share as a source of payment.") .WithColor(WarningMessageColor) .Build(); - + _userMoreThanOneTagUsed = new EmbedBuilder() .WithTitle("Broken Guideline: Colliding tags used") .WithDescription( @@ -314,7 +314,7 @@ private void ConstructEmbeds() "Be sure to read the guidelines before posting.") .WithColor(DeletedMessageColor) .Build(); - + _userDidntUseTags = new EmbedBuilder() .WithTitle("Broken Guideline: No tags used") .WithDescription( @@ -323,7 +323,7 @@ private void ConstructEmbeds() .WithColor(DeletedMessageColor) .Build(); } - + private Embed GetDeletedMessageEmbed() { var message = MessageToBeDeleted.Replace("%s", GetDynamicTimeStampString(TimeBeforeDeletingForumInSec)); @@ -365,11 +365,11 @@ private bool IsThreadUsingMoreThanOneTag(SocketThreadChannel thread) { int clashingTagCount = 0; var tags = thread.AppliedTags; - + if (tags.Contains(_tagIsHiring.Id)) clashingTagCount++; if (tags.Contains(_tagWantsWork.Id)) clashingTagCount++; if (tags.Contains(_tagUnpaidCollab.Id)) clashingTagCount++; - + return clashingTagCount > 1; } @@ -378,7 +378,7 @@ private bool DoesThreadHaveAValidTag(SocketThreadChannel thread) var tags = thread.AppliedTags; return tags.Contains(_tagIsHiring.Id) || tags.Contains(_tagWantsWork.Id) || tags.Contains(_tagUnpaidCollab.Id); } - + private async Task DeleteThread(SocketThreadChannel thread) { await thread.SendMessageAsync(embed: GetDeletedMessageEmbed()); @@ -392,5 +392,5 @@ private string GetDynamicTimeStampString(int addSeconds) } #endregion // Basic Utility - + } \ No newline at end of file diff --git a/DiscordBot/Services/Tips/Components/Tip.cs b/DiscordBot/Services/Tips/Components/Tip.cs index bfaab3f4..fdee117b 100644 --- a/DiscordBot/Services/Tips/Components/Tip.cs +++ b/DiscordBot/Services/Tips/Components/Tip.cs @@ -2,11 +2,11 @@ namespace DiscordBot.Services.Tips.Components; -public class Tip: IEntity +public class Tip : IEntity { - public ulong Id { get; set; } - public string Content { get; set; } - public List Keywords { get; set; } - public List ImagePaths { get; set; } - public int Requests { get; set; } + public ulong Id { get; set; } + public string Content { get; set; } + public List Keywords { get; set; } + public List ImagePaths { get; set; } + public int Requests { get; set; } } diff --git a/DiscordBot/Services/UnityHelp/CannedResponseService.cs b/DiscordBot/Services/UnityHelp/CannedResponseService.cs index ca826aba..03c16c7a 100644 --- a/DiscordBot/Services/UnityHelp/CannedResponseService.cs +++ b/DiscordBot/Services/UnityHelp/CannedResponseService.cs @@ -3,9 +3,9 @@ namespace DiscordBot.Service; public class CannedResponseService { private const string ServiceName = "CannedResponseService"; - + #region Configuration - + public enum CannedResponseType { HowToAsk, @@ -55,7 +55,7 @@ public enum CannedHelp GameTooBig = CannedResponseType.GameTooBig, HowToGoogle = CannedResponseType.HowToGoogle, } - + public enum CannedResources { Programming = CannedResponseType.Programming, @@ -70,11 +70,11 @@ public enum CannedResources // PerformanceAndOptimization = CannedResponseType.PerformanceAndOptimization, // UIUX = CannedResponseType.UIUX } - + private readonly Color _defaultEmbedColor = new Color(0x00, 0x80, 0xFF); #region Canned Help - + private readonly EmbedBuilder _howToAskEmbed = new EmbedBuilder { Title = "How to Ask", @@ -84,7 +84,7 @@ public enum CannedResources "See: [How to Ask](https://stackoverflow.com/help/how-to-ask)", Url = "https://stackoverflow.com/help/how-to-ask", }; - + private readonly EmbedBuilder _pasteEmbed = new EmbedBuilder { Title = "How to Paste Code", @@ -102,14 +102,14 @@ public enum CannedResources "This will make your code easier to read and copy. If your code is too long, consider using a service like [GitHub Gist](https://gist.github.com/) or [Pastebin](https://pastebin.com/).", Url = "https://pastebin.com/", }; - + private readonly EmbedBuilder _noCodeEmbed = new EmbedBuilder { Title = "No Code Provided", Description = "***Where the code at?*** It appears you're trying to ask something that would benefit from showing what you've tried, but you haven't provided much code. " + "Someone who wants to help you won't be able to do so without seeing the code you're working with." }; - + private readonly EmbedBuilder _xyProblemEmbed = new EmbedBuilder { Title = "XY Problem", @@ -120,7 +120,7 @@ public enum CannedResources "- If you've tried something, tell us what you tried", Url = "https://xyproblem.info/", }; - + private readonly EmbedBuilder _gameTooBigEmbed = new EmbedBuilder { Title = "Game Too Big", @@ -138,7 +138,7 @@ public enum CannedResources "See: [How to Google](https://www.lifehack.org/articles/technology/20-tips-use-google-search-efficiently.html)", Url = "https://www.lifehack.org/articles/technology/20-tips-use-google-search-efficiently.html", }; - + private readonly EmbedBuilder _deltaTime = new EmbedBuilder { Title = "Frame Independence", @@ -155,11 +155,11 @@ public enum CannedResources "[Update](https://docs.unity3d.com/ScriptReference/MonoBehaviour.Update.html) or " + "`fixedDeltaTime` [FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html) for consistent speed.\n" + "See: [Time Frame Management](https://docs.unity3d.com/Manual/TimeFrameManagement.html), " + - "[FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html), " + + "[FixedUpdate](https://docs.unity3d.com/ScriptReference/MonoBehaviour.FixedUpdate.html), " + "[DeltaTime](https://docs.unity3d.com/ScriptReference/Time-deltaTime.html)", Url = "https://docs.unity3d.com/Manual/TimeFrameManagement.html", }; - + private readonly EmbedBuilder _debugging = new EmbedBuilder { Title = "Debugging in Unity", @@ -172,7 +172,7 @@ public enum CannedResources "Debugging improves with practice, enhancing your bug identification and resolution skills.", Url = "https://docs.unity3d.com/Manual/ManagedCodeDebugging.html", }; - + private readonly EmbedBuilder _folderStructure = new EmbedBuilder { Title = "Folder Structure", @@ -185,11 +185,11 @@ public enum CannedResources "See: [Organizing Your Project](https://unity.com/how-to/organizing-your-project)", Url = "https://unity.com/how-to/organizing-your-project", }; - + #endregion #region Canned Resources - + private readonly EmbedBuilder _programmingEmbed = new EmbedBuilder { Title = "Programming Resources", @@ -202,7 +202,7 @@ public enum CannedResources "- Design Patterns: [Game Programming Patterns](https://gameprogrammingpatterns.com/)", Url = "https://learn.unity.com/project/roll-a-ball" }; - + private readonly EmbedBuilder _artEmbed = new EmbedBuilder { Title = "Art Resources", @@ -212,7 +212,7 @@ public enum CannedResources "- Varying Assets: [Itch.io Royalty Free Assets](https://itch.io/game-assets/free/tag-royalty-free)\n" + "- Blender Discord: [Server Invite](https://discord.gg/blender)" }; - + private readonly EmbedBuilder _threeDEmbed = new EmbedBuilder { Title = "3D Resources", @@ -222,7 +222,7 @@ public enum CannedResources "- Varying Assets: [Itch.io Royalty Free Assets](https://itch.io/game-assets/free/tag-3d/tag-royalty-free)\n" + "- Blender Discord: [Server Invite](https://discord.gg/blender)" }; - + private readonly EmbedBuilder _twoDEmbed = new EmbedBuilder { Title = "2D Resources", @@ -231,7 +231,7 @@ public enum CannedResources "- Varying Assets: [Itch.io Royalty Free Assets](https://itch.io/game-assets/free/tag-2d)\n" + "- Blender Discord: [Server Invite](https://discord.gg/blender)" }; - + private readonly EmbedBuilder _audioEmbed = new EmbedBuilder { Title = "Audio Resources", @@ -242,7 +242,7 @@ public enum CannedResources "- Audio Editor: [Audacity](https://www.audacityteam.org/)\n" + "- Sound Design Explained: [PitchBlends](https://www.pitchbends.com/posts/what-is-sound-design)" }; - + private readonly EmbedBuilder _designEmbed = new EmbedBuilder { Title = "Design Resources", @@ -254,24 +254,24 @@ public enum CannedResources "- Iconography: [Flaticon](https://www.flaticon.com/)\n" + "- Free Icons: [Icon Monstr](https://iconmonstr.com/)" }; - + #endregion - + #endregion // Configuration - + public EmbedBuilder GetCannedResponse(CannedResponseType type, IUser requestor = null) { var embed = GetUnbuiltCannedResponse(type); if (embed == null) return null; - + if (requestor != null) embed.FooterRequestedBy(requestor); embed.WithColor(_defaultEmbedColor); - + return embed; } - + public EmbedBuilder GetUnbuiltCannedResponse(CannedResponseType type) { return type switch diff --git a/DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs b/DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs index 48c7677a..35a40bd2 100644 --- a/DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs +++ b/DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs @@ -12,7 +12,7 @@ public class HelpBotMessage { public ulong MessageId { get; set; } public HelpMessageType Type { get; set; } - + public HelpBotMessage(ulong messageId, HelpMessageType type) { MessageId = messageId; diff --git a/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs b/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs index a3779b3a..d5462654 100644 --- a/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs +++ b/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs @@ -10,17 +10,17 @@ public class ThreadContainer public bool IsResolved { get; set; } = false; public bool HasInteraction { get; set; } = false; - - + + public ulong BotsLastMessage { get; set; } public CancellationTokenSource CancellationToken { get; set; } public DateTime ExpectedShutdownTime { get; set; } - + /// /// Any message the bot sends that could need to be tracked/deleted later is stored here. /// public Dictionary HelpMessages { get; set; } = new(); - + public bool HasMessage(HelpMessageType type) => HelpMessages.ContainsKey(type); public ulong GetMessageId(HelpMessageType type) => HelpMessages[type].MessageId; public void AddMessage(HelpMessageType type, ulong messageId) => HelpMessages.Add(type, new HelpBotMessage(messageId, type)); diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index 2ae6b3c4..ff9263ba 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -13,11 +13,11 @@ public class UnityHelpService private readonly DiscordSocketClient _client; private readonly ILoggingService _logging; private SocketRole ModeratorRole { get; set; } - + #region Configuration - + private static readonly Emoji ThumbUpEmoji = new Emoji("👍"); - + private const int TimeBeforeClosedForResolvedTag = 10; private readonly Embed _resolvedWarnOfPendingCloseEmbedHasPin = new EmbedBuilder() .WithTitle($"Issue Resolved") @@ -46,7 +46,7 @@ public class UnityHelpService .WithColor(Color.LightOrange) .Build(); private const int StealthDeleteTime = 60 * 5; - + private readonly Embed _noAppliedTagsEmbed = new EmbedBuilder() .WithTitle("Warning: No Tags Applied") .WithDescription($"Consider adding tags to your question to help others find it!\n" + @@ -66,13 +66,13 @@ public class UnityHelpService .WithFooter("Be descriptive of the problem!") .WithColor(Color.LightOrange) .Build(); - + #endregion // Configuration #region Extra Details - + private readonly IForumChannel _helpChannel; - + private readonly ForumTag _resolvedForumTag; #endregion // Extra Details @@ -81,7 +81,7 @@ public UnityHelpService(DiscordSocketClient client, BotSettings settings, ILoggi { _client = client; _logging = logging; - + ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.ModeratorRoleId); if (!settings.UnityHelpBabySitterEnabled) @@ -89,7 +89,7 @@ public UnityHelpService(DiscordSocketClient client, BotSettings settings, ILoggi LoggingService.LogServiceDisabled(ServiceName, nameof(settings.UnityHelpBabySitterEnabled)); return; } - + // get the help channel settings.GenericHelpChannel _helpChannel = _client.GetChannel(settings.GenericHelpChannel.Id) as IForumChannel; if (_helpChannel == null) @@ -107,15 +107,15 @@ public UnityHelpService(DiscordSocketClient client, BotSettings settings, ILoggi _client.ThreadCreated += GatewayOnThreadCreated; _client.ThreadUpdated += GatewayOnThreadUpdated; _client.ThreadDeleted += GatewayOnThreadDeleted; - + _client.ThreadMemberJoined += GatewayOnThreadMemberJoinedThread; _client.ThreadMemberLeft += GatewayOnThreadMemberLeftThread; - + _client.MessageReceived += GatewayOnMessageReceived; _client.MessageUpdated += GatewayOnMessageUpdated; Task.Run(LoadActiveThreads); - + LoggingService.LogServiceEnabled(ServiceName); } @@ -155,12 +155,12 @@ private async Task LoadActiveThreads() } #region Thread Tracking - + // Threads we're currently tracking private readonly Dictionary _activeThreads = new(); #region Thread Creation - + private async Task OnThreadCreated(SocketThreadChannel thread) { ThreadContainer container = new() @@ -170,9 +170,9 @@ private async Task OnThreadCreated(SocketThreadChannel thread) Owner = thread.Owner.Id, }; _activeThreads.Add(thread.Id, container); - + bool warnHelpTitle = false; - + // Check message length and inform user if too short var firstMessage = (await thread.GetMessagesAsync(1).FlattenAsync()).FirstOrDefault(); container.FirstUserMessage = firstMessage!.Id; @@ -182,7 +182,7 @@ private async Task OnThreadCreated(SocketThreadChannel thread) container.AddMessage(HelpMessageType.QuestionLength, botResponse.Id); // container.WarningMessage = botResponse.Id; } - + var threadTitle = thread.Name; if (threadTitle.IsAllCaps()) { @@ -205,7 +205,7 @@ private async Task OnThreadCreated(SocketThreadChannel thread) // Sets up the thread to be closed after a certain amount of time (This will quickly be removed if anyone interacts with the thread) await StealthDeleteThreadInTime(container); } - + private Task GatewayOnThreadCreated(SocketThreadChannel thread) { if (!thread.IsThreadInChannel(_helpChannel.Id)) @@ -220,13 +220,13 @@ private Task GatewayOnThreadCreated(SocketThreadChannel thread) // Ignore new thread if age is over, 5 mins? if (thread.CreatedAt < DateTime.Now.AddMinutes(-5)) return Task.CompletedTask; - + LoggingService.DebugLog($"[{ServiceName}] New Thread Created: {thread.Id} - {thread.Name}", LogSeverity.Debug); Task.Run(() => OnThreadCreated(thread)); - + return Task.CompletedTask; } - + #endregion // Thread Creation #region Thread Update @@ -278,7 +278,7 @@ private async Task OnThreadUpdated(SocketThreadChannel before, SocketThreadChann // // } } - + private async Task GatewayOnThreadUpdated(Cacheable before, SocketThreadChannel after) { if (!after.IsThreadInChannel(_helpChannel.Id)) @@ -296,16 +296,16 @@ private async Task GatewayOnThreadUpdated(Cacheable } LoggingService.DebugLog($"[{ServiceName}] Thread Updated: {after.Id} - {after.Name}", LogSeverity.Debug); - + #pragma warning disable CS4014 Task.Run(() => OnThreadUpdated(beforeThread, afterThread)); #pragma warning restore CS4014 } - + #endregion // Thread Update #region Thread Deleted - + private async Task OnThreadDeleted(SocketThreadChannel channel) { await EndThreadTracking(channel.Id); @@ -315,7 +315,7 @@ private async Task GatewayOnThreadDeleted(Cacheable { if (!_activeThreads.ContainsKey(threadId.Id)) return; - + LoggingService.DebugLog($"[{ServiceName}] Thread Deleted: {threadId.Id}", LogSeverity.Debug); var thread = await threadId.GetOrDownloadAsync(); @@ -323,16 +323,16 @@ private async Task GatewayOnThreadDeleted(Cacheable Task.Run(() => OnThreadDeleted(thread)); #pragma warning restore CS4014 } - + #endregion // Thread Deleted #region User Joins/Leaves Thread - + private Task GatewayOnThreadMemberJoinedThread(SocketThreadUser user) { if (user.IsUserBotOrWebhook()) return Task.CompletedTask; - + if (!user.Thread.IsThreadInChannel(_helpChannel.Id)) return Task.CompletedTask; if (!_activeThreads.TryGetValue(user.Thread.Id, out var thread)) @@ -346,20 +346,20 @@ private Task GatewayOnThreadMemberLeftThread(SocketThreadUser user) { if (!user.Thread.IsThreadInChannel(_helpChannel.Id)) return Task.CompletedTask; - + return Task.CompletedTask; // TODO : (James) Check if user was author? If so, close thread? } - + #endregion // User Joins/Leaves Thread #region Message Received - + private async Task OnMessageReceived(SocketMessage message) { var thread = _activeThreads[message.Channel.Id]; - + thread.LatestUserMessage = message.Id; // If Author is only one who has interacted with the thread, we don't need to update anything else if (!thread.HasInteraction && message.Author.Id == thread.Owner) @@ -382,7 +382,7 @@ private async Task OnMessageReceived(SocketMessage message) await RequestThreadShutdownInTime(thread, HasResponseMessageRequestClose + HasResponseExtraMessage, HasResponseIdleTimeOtherUser); } } - + private Task GatewayOnMessageReceived(SocketMessage message) { if (!message.Channel.IsThreadInChannel(_helpChannel.Id)) @@ -391,7 +391,7 @@ private Task GatewayOnMessageReceived(SocketMessage message) return Task.CompletedTask; if (!_activeThreads.TryGetValue(message.Channel.Id, out var thread)) return Task.CompletedTask; - + LoggingService.DebugLog($"[{ServiceName}] Help Message Received: {message.Id} - {message.Content}", LogSeverity.Debug); Task.Run(() => OnMessageReceived(message)); return Task.CompletedTask; @@ -400,7 +400,7 @@ private Task GatewayOnMessageReceived(SocketMessage message) private async Task OnMessageUpdated(IMessage before, IMessage after, SocketThreadChannel channel) { var thread = _activeThreads[channel.Id]; - + if (thread.HasMessage(HelpMessageType.QuestionLength) && before.Id == thread.FirstUserMessage) { if (after.Content.Length > MinimumLengthMessage) @@ -412,7 +412,7 @@ private async Task OnMessageUpdated(IMessage before, IMessage after, SocketThrea } } } - + private async Task GatewayOnMessageUpdated(Cacheable before, SocketMessage after, ISocketMessageChannel channel) { if (channel is not SocketThreadChannel threadChannel) @@ -421,14 +421,14 @@ private async Task GatewayOnMessageUpdated(Cacheable before, So return; if (after.Author.IsUserBotOrWebhook()) return; - + if (!_activeThreads.TryGetValue(channel.Id, out var thread)) return; - + // This is done a bit late as we may need to check message from other authors if (thread.Owner != after.Author.Id) return; - + var beforeMsg = await before.GetOrDownloadAsync(); if (beforeMsg == null) return; @@ -450,7 +450,7 @@ private async Task GatewayOnMessageUpdated(Cacheable before, So } #endregion // Message Received - + #endregion // Thread Tracking #region Event Handlers @@ -477,17 +477,17 @@ private async Task OnReactionAdded(Cacheable messageCache, await CloseThread(channel, true); }); } - + public async Task OnUserRequestChannelClose(IUser user, SocketThreadChannel channel) { if (channel.ParentChannel.Id != _helpChannel.Id) return string.Empty; if (!_activeThreads.TryGetValue(channel.Id, out var thread)) return string.Empty; - + if (thread.Owner != user.Id) return string.Empty; - + await CloseThread(channel, true); return "Your thread has been closed."; } @@ -501,11 +501,11 @@ private async Task CloseThreadInTime(ThreadContainer thread, string message, int await Task.Delay(TimeSpan.FromMinutes(minutes)); if (thread.HasInteraction) return; - + var channel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel; if (channel == null) return; - + if (!string.IsNullOrEmpty(message)) await channel.SendMessageAsync(message); else @@ -516,12 +516,12 @@ private async Task CloseThreadInTime(ThreadContainer thread, string message, int if (!(await IsValidThread(thread))) return; - + var expectedShutdownTime = DateTime.Now.AddMinutes(minutes); var threadChannel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel; // Check if token already created, each thread shares its own token with any relevant action (close, delete, etc) await CancelPreviousWarning(thread, expectedShutdownTime); - + thread.CancellationToken ??= new CancellationTokenSource(); // Send our message if (!string.IsNullOrEmpty(message)) @@ -540,25 +540,25 @@ private async Task RequestThreadShutdownInTime(ThreadContainer thread, string ms { if (!(await IsValidThread(thread))) return; - + var expectedWarnTime = DateTime.Now.AddMinutes(minutes); var threadChannel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel; // Check if token already created, each thread shares its own token with any relevant action (close, delete, etc) await CancelPreviousWarning(thread, expectedWarnTime); thread.CancellationToken ??= new CancellationTokenSource(); - + thread.ExpectedShutdownTime = expectedWarnTime; await Task.Delay(minutes * 60 * 1000, thread.CancellationToken.Token); if (await IsTaskCancelled(thread)) return; - + msgString = string.Format(msgString, threadChannel.Owner.Mention); var sentMessage = await threadChannel.SendMessageAsync(msgString); // add the lock reaction await sentMessage.AddReactionAsync(CloseEmoji); thread.LatestUserMessage = sentMessage.Id; } - + /// /// When a thread is first started, this is called first to set it up to be closed after a certain amount of time /// This will quickly be canceled if the thread is interacted with. @@ -569,7 +569,7 @@ private async Task StealthDeleteThreadInTime(ThreadContainer thread) return; var expectedShutdownTime = DateTime.Now.AddMinutes(NoResponseNotResolvedIdleTime); - + await CancelPreviousWarning(thread, expectedShutdownTime); var threadChannel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel; @@ -583,7 +583,7 @@ private async Task StealthDeleteThreadInTime(ThreadContainer thread) // We prompt chat that the thread is going to be deleted in x number of hours, which will double as a bump. var botResponse = await threadChannel.SendMessageAsync(embed: _stealthDeleteEmbed); thread.BotsLastMessage = botResponse.Id; - + // Wait for the next set of time to pass thread.ExpectedShutdownTime = DateTime.Now.AddMinutes(StealthDeleteTime); await Task.Delay(StealthDeleteTime * 60 * 1000, thread.CancellationToken.Token); @@ -594,10 +594,10 @@ private async Task StealthDeleteThreadInTime(ThreadContainer thread) } #endregion // Bulk Behaviour Handler - - + + #region Generic Methods - + private async Task CloseThread(IThreadChannel channel, bool includeResolvedTag = false) { var appliedTags = channel.AppliedTags.ToList(); @@ -639,14 +639,14 @@ private async Task CancelPreviousWarning(ThreadContainer thread, DateTime newShu await RemoveContainerPreviousComment(thread); } } - + private async Task> GetHelpActiveThreads() { var messages = await _helpChannel.GetActiveThreadsAsync(); var helpThreads = messages.Where(x => x.CategoryId == _helpChannel.Id).ToList(); return helpThreads; } - + public async Task MarkResponseAsAnswer(IUser requester, IMessage message) { if (message.Channel is not IThreadChannel channel) @@ -681,7 +681,7 @@ public async Task MarkResponseAsAnswer(IUser requester, IMessage message if (!thread.IsResolved) await CloseThread(channel, true); - + thread.PinnedAnswer = message.Id; return "New answer pinned"; } @@ -715,7 +715,7 @@ private async Task IsValidThread(ThreadContainer thread) } return true; } - + private Task IsTaskCancelled(ThreadContainer thread) { if (thread.CancellationToken == null) @@ -727,27 +727,27 @@ private Task IsTaskCancelled(ThreadContainer thread) } return Task.FromResult(false); } - + // Check if the user is the expected id and return true if so, if not then return false (Special: Moderator will return true) private bool IsValidAuthorUser(SocketGuildUser user, ulong authorId) { if (user == null || user.IsUserBotOrWebhook()) return false; - + if (user.Id == authorId) return true; // If the user is moderator they can act on behalf of the author if (user.HasRoleGroup(ModeratorRole)) return true; - + return false; } - + public int GetTrackedQuestionCount() { return _activeThreads.Count; } #endregion // Utility Methods - + } diff --git a/DiscordBot/Services/UserExtendedService.cs b/DiscordBot/Services/UserExtendedService.cs index 7a3df56f..8adfadd1 100644 --- a/DiscordBot/Services/UserExtendedService.cs +++ b/DiscordBot/Services/UserExtendedService.cs @@ -7,10 +7,10 @@ namespace DiscordBot.Services; public class UserExtendedService { private readonly DatabaseService _databaseService; - + // Cached Information private Dictionary _cityCachedName = new(); - + public UserExtendedService(DatabaseService databaseService) { _databaseService = databaseService; @@ -24,30 +24,30 @@ public async Task SetUserDefaultCity(IUser user, string city) _cityCachedName[user.Id] = city; return true; } - + public async Task DoesUserHaveDefaultCity(IUser user) { // Quickest check if we have cached result if (_cityCachedName.ContainsKey(user.Id)) return true; - + // Check database var res = await _databaseService.Query.GetDefaultCity(user.Id.ToString()); if (string.IsNullOrEmpty(res)) return false; - + // Cache result _cityCachedName[user.Id] = res; return true; } - + public async Task GetUserDefaultCity(IUser user) { if (await DoesUserHaveDefaultCity(user)) return _cityCachedName[user.Id]; return ""; } - + public async Task RemoveUserDefaultCity(IUser user) { // Update Database diff --git a/DiscordBot/Services/WeatherService.cs b/DiscordBot/Services/WeatherService.cs index 4c9190a5..cabc9643 100644 --- a/DiscordBot/Services/WeatherService.cs +++ b/DiscordBot/Services/WeatherService.cs @@ -8,7 +8,7 @@ namespace DiscordBot.Services; public class WeatherService { private const string ServiceName = "FeedService"; - + private readonly DiscordSocketClient _client; private readonly ILoggingService _loggingService; private readonly string _weatherApiKey; @@ -24,8 +24,8 @@ public WeatherService(DiscordSocketClient client, ILoggingService loggingService _loggingService.LogAction($"[{ServiceName}] Error: Weather API Key is not set.", ExtendedLogSeverity.Warning); } } - - + + public async Task GetWeather(string city, string unit = "metric") { var query = $"https://api.openweathermap.org/data/2.5/weather?q={city}&appid={_weatherApiKey}&units={unit}"; @@ -37,7 +37,7 @@ public WeatherService(DiscordSocketClient client, ILoggingService loggingService var query = $"https://api.openweathermap.org/data/2.5/air_pollution?lat={lat}&lon={lon}&appid={_weatherApiKey}"; return await SerializeUtil.LoadUrlDeserializeResult(query); } - + public async Task<(bool exists, WeatherContainer.Result result)> CityExists(string city) { var res = await GetWeather(city: city); diff --git a/DiscordBot/Utils/StringUtil.cs b/DiscordBot/Utils/StringUtil.cs index 1f1ba0b4..4b0a50c9 100644 --- a/DiscordBot/Utils/StringUtil.cs +++ b/DiscordBot/Utils/StringUtil.cs @@ -5,15 +5,15 @@ namespace DiscordBot.Utils; public static class StringUtil { private static readonly Regex CurrencyRegex = - new (@"(?:\$\s*\d+|\d+\s*\$|\d*\s*(?:USD|£|pounds|€|EUR|euro|euros|GBP|円|YEN))", RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(250)); - private static readonly Regex RevShareRegex = new (@"\b(?:rev-share|revshare|rev share)\b", RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(250)); - + new(@"(?:\$\s*\d+|\d+\s*\$|\d*\s*(?:USD|£|pounds|€|EUR|euro|euros|GBP|円|YEN))", RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(250)); + private static readonly Regex RevShareRegex = new(@"\b(?:rev-share|revshare|rev share)\b", RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(250)); + // a string extension that checks if the contents of the string contains a limited selection of currency symbols/words public static bool ContainsCurrencySymbol(this string str) { return !string.IsNullOrWhiteSpace(str) && CurrencyRegex.IsMatch(str); } - + public static bool ContainsRevShare(this string str) { return !string.IsNullOrWhiteSpace(str) && RevShareRegex.IsMatch(str); @@ -24,7 +24,7 @@ public static string MessageSelfDestructIn(int secondsFromNow) var time = DateTime.Now.ToUnixTimestamp() + secondsFromNow; return $"Self-delete: ****"; } - + /// /// Sanitizes @everyone and @here mentions by adding a zero-width space after the @ symbol. /// @@ -32,5 +32,5 @@ public static string SanitizeEveryoneHereMentions(this string str) { return str.Replace("@everyone", "@\u200beveryone").Replace("@here", "@\u200bhere"); } - + } \ No newline at end of file From c83567a00e6e09fc7401056dfa531938f28c44d4 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 03:45:57 +0200 Subject: [PATCH 24/48] fix(nullable): resolve all CS8618 non-nullable property warnings Initialize ~306 properties across 52 files: - DTOs/settings: string.Empty for strings, [] for collections, null! for objects - Discord modules: null! for DI-injected properties - Services: null! for construct-time static assignments in Program.cs Warning count: 584 -> 249 --- DiscordBot/Data/UnityAPI.cs | 184 +++++++++--------- DiscordBot/Domain/Casino/AIAction.cs | 2 +- DiscordBot/Domain/ProfileData.cs | 6 +- DiscordBot/Extensions/UserDBRepository.cs | 2 +- DiscordBot/Modules/AirportModule.cs | 14 +- .../Modules/Casino/CasinoSlashModule.Games.cs | 2 +- .../Modules/Casino/CasinoSlashModule.cs | 14 +- DiscordBot/Modules/CodeTipModule.cs | 2 +- DiscordBot/Modules/ConvertModule.cs | 2 +- DiscordBot/Modules/DuelSlashModule.cs | 4 +- DiscordBot/Modules/EmbedModule.cs | 2 +- DiscordBot/Modules/FunModule.cs | 4 +- DiscordBot/Modules/ProfileModule.cs | 4 +- DiscordBot/Modules/RankModule.cs | 4 +- DiscordBot/Modules/ReminderModule.cs | 4 +- DiscordBot/Modules/RulesModule.cs | 6 +- DiscordBot/Modules/SearchModule.cs | 8 +- DiscordBot/Modules/ServerModule.cs | 6 +- DiscordBot/Modules/ServerSlashModule.cs | 8 +- DiscordBot/Modules/TicketModule.cs | 4 +- DiscordBot/Modules/TipModule.cs | 6 +- .../UnityHelp/CannedInteractiveModule.cs | 6 +- .../Modules/UnityHelp/CannedResponseModule.cs | 4 +- .../Modules/UnityHelp/GeneralHelpModule.cs | 2 +- .../UnityHelp/UnityHelpInteractiveModule.cs | 4 +- .../Modules/UnityHelp/UnityHelpModule.cs | 4 +- .../Modules/Weather/WeatherContainers.cs | 36 ++-- DiscordBot/Modules/Weather/WeatherModule.cs | 4 +- DiscordBot/Program.cs | 24 +-- DiscordBot/Services/AirportService.cs | 100 +++++----- DiscordBot/Services/AuditLogService.cs | 2 +- .../Services/BirthdayAnnouncementService.cs | 2 +- DiscordBot/Services/CommandHandlingService.cs | 6 +- DiscordBot/Services/CurrencyService.cs | 4 +- DiscordBot/Services/EmbedParsingService.cs | 34 ++-- DiscordBot/Services/FeedService.cs | 6 +- DiscordBot/Services/LoggingService.cs | 2 +- .../Services/Recruitment/RecruitService.cs | 12 +- DiscordBot/Services/ReminderService.cs | 2 +- DiscordBot/Services/Tips/Components/Tip.cs | 6 +- DiscordBot/Services/Tips/TipService.cs | 2 +- .../UnityHelp/Components/ThreadContainer.cs | 2 +- .../Services/UnityHelp/UnityHelpService.cs | 2 +- DiscordBot/Services/UpdateService.cs | 26 +-- DiscordBot/Settings/Deserialized/Rules.cs | 6 +- DiscordBot/Settings/Deserialized/Settings.cs | 62 +++--- DiscordBot/Skin/AvatarBorderSkinModule.cs | 2 +- DiscordBot/Skin/BaseTextSkinModule.cs | 8 +- .../RectangleSampleAvatarColorSkinModule.cs | 4 +- DiscordBot/Skin/SkinData.cs | 8 +- DiscordBot/Skin/SkinLayer.cs | 2 +- DiscordBot/Skin/XpBarSkinModule.cs | 2 +- 52 files changed, 337 insertions(+), 337 deletions(-) diff --git a/DiscordBot/Data/UnityAPI.cs b/DiscordBot/Data/UnityAPI.cs index dfbd3010..3c41ea8f 100644 --- a/DiscordBot/Data/UnityAPI.cs +++ b/DiscordBot/Data/UnityAPI.cs @@ -2,49 +2,49 @@ namespace DiscordBot.Data; public class Rating { - public object Count { get; set; } + public object Count { get; set; } = null!; public int Average { get; set; } } public class Kategory { - public string Slug { get; set; } - public string Name { get; set; } - public string Id { get; set; } + public string Slug { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; } public class Category { - public string TreeId { get; set; } - public string LabelEnglish { get; set; } - public string Label { get; set; } - public string Id { get; set; } - public string Multiple { get; set; } + public string TreeId { get; set; } = string.Empty; + public string LabelEnglish { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; + public string Multiple { get; set; } = string.Empty; } public class Publisher { - public string LabelEnglish { get; set; } - public string Url { get; set; } - public string Slug { get; set; } - public string Label { get; set; } - public string Id { get; set; } - public string SupportEmail { get; set; } - public object SupportUrl { get; set; } + public string LabelEnglish { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; + public string SupportEmail { get; set; } = string.Empty; + public object SupportUrl { get; set; } = null!; } public class Link { - public string Type { get; set; } - public string Id { get; set; } + public string Type { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; } public class List { - public string Slug { get; set; } - public string SlugV2 { get; set; } - public string Name { get; set; } - public object Overlay { get; set; } + public string Slug { get; set; } = string.Empty; + public string SlugV2 { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public object Overlay { get; set; } = null!; } public class Flags @@ -53,113 +53,113 @@ public class Flags public class Image { - public string Link { get; set; } - public string Width { get; set; } - public string Name { get; set; } - public string Type { get; set; } - public string Height { get; set; } - public string Thumb { get; set; } + public string Link { get; set; } = string.Empty; + public string Width { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Height { get; set; } = string.Empty; + public string Thumb { get; set; } = string.Empty; } public class Keyimage { - public string Small { get; set; } - public string Big { get; set; } - public object SmallLegacy { get; set; } - public object Facebook { get; set; } - public object BigLegacy { get; set; } - public string Icon { get; set; } - public string Icon75 { get; set; } - public string Icon25 { get; set; } + public string Small { get; set; } = string.Empty; + public string Big { get; set; } = string.Empty; + public object SmallLegacy { get; set; } = null!; + public object Facebook { get; set; } = null!; + public object BigLegacy { get; set; } = null!; + public string Icon { get; set; } = string.Empty; + public string Icon75 { get; set; } = string.Empty; + public string Icon25 { get; set; } = string.Empty; } public class Daily { - public string Icon { get; set; } - public Rating Rating { get; set; } + public string Icon { get; set; } = string.Empty; + public Rating Rating { get; set; } = null!; public int Remaining { get; set; } - public Kategory Kategory { get; set; } - public string PackageVersionId { get; set; } - public string Slug { get; set; } - public Category Category { get; set; } - public string Hotness { get; set; } - public string Id { get; set; } - public Publisher Publisher { get; set; } - public List List { get; set; } - public Link Link { get; set; } - public Flags Flags { get; set; } - public Keyimage Keyimage { get; set; } - public string Description { get; set; } - public string TitleEnglish { get; set; } - public string Title { get; set; } + public Kategory Kategory { get; set; } = null!; + public string PackageVersionId { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public Category Category { get; set; } = null!; + public string Hotness { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; + public Publisher Publisher { get; set; } = null!; + public List List { get; set; } = []; + public Link Link { get; set; } = null!; + public Flags Flags { get; set; } = null!; + public Keyimage Keyimage { get; set; } = null!; + public string Description { get; set; } = string.Empty; + public string TitleEnglish { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; } public class Content { - public string Pubdate { get; set; } - public string MinUnityVersion { get; set; } - public Rating Rating { get; set; } - public Kategory Kategory { get; set; } - public List UnityVersions { get; set; } - public string Url { get; set; } - public string PackageVersionId { get; set; } - public string Slug { get; set; } - public Category Category { get; set; } - public string Id { get; set; } - public Publisher Publisher { get; set; } - public string Sizetext { get; set; } - public List List { get; set; } - public Link Link { get; set; } - public List Images { get; set; } - public Flags Flags { get; set; } - public string Version { get; set; } - public string FirstPublishedAt { get; set; } - public Keyimage Keyimage { get; set; } + public string Pubdate { get; set; } = string.Empty; + public string MinUnityVersion { get; set; } = string.Empty; + public Rating Rating { get; set; } = null!; + public Kategory Kategory { get; set; } = null!; + public List UnityVersions { get; set; } = []; + public string Url { get; set; } = string.Empty; + public string PackageVersionId { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public Category Category { get; set; } = null!; + public string Id { get; set; } = string.Empty; + public Publisher Publisher { get; set; } = null!; + public string Sizetext { get; set; } = string.Empty; + public List List { get; set; } = []; + public Link Link { get; set; } = null!; + public List Images { get; set; } = []; + public Flags Flags { get; set; } = null!; + public string Version { get; set; } = string.Empty; + public string FirstPublishedAt { get; set; } = string.Empty; + public Keyimage Keyimage { get; set; } = null!; public int License { get; set; } - public string Description { get; set; } - public List Upgrades { get; set; } - public string Publishnotes { get; set; } - public string Title { get; set; } - public string ShortUrl { get; set; } - public List Upgradables { get; set; } + public string Description { get; set; } = string.Empty; + public List Upgrades { get; set; } = []; + public string Publishnotes { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string ShortUrl { get; set; } = string.Empty; + public List Upgradables { get; set; } = []; } public class DailyObject { - public string Banner { get; set; } - public string Feed { get; set; } - public string Status { get; set; } + public string Banner { get; set; } = string.Empty; + public string Feed { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; public int DaysLeft { get; set; } public int Total { get; set; } - public Daily Daily { get; set; } + public Daily Daily { get; set; } = null!; public int Remaining { get; set; } - public string Badge { get; set; } - public string Title { get; set; } + public string Badge { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; public bool Countdown { get; set; } - public List Results { get; set; } + public List Results { get; set; } = []; } public class PackageObject { - public Content Content { get; set; } + public Content Content { get; set; } = null!; } public class PriceObject { - public string Vat { get; set; } - public string PriceExvat { get; set; } - public string Price { get; set; } + public string Vat { get; set; } = string.Empty; + public string PriceExvat { get; set; } = string.Empty; + public string Price { get; set; } = string.Empty; public bool IsFree { get; set; } } public class Result { - public string Category { get; set; } - public string Title { get; set; } - public string Publisher { get; set; } + public string Category { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Publisher { get; set; } = string.Empty; } public class PackageHeadObject { - public Result Result { get; set; } + public Result Result { get; set; } = null!; } \ No newline at end of file diff --git a/DiscordBot/Domain/Casino/AIAction.cs b/DiscordBot/Domain/Casino/AIAction.cs index 461b23ff..33e14e3b 100644 --- a/DiscordBot/Domain/Casino/AIAction.cs +++ b/DiscordBot/Domain/Casino/AIAction.cs @@ -1,4 +1,4 @@ public class AIAction { - public Func Execute { get; set; } + public Func Execute { get; set; } = null!; } \ No newline at end of file diff --git a/DiscordBot/Domain/ProfileData.cs b/DiscordBot/Domain/ProfileData.cs index 2942a5fc..2d2472d8 100644 --- a/DiscordBot/Domain/ProfileData.cs +++ b/DiscordBot/Domain/ProfileData.cs @@ -5,8 +5,8 @@ namespace DiscordBot.Domain; public class ProfileData { public ulong UserId { get; set; } - public string Nickname { get; set; } - public string Username { get; set; } + public string Nickname { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; public long XpTotal { get; set; } public long XpRank { get; set; } public long KarmaRank { get; set; } @@ -18,5 +18,5 @@ public class ProfileData public int MaxXpShown { get; set; } public float XpPercentage { get; set; } public Color MainRoleColor { get; set; } - public MagickImage Picture { get; set; } + public MagickImage Picture { get; set; } = null!; } \ No newline at end of file diff --git a/DiscordBot/Extensions/UserDBRepository.cs b/DiscordBot/Extensions/UserDBRepository.cs index ce932b21..dbd0a1f5 100644 --- a/DiscordBot/Extensions/UserDBRepository.cs +++ b/DiscordBot/Extensions/UserDBRepository.cs @@ -5,7 +5,7 @@ namespace DiscordBot.Extensions; public class ServerUser { // ReSharper disable once InconsistentNaming - public string UserID { get; set; } + public string UserID { get; set; } = string.Empty; public int Karma { get; set; } public int KarmaWeekly { get; set; } public int KarmaMonthly { get; set; } diff --git a/DiscordBot/Modules/AirportModule.cs b/DiscordBot/Modules/AirportModule.cs index e2025662..233d8663 100644 --- a/DiscordBot/Modules/AirportModule.cs +++ b/DiscordBot/Modules/AirportModule.cs @@ -11,10 +11,10 @@ public class AirportModule : ModuleBase { #region Dependency Injection - public AirportService AirportService { get; set; } - public BotSettings Settings { get; set; } + public AirportService AirportService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; // Needed to locate cities lon/lat easier - public WeatherService WeatherService { get; set; } + public WeatherService WeatherService { get; set; } = null!; #endregion // Dependency Injection @@ -22,14 +22,14 @@ public class AirportModule : ModuleBase public class FlightResults { - public string iata { get; set; } - public string fs { get; set; } - public string name { get; set; } + public string iata { get; set; } = string.Empty; + public string fs { get; set; } = string.Empty; + public string name { get; set; } = string.Empty; } public class FlightRoot { - public List data { get; set; } + public List data { get; set; } = []; } #endregion // API Results diff --git a/DiscordBot/Modules/Casino/CasinoSlashModule.Games.cs b/DiscordBot/Modules/Casino/CasinoSlashModule.Games.cs index c0d6926d..87d2ac4a 100644 --- a/DiscordBot/Modules/Casino/CasinoSlashModule.Games.cs +++ b/DiscordBot/Modules/Casino/CasinoSlashModule.Games.cs @@ -17,7 +17,7 @@ public partial class CasinoSlashModule : InteractionModuleBase { - public CasinoService CasinoService { get; set; } - public ILoggingService LoggingService { get; set; } - public BotSettings BotSettings { get; set; } - public TransactionFormatter TransactionFormatter { get; set; } + public CasinoService CasinoService { get; set; } = null!; + public ILoggingService LoggingService { get; set; } = null!; + public BotSettings BotSettings { get; set; } = null!; + public TransactionFormatter TransactionFormatter { get; set; } = null!; private async Task CheckChannelPermissions() { diff --git a/DiscordBot/Modules/CodeTipModule.cs b/DiscordBot/Modules/CodeTipModule.cs index 13136a4d..1d4c5f5f 100644 --- a/DiscordBot/Modules/CodeTipModule.cs +++ b/DiscordBot/Modules/CodeTipModule.cs @@ -6,7 +6,7 @@ namespace DiscordBot.Modules; [Group("UserModule"), Alias("")] public class CodeTipModule : ModuleBase { - public CodeCheckService CodeCheckService { get; set; } + public CodeCheckService CodeCheckService { get; set; } = null!; [Command("CodeTip"), Priority(20)] [Summary("Show code formatting example. Syntax: !codetip userToPing(optional)")] diff --git a/DiscordBot/Modules/ConvertModule.cs b/DiscordBot/Modules/ConvertModule.cs index 0b7bdf74..493dcb57 100644 --- a/DiscordBot/Modules/ConvertModule.cs +++ b/DiscordBot/Modules/ConvertModule.cs @@ -8,7 +8,7 @@ namespace DiscordBot.Modules; [Group("UserModule"), Alias("")] public class ConvertModule : ModuleBase { - public CurrencyService CurrencyService { get; set; } + public CurrencyService CurrencyService { get; set; } = null!; [Command("FtoC"), Priority(28)] [Summary("Converts a temperature in fahrenheit to celsius. Syntax : !ftoc temperature")] diff --git a/DiscordBot/Modules/DuelSlashModule.cs b/DiscordBot/Modules/DuelSlashModule.cs index 5ec2f15c..ec6c8751 100644 --- a/DiscordBot/Modules/DuelSlashModule.cs +++ b/DiscordBot/Modules/DuelSlashModule.cs @@ -5,8 +5,8 @@ namespace DiscordBot.Modules; public class DuelSlashModule : InteractionModuleBase { - public DuelService DuelService { get; set; } - public ILoggingService LoggingService { get; set; } + public DuelService DuelService { get; set; } = null!; + public ILoggingService LoggingService { get; set; } = null!; [SlashCommand("duel", "Challenge another user to a duel!")] public async Task Duel( diff --git a/DiscordBot/Modules/EmbedModule.cs b/DiscordBot/Modules/EmbedModule.cs index 2da616ae..a822b79b 100644 --- a/DiscordBot/Modules/EmbedModule.cs +++ b/DiscordBot/Modules/EmbedModule.cs @@ -8,7 +8,7 @@ namespace DiscordBot.Modules; [RequireAdmin] public class EmbedModule : ModuleBase { - public EmbedParsingService EmbedParsingService { get; set; } + public EmbedParsingService EmbedParsingService { get; set; } = null!; /// /// Generate an embed diff --git a/DiscordBot/Modules/FunModule.cs b/DiscordBot/Modules/FunModule.cs index c3ada19b..dce682ee 100644 --- a/DiscordBot/Modules/FunModule.cs +++ b/DiscordBot/Modules/FunModule.cs @@ -9,8 +9,8 @@ namespace DiscordBot.Modules; [Group("UserModule"), Alias("")] public class FunModule : ModuleBase { - public ILoggingService LoggingService { get; set; } - public BotSettings Settings { get; set; } + public ILoggingService LoggingService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; private readonly Random _random = new(); private FuzzTable _slapObjects = new(); diff --git a/DiscordBot/Modules/ProfileModule.cs b/DiscordBot/Modules/ProfileModule.cs index 555aa025..8e538427 100644 --- a/DiscordBot/Modules/ProfileModule.cs +++ b/DiscordBot/Modules/ProfileModule.cs @@ -6,8 +6,8 @@ namespace DiscordBot.Modules; [Group("UserModule"), Alias("")] public class ProfileModule : ModuleBase { - public ProfileCardService ProfileCardService { get; set; } - public ILoggingService LoggingService { get; set; } + public ProfileCardService ProfileCardService { get; set; } = null!; + public ILoggingService LoggingService { get; set; } = null!; [Command("Karma"), Priority(95)] [Summary("Description of what Karma is.")] diff --git a/DiscordBot/Modules/RankModule.cs b/DiscordBot/Modules/RankModule.cs index 4905f9e8..917bc38a 100644 --- a/DiscordBot/Modules/RankModule.cs +++ b/DiscordBot/Modules/RankModule.cs @@ -6,8 +6,8 @@ namespace DiscordBot.Modules; [Group("UserModule"), Alias("")] public class RankModule : ModuleBase { - public DatabaseService DatabaseService { get; set; } - public ILoggingService LoggingService { get; set; } + public DatabaseService DatabaseService { get; set; } = null!; + public ILoggingService LoggingService { get; set; } = null!; [Command("Top"), Priority(6)] [Summary("Display top 10 users by level.")] diff --git a/DiscordBot/Modules/ReminderModule.cs b/DiscordBot/Modules/ReminderModule.cs index 90a06a55..0b87b327 100644 --- a/DiscordBot/Modules/ReminderModule.cs +++ b/DiscordBot/Modules/ReminderModule.cs @@ -12,8 +12,8 @@ public class ReminderModule : ModuleBase { #region Dependency Injection - public ReminderService ReminderService { get; set; } - public BotSettings Settings { get; set; } + public ReminderService ReminderService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; #endregion diff --git a/DiscordBot/Modules/RulesModule.cs b/DiscordBot/Modules/RulesModule.cs index 808dcbeb..d6386b45 100644 --- a/DiscordBot/Modules/RulesModule.cs +++ b/DiscordBot/Modules/RulesModule.cs @@ -10,9 +10,9 @@ namespace DiscordBot.Modules; [Group("UserModule"), Alias("")] public class RulesModule : ModuleBase { - public WelcomeService WelcomeService { get; set; } - public UpdateService UpdateService { get; set; } - public Rules Rules { get; set; } + public WelcomeService WelcomeService { get; set; } = null!; + public UpdateService UpdateService { get; set; } = null!; + public Rules Rules { get; set; } = null!; [Command("Rules"), Priority(1)] [Summary("Rules of current channel by DM.")] diff --git a/DiscordBot/Modules/SearchModule.cs b/DiscordBot/Modules/SearchModule.cs index 84786183..0227d09f 100644 --- a/DiscordBot/Modules/SearchModule.cs +++ b/DiscordBot/Modules/SearchModule.cs @@ -10,10 +10,10 @@ namespace DiscordBot.Modules; [Group("UserModule"), Alias("")] public class SearchModule : ModuleBase { - public ILoggingService LoggingService { get; set; } - public BotSettings Settings { get; set; } - public UpdateService UpdateService { get; set; } - public SearchService SearchService { get; set; } + public ILoggingService LoggingService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; + public UpdateService UpdateService { get; set; } = null!; + public SearchService SearchService { get; set; } = null!; [Command("Search"), Priority(25)] [Summary("Searches DuckDuckGo for results. Syntax: !search c# lambda help")] diff --git a/DiscordBot/Modules/ServerModule.cs b/DiscordBot/Modules/ServerModule.cs index b62862e7..ed27b2c2 100644 --- a/DiscordBot/Modules/ServerModule.cs +++ b/DiscordBot/Modules/ServerModule.cs @@ -8,9 +8,9 @@ namespace DiscordBot.Modules; [Group("UserModule"), Alias("")] public class ServerModule : ModuleBase { - public CommandHandlingService CommandHandlingService { get; set; } - public ServerService ServerService { get; set; } - public BotSettings Settings { get; set; } + public CommandHandlingService CommandHandlingService { get; set; } = null!; + public ServerService ServerService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; [Command("Help"), Priority(100)] [Summary("Does what you see now.")] diff --git a/DiscordBot/Modules/ServerSlashModule.cs b/DiscordBot/Modules/ServerSlashModule.cs index a1f3c21d..797659f6 100644 --- a/DiscordBot/Modules/ServerSlashModule.cs +++ b/DiscordBot/Modules/ServerSlashModule.cs @@ -6,10 +6,10 @@ namespace DiscordBot.Modules; public class ServerSlashModule : InteractionModuleBase { - public CommandHandlingService CommandHandlingService { get; set; } - public WelcomeService WelcomeService { get; set; } - public ServerService ServerService { get; set; } - public BotSettings BotSettings { get; set; } + public CommandHandlingService CommandHandlingService { get; set; } = null!; + public WelcomeService WelcomeService { get; set; } = null!; + public ServerService ServerService { get; set; } = null!; + public BotSettings BotSettings { get; set; } = null!; #region Help diff --git a/DiscordBot/Modules/TicketModule.cs b/DiscordBot/Modules/TicketModule.cs index f08087e7..9da3098f 100644 --- a/DiscordBot/Modules/TicketModule.cs +++ b/DiscordBot/Modules/TicketModule.cs @@ -10,8 +10,8 @@ public class TicketModule : ModuleBase { #region Dependency Injection - public CommandHandlingService CommandHandlingService { get; set; } - public BotSettings Settings { get; set; } + public CommandHandlingService CommandHandlingService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; #endregion diff --git a/DiscordBot/Modules/TipModule.cs b/DiscordBot/Modules/TipModule.cs index ce93f0a2..a7dea97b 100644 --- a/DiscordBot/Modules/TipModule.cs +++ b/DiscordBot/Modules/TipModule.cs @@ -13,9 +13,9 @@ public class TipModule : ModuleBase { #region Dependency Injection - public CommandHandlingService CommandHandlingService { get; set; } - public BotSettings Settings { get; set; } - public TipService TipService { get; set; } + public CommandHandlingService CommandHandlingService { get; set; } = null!; + public BotSettings Settings { get; set; } = null!; + public TipService TipService { get; set; } = null!; #endregion diff --git a/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs b/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs index a085975b..f5638829 100644 --- a/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs +++ b/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs @@ -12,9 +12,9 @@ public class CannedInteractiveModule : InteractionModuleBase { #region Dependency Injection - public UnityHelpService HelpService { get; set; } - public BotSettings BotSettings { get; set; } - public CannedResponseService CannedResponseService { get; set; } + public UnityHelpService HelpService { get; set; } = null!; + public BotSettings BotSettings { get; set; } = null!; + public CannedResponseService CannedResponseService { get; set; } = null!; #endregion // Dependency Injection diff --git a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs b/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs index 6fc83d65..2aaa24fe 100644 --- a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs +++ b/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs @@ -11,8 +11,8 @@ public class CannedResponseModule : ModuleBase { #region Dependency Injection - public BotSettings BotSettings { get; set; } - public CannedResponseService CannedResponseService { get; set; } + public BotSettings BotSettings { get; set; } = null!; + public CannedResponseService CannedResponseService { get; set; } = null!; #endregion // Dependency Injection diff --git a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs b/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs index 60c74636..083fd463 100644 --- a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs +++ b/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs @@ -10,7 +10,7 @@ public class GeneralHelpModule : ModuleBase { #region Dependency Injection - public BotSettings BotSettings { get; set; } + public BotSettings BotSettings { get; set; } = null!; #endregion // Dependency Injection diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs b/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs index 253fbd9e..112ce675 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs +++ b/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs @@ -9,8 +9,8 @@ public class UnityHelpInteractiveModule : InteractionModuleBase { #region Dependency Injection - public UnityHelpService HelpService { get; set; } - public BotSettings BotSettings { get; set; } + public UnityHelpService HelpService { get; set; } = null!; + public BotSettings BotSettings { get; set; } = null!; #endregion // Dependency Injection diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs b/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs index a0522051..f4339660 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs +++ b/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs @@ -10,8 +10,8 @@ public class UnityHelpModule : ModuleBase { #region Dependency Injection - public UnityHelpService HelpService { get; set; } - public BotSettings BotSettings { get; set; } + public UnityHelpService HelpService { get; set; } = null!; + public BotSettings BotSettings { get; set; } = null!; #endregion // Dependency Injection diff --git a/DiscordBot/Modules/Weather/WeatherContainers.cs b/DiscordBot/Modules/Weather/WeatherContainers.cs index 2294950e..cfb61689 100644 --- a/DiscordBot/Modules/Weather/WeatherContainers.cs +++ b/DiscordBot/Modules/Weather/WeatherContainers.cs @@ -17,9 +17,9 @@ public class Coord public class Weather { public int id { get; set; } - [JsonProperty("main")] public string Name { get; set; } - public string Description { get; set; } - public string Icon { get; set; } + [JsonProperty("main")] public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Icon { get; set; } = string.Empty; } public class Main @@ -60,27 +60,27 @@ public class Sys public int type { get; set; } public int id { get; set; } public double message { get; set; } - public string country { get; set; } + public string country { get; set; } = string.Empty; public int sunrise { get; set; } public int sunset { get; set; } } public class Result { - public Coord coord { get; set; } - public List weather { get; set; } - public string @base { get; set; } - public Main main { get; set; } + public Coord coord { get; set; } = null!; + public List weather { get; set; } = []; + public string @base { get; set; } = string.Empty; + public Main main { get; set; } = null!; public int visibility { get; set; } - public Wind wind { get; set; } - public Clouds clouds { get; set; } - public Rain rain { get; set; } - public Snow snow { get; set; } + public Wind wind { get; set; } = null!; + public Clouds clouds { get; set; } = null!; + public Rain rain { get; set; } = null!; + public Snow snow { get; set; } = null!; public int dt { get; set; } - public Sys sys { get; set; } + public Sys sys { get; set; } = null!; public int timezone { get; set; } public int id { get; set; } - public string name { get; set; } + public string name { get; set; } = string.Empty; public int cod { get; set; } } } @@ -113,14 +113,14 @@ public class Components public class List { - public Main main { get; set; } - public Components components { get; set; } + public Main main { get; set; } = null!; + public Components components { get; set; } = null!; public int dt { get; set; } } public class Result { - public Coord coord { get; set; } - public List list { get; set; } + public Coord coord { get; set; } = null!; + public List list { get; set; } = []; } } diff --git a/DiscordBot/Modules/Weather/WeatherModule.cs b/DiscordBot/Modules/Weather/WeatherModule.cs index 800fbb42..d775f042 100644 --- a/DiscordBot/Modules/Weather/WeatherModule.cs +++ b/DiscordBot/Modules/Weather/WeatherModule.cs @@ -13,8 +13,8 @@ public class WeatherModule : ModuleBase { #region Dependency Injection - public WeatherService WeatherService { get; set; } - public UserExtendedService UserExtendedService { get; set; } + public WeatherService WeatherService { get; set; } = null!; + public UserExtendedService UserExtendedService { get; set; } = null!; #endregion diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index ed9b4d65..c63b368c 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -16,18 +16,18 @@ public class Program { private int _isInitialized = 0; - private static Rules _rules; - private static BotSettings _settings; - private static UserSettings _userSettings; - private DiscordSocketClient _client; - private CommandHandlingService _commandHandlingService; - - private CommandService _commandService; - private InteractionService _interactionService; - private IServiceProvider _services; - - private UnityHelpService _unityHelpService; - private RecruitService _recruitService; + private static Rules _rules = null!; + private static BotSettings _settings = null!; + private static UserSettings _userSettings = null!; + private DiscordSocketClient _client = null!; + private CommandHandlingService _commandHandlingService = null!; + + private CommandService _commandService = null!; + private InteractionService _interactionService = null!; + private IServiceProvider _services = null!; + + private UnityHelpService _unityHelpService = null!; + private RecruitService _recruitService = null!; private readonly CancellationTokenSource _cts = new(); diff --git a/DiscordBot/Services/AirportService.cs b/DiscordBot/Services/AirportService.cs index 6f301161..59b92023 100644 --- a/DiscordBot/Services/AirportService.cs +++ b/DiscordBot/Services/AirportService.cs @@ -29,39 +29,39 @@ public class AirportService public class AmadeusRoot { - public List data { get; set; } + public List data { get; set; } = []; } public class FlightInfo { - public string type { get; set; } - public string id { get; set; } - public string source { get; set; } + public string type { get; set; } = string.Empty; + public string id { get; set; } = string.Empty; + public string source { get; set; } = string.Empty; public bool instantTicketingRequired { get; set; } public bool nonHomogeneous { get; set; } public bool oneWay { get; set; } - public string lastTicketingDate { get; set; } + public string lastTicketingDate { get; set; } = string.Empty; public int numberOfBookableSeats { get; set; } - public List itineraries { get; set; } - public Price price { get; set; } - public PricingOptions pricingOptions { get; set; } - public List validatingAirlineCodes { get; set; } + public List itineraries { get; set; } = []; + public Price price { get; set; } = null!; + public PricingOptions pricingOptions { get; set; } = null!; + public List validatingAirlineCodes { get; set; } = []; // public List travelerPricings { get; set; } } public class PricingOptions { - public List fareType { get; set; } + public List fareType { get; set; } = []; public bool includedCheckedBagsOnly { get; set; } } public class Price { - public string currency { get; set; } - public string total { get; set; } - public string @base { get; set; } - public List fees { get; set; } - public string grandTotal { get; set; } + public string currency { get; set; } = string.Empty; + public string total { get; set; } = string.Empty; + public string @base { get; set; } = string.Empty; + public List fees { get; set; } = []; + public string grandTotal { get; set; } = string.Empty; public double GrandTotalNumber() { @@ -71,47 +71,47 @@ public double GrandTotalNumber() public class Fee { - public string amount { get; set; } - public string type { get; set; } + public string amount { get; set; } = string.Empty; + public string type { get; set; } = string.Empty; } public class Itinerary { - public string duration { get; set; } - public List segments { get; set; } + public string duration { get; set; } = string.Empty; + public List segments { get; set; } = []; } public class Segment { - public FlightDetails departure { get; set; } - public FlightDetails arrival { get; set; } - public string carrierCode { get; set; } - public string number { get; set; } + public FlightDetails departure { get; set; } = null!; + public FlightDetails arrival { get; set; } = null!; + public string carrierCode { get; set; } = string.Empty; + public string number { get; set; } = string.Empty; // public Aircraft aircraft { get; set; } // public Operating operating { get; set; } - public string duration { get; set; } - public string id { get; set; } + public string duration { get; set; } = string.Empty; + public string id { get; set; } = string.Empty; public int numberOfStops { get; set; } public bool blacklistedInEU { get; set; } } public class FlightDetails { - public string iataCode { get; set; } + public string iataCode { get; set; } = string.Empty; public DateTime at { get; set; } } public class AmadeusAuthRoot { - public string type { get; set; } - public string username { get; set; } - public string application_name { get; set; } - public string client_id { get; set; } - public string token_type { get; set; } - public string access_token { get; set; } + public string type { get; set; } = string.Empty; + public string username { get; set; } = string.Empty; + public string application_name { get; set; } = string.Empty; + public string client_id { get; set; } = string.Empty; + public string token_type { get; set; } = string.Empty; + public string access_token { get; set; } = string.Empty; public int expires_in { get; set; } - public string state { get; set; } - public string scope { get; set; } + public string state { get; set; } = string.Empty; + public string scope { get; set; } = string.Empty; } #endregion // Return Results @@ -129,15 +129,15 @@ public class AmadeusAuthRoot public class AirLabsAirport { - public string icao_code { get; set; } - public string country_code { get; set; } - public string iata_code { get; set; } + public string icao_code { get; set; } = string.Empty; + public string country_code { get; set; } = string.Empty; + public string iata_code { get; set; } = string.Empty; public double lng { get; set; } - public string city { get; set; } - public string timezone { get; set; } - public string name { get; set; } - public string city_code { get; set; } - public string slug { get; set; } + public string city { get; set; } = string.Empty; + public string timezone { get; set; } = string.Empty; + public string name { get; set; } = string.Empty; + public string city_code { get; set; } = string.Empty; + public string slug { get; set; } = string.Empty; public double lat { get; set; } public int popularity { get; set; } public double distance { get; set; } @@ -145,12 +145,12 @@ public class AirLabsAirport public class AirLabsCity { - public string country_code { get; set; } + public string country_code { get; set; } = string.Empty; public double lng { get; set; } - public string timezone { get; set; } - public string name { get; set; } - public string city_code { get; set; } - public string slug { get; set; } + public string timezone { get; set; } = string.Empty; + public string name { get; set; } = string.Empty; + public string city_code { get; set; } = string.Empty; + public string slug { get; set; } = string.Empty; public double lat { get; set; } public int popularity { get; set; } public double distance { get; set; } @@ -158,13 +158,13 @@ public class AirLabsCity public class AirLabsRoot { - public List airports { get; set; } - public List cities { get; set; } + public List airports { get; set; } = []; + public List cities { get; set; } = []; } public class AirLabsSuperRoot { - public AirLabsRoot response { get; set; } + public AirLabsRoot response { get; set; } = null!; } #endregion // Return Results diff --git a/DiscordBot/Services/AuditLogService.cs b/DiscordBot/Services/AuditLogService.cs index 10bd2fb7..4069d00c 100644 --- a/DiscordBot/Services/AuditLogService.cs +++ b/DiscordBot/Services/AuditLogService.cs @@ -12,7 +12,7 @@ public class AuditLogService private static readonly Color DeletedMessageColor = new(200, 128, 128); private static readonly Color EditedMessageColor = new(255, 255, 128); - private readonly IMessageChannel _botAnnouncementChannel; + private readonly IMessageChannel _botAnnouncementChannel = null!; public AuditLogService(DiscordSocketClient client, BotSettings settings, ILoggingService loggingService) { diff --git a/DiscordBot/Services/BirthdayAnnouncementService.cs b/DiscordBot/Services/BirthdayAnnouncementService.cs index 0b556c2a..03c4f5ad 100644 --- a/DiscordBot/Services/BirthdayAnnouncementService.cs +++ b/DiscordBot/Services/BirthdayAnnouncementService.cs @@ -265,7 +265,7 @@ public async Task RestartService() public class BirthdayInfo { - public string Name { get; set; } + public string Name { get; set; } = null!; public DateTime BirthDate { get; set; } public int? Age { get; set; } } \ No newline at end of file diff --git a/DiscordBot/Services/CommandHandlingService.cs b/DiscordBot/Services/CommandHandlingService.cs index 181aacb6..2b514f7a 100644 --- a/DiscordBot/Services/CommandHandlingService.cs +++ b/DiscordBot/Services/CommandHandlingService.cs @@ -13,10 +13,10 @@ namespace DiscordBot.Services; public class CommandHistoryInfo { - public string Command { get; set; } - public string User { get; set; } + public string Command { get; set; } = null!; + public string User { get; set; } = null!; public ulong UserId { get; set; } - public string Channel { get; set; } + public string Channel { get; set; } = null!; public DateTime Time { get; set; } public string Error { get; set; } = string.Empty; } diff --git a/DiscordBot/Services/CurrencyService.cs b/DiscordBot/Services/CurrencyService.cs index dc36726c..a890bea4 100644 --- a/DiscordBot/Services/CurrencyService.cs +++ b/DiscordBot/Services/CurrencyService.cs @@ -16,8 +16,8 @@ public class CurrencyService private class Currency { - public string Name { get; set; } - public string Short { get; set; } + public string Name { get; set; } = null!; + public string Short { get; set; } = null!; } #endregion // Configuration diff --git a/DiscordBot/Services/EmbedParsingService.cs b/DiscordBot/Services/EmbedParsingService.cs index 6b34bdda..5079af52 100644 --- a/DiscordBot/Services/EmbedParsingService.cs +++ b/DiscordBot/Services/EmbedParsingService.cs @@ -18,44 +18,44 @@ private class EmbedData { public class Footer { - public string icon_url; - public string text; + public string icon_url = string.Empty; + public string text = string.Empty; } public class Thumbnail { - public string url; + public string url = string.Empty; } public class Image { - public string url; + public string url = string.Empty; } public class Author { - public string name; - public string url; - public string icon_url; + public string name = string.Empty; + public string url = string.Empty; + public string icon_url = string.Empty; } public class Field { - public string name; - public string value; + public string name = string.Empty; + public string value = string.Empty; public bool? inline; } - public string title; - public string description; - public string url; + public string title = string.Empty; + public string description = string.Empty; + public string url = string.Empty; public uint? color; public DateTimeOffset? timestamp; - public Footer footer; - public Thumbnail thumbnail; - public Image image; - public Author author; - public Field[] fields; + public Footer footer = null!; + public Thumbnail thumbnail = null!; + public Image image = null!; + public Author author = null!; + public Field[] fields = []; } #pragma warning restore 0649 diff --git a/DiscordBot/Services/FeedService.cs b/DiscordBot/Services/FeedService.cs index b8c411ab..917b670e 100644 --- a/DiscordBot/Services/FeedService.cs +++ b/DiscordBot/Services/FeedService.cs @@ -23,9 +23,9 @@ public class FeedService private class ForumNewsFeed { - public string TitleFormat { get; set; } - public string Url { get; set; } - public List IncludeTags { get; set; } + public string TitleFormat { get; set; } = null!; + public string Url { get; set; } = null!; + public List IncludeTags { get; set; } = null!; public bool IsRelease { get; set; } = false; } diff --git a/DiscordBot/Services/LoggingService.cs b/DiscordBot/Services/LoggingService.cs index 4624da63..a21a14b5 100644 --- a/DiscordBot/Services/LoggingService.cs +++ b/DiscordBot/Services/LoggingService.cs @@ -67,7 +67,7 @@ public class LoggingService : ILoggingService { private const string ServiceName = "LoggingService"; - private readonly ISocketMessageChannel _logChannel; + private readonly ISocketMessageChannel _logChannel = null!; // Configuration private const long MaxLogSize = 1024 * 1024 * 2; // 2MB diff --git a/DiscordBot/Services/Recruitment/RecruitService.cs b/DiscordBot/Services/Recruitment/RecruitService.cs index 1c4a9482..08931805 100644 --- a/DiscordBot/Services/Recruitment/RecruitService.cs +++ b/DiscordBot/Services/Recruitment/RecruitService.cs @@ -19,7 +19,7 @@ public class RecruitService private readonly ForumTag _tagUnpaidCollab; private readonly ForumTag _tagPosFilled; - private readonly IForumChannel _recruitChannel; + private readonly IForumChannel _recruitChannel = null!; #endregion // Extra Details @@ -39,12 +39,12 @@ public class RecruitService private const string MessageToBeEdited = "This post will remain editable until %s, make any desired changes to your thread. After that the thread will be locked."; - private Embed _userHiringButNoPrice; - private Embed _userWantsWorkButNoPrice; + private Embed _userHiringButNoPrice = null!; + private Embed _userWantsWorkButNoPrice = null!; - private Embed _userDidntUseTags; - private Embed _userRevShareMentioned; - private Embed _userMoreThanOneTagUsed; + private Embed _userDidntUseTags = null!; + private Embed _userRevShareMentioned = null!; + private Embed _userMoreThanOneTagUsed = null!; Dictionary _botSanityCheck = new Dictionary(); diff --git a/DiscordBot/Services/ReminderService.cs b/DiscordBot/Services/ReminderService.cs index c0757a8d..e2b26361 100644 --- a/DiscordBot/Services/ReminderService.cs +++ b/DiscordBot/Services/ReminderService.cs @@ -9,7 +9,7 @@ public class ReminderItem public ulong ChannelId { get; set; } public ulong MessageId { get; set; } public ulong UserId { get; set; } - public string Message { get; set; } + public string Message { get; set; } = null!; public DateTime When { get; set; } } diff --git a/DiscordBot/Services/Tips/Components/Tip.cs b/DiscordBot/Services/Tips/Components/Tip.cs index fdee117b..187bdb8a 100644 --- a/DiscordBot/Services/Tips/Components/Tip.cs +++ b/DiscordBot/Services/Tips/Components/Tip.cs @@ -5,8 +5,8 @@ namespace DiscordBot.Services.Tips.Components; public class Tip : IEntity { public ulong Id { get; set; } - public string Content { get; set; } - public List Keywords { get; set; } - public List ImagePaths { get; set; } + public string Content { get; set; } = string.Empty; + public List Keywords { get; set; } = []; + public List ImagePaths { get; set; } = []; public int Requests { get; set; } } diff --git a/DiscordBot/Services/Tips/TipService.cs b/DiscordBot/Services/Tips/TipService.cs index 469632b6..5b4351dd 100644 --- a/DiscordBot/Services/Tips/TipService.cs +++ b/DiscordBot/Services/Tips/TipService.cs @@ -19,7 +19,7 @@ public class TipService private readonly BotSettings _settings; private readonly ILoggingService _loggingService; private readonly IHttpClientFactory _httpClientFactory; - private readonly string _imageDirectory; + private readonly string _imageDirectory = null!; private ConcurrentDictionary> _tips = new(); private bool _isRunning = false; diff --git a/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs b/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs index d5462654..021aabad 100644 --- a/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs +++ b/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs @@ -13,7 +13,7 @@ public class ThreadContainer public ulong BotsLastMessage { get; set; } - public CancellationTokenSource CancellationToken { get; set; } + public CancellationTokenSource CancellationToken { get; set; } = null!; public DateTime ExpectedShutdownTime { get; set; } /// diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index ff9263ba..798302e7 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -71,7 +71,7 @@ public class UnityHelpService #region Extra Details - private readonly IForumChannel _helpChannel; + private readonly IForumChannel _helpChannel = null!; private readonly ForumTag _resolvedForumTag; diff --git a/DiscordBot/Services/UpdateService.cs b/DiscordBot/Services/UpdateService.cs index e08984c1..7d642339 100644 --- a/DiscordBot/Services/UpdateService.cs +++ b/DiscordBot/Services/UpdateService.cs @@ -26,9 +26,9 @@ public UserData() public class FaqData { - public string Question { get; set; } - public string Answer { get; set; } - public string[] Keywords { get; set; } + public string Question { get; set; } = null!; + public string Answer { get; set; } = null!; + public string[] Keywords { get; set; } = null!; } public class FeedData @@ -47,19 +47,19 @@ public FeedData() public class UpdateService { private const string ServiceName = "UpdateService"; - private readonly ILoggingService _loggingService; + private readonly ILoggingService _loggingService = null!; private readonly FeedService _feedService; private readonly BotSettings _settings; private readonly CancellationToken _token; - private string[][] _apiDatabase; + private string[][] _apiDatabase = null!; - private BotData _botData; + private BotData _botData = null!; private readonly DiscordSocketClient _client; - private List _faqData; - private FeedData _feedData; + private List _faqData = null!; + private FeedData _feedData = null!; - private string[][] _manualDatabase; - private UserData _userData; + private string[][] _manualDatabase = null!; + private UserData _userData = null!; public UpdateService(DiscordSocketClient client, DatabaseService databaseService, BotSettings settings, FeedService feedService, ILoggingService loggingService, @@ -297,12 +297,12 @@ private class WikiPage public long Index { get; set; } [JsonProperty("title")] - public string Title { get; set; } + public string Title { get; set; } = null!; [JsonProperty("extract")] - public string Extract { get; set; } + public string Extract { get; set; } = null!; [JsonProperty("fullurl")] - public Uri FullUrl { get; set; } + public Uri FullUrl { get; set; } = null!; } } \ No newline at end of file diff --git a/DiscordBot/Settings/Deserialized/Rules.cs b/DiscordBot/Settings/Deserialized/Rules.cs index 8acf1938..8bf9840c 100644 --- a/DiscordBot/Settings/Deserialized/Rules.cs +++ b/DiscordBot/Settings/Deserialized/Rules.cs @@ -2,12 +2,12 @@ public class Rules { - public List Channel { get; set; } + public List Channel { get; set; } = []; } public class ChannelData { public ulong Id { get; set; } - public string Header { get; set; } - public string Content { get; set; } + public string Header { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; } \ No newline at end of file diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index 7f17d551..13f65150 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -4,11 +4,11 @@ public class BotSettings { #region Important Settings - public string Token { get; set; } - public string Invite { get; set; } + public string Token { get; set; } = string.Empty; + public string Invite { get; set; } = string.Empty; - public string DbConnectionString { get; set; } - public string ServerRootPath { get; set; } + public string DbConnectionString { get; set; } = string.Empty; + public string ServerRootPath { get; set; } = string.Empty; public string AssetsRootPath { get; set; } = "./Assets"; public char Prefix { get; set; } public ulong GuildId { get; set; } @@ -26,11 +26,11 @@ public class BotSettings public string UserModuleSlapObjectsTable { get; set; } = null; //NOTE: Deserializer will not override a List from the json if a default one is made here. - public List UserModuleSlapChoices { get; set; } + public List UserModuleSlapChoices { get; set; } = []; // = { "trout", "duck", "truck", "paddle", "magikarp", "sausage", "student loan", // "life choice", "bug report", "unhandled exception", "null pointer", "keyboard", // "cheese wheel", "banana peel", "unresolved bug", "low poly donut" }; - public List UserModuleSlapFails { get; set; } + public List UserModuleSlapFails { get; set; } = []; // = { "hurting themselves" }; #endregion // Fun Commands @@ -47,7 +47,7 @@ public class BotSettings public bool BirthdayAnnouncementEnabled { get; set; } = true; public int BirthdayCheckIntervalMinutes { get; set; } = 240; // Check every 4 hours by default - public ChannelInfo BirthdayAnnouncementChannel { get; set; } + public ChannelInfo BirthdayAnnouncementChannel { get; set; } = null!; #endregion // Birthday Announcements @@ -55,28 +55,28 @@ public class BotSettings #region Channels - public ChannelInfo IntroductionChannel { get; set; } - public ChannelInfo GeneralChannel { get; set; } - public ChannelInfo GenericHelpChannel { get; set; } + public ChannelInfo IntroductionChannel { get; set; } = null!; + public ChannelInfo GeneralChannel { get; set; } = null!; + public ChannelInfo GenericHelpChannel { get; set; } = null!; - public ChannelInfo BotAnnouncementChannel { get; set; } - public ChannelInfo BotCommandsChannel { get; set; } - public ChannelInfo UnityNewsChannel { get; set; } - public ChannelInfo UnityReleasesChannel { get; set; } - public ChannelInfo RulesChannel { get; set; } + public ChannelInfo BotAnnouncementChannel { get; set; } = null!; + public ChannelInfo BotCommandsChannel { get; set; } = null!; + public ChannelInfo UnityNewsChannel { get; set; } = null!; + public ChannelInfo UnityReleasesChannel { get; set; } = null!; + public ChannelInfo RulesChannel { get; set; } = null!; // Recruitment Channels - public ChannelInfo RecruitmentChannel { get; set; } + public ChannelInfo RecruitmentChannel { get; set; } = null!; - public ChannelInfo MemeChannel { get; set; } + public ChannelInfo MemeChannel { get; set; } = null!; #region Complaint Channel public ulong ComplaintCategoryId { get; set; } - public string ComplaintChannelPrefix { get; set; } + public string ComplaintChannelPrefix { get; set; } = string.Empty; public ulong ClosedComplaintCategoryId { get; set; } - public string ClosedComplaintChannelPrefix { get; set; } + public string ClosedComplaintChannelPrefix { get; set; } = string.Empty; #endregion // Complaint Channel @@ -93,10 +93,10 @@ public class BotSettings #region Recruitment Thread - public string TagLookingToHire { get; set; } - public string TagLookingForWork { get; set; } - public string TagUnpaidCollab { get; set; } - public string TagPositionFilled { get; set; } + public string TagLookingToHire { get; set; } = string.Empty; + public string TagLookingForWork { get; set; } = string.Empty; + public string TagUnpaidCollab { get; set; } = string.Empty; + public string TagPositionFilled { get; set; } = string.Empty; public int EditPermissionAccessTimeMin { get; set; } = 3; @@ -106,7 +106,7 @@ public class BotSettings #region Tips - public string TipImageDirectory { get; set; } + public string TipImageDirectory { get; set; } = string.Empty; public int TipMaxImageFileSize { get; set; } = 1024 * 1024 * 10; // 10MB // Unlikely, but we prevent exploitation by limiting the max directory size to avoid VPS disk space issues @@ -114,18 +114,18 @@ public class BotSettings #endregion // Tips - public string TagUnitHelpResolvedTag { get; set; } + public string TagUnitHelpResolvedTag { get; set; } = string.Empty; #endregion // Unity Help Threads #region API Keys - public string WeatherAPIKey { get; set; } + public string WeatherAPIKey { get; set; } = string.Empty; - public string FlightAPIKey { get; set; } - public string FlightAPISecret { get; set; } + public string FlightAPIKey { get; set; } = string.Empty; + public string FlightAPISecret { get; set; } = string.Empty; - public string AirLabAPIKey { get; set; } + public string AirLabAPIKey { get; set; } = string.Empty; #endregion // API Keys @@ -144,7 +144,7 @@ public class BotSettings #region Other - public string WikipediaSearchPage { get; set; } + public string WikipediaSearchPage { get; set; } = string.Empty; #endregion // Other @@ -155,7 +155,7 @@ public class BotSettings // Channel Information. Description and Channel ID public class ChannelInfo { - public string Desc { get; set; } + public string Desc { get; set; } = string.Empty; public ulong Id { get; set; } } diff --git a/DiscordBot/Skin/AvatarBorderSkinModule.cs b/DiscordBot/Skin/AvatarBorderSkinModule.cs index c47556f9..4e3ddc0a 100644 --- a/DiscordBot/Skin/AvatarBorderSkinModule.cs +++ b/DiscordBot/Skin/AvatarBorderSkinModule.cs @@ -14,7 +14,7 @@ public AvatarBorderSkinModule() public double StartY { get; set; } public double Size { get; set; } - public string Type { get; set; } + public string Type { get; set; } = string.Empty; public Drawables GetDrawables(ProfileData data) { diff --git a/DiscordBot/Skin/BaseTextSkinModule.cs b/DiscordBot/Skin/BaseTextSkinModule.cs index c5eb18a6..5e59edf0 100644 --- a/DiscordBot/Skin/BaseTextSkinModule.cs +++ b/DiscordBot/Skin/BaseTextSkinModule.cs @@ -21,17 +21,17 @@ public BaseTextSkinModule() public bool StrokeAntiAlias { get; set; } public bool TextAntiAlias { get; set; } - public string StrokeColor { get; set; } + public string StrokeColor { get; set; } = string.Empty; public double StrokeWidth { get; set; } - public string FillColor { get; set; } + public string FillColor { get; set; } = string.Empty; public string Font { get; set; } public double FontPointSize { get; set; } - public string Text { get; set; } + public string Text { get; set; } = string.Empty; public double TextKerning { get; set; } [JsonConverter(typeof(StringEnumConverter))] public TextAlignment TextAlignment { get; set; } - public virtual string Type { get; set; } + public virtual string Type { get; set; } = string.Empty; public virtual Drawables GetDrawables(ProfileData data) { diff --git a/DiscordBot/Skin/RectangleSampleAvatarColorSkinModule.cs b/DiscordBot/Skin/RectangleSampleAvatarColorSkinModule.cs index 53b01f01..88bf5c8d 100644 --- a/DiscordBot/Skin/RectangleSampleAvatarColorSkinModule.cs +++ b/DiscordBot/Skin/RectangleSampleAvatarColorSkinModule.cs @@ -13,9 +13,9 @@ public class RectangleSampleAvatarColorSkinModule : ISkinModule public int Width { get; set; } public int Height { get; set; } public bool WhiteFix { get; set; } - public string DefaultColor { get; set; } + public string DefaultColor { get; set; } = string.Empty; - public string Type { get; set; } + public string Type { get; set; } = string.Empty; public Drawables GetDrawables(ProfileData data) { diff --git a/DiscordBot/Skin/SkinData.cs b/DiscordBot/Skin/SkinData.cs index 733eba93..c3221145 100644 --- a/DiscordBot/Skin/SkinData.cs +++ b/DiscordBot/Skin/SkinData.cs @@ -7,10 +7,10 @@ public SkinData() Layers = new List(); } - public string Name { get; set; } - public string Codename { get; set; } - public string Description { get; set; } + public string Name { get; set; } = string.Empty; + public string Codename { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; public int AvatarSize { get; set; } - public string Background { get; set; } + public string Background { get; set; } = string.Empty; public List Layers { get; set; } } \ No newline at end of file diff --git a/DiscordBot/Skin/SkinLayer.cs b/DiscordBot/Skin/SkinLayer.cs index 5f2cd768..ec599969 100644 --- a/DiscordBot/Skin/SkinLayer.cs +++ b/DiscordBot/Skin/SkinLayer.cs @@ -7,7 +7,7 @@ public SkinLayer() Modules = new List(); } - public string Image { get; set; } + public string Image { get; set; } = string.Empty; public double StartX { get; set; } public double StartY { get; set; } public double Width { get; set; } diff --git a/DiscordBot/Skin/XpBarSkinModule.cs b/DiscordBot/Skin/XpBarSkinModule.cs index e46e7529..c105a64c 100644 --- a/DiscordBot/Skin/XpBarSkinModule.cs +++ b/DiscordBot/Skin/XpBarSkinModule.cs @@ -26,7 +26,7 @@ public XpBarSkinModule() public string InsideStrokeColor { get; set; } public string InsideFillColor { get; set; } - public string Type { get; set; } + public string Type { get; set; } = string.Empty; public Drawables GetDrawables(ProfileData data) { From 930c85279a07b08d47d862a10014fad769177b08 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 03:51:20 +0200 Subject: [PATCH 25/48] fix(nullable): resolve all CS8603 possible null return warnings Make return types nullable across 16 files where methods may return null. Cascading effect: callers now surface CS8602/CS8604 warnings for unhandled nulls. --- DiscordBot/Extensions/TaskExtensions.cs | 8 ++++---- DiscordBot/Modules/AirportModule.cs | 4 ++-- DiscordBot/Modules/EmbedModule.cs | 2 +- DiscordBot/Modules/Weather/WeatherModule.cs | 8 ++++---- DiscordBot/Services/AirportService.cs | 6 +++--- DiscordBot/Services/DatabaseService.cs | 10 +++++----- DiscordBot/Services/ProfileCardService.cs | 2 +- DiscordBot/Services/ReleaseNotesParser.cs | 4 ++-- DiscordBot/Services/SearchService.cs | 8 ++++---- DiscordBot/Services/Tips/TipService.cs | 2 +- DiscordBot/Services/UnityHelp/CannedResponseService.cs | 4 ++-- DiscordBot/Services/UpdateService.cs | 4 ++-- DiscordBot/Services/WeatherService.cs | 4 ++-- DiscordBot/Skin/SkinModuleJsonConverter.cs | 2 +- DiscordBot/Utils/SerializeUtil.cs | 2 +- DiscordBot/Utils/WebUtil.cs | 10 +++++----- 16 files changed, 40 insertions(+), 40 deletions(-) diff --git a/DiscordBot/Extensions/TaskExtensions.cs b/DiscordBot/Extensions/TaskExtensions.cs index f4cdf5a9..0eede7ad 100644 --- a/DiscordBot/Extensions/TaskExtensions.cs +++ b/DiscordBot/Extensions/TaskExtensions.cs @@ -26,8 +26,8 @@ public static Func Guarded(Func public static class TaskExtensions { - public static Task DeleteAfterTime(this IDeletable message, int seconds = 0, int minutes = 0, int hours = 0, int days = 0) => message?.DeleteAfterTimeSpan(new TimeSpan(days, hours, minutes, seconds)); - public static Task DeleteAfterSeconds(this IDeletable message, double seconds) => message?.DeleteAfterTimeSpan(TimeSpan.FromSeconds(seconds)); + public static Task? DeleteAfterTime(this IDeletable message, int seconds = 0, int minutes = 0, int hours = 0, int days = 0) => message?.DeleteAfterTimeSpan(new TimeSpan(days, hours, minutes, seconds)); + public static Task? DeleteAfterSeconds(this IDeletable message, double seconds) => message?.DeleteAfterTimeSpan(TimeSpan.FromSeconds(seconds)); public static Task DeleteAfterTimeSpan(this IDeletable message, TimeSpan timeSpan) { @@ -37,8 +37,8 @@ public static Task DeleteAfterTimeSpan(this IDeletable message, TimeSpan timeSpa }); } - public static Task DeleteAfterTime(this Task task, int seconds = 0, int minutes = 0, int hours = 0, int days = 0, bool awaitDeletion = false) where T : IDeletable => task?.DeleteAfterTimeSpan(new TimeSpan(days, hours, minutes, seconds), awaitDeletion); - public static Task DeleteAfterSeconds(this Task task, double seconds, bool awaitDeletion = false) where T : IDeletable => task?.DeleteAfterTimeSpan(TimeSpan.FromSeconds(seconds), awaitDeletion); + public static Task? DeleteAfterTime(this Task task, int seconds = 0, int minutes = 0, int hours = 0, int days = 0, bool awaitDeletion = false) where T : IDeletable => task?.DeleteAfterTimeSpan(new TimeSpan(days, hours, minutes, seconds), awaitDeletion); + public static Task? DeleteAfterSeconds(this Task task, double seconds, bool awaitDeletion = false) where T : IDeletable => task?.DeleteAfterTimeSpan(TimeSpan.FromSeconds(seconds), awaitDeletion); public static Task DeleteAfterTimeSpan(this Task task, TimeSpan timeSpan, bool awaitDeletion = false) where T : IDeletable { diff --git a/DiscordBot/Modules/AirportModule.cs b/DiscordBot/Modules/AirportModule.cs index 233d8663..6d1dc107 100644 --- a/DiscordBot/Modules/AirportModule.cs +++ b/DiscordBot/Modules/AirportModule.cs @@ -124,7 +124,7 @@ public async Task FlyTo(string from, string to) #region Utility Methods - private async Task GetCity(string city, EmbedBuilder embed, IUserMessage msg) + private async Task GetCity(string city, EmbedBuilder embed, IUserMessage msg) { var cityResult = await WeatherService.GetWeather(city); if (cityResult == null) @@ -137,7 +137,7 @@ public async Task FlyTo(string from, string to) return cityResult; } - private async Task GetAirport(WeatherContainer.Result weather, EmbedBuilder embed, IUserMessage msg) + private async Task GetAirport(WeatherContainer.Result weather, EmbedBuilder embed, IUserMessage msg) { var airportResult = await AirportService.GetClosestAirport(weather.coord.Lat, weather.coord.Lon); if (airportResult == null) diff --git a/DiscordBot/Modules/EmbedModule.cs b/DiscordBot/Modules/EmbedModule.cs index a822b79b..d94b0b2c 100644 --- a/DiscordBot/Modules/EmbedModule.cs +++ b/DiscordBot/Modules/EmbedModule.cs @@ -40,7 +40,7 @@ public async Task EmbedCommand(string url, IMessageChannel channel = null, ulong await SendEmbedToChannel(builtEmbed, channel, messageId); } - private async Task TryGetEmbedFromUrl(string url) + private async Task TryGetEmbedFromUrl(string url) { bool result = Uri.TryCreate(url, UriKind.Absolute, out var uriResult) && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); diff --git a/DiscordBot/Modules/Weather/WeatherModule.cs b/DiscordBot/Modules/Weather/WeatherModule.cs index d775f042..d9736c99 100644 --- a/DiscordBot/Modules/Weather/WeatherModule.cs +++ b/DiscordBot/Modules/Weather/WeatherModule.cs @@ -36,7 +36,7 @@ public async Task WeatherHelp() #region Temperature - private async Task TemperatureEmbed(string city, string replaceCityWith = "") + private async Task TemperatureEmbed(string city, string replaceCityWith = "") { WeatherContainer.Result res = await WeatherService.GetWeather(city: city); if (!await IsResultsValid(res)) @@ -85,7 +85,7 @@ public async Task Temperature(params string[] city) #region Weather - private async Task WeatherEmbed(string city, string replaceCityWith = "") + private async Task WeatherEmbed(string city, string replaceCityWith = "") { WeatherContainer.Result res = await WeatherService.GetWeather(city: city); if (!await IsResultsValid(res)) @@ -164,7 +164,7 @@ public async Task CurentWeather(params string[] city) #region Pollution - private async Task PollutionEmbed(string city, string replaceCityWith = "") + private async Task PollutionEmbed(string city, string replaceCityWith = "") { WeatherContainer.Result res = await WeatherService.GetWeather(city: city); if (!await IsResultsValid(res)) @@ -241,7 +241,7 @@ public async Task Pollution(params string[] city) #region Time - private async Task TimeEmbed(string city, string replaceCityWith = "") + private async Task TimeEmbed(string city, string replaceCityWith = "") { WeatherContainer.Result res = await WeatherService.GetWeather(city: city); if (!await IsResultsValid(res)) diff --git a/DiscordBot/Services/AirportService.cs b/DiscordBot/Services/AirportService.cs index 59b92023..2e2c9e8b 100644 --- a/DiscordBot/Services/AirportService.cs +++ b/DiscordBot/Services/AirportService.cs @@ -182,7 +182,7 @@ public AirportService(DiscordSocketClient client, ILoggingService loggingService _airLabsNearbyCityRoute += _airLabsAPIInclude + _airLabsAPIRequiredFields; } - public async Task GetClosestAirport(double lat, double lng) + public async Task GetClosestAirport(double lat, double lng) { var url = string.Format(_airLabsNearbyCityRoute, lat, lng); @@ -194,7 +194,7 @@ public async Task GetClosestAirport(double lat, double lng) return result.response.airports.FirstOrDefault(a => !string.IsNullOrEmpty(a.iata_code)); } - public async Task GetFlightTickets(string from, string to) + public async Task GetFlightTickets(string from, string to) { return null; @@ -224,7 +224,7 @@ public async Task GetValidationToken() return true; } - public async Task> GetFlightInfo(string from, string to, int daysFromNow = 2) + public async Task?> GetFlightInfo(string from, string to, int daysFromNow = 2) { if (!await GetValidationToken()) return null; diff --git a/DiscordBot/Services/DatabaseService.cs b/DiscordBot/Services/DatabaseService.cs index 5fe2a940..00edae01 100644 --- a/DiscordBot/Services/DatabaseService.cs +++ b/DiscordBot/Services/DatabaseService.cs @@ -15,7 +15,7 @@ public class DatabaseService private readonly ILoggingService _logging; private string ConnectionString { get; } - private ICasinoRepo CreateCasinoQuery() + private ICasinoRepo? CreateCasinoQuery() { try { @@ -29,7 +29,7 @@ private ICasinoRepo CreateCasinoQuery() } } - private IServerUserRepo CreateQuery() + private IServerUserRepo? CreateQuery() { try { @@ -43,8 +43,8 @@ private IServerUserRepo CreateQuery() } } - public IServerUserRepo Query => CreateQuery(); - public ICasinoRepo CasinoQuery => CreateCasinoQuery(); + public IServerUserRepo? Query => CreateQuery(); + public ICasinoRepo? CasinoQuery => CreateCasinoQuery(); public DatabaseService(ILoggingService logging, BotSettings settings) { @@ -214,7 +214,7 @@ await _logging.LogChannelAndFile( /// Adds a new user to the database if they don't already exist. /// /// Existing or newly created user. Null on database error. - public async Task GetOrAddUser(SocketGuildUser socketUser) + public async Task GetOrAddUser(SocketGuildUser socketUser) { if (socketUser == null) { diff --git a/DiscordBot/Services/ProfileCardService.cs b/DiscordBot/Services/ProfileCardService.cs index a65a71f2..b80b83db 100644 --- a/DiscordBot/Services/ProfileCardService.cs +++ b/DiscordBot/Services/ProfileCardService.cs @@ -28,7 +28,7 @@ public ProfileCardService(DatabaseService databaseService, ILoggingService loggi _xpService = xpService; } - private SkinData GetSkinData() => + private SkinData? GetSkinData() => JsonConvert.DeserializeObject(File.ReadAllText($"{_settings.AssetsRootPath}/skins/skin.json"), new SkinModuleJsonConverter()); diff --git a/DiscordBot/Services/ReleaseNotesParser.cs b/DiscordBot/Services/ReleaseNotesParser.cs index 3291f36d..171c404e 100644 --- a/DiscordBot/Services/ReleaseNotesParser.cs +++ b/DiscordBot/Services/ReleaseNotesParser.cs @@ -56,14 +56,14 @@ public List Parse(string summaryHtml) return releaseNotes; } - private static HtmlNode FindH3Sibling(HtmlNode parent, string text) + private static HtmlNode? FindH3Sibling(HtmlNode parent, string text) { return parent.ChildNodes .FirstOrDefault(x => x.Name == "h3" && x.InnerText.Contains(text)) ?.NextSibling; } - private static HtmlNode FindH4Sibling(HtmlNode parent, string text) + private static HtmlNode? FindH4Sibling(HtmlNode parent, string text) { return parent.ChildNodes .FirstOrDefault(x => x.Name == "h4" && x.InnerText == text) diff --git a/DiscordBot/Services/SearchService.cs b/DiscordBot/Services/SearchService.cs index 0e51b4ad..e66e1c78 100644 --- a/DiscordBot/Services/SearchService.cs +++ b/DiscordBot/Services/SearchService.cs @@ -7,7 +7,7 @@ public class SearchService { public record SearchResult(string Title, string Url); - public record DocSearchResult(string PageName, string Title, string BaseUrl, string Description = null); + public record DocSearchResult(string PageName, string Title, string BaseUrl, string? Description = null); public List SearchDuckDuckGo(string query, uint maxResults = 3, string site = "") { @@ -43,7 +43,7 @@ public List SearchDuckDuckGo(string query, uint maxResults = 3, st return results; } - public DocSearchResult FindBestMatch(string query, string[][] database, string baseUrl) + public DocSearchResult? FindBestMatch(string query, string[][] database, string baseUrl) { var minimumScore = double.MaxValue; string[] mostSimilarPage = null; @@ -62,7 +62,7 @@ public DocSearchResult FindBestMatch(string query, string[][] database, string b return new DocSearchResult(mostSimilarPage[0], mostSimilarPage[1], baseUrl); } - public string FetchPageDescription(string url, string descriptionXPath, string nextSiblingFilter = null) + public string? FetchPageDescription(string url, string descriptionXPath, string? nextSiblingFilter = null) { var doc = new HtmlWeb().Load(url); var node = doc.DocumentNode.SelectSingleNode(descriptionXPath); @@ -83,7 +83,7 @@ public string FetchPageDescription(string url, string descriptionXPath, string n return text; } - public string FetchManualLink(string url) + public string? FetchManualLink(string url) { var doc = new HtmlWeb().Load(url); var manualLink = doc.DocumentNode.SelectSingleNode("//a[contains(@class, 'switch-link')]"); diff --git a/DiscordBot/Services/Tips/TipService.cs b/DiscordBot/Services/Tips/TipService.cs index 5b4351dd..ae35ee22 100644 --- a/DiscordBot/Services/Tips/TipService.cs +++ b/DiscordBot/Services/Tips/TipService.cs @@ -330,7 +330,7 @@ public string DumpTipDatabase() return JsonConvert.SerializeObject(_tips); } - public Tip GetTip(ulong Id) + public Tip? GetTip(ulong Id) { foreach (var kvp in _tips) foreach (var tip in kvp.Value) diff --git a/DiscordBot/Services/UnityHelp/CannedResponseService.cs b/DiscordBot/Services/UnityHelp/CannedResponseService.cs index 03c16c7a..1a5bca3e 100644 --- a/DiscordBot/Services/UnityHelp/CannedResponseService.cs +++ b/DiscordBot/Services/UnityHelp/CannedResponseService.cs @@ -259,7 +259,7 @@ public enum CannedResources #endregion // Configuration - public EmbedBuilder GetCannedResponse(CannedResponseType type, IUser requestor = null) + public EmbedBuilder? GetCannedResponse(CannedResponseType type, IUser? requestor = null) { var embed = GetUnbuiltCannedResponse(type); if (embed == null) @@ -272,7 +272,7 @@ public EmbedBuilder GetCannedResponse(CannedResponseType type, IUser requestor = return embed; } - public EmbedBuilder GetUnbuiltCannedResponse(CannedResponseType type) + public EmbedBuilder? GetUnbuiltCannedResponse(CannedResponseType type) { return type switch { diff --git a/DiscordBot/Services/UpdateService.cs b/DiscordBot/Services/UpdateService.cs index 7d642339..db8c28f1 100644 --- a/DiscordBot/Services/UpdateService.cs +++ b/DiscordBot/Services/UpdateService.cs @@ -110,14 +110,14 @@ private async Task SaveDataToFile() catch (OperationCanceledException) { } } - public async Task GetManualDatabase() + public async Task GetManualDatabase() { if (_manualDatabase == null) await LoadDocDatabase(); return _manualDatabase; } - public async Task GetApiDatabase() + public async Task GetApiDatabase() { if (_apiDatabase == null) await LoadDocDatabase(); diff --git a/DiscordBot/Services/WeatherService.cs b/DiscordBot/Services/WeatherService.cs index cabc9643..2eeff6b0 100644 --- a/DiscordBot/Services/WeatherService.cs +++ b/DiscordBot/Services/WeatherService.cs @@ -26,13 +26,13 @@ public WeatherService(DiscordSocketClient client, ILoggingService loggingService } - public async Task GetWeather(string city, string unit = "metric") + public async Task GetWeather(string city, string unit = "metric") { var query = $"https://api.openweathermap.org/data/2.5/weather?q={city}&appid={_weatherApiKey}&units={unit}"; return await SerializeUtil.LoadUrlDeserializeResult(query); } - public async Task GetPollution(double lon, double lat) + public async Task GetPollution(double lon, double lat) { var query = $"https://api.openweathermap.org/data/2.5/air_pollution?lat={lat}&lon={lon}&appid={_weatherApiKey}"; return await SerializeUtil.LoadUrlDeserializeResult(query); diff --git a/DiscordBot/Skin/SkinModuleJsonConverter.cs b/DiscordBot/Skin/SkinModuleJsonConverter.cs index f97cdb61..fed7d353 100644 --- a/DiscordBot/Skin/SkinModuleJsonConverter.cs +++ b/DiscordBot/Skin/SkinModuleJsonConverter.cs @@ -9,7 +9,7 @@ public class SkinModuleJsonConverter : JsonConverter public override bool CanConvert(Type objectType) => objectType == typeof(ISkinModule); - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { var jo = JObject.Load(reader); Type type; diff --git a/DiscordBot/Utils/SerializeUtil.cs b/DiscordBot/Utils/SerializeUtil.cs index 9b42fde0..0e45e519 100644 --- a/DiscordBot/Utils/SerializeUtil.cs +++ b/DiscordBot/Utils/SerializeUtil.cs @@ -83,7 +83,7 @@ private static async Task AtomicWriteTextAsync(string path, string content) File.Move(tmpPath, path, overwrite: true); } - public static async Task LoadUrlDeserializeResult(string url) + public static async Task LoadUrlDeserializeResult(string url) { var result = await InternetExtensions.GetHttpContents(url); var resultObject = JsonConvert.DeserializeObject(result); diff --git a/DiscordBot/Utils/WebUtil.cs b/DiscordBot/Utils/WebUtil.cs index ce715c75..9c1f37f3 100644 --- a/DiscordBot/Utils/WebUtil.cs +++ b/DiscordBot/Utils/WebUtil.cs @@ -30,7 +30,7 @@ public static async Task GetContent(string url) /// Returns the Html document of a url, or null if the request fails. /// Internally calls GetContent and parses the result. /// - public static async Task GetHtmlDocument(string url) + public static async Task GetHtmlDocument(string url) { try { @@ -49,7 +49,7 @@ public static async Task GetHtmlDocument(string url) /// Returns the Html node of a url and xpath, or null if the request fails. /// Internally calls GetHtmlDocument and parses the result with xpath. /// - public static async Task GetHtmlNode(string url, string xpath) + public static async Task GetHtmlNode(string url, string xpath) { try { @@ -65,7 +65,7 @@ public static async Task GetHtmlNode(string url, string xpath) /// /// Returns the Html nodes of a url and xpath, or null if the request fails. /// - public static async Task GetHtmlNodes(string url, string xpath) + public static async Task GetHtmlNodes(string url, string xpath) { try { @@ -81,7 +81,7 @@ public static async Task GetHtmlNodes(string url, string xpa /// /// Returns the decoded inner text of a url and xpath, or an empty string if the request fails. /// - public static async Task GetHtmlNodeInnerText(string url, string xpath) + public static async Task GetHtmlNodeInnerText(string url, string xpath) { try { @@ -117,7 +117,7 @@ public static async Task GetXMLContent(string url) /// /// Returns a deserialized object from a JSON string. If the string is empty or can't be deserialized, it returns the default value of the type. /// - public static async Task GetObjectFromJson(string url) + public static async Task GetObjectFromJson(string url) { try { From f054dc901da1f84f33ad6d6daad83a4f68612e95 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 03:54:32 +0200 Subject: [PATCH 26/48] fix(nullable): resolve all CS8625 null literal to non-nullable warnings Make 24 parameters/variables/fields nullable across 16 files where null is legitimately passed or assigned. --- DiscordBot/Data/FuzzTable.cs | 2 +- DiscordBot/Extensions/MessageExtensions.cs | 2 +- DiscordBot/Extensions/StringExtensions.cs | 2 +- DiscordBot/Extensions/UserDBRepository.cs | 2 +- DiscordBot/Modules/Casino/CasinoSlashModule.cs | 2 +- DiscordBot/Modules/CodeTipModule.cs | 2 +- DiscordBot/Modules/EmbedModule.cs | 4 ++-- DiscordBot/Modules/QuoteModule.cs | 2 +- DiscordBot/Modules/Weather/WeatherModule.cs | 8 ++++---- DiscordBot/Services/CommandHandlingService.cs | 2 +- DiscordBot/Services/LoggingService.cs | 10 +++++----- DiscordBot/Services/Tips/TipService.cs | 2 +- .../Services/UnityHelp/Components/ThreadContainer.cs | 2 +- DiscordBot/Services/UnityHelp/UnityHelpService.cs | 2 +- DiscordBot/Services/WelcomeService.cs | 2 +- DiscordBot/Settings/Deserialized/Settings.cs | 2 +- 16 files changed, 24 insertions(+), 24 deletions(-) diff --git a/DiscordBot/Data/FuzzTable.cs b/DiscordBot/Data/FuzzTable.cs index 66c65a48..9820ba12 100644 --- a/DiscordBot/Data/FuzzTable.cs +++ b/DiscordBot/Data/FuzzTable.cs @@ -20,7 +20,7 @@ namespace DiscordBot.Data; public class FuzzTable { private static Random random = new(); - private static Regex parenContents = null; + private static Regex? parenContents = null; private static TimeSpan timeout = new(10 * 10000/*x10nanoseconds*/); private List choices = new(); diff --git a/DiscordBot/Extensions/MessageExtensions.cs b/DiscordBot/Extensions/MessageExtensions.cs index 22d741ff..1f342059 100644 --- a/DiscordBot/Extensions/MessageExtensions.cs +++ b/DiscordBot/Extensions/MessageExtensions.cs @@ -2,7 +2,7 @@ namespace DiscordBot.Extensions; public static class MessageExtensions { - public static async Task TrySendMessage(this IDMChannel channel, string message = "", Embed embed = null) + public static async Task TrySendMessage(this IDMChannel channel, string message = "", Embed? embed = null) { try { diff --git a/DiscordBot/Extensions/StringExtensions.cs b/DiscordBot/Extensions/StringExtensions.cs index 43e46f58..ad14b5a9 100644 --- a/DiscordBot/Extensions/StringExtensions.cs +++ b/DiscordBot/Extensions/StringExtensions.cs @@ -142,7 +142,7 @@ public static string ToCapitalizeFirstLetter(this string str) /// /// array or list of element phrases to be listed /// final conjunction; defaults to "and" if not given - public static string ToCommaList(this string[] nouns, string conj = null) + public static string ToCommaList(this string[] nouns, string? conj = null) { if (conj == null) conj = "and"; diff --git a/DiscordBot/Extensions/UserDBRepository.cs b/DiscordBot/Extensions/UserDBRepository.cs index dbd0a1f5..9443e3d3 100644 --- a/DiscordBot/Extensions/UserDBRepository.cs +++ b/DiscordBot/Extensions/UserDBRepository.cs @@ -79,7 +79,7 @@ public interface IServerUserRepo [Sql($"UPDATE {UserProps.TableName} SET {UserProps.Level} = @level WHERE {UserProps.UserID} = @userId")] Task UpdateLevel(string userId, int level); [Sql($"UPDATE {UserProps.TableName} SET {UserProps.DefaultCity} = @city WHERE {UserProps.UserID} = @userId")] - Task UpdateDefaultCity(string userId, string city); + Task UpdateDefaultCity(string userId, string? city); #endregion // Update Values diff --git a/DiscordBot/Modules/Casino/CasinoSlashModule.cs b/DiscordBot/Modules/Casino/CasinoSlashModule.cs index 480c7cff..72f8fa61 100644 --- a/DiscordBot/Modules/Casino/CasinoSlashModule.cs +++ b/DiscordBot/Modules/Casino/CasinoSlashModule.cs @@ -215,7 +215,7 @@ public async Task TokenHistoryAdmin( await DisplayTransactionHistory(userId: null, page: 1, targetUser: targetUser, isInitialCall: true); } - private async Task DisplayTransactionHistory(string userId = null, int page = 1, SocketGuildUser? targetUser = null, bool isInitialCall = false) + private async Task DisplayTransactionHistory(string? userId = null, int page = 1, SocketGuildUser? targetUser = null, bool isInitialCall = false) { try { diff --git a/DiscordBot/Modules/CodeTipModule.cs b/DiscordBot/Modules/CodeTipModule.cs index 1d4c5f5f..8e01e3da 100644 --- a/DiscordBot/Modules/CodeTipModule.cs +++ b/DiscordBot/Modules/CodeTipModule.cs @@ -11,7 +11,7 @@ public class CodeTipModule : ModuleBase [Command("CodeTip"), Priority(20)] [Summary("Show code formatting example. Syntax: !codetip userToPing(optional)")] [Alias("codetips")] - public async Task CodeTip(IUser user = null) + public async Task CodeTip(IUser? user = null) { var message = user != null ? user.Mention + ", " : ""; message += "When posting code, format it like so:" + Environment.NewLine; diff --git a/DiscordBot/Modules/EmbedModule.cs b/DiscordBot/Modules/EmbedModule.cs index d94b0b2c..5708be0d 100644 --- a/DiscordBot/Modules/EmbedModule.cs +++ b/DiscordBot/Modules/EmbedModule.cs @@ -15,7 +15,7 @@ public class EmbedModule : ModuleBase /// [RequireAdmin] [Command("embed"), Summary("Generate an embed.")] - public async Task EmbedCommand(IMessageChannel channel = null, ulong messageId = 0) + public async Task EmbedCommand(IMessageChannel? channel = null, ulong messageId = 0) { await Context.Message.DeleteAsync(); channel ??= Context.Channel; @@ -32,7 +32,7 @@ public async Task EmbedCommand(IMessageChannel channel = null, ulong messageId = } [Command("embed"), Summary("Generate an embed from an URL (hastebin).")] - public async Task EmbedCommand(string url, IMessageChannel channel = null, ulong messageId = 0) + public async Task EmbedCommand(string url, IMessageChannel? channel = null, ulong messageId = 0) { await Context.Message.DeleteAsync(); Discord.Embed builtEmbed = await TryGetEmbedFromUrl(url); diff --git a/DiscordBot/Modules/QuoteModule.cs b/DiscordBot/Modules/QuoteModule.cs index d5fdd340..504ba67a 100644 --- a/DiscordBot/Modules/QuoteModule.cs +++ b/DiscordBot/Modules/QuoteModule.cs @@ -32,7 +32,7 @@ public async Task QuoteMessageCommand(ulong messageId, ulong channel) [Command("Quote"), HideFromHelp] [Summary("Quote a message. Syntax : !quote messageid (#channel)")] - public async Task QuoteMessage(ulong messageId, IMessageChannel channel = null) + public async Task QuoteMessage(ulong messageId, IMessageChannel? channel = null) { channel ??= Context.Channel; var message = await channel.GetMessageAsync(messageId); diff --git a/DiscordBot/Modules/Weather/WeatherModule.cs b/DiscordBot/Modules/Weather/WeatherModule.cs index d9736c99..9e5b0834 100644 --- a/DiscordBot/Modules/Weather/WeatherModule.cs +++ b/DiscordBot/Modules/Weather/WeatherModule.cs @@ -54,7 +54,7 @@ public async Task WeatherHelp() [Command("Temperature"), HideFromHelp] [Summary("Attempts to provide the temperature of the user provided.")] [Alias("temp"), Priority(20)] - public async Task Temperature(IUser user = null) + public async Task Temperature(IUser? user = null) { user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) @@ -134,7 +134,7 @@ public async Task Temperature(params string[] city) [Command("Weather"), HideFromHelp, Priority(20)] [Summary("Attempts to provide the weather of the user provided.")] - public async Task CurentWeather(IUser user = null) + public async Task CurentWeather(IUser? user = null) { user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) @@ -211,7 +211,7 @@ public async Task CurentWeather(params string[] city) [Command("Pollution"), HideFromHelp, Priority(21)] [Summary("Attempts to provide the pollution conditions of the user provided.")] - public async Task Pollution(IUser user = null) + public async Task Pollution(IUser? user = null) { user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) @@ -259,7 +259,7 @@ public async Task Pollution(params string[] city) [Command("Time"), HideFromHelp, Priority(22)] [Summary("Attempts to provide the time of the user provided.")] - public async Task Time(IUser user = null) + public async Task Time(IUser? user = null) { user ??= Context.User; if (!await DoesUserHaveDefaultCity(user)) diff --git a/DiscordBot/Services/CommandHandlingService.cs b/DiscordBot/Services/CommandHandlingService.cs index 2b514f7a..c8b71a0b 100644 --- a/DiscordBot/Services/CommandHandlingService.cs +++ b/DiscordBot/Services/CommandHandlingService.cs @@ -285,7 +285,7 @@ private async Task HandleInteraction(SocketInteraction arg) } } - public void AddToCommandHistory(SocketUserMessage message, string error = default) + public void AddToCommandHistory(SocketUserMessage message, string? error = default) { _commandHistory.Add(new CommandHistoryInfo() { diff --git a/DiscordBot/Services/LoggingService.cs b/DiscordBot/Services/LoggingService.cs index a21a14b5..1ad7dfd8 100644 --- a/DiscordBot/Services/LoggingService.cs +++ b/DiscordBot/Services/LoggingService.cs @@ -110,7 +110,7 @@ public LoggingService(DiscordSocketClient client, BotSettings settings) } } - public async Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) + public async Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed? embed = null) { if (behaviour.HasFlag(LogBehaviour.Console)) LogToConsole(message, severity); @@ -122,7 +122,7 @@ public async Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverit await LogToFile(message, severity); } - public async Task LogToChannel(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) + public async Task LogToChannel(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed? embed = null) { if (_logChannel == null) return; @@ -267,19 +267,19 @@ public interface ILoggingService /// Message /// Info, Error, Warn, etc (Included in File and Console logging) /// Embed, only used by Channel Logging - Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null); + Task Log(LogBehaviour behaviour, string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed? embed = null); /// /// 'Short hand' for logging to all CURRENT supported behaviours, console, channel and file. /// Same as calling `Log(LogBehaviour.ConsoleChannelAndFile, message, severity, embed);` /// - Task LogAction(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) => + Task LogAction(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed? embed = null) => Log(LogBehaviour.ConsoleChannelAndFile, message, severity, embed); /// /// 'Short hand' for logging to channel and file. /// Same as calling `Log(LogBehaviour.ChannelAndFile, message, severity, embed);` /// - Task LogChannelAndFile(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed embed = null) => + Task LogChannelAndFile(string message, ExtendedLogSeverity severity = ExtendedLogSeverity.Info, Embed? embed = null) => Log(LogBehaviour.ChannelAndFile, message, severity, embed); } \ No newline at end of file diff --git a/DiscordBot/Services/Tips/TipService.cs b/DiscordBot/Services/Tips/TipService.cs index ae35ee22..3b459001 100644 --- a/DiscordBot/Services/Tips/TipService.cs +++ b/DiscordBot/Services/Tips/TipService.cs @@ -25,7 +25,7 @@ public class TipService private bool _isRunning = false; private bool _readOnly = false; - private Regex keywordPattern = null; + private Regex? keywordPattern = null; public TipService(BotSettings settings, ILoggingService loggingService, IHttpClientFactory httpClientFactory) { diff --git a/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs b/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs index 021aabad..35ff5d8f 100644 --- a/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs +++ b/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs @@ -13,7 +13,7 @@ public class ThreadContainer public ulong BotsLastMessage { get; set; } - public CancellationTokenSource CancellationToken { get; set; } = null!; + public CancellationTokenSource? CancellationToken { get; set; } public DateTime ExpectedShutdownTime { get; set; } /// diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index 798302e7..62e05005 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -496,7 +496,7 @@ public async Task OnUserRequestChannelClose(IUser user, SocketThreadChan #region Bulk Behaviour Handler - private async Task CloseThreadInTime(ThreadContainer thread, string message, int minutes, Embed embed = null) + private async Task CloseThreadInTime(ThreadContainer thread, string message, int minutes, Embed? embed = null) { await Task.Delay(TimeSpan.FromMinutes(minutes)); if (thread.HasInteraction) diff --git a/DiscordBot/Services/WelcomeService.cs b/DiscordBot/Services/WelcomeService.cs index d93323f9..092522ae 100644 --- a/DiscordBot/Services/WelcomeService.cs +++ b/DiscordBot/Services/WelcomeService.cs @@ -181,7 +181,7 @@ private async Task DelayedWelcomeService() } } - private async Task ProcessWelcomeUser(ulong userID, IUser user = null) + private async Task ProcessWelcomeUser(ulong userID, IUser? user = null) { if (_welcomeNoticeUsers.Exists(u => u.id == userID)) // If we didn't get the user passed in, we try grab it diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index 13f65150..f2f62870 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -24,7 +24,7 @@ public class BotSettings #region Fun Commands - public string UserModuleSlapObjectsTable { get; set; } = null; + public string? UserModuleSlapObjectsTable { get; set; } = null; //NOTE: Deserializer will not override a List from the json if a default one is made here. public List UserModuleSlapChoices { get; set; } = []; // = { "trout", "duck", "truck", "paddle", "magikarp", "sausage", "student loan", From c80014d31eb0e218fa597039191a96c1ad596fa5 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 04:06:24 +0200 Subject: [PATCH 27/48] fix(nullable): resolve CS8600, CS8601, CS8619, CS0108, CS0219, CS8629, CS8765 warnings - CS8600: make variables nullable where null conversion detected (25 fixes) - CS8601: make fields/variables nullable for possible null assignment (5 fixes) - CS8619: fix nullability mismatch in tuple type annotations (5 fixes) - CS0108: add 'new' keyword for member hiding (1 fix) - CS0219: remove unused variable (1 fix) - CS8629: add null handling for nullable value types (1 fix) - CS8765: match override parameter nullability to base type (1 fix) 27 files changed, 44 warnings resolved --- DiscordBot/Attributes/BotCommandChannelAttribute.cs | 2 +- DiscordBot/Data/FuzzTable.cs | 2 +- .../Discord/RockPaperScissorsDiscordGameSession.cs | 4 ++-- DiscordBot/Modules/BirthdayModule.cs | 2 +- DiscordBot/Modules/EmbedModule.cs | 2 +- DiscordBot/Modules/RulesModule.cs | 2 +- DiscordBot/Modules/ServerSlashModule.cs | 2 +- DiscordBot/Modules/TipModule.cs | 6 +++--- DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs | 2 +- DiscordBot/Modules/Weather/WeatherModule.cs | 10 +++++----- DiscordBot/Services/AuditLogService.cs | 2 +- DiscordBot/Services/Casino/TransactionFormatter.cs | 4 ++-- DiscordBot/Services/CommandHandlingService.cs | 6 +++--- DiscordBot/Services/DatabaseService.cs | 2 +- DiscordBot/Services/FeedService.cs | 2 +- DiscordBot/Services/LoggingService.cs | 2 +- DiscordBot/Services/ProfileCardService.cs | 2 +- DiscordBot/Services/Recruitment/RecruitService.cs | 2 +- DiscordBot/Services/ReminderService.cs | 2 +- DiscordBot/Services/SearchService.cs | 2 +- DiscordBot/Services/Tips/TipService.cs | 2 +- DiscordBot/Services/UnityHelp/UnityHelpService.cs | 7 +------ DiscordBot/Services/UpdateService.cs | 8 ++++---- DiscordBot/Services/WeatherService.cs | 2 +- DiscordBot/Skin/CustomTextSkinModule.cs | 2 +- DiscordBot/Skin/SkinModuleJsonConverter.cs | 4 ++-- DiscordBot/Utils/WebUtil.cs | 2 +- 27 files changed, 41 insertions(+), 46 deletions(-) diff --git a/DiscordBot/Attributes/BotCommandChannelAttribute.cs b/DiscordBot/Attributes/BotCommandChannelAttribute.cs index d8eada5d..cc2b525a 100644 --- a/DiscordBot/Attributes/BotCommandChannelAttribute.cs +++ b/DiscordBot/Attributes/BotCommandChannelAttribute.cs @@ -16,7 +16,7 @@ public override async Task CheckPermissionsAsync(ICommandCon return await Task.FromResult(PreconditionResult.FromSuccess()); } - Task task = context.Message.DeleteAfterSeconds(seconds: 10); + _ = context.Message.DeleteAfterSeconds(seconds: 10); return await Task.FromResult(PreconditionResult.FromError($"This command can only be used in <#{settings.BotCommandsChannel.Id.ToString()}>.")); } } \ No newline at end of file diff --git a/DiscordBot/Data/FuzzTable.cs b/DiscordBot/Data/FuzzTable.cs index 9820ba12..de3be2d7 100644 --- a/DiscordBot/Data/FuzzTable.cs +++ b/DiscordBot/Data/FuzzTable.cs @@ -121,7 +121,7 @@ public static string Evaluate(string fuzz) RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled, timeout); - string before = null; + string? before = null; while (fuzz != before) { before = fuzz; diff --git a/DiscordBot/Domain/Casino/Discord/RockPaperScissorsDiscordGameSession.cs b/DiscordBot/Domain/Casino/Discord/RockPaperScissorsDiscordGameSession.cs index 91900739..0882d2d7 100644 --- a/DiscordBot/Domain/Casino/Discord/RockPaperScissorsDiscordGameSession.cs +++ b/DiscordBot/Domain/Casino/Discord/RockPaperScissorsDiscordGameSession.cs @@ -7,7 +7,7 @@ public RockPaperScissorsDiscordGameSession(RockPaperScissors game, int maxSeats, : base(game, maxSeats, client, user, guild) { } - private string GetCurrentPlayerName() + private new string GetCurrentPlayerName() { if (Game.CurrentPlayer == null) return "All players have chosen"; return GetPlayerName((DiscordGamePlayer)Game.CurrentPlayer); @@ -26,7 +26,7 @@ private string GenerateGameDescription() if (Game.State == GameState.Finished) { var choice = Game.GameData[p].Choice; - playerHand = $"{RockPaperScissors.GetChoiceEmoji(choice.Value)} {choice.Value}"; + playerHand = $"{RockPaperScissors.GetChoiceEmoji(choice!.Value)} {choice!.Value}"; } description += GeneratePlayerHandDescription(p, playerHand, ""); } diff --git a/DiscordBot/Modules/BirthdayModule.cs b/DiscordBot/Modules/BirthdayModule.cs index b389de0a..bb9b25cc 100644 --- a/DiscordBot/Modules/BirthdayModule.cs +++ b/DiscordBot/Modules/BirthdayModule.cs @@ -33,7 +33,7 @@ public async Task Birthday(IUser user) var birthdate = default(DateTime); - HtmlAgilityPack.HtmlNode matchedNode = null; + HtmlAgilityPack.HtmlNode? matchedNode = null; var matchedLength = int.MaxValue; foreach (var row in relevantNodes) diff --git a/DiscordBot/Modules/EmbedModule.cs b/DiscordBot/Modules/EmbedModule.cs index 5708be0d..194dc675 100644 --- a/DiscordBot/Modules/EmbedModule.cs +++ b/DiscordBot/Modules/EmbedModule.cs @@ -35,7 +35,7 @@ public async Task EmbedCommand(IMessageChannel? channel = null, ulong messageId public async Task EmbedCommand(string url, IMessageChannel? channel = null, ulong messageId = 0) { await Context.Message.DeleteAsync(); - Discord.Embed builtEmbed = await TryGetEmbedFromUrl(url); + Discord.Embed? builtEmbed = await TryGetEmbedFromUrl(url); if (builtEmbed != null) await SendEmbedToChannel(builtEmbed, channel, messageId); } diff --git a/DiscordBot/Modules/RulesModule.cs b/DiscordBot/Modules/RulesModule.cs index d6386b45..98073a36 100644 --- a/DiscordBot/Modules/RulesModule.cs +++ b/DiscordBot/Modules/RulesModule.cs @@ -100,7 +100,7 @@ public async Task SearchFaqs(params string[] queries) else if (queries.Length > 0 && !(queries.Length == 1 && queries[0].Equals("list"))) { var minimumScore = double.MaxValue; - FaqData mostSimilarFaq = null; + FaqData? mostSimilarFaq = null; var query = string.Join(" ", queries); foreach (var faq in faqDataList) diff --git a/DiscordBot/Modules/ServerSlashModule.cs b/DiscordBot/Modules/ServerSlashModule.cs index 797659f6..078715b7 100644 --- a/DiscordBot/Modules/ServerSlashModule.cs +++ b/DiscordBot/Modules/ServerSlashModule.cs @@ -57,7 +57,7 @@ await Context.Interaction.ModifyOriginalResponseAsync(msg => embedBuilder.Title = "User Module Commands"; embedBuilder.Color = Color.LighterGrey; - List helpMessages = null; + List? helpMessages = null; if (search == string.Empty) { helpMessages = CommandHandlingService.GetCommandListMessages("UserModule", false, true, false); diff --git a/DiscordBot/Modules/TipModule.cs b/DiscordBot/Modules/TipModule.cs index a7dea97b..9383d780 100644 --- a/DiscordBot/Modules/TipModule.cs +++ b/DiscordBot/Modules/TipModule.cs @@ -104,7 +104,7 @@ public async Task AddTip(string keywords, string content = "") [RequireModerator] public async Task RemoveTip(ulong tipId) { - Tip tip = TipService.GetTip(tipId); + Tip? tip = TipService.GetTip(tipId); if (tip == null) { await Context.Channel.SendMessageAsync("No such tip found to be removed.").DeleteAfterSeconds(5); @@ -119,7 +119,7 @@ public async Task RemoveTip(ulong tipId) [RequireModerator] public async Task ReplaceTip(ulong tipId, string content = "") { - Tip tip = TipService.GetTip(tipId); + Tip? tip = TipService.GetTip(tipId); if (tip == null) { await Context.Channel.SendMessageAsync("No such tip found to be replaced.").DeleteAfterSeconds(5); @@ -151,7 +151,7 @@ public async Task ListTips(params string[] keywords) int floodCount = 20; - List tips = null; + List? tips = null; if (keywords?.Length > 0) { var terms = string.Join(",", keywords); diff --git a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs b/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs index 083fd463..91296c76 100644 --- a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs +++ b/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs @@ -31,7 +31,7 @@ public async Task RespondWithErrorDocumentation(string error) "https://docs.microsoft.com/en-us/dotnet/csharp/misc/" }; - HtmlDocument errorPage = null; + HtmlDocument? errorPage = null; string usedUrl = string.Empty; foreach (var url in urls) diff --git a/DiscordBot/Modules/Weather/WeatherModule.cs b/DiscordBot/Modules/Weather/WeatherModule.cs index 9e5b0834..f76b280a 100644 --- a/DiscordBot/Modules/Weather/WeatherModule.cs +++ b/DiscordBot/Modules/Weather/WeatherModule.cs @@ -38,7 +38,7 @@ public async Task WeatherHelp() private async Task TemperatureEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: city); + WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); if (!await IsResultsValid(res)) return null; @@ -87,7 +87,7 @@ public async Task Temperature(params string[] city) private async Task WeatherEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: city); + WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); if (!await IsResultsValid(res)) return null; @@ -166,12 +166,12 @@ public async Task CurentWeather(params string[] city) private async Task PollutionEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: city); + WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); if (!await IsResultsValid(res)) return null; // We can't really combine the call as having WeatherResults helps with other details - PollutionContainer.Result polResult = + PollutionContainer.Result? polResult = await WeatherService.GetPollution(Math.Round(res.coord.Lon, 4), Math.Round(res.coord.Lat, 4)); @@ -243,7 +243,7 @@ public async Task Pollution(params string[] city) private async Task TimeEmbed(string city, string replaceCityWith = "") { - WeatherContainer.Result res = await WeatherService.GetWeather(city: city); + WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); if (!await IsResultsValid(res)) return null; diff --git a/DiscordBot/Services/AuditLogService.cs b/DiscordBot/Services/AuditLogService.cs index 4069d00c..3a85fef0 100644 --- a/DiscordBot/Services/AuditLogService.cs +++ b/DiscordBot/Services/AuditLogService.cs @@ -12,7 +12,7 @@ public class AuditLogService private static readonly Color DeletedMessageColor = new(200, 128, 128); private static readonly Color EditedMessageColor = new(255, 255, 128); - private readonly IMessageChannel _botAnnouncementChannel = null!; + private readonly IMessageChannel? _botAnnouncementChannel = null!; public AuditLogService(DiscordSocketClient client, BotSettings settings, ILoggingService loggingService) { diff --git a/DiscordBot/Services/Casino/TransactionFormatter.cs b/DiscordBot/Services/Casino/TransactionFormatter.cs index fcb6470f..254baeab 100644 --- a/DiscordBot/Services/Casino/TransactionFormatter.cs +++ b/DiscordBot/Services/Casino/TransactionFormatter.cs @@ -31,7 +31,7 @@ public class TransactionFormatter private static (string emoji, string title, string description) FormatGift( TokenTransaction transaction, SocketGuild guild) { - SocketGuildUser user = null; + SocketGuildUser? user = null; var userId = transaction.Details?.GetValueOrDefault(transaction.Amount >= 0 ? "from" : "to"); if (userId != null) user = guild.GetUser(ulong.Parse(userId)); @@ -57,7 +57,7 @@ private static (string emoji, string title, string description) FormatAdmin( { var adminId = transaction.Details?.GetValueOrDefault("admin"); var action = transaction.Details?.GetValueOrDefault("action"); - SocketGuildUser admin = null; + SocketGuildUser? admin = null; if (adminId != null) admin = guild.GetUser(ulong.Parse(adminId)); string title = action switch diff --git a/DiscordBot/Services/CommandHandlingService.cs b/DiscordBot/Services/CommandHandlingService.cs index c8b71a0b..57087cce 100644 --- a/DiscordBot/Services/CommandHandlingService.cs +++ b/DiscordBot/Services/CommandHandlingService.cs @@ -18,7 +18,7 @@ public class CommandHistoryInfo public ulong UserId { get; set; } public string Channel { get; set; } = null!; public DateTime Time { get; set; } - public string Error { get; set; } = string.Empty; + public string? Error { get; set; } = string.Empty; } public class CommandHandlingService @@ -119,7 +119,7 @@ ILoggingService loggingService public List GetCommandListMessages(string moduleName, bool orderByName = false, bool includeArgs = true, bool includeModuleName = true) { var tupleKey = (moduleName, orderByName, includeArgs, includeModuleName); - if (!_commandListMessages.TryGetValue(tupleKey, out List commandResults)) + if (!_commandListMessages.TryGetValue(tupleKey, out List? commandResults)) { GenerateCommandListOutputs(tupleKey); commandResults = _commandListMessages[tupleKey]; @@ -133,7 +133,7 @@ public List GetCommandListMessages(string moduleName, bool orderByName = public string GetCommandList(string moduleName, bool orderByName = false, bool includeArgs = true, bool includeModuleName = true) { var tupleKey = (moduleName, orderByName, includeArgs, includeModuleName); - if (!_commandList.TryGetValue(tupleKey, out string commandResults)) + if (!_commandList.TryGetValue(tupleKey, out string? commandResults)) { GenerateCommandListOutputs(tupleKey); commandResults = _commandList[tupleKey]; diff --git a/DiscordBot/Services/DatabaseService.cs b/DiscordBot/Services/DatabaseService.cs index 00edae01..7d14f3b4 100644 --- a/DiscordBot/Services/DatabaseService.cs +++ b/DiscordBot/Services/DatabaseService.cs @@ -53,7 +53,7 @@ public DatabaseService(ILoggingService logging, BotSettings settings) ConnectionString = settings.DbConnectionString; _logging = logging; - DbConnection c = null; + DbConnection? c = null; try { c = new NpgsqlConnection(ConnectionString); diff --git a/DiscordBot/Services/FeedService.cs b/DiscordBot/Services/FeedService.cs index 917b670e..71761b08 100644 --- a/DiscordBot/Services/FeedService.cs +++ b/DiscordBot/Services/FeedService.cs @@ -72,7 +72,7 @@ public FeedService(DiscordSocketClient client, BotSettings settings, ILoggingSer private async Task GetFeedData(string url) { - SyndicationFeed feed = null; + SyndicationFeed? feed = null; try { var content = await Utils.WebUtil.GetXMLContent(url); diff --git a/DiscordBot/Services/LoggingService.cs b/DiscordBot/Services/LoggingService.cs index 1ad7dfd8..12b5bd93 100644 --- a/DiscordBot/Services/LoggingService.cs +++ b/DiscordBot/Services/LoggingService.cs @@ -67,7 +67,7 @@ public class LoggingService : ILoggingService { private const string ServiceName = "LoggingService"; - private readonly ISocketMessageChannel _logChannel = null!; + private readonly ISocketMessageChannel? _logChannel; // Configuration private const long MaxLogSize = 1024 * 1024 * 2; // 2MB diff --git a/DiscordBot/Services/ProfileCardService.cs b/DiscordBot/Services/ProfileCardService.cs index b80b83db..9320f307 100644 --- a/DiscordBot/Services/ProfileCardService.cs +++ b/DiscordBot/Services/ProfileCardService.cs @@ -58,7 +58,7 @@ public async Task GenerateProfileCard(IUser user) var percentage = (float)xpShown / maxXpShown; var u = (IGuildUser)user; - IRole mainRole = null; + IRole? mainRole = null; foreach (var id in u.RoleIds) { var role = u.Guild.GetRole(id); diff --git a/DiscordBot/Services/Recruitment/RecruitService.cs b/DiscordBot/Services/Recruitment/RecruitService.cs index 08931805..f749c62f 100644 --- a/DiscordBot/Services/Recruitment/RecruitService.cs +++ b/DiscordBot/Services/Recruitment/RecruitService.cs @@ -19,7 +19,7 @@ public class RecruitService private readonly ForumTag _tagUnpaidCollab; private readonly ForumTag _tagPosFilled; - private readonly IForumChannel _recruitChannel = null!; + private readonly IForumChannel? _recruitChannel; #endregion // Extra Details diff --git a/DiscordBot/Services/ReminderService.cs b/DiscordBot/Services/ReminderService.cs index e2b26361..8b3a64f8 100644 --- a/DiscordBot/Services/ReminderService.cs +++ b/DiscordBot/Services/ReminderService.cs @@ -137,7 +137,7 @@ private async Task CheckReminders() { _reminders.Remove(reminder); - IUserMessage message = null; + IUserMessage? message = null; var channel = _client.GetChannel(reminder.ChannelId) as SocketTextChannel; if (channel != null) message = await channel.GetMessageAsync(reminder.MessageId) as IUserMessage; diff --git a/DiscordBot/Services/SearchService.cs b/DiscordBot/Services/SearchService.cs index e66e1c78..c21ab5bb 100644 --- a/DiscordBot/Services/SearchService.cs +++ b/DiscordBot/Services/SearchService.cs @@ -46,7 +46,7 @@ public List SearchDuckDuckGo(string query, uint maxResults = 3, st public DocSearchResult? FindBestMatch(string query, string[][] database, string baseUrl) { var minimumScore = double.MaxValue; - string[] mostSimilarPage = null; + string[]? mostSimilarPage = null; foreach (var p in database) { diff --git a/DiscordBot/Services/Tips/TipService.cs b/DiscordBot/Services/Tips/TipService.cs index 3b459001..db731843 100644 --- a/DiscordBot/Services/Tips/TipService.cs +++ b/DiscordBot/Services/Tips/TipService.cs @@ -271,7 +271,7 @@ public async Task ReloadTipDatabase() if (File.Exists(jsonPath)) { var json = File.ReadAllText(jsonPath); - _tips = JsonConvert.DeserializeObject>>(json); + _tips = JsonConvert.DeserializeObject>>(json)!; _loggingService.LogAction( $"[{ServiceName}] Tip index has {_tips.Count} keywords.", ExtendedLogSeverity.Info); diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index 62e05005..1ae1ad34 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -91,7 +91,7 @@ public UnityHelpService(DiscordSocketClient client, BotSettings settings, ILoggi } // get the help channel settings.GenericHelpChannel - _helpChannel = _client.GetChannel(settings.GenericHelpChannel.Id) as IForumChannel; + _helpChannel = (_client.GetChannel(settings.GenericHelpChannel.Id) as IForumChannel)!; if (_helpChannel == null) { LoggingService.LogToConsole($"[{ServiceName}] Help channel not found", LogSeverity.Error); @@ -171,8 +171,6 @@ private async Task OnThreadCreated(SocketThreadChannel thread) }; _activeThreads.Add(thread.Id, container); - bool warnHelpTitle = false; - // Check message length and inform user if too short var firstMessage = (await thread.GetMessagesAsync(1).FlattenAsync()).FirstOrDefault(); container.FirstUserMessage = firstMessage!.Id; @@ -190,9 +188,6 @@ private async Task OnThreadCreated(SocketThreadChannel thread) } await thread.ModifyAsync(x => x.Name = threadTitle.ToCapitalizeFirstLetter()); - if (thread.Name.Contains(" help", StringComparison.CurrentCultureIgnoreCase)) - warnHelpTitle = true; - // If not tags attached, let them know they should add some if (thread.AppliedTags.Count == 0) { diff --git a/DiscordBot/Services/UpdateService.cs b/DiscordBot/Services/UpdateService.cs index db8c28f1..3ad301de 100644 --- a/DiscordBot/Services/UpdateService.cs +++ b/DiscordBot/Services/UpdateService.cs @@ -67,7 +67,7 @@ public UpdateService(DiscordSocketClient client, { _client = client; _feedService = feedService; - _loggingService = loggingService as LoggingService; + _loggingService = (loggingService as LoggingService)!; _settings = settings; _token = cts.Token; @@ -132,9 +132,9 @@ private async Task LoadDocDatabase() File.Exists($"{_settings.ServerRootPath}/unityapi.json")) { var json = await File.ReadAllTextAsync($"{_settings.ServerRootPath}/unitymanual.json", _token); - _manualDatabase = JsonConvert.DeserializeObject(json); + _manualDatabase = JsonConvert.DeserializeObject(json)!; json = await File.ReadAllTextAsync($"{_settings.ServerRootPath}/unityapi.json", _token); - _apiDatabase = JsonConvert.DeserializeObject(json); + _apiDatabase = JsonConvert.DeserializeObject(json)!; } else await DownloadDocDatabase(); @@ -220,7 +220,7 @@ private async Task UpdateRssFeeds() catch (OperationCanceledException) { } } - public async Task<(string name, string extract, string url)> DownloadWikipediaArticle(string searchQuery) + public async Task<(string? name, string? extract, string? url)> DownloadWikipediaArticle(string searchQuery) { var wikiSearchUri = Uri.EscapeUriString(_settings.WikipediaSearchPage + searchQuery); var htmlWeb = new HtmlWeb { CaptureRedirect = true }; diff --git a/DiscordBot/Services/WeatherService.cs b/DiscordBot/Services/WeatherService.cs index 2eeff6b0..d82e2894 100644 --- a/DiscordBot/Services/WeatherService.cs +++ b/DiscordBot/Services/WeatherService.cs @@ -38,7 +38,7 @@ public WeatherService(DiscordSocketClient client, ILoggingService loggingService return await SerializeUtil.LoadUrlDeserializeResult(query); } - public async Task<(bool exists, WeatherContainer.Result result)> CityExists(string city) + public async Task<(bool exists, WeatherContainer.Result? result)> CityExists(string city) { var res = await GetWeather(city: city); var exists = !object.Equals(res, default(WeatherContainer.Result)); diff --git a/DiscordBot/Skin/CustomTextSkinModule.cs b/DiscordBot/Skin/CustomTextSkinModule.cs index a66b2178..ae76fc6f 100644 --- a/DiscordBot/Skin/CustomTextSkinModule.cs +++ b/DiscordBot/Skin/CustomTextSkinModule.cs @@ -26,7 +26,7 @@ public override Drawables GetDrawables(ProfileData data) { var prop = typeof(ProfileData).GetProperty(match.ToString()); if (prop == null) continue; - var value = (dynamic)prop.GetValue(data, null); + var value = (dynamic?)prop.GetValue(data, null); Text = Text.Replace("{" + match + "}", value.ToString()); } /* ALL properties of ProfileData.cs can be used! diff --git a/DiscordBot/Skin/SkinModuleJsonConverter.cs b/DiscordBot/Skin/SkinModuleJsonConverter.cs index fed7d353..6a8dfb3f 100644 --- a/DiscordBot/Skin/SkinModuleJsonConverter.cs +++ b/DiscordBot/Skin/SkinModuleJsonConverter.cs @@ -16,7 +16,7 @@ public class SkinModuleJsonConverter : JsonConverter try { var t = $"DiscordBot.Skin.{jo["Type"].Value()}SkinModule"; - type = Type.GetType(t); + type = Type.GetType(t)!; return jo.ToObject(type); } catch (Exception e) @@ -26,7 +26,7 @@ public class SkinModuleJsonConverter : JsonConverter } } - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { throw new NotImplementedException(); } diff --git a/DiscordBot/Utils/WebUtil.cs b/DiscordBot/Utils/WebUtil.cs index 9c1f37f3..4208c7a8 100644 --- a/DiscordBot/Utils/WebUtil.cs +++ b/DiscordBot/Utils/WebUtil.cs @@ -134,7 +134,7 @@ public static async Task GetXMLContent(string url) /// /// Returns a deserialized object from a JSON string, or null if the string is empty or can't be deserialized. /// - public static async Task<(bool success, T result)> TryGetObjectFromJson(string url) + public static async Task<(bool success, T? result)> TryGetObjectFromJson(string url) { try { From e93ab2b503a5723f22dffb9a647a5e32a91609bf Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 04:13:58 +0200 Subject: [PATCH 28/48] fix(nullable): resolve all CS8604 warnings (possible null argument) - Make parameters nullable where methods already handle null - Add null-forgiving operator where values are contextually guaranteed non-null - Add null-coalescing fallback in EmbedModule 12 files changed, 44 warnings resolved --- DiscordBot/Modules/EmbedModule.cs | 1 + DiscordBot/Modules/FunModule.cs | 2 +- DiscordBot/Modules/RulesModule.cs | 2 +- DiscordBot/Modules/SearchModule.cs | 6 +++--- DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs | 2 +- DiscordBot/Modules/UnityHelp/UnityHelpModule.cs | 2 +- DiscordBot/Services/BirthdayAnnouncementService.cs | 2 +- DiscordBot/Services/DatabaseService.cs | 2 +- DiscordBot/Services/ReleaseNotesParser.cs | 4 ++-- DiscordBot/Services/UnityHelp/UnityHelpService.cs | 4 ++-- DiscordBot/Skin/CustomTextSkinModule.cs | 2 +- DiscordBot/Skin/SkinModuleJsonConverter.cs | 2 +- 12 files changed, 16 insertions(+), 15 deletions(-) diff --git a/DiscordBot/Modules/EmbedModule.cs b/DiscordBot/Modules/EmbedModule.cs index 194dc675..f73e70ee 100644 --- a/DiscordBot/Modules/EmbedModule.cs +++ b/DiscordBot/Modules/EmbedModule.cs @@ -35,6 +35,7 @@ public async Task EmbedCommand(IMessageChannel? channel = null, ulong messageId public async Task EmbedCommand(string url, IMessageChannel? channel = null, ulong messageId = 0) { await Context.Message.DeleteAsync(); + channel ??= Context.Channel; Discord.Embed? builtEmbed = await TryGetEmbedFromUrl(url); if (builtEmbed != null) await SendEmbedToChannel(builtEmbed, channel, messageId); diff --git a/DiscordBot/Modules/FunModule.cs b/DiscordBot/Modules/FunModule.cs index dce682ee..3b7f6ed5 100644 --- a/DiscordBot/Modules/FunModule.cs +++ b/DiscordBot/Modules/FunModule.cs @@ -23,7 +23,7 @@ public async Task SlapUser(params IUser[] users) try { if (_slapObjects.Count == 0) - _slapObjects.Load(Settings.UserModuleSlapObjectsTable); + _slapObjects.Load(Settings.UserModuleSlapObjectsTable!); } catch (Exception e) { diff --git a/DiscordBot/Modules/RulesModule.cs b/DiscordBot/Modules/RulesModule.cs index 98073a36..d1ab7734 100644 --- a/DiscordBot/Modules/RulesModule.cs +++ b/DiscordBot/Modules/RulesModule.cs @@ -53,7 +53,7 @@ public async Task GlobalRules(int seconds = 60) [Summary("Condensed version of the rules and links to quality resources.")] public async Task ServerWelcome() { - if (!await WelcomeService.DMFormattedWelcome(Context.User as SocketGuildUser)) + if (!await WelcomeService.DMFormattedWelcome((Context.User as SocketGuildUser)!)) { await ReplyAsync("Could not send welcome, your DMs are disabled.").DeleteAfterSeconds(seconds: 2); } diff --git a/DiscordBot/Modules/SearchModule.cs b/DiscordBot/Modules/SearchModule.cs index 0227d09f..ed9a5e2c 100644 --- a/DiscordBot/Modules/SearchModule.cs +++ b/DiscordBot/Modules/SearchModule.cs @@ -58,7 +58,7 @@ public async Task SearchManual(params string[] queries) { var pages = await UpdateService.GetManualDatabase(); var query = string.Join(" ", queries); - var match = SearchService.FindBestMatch(query, pages, "https://docs.unity3d.com/Manual"); + var match = SearchService.FindBestMatch(query, pages!, "https://docs.unity3d.com/Manual"); if (match != null) { @@ -89,7 +89,7 @@ public async Task SearchApi(params string[] queries) { var pages = await UpdateService.GetApiDatabase(); var query = string.Join(" ", queries); - var match = SearchService.FindBestMatch(query, pages, "https://docs.unity3d.com/ScriptReference"); + var match = SearchService.FindBestMatch(query, pages!, "https://docs.unity3d.com/ScriptReference"); if (match != null) { @@ -132,7 +132,7 @@ public async Task SearchWikipedia([Remainder] string query) return; } - await ReplyAsync(embed: GetWikipediaEmbed(article.name, article.extract, article.url)); + await ReplyAsync(embed: GetWikipediaEmbed(article.name!, article.extract!, article.url!)); } private Embed GetWikipediaEmbed(string subject, string articleExtract, string articleUrl) diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs b/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs index 112ce675..db752273 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs +++ b/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs @@ -36,7 +36,7 @@ await Context.Interaction.FollowupAsync( } var response = - await HelpService.OnUserRequestChannelClose(Context.User, Context.Channel as SocketThreadChannel); + await HelpService.OnUserRequestChannelClose(Context.User, (Context.Channel as SocketThreadChannel)!); await Context.Interaction.FollowupAsync(response, ephemeral: true); } diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs b/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs index f4339660..cc177e99 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs +++ b/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs @@ -23,7 +23,7 @@ public async Task ResolveAsync() return; if (!IsValidUser() || !IsInHelpChannel()) await Context.Message.DeleteAsync(); - await HelpService.OnUserRequestChannelClose(Context.User, Context.Channel as SocketThreadChannel); + await HelpService.OnUserRequestChannelClose(Context.User, (Context.Channel as SocketThreadChannel)!); } [Command("pending-questions")] diff --git a/DiscordBot/Services/BirthdayAnnouncementService.cs b/DiscordBot/Services/BirthdayAnnouncementService.cs index 03c4f5ad..9f83b2f7 100644 --- a/DiscordBot/Services/BirthdayAnnouncementService.cs +++ b/DiscordBot/Services/BirthdayAnnouncementService.cs @@ -174,7 +174,7 @@ private async Task> GetTodaysBirthdays() return birthdays; } - private bool TryParseBirthdayDate(string dateString, string yearString, out DateTime birthDate) + private bool TryParseBirthdayDate(string dateString, string? yearString, out DateTime birthDate) { birthDate = default; diff --git a/DiscordBot/Services/DatabaseService.cs b/DiscordBot/Services/DatabaseService.cs index 7d14f3b4..61987bd2 100644 --- a/DiscordBot/Services/DatabaseService.cs +++ b/DiscordBot/Services/DatabaseService.cs @@ -214,7 +214,7 @@ await _logging.LogChannelAndFile( /// Adds a new user to the database if they don't already exist. /// /// Existing or newly created user. Null on database error. - public async Task GetOrAddUser(SocketGuildUser socketUser) + public async Task GetOrAddUser(SocketGuildUser? socketUser) { if (socketUser == null) { diff --git a/DiscordBot/Services/ReleaseNotesParser.cs b/DiscordBot/Services/ReleaseNotesParser.cs index 171c404e..f3216811 100644 --- a/DiscordBot/Services/ReleaseNotesParser.cs +++ b/DiscordBot/Services/ReleaseNotesParser.cs @@ -70,7 +70,7 @@ public List Parse(string summaryHtml) ?.NextSibling; } - private string BuildSection(string title, HtmlNode node, string contents = "", + private string BuildSection(string title, HtmlNode? node, string contents = "", int maxLength = Constants.MaxLengthChannelMessage - MaxFeedLengthBuffer) { if (node == null) @@ -109,7 +109,7 @@ private string BuildSection(string title, HtmlNode node, string contents = "", return summary; } - private static string GetNodeLiCountString(string title, HtmlNode node) + private static string GetNodeLiCountString(string title, HtmlNode? node) { if (node == null) return string.Empty; diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index 1ae1ad34..6ee2a456 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -430,7 +430,7 @@ private async Task GatewayOnMessageUpdated(Cacheable before, So LoggingService.DebugLog($"[{ServiceName}] Help Message Updated: {after.Id} - {after.Content}", LogSeverity.Debug); #pragma warning disable CS4014 - Task.Run(() => OnMessageUpdated(beforeMsg, after, channel as SocketThreadChannel)); + Task.Run(() => OnMessageUpdated(beforeMsg, after, (channel as SocketThreadChannel)!)); #pragma warning restore CS4014 if (after.Reactions.ContainsKey(CloseEmoji)) @@ -724,7 +724,7 @@ private Task IsTaskCancelled(ThreadContainer thread) } // Check if the user is the expected id and return true if so, if not then return false (Special: Moderator will return true) - private bool IsValidAuthorUser(SocketGuildUser user, ulong authorId) + private bool IsValidAuthorUser(SocketGuildUser? user, ulong authorId) { if (user == null || user.IsUserBotOrWebhook()) return false; diff --git a/DiscordBot/Skin/CustomTextSkinModule.cs b/DiscordBot/Skin/CustomTextSkinModule.cs index ae76fc6f..fa04bdbb 100644 --- a/DiscordBot/Skin/CustomTextSkinModule.cs +++ b/DiscordBot/Skin/CustomTextSkinModule.cs @@ -24,7 +24,7 @@ public override Drawables GetDrawables(ProfileData data) var mc = reg.Matches(Text); foreach (var match in mc) { - var prop = typeof(ProfileData).GetProperty(match.ToString()); + var prop = typeof(ProfileData).GetProperty(match.ToString()!); if (prop == null) continue; var value = (dynamic?)prop.GetValue(data, null); Text = Text.Replace("{" + match + "}", value.ToString()); diff --git a/DiscordBot/Skin/SkinModuleJsonConverter.cs b/DiscordBot/Skin/SkinModuleJsonConverter.cs index 6a8dfb3f..7f673af1 100644 --- a/DiscordBot/Skin/SkinModuleJsonConverter.cs +++ b/DiscordBot/Skin/SkinModuleJsonConverter.cs @@ -15,7 +15,7 @@ public class SkinModuleJsonConverter : JsonConverter Type type; try { - var t = $"DiscordBot.Skin.{jo["Type"].Value()}SkinModule"; + var t = $"DiscordBot.Skin.{jo["Type"]!.Value()}SkinModule"; type = Type.GetType(t)!; return jo.ToObject(type); } From fde6bc88ad0ca7c65d336a5b6fe4a6a619ab013f Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 14:54:14 +0200 Subject: [PATCH 29/48] fix(nullable): resolve all CS8602 warnings (possible null dereference) - Add null-conditional ?. operators for nullable member access - Add null checks with early returns for nullable service/API results - Add null-forgiving ! for provably non-null Task? returns from DeleteAfterSeconds/DeleteAfterTime - Add ?? Task.CompletedTask for awaited nullable Task chains - Fix DatabaseService.Query null handling in RankModule 40 files changed, 304 warnings resolved --- DiscordBot/Extensions/TaskExtensions.cs | 4 +- DiscordBot/Modules/AirportModule.cs | 10 +-- DiscordBot/Modules/BirthdayModule.cs | 63 ++++++++++--------- .../Modules/Casino/CasinoSlashModule.cs | 2 +- DiscordBot/Modules/CodeTipModule.cs | 4 +- DiscordBot/Modules/ConvertModule.cs | 4 +- DiscordBot/Modules/EmbedModule.cs | 18 +++--- DiscordBot/Modules/FunModule.cs | 10 +-- DiscordBot/Modules/ProfileModule.cs | 6 +- DiscordBot/Modules/QuoteModule.cs | 10 +-- DiscordBot/Modules/RankModule.cs | 30 ++++++--- DiscordBot/Modules/ReminderModule.cs | 35 +++++------ DiscordBot/Modules/RulesModule.cs | 12 ++-- DiscordBot/Modules/SearchModule.cs | 4 +- DiscordBot/Modules/ServerModule.cs | 6 +- DiscordBot/Modules/TicketModule.cs | 2 +- DiscordBot/Modules/TipModule.cs | 10 +-- .../UnityHelp/CannedInteractiveModule.cs | 2 + .../Modules/UnityHelp/CannedResponseModule.cs | 1 + .../Modules/UnityHelp/GeneralHelpModule.cs | 6 +- .../Modules/UnityHelp/UnityHelpModule.cs | 2 +- DiscordBot/Modules/Weather/WeatherModule.cs | 18 +++--- DiscordBot/Services/AirportService.cs | 2 + DiscordBot/Services/AuditLogService.cs | 4 +- DiscordBot/Services/Casino/CasinoService.cs | 42 ++++++++----- DiscordBot/Services/CodeCheckService.cs | 16 ++--- DiscordBot/Services/CommandHandlingService.cs | 2 +- DiscordBot/Services/CurrencyService.cs | 4 +- DiscordBot/Services/DatabaseService.cs | 22 +++++-- DiscordBot/Services/EmbedParsingService.cs | 1 + DiscordBot/Services/EveryoneScoldService.cs | 4 +- DiscordBot/Services/KarmaService.cs | 18 +++--- DiscordBot/Services/ProfileCardService.cs | 2 + .../Services/Recruitment/RecruitService.cs | 10 ++- .../Services/UnityHelp/UnityHelpService.cs | 10 ++- DiscordBot/Services/UpdateService.cs | 4 +- DiscordBot/Services/UserExtendedService.cs | 12 +++- DiscordBot/Services/XpService.cs | 17 +++-- DiscordBot/Skin/CustomTextSkinModule.cs | 2 +- DiscordBot/Utils/WebUtil.cs | 4 +- 40 files changed, 258 insertions(+), 177 deletions(-) diff --git a/DiscordBot/Extensions/TaskExtensions.cs b/DiscordBot/Extensions/TaskExtensions.cs index 0eede7ad..101bb9cc 100644 --- a/DiscordBot/Extensions/TaskExtensions.cs +++ b/DiscordBot/Extensions/TaskExtensions.cs @@ -33,7 +33,7 @@ public static Task DeleteAfterTimeSpan(this IDeletable message, TimeSpan timeSpa { return Task.Delay(timeSpan).ContinueWith(async _ => { - if (message != null) await message?.DeleteAsync(); + if (message != null) await message.DeleteAsync(); }); } @@ -42,7 +42,7 @@ public static Task DeleteAfterTimeSpan(this IDeletable message, TimeSpan timeSpa public static Task DeleteAfterTimeSpan(this Task task, TimeSpan timeSpan, bool awaitDeletion = false) where T : IDeletable { - var deletion = Task.Run(async () => await (await task)?.DeleteAfterTimeSpan(timeSpan)); + var deletion = Task.Run(async () => await ((await task)?.DeleteAfterTimeSpan(timeSpan) ?? Task.CompletedTask)); return awaitDeletion ? deletion : task; } diff --git a/DiscordBot/Modules/AirportModule.cs b/DiscordBot/Modules/AirportModule.cs index 6d1dc107..98b1993c 100644 --- a/DiscordBot/Modules/AirportModule.cs +++ b/DiscordBot/Modules/AirportModule.cs @@ -43,8 +43,8 @@ public async Task FlyTo(string from, string to) // Make sure command is in Bot-Commands or OffTopic if (Context.Channel.Id != Settings.BotCommandsChannel.Id && Context.Channel.Id != Settings.GeneralChannel.Id) { - await ReplyAsync($"Command can only be used in <#{Settings.BotCommandsChannel.Id}> or <#{Settings.GeneralChannel.Id}>.").DeleteAfterSeconds(5f); - await Context.Message.DeleteAfterSeconds(2f); + await (ReplyAsync($"Command can only be used in <#{Settings.BotCommandsChannel.Id}> or <#{Settings.GeneralChannel.Id}>.").DeleteAfterSeconds(5f) ?? Task.CompletedTask); + await (Context.Message.DeleteAfterSeconds(2f) ?? Task.CompletedTask); return; } @@ -86,7 +86,7 @@ public async Task FlyTo(string from, string to) { embed.Description += "\\nNo flights found, sorry."; await msg.ModifyAsync(x => x.Embed = embed.Build()); - await msg.DeleteAfterSeconds(30f); + await (msg.DeleteAfterSeconds(30f) ?? Task.CompletedTask); return; } @@ -131,7 +131,7 @@ public async Task FlyTo(string from, string to) { embed.Description += $"\n{city} could not be found."; await msg.ModifyAsync(x => x.Embed = embed.Build()); - await msg.DeleteAfterSeconds(10f); + await (msg.DeleteAfterSeconds(10f) ?? Task.CompletedTask); return null; } return cityResult; @@ -144,7 +144,7 @@ public async Task FlyTo(string from, string to) { embed.Description += $"\nAirport near {weather.name} ({weather.sys.country}) could not be found."; await msg.ModifyAsync(x => x.Embed = embed.Build()); - await msg.DeleteAfterSeconds(10f); + await (msg.DeleteAfterSeconds(10f) ?? Task.CompletedTask); return null; } return airportResult; diff --git a/DiscordBot/Modules/BirthdayModule.cs b/DiscordBot/Modules/BirthdayModule.cs index bb9b25cc..f37e30e4 100644 --- a/DiscordBot/Modules/BirthdayModule.cs +++ b/DiscordBot/Modules/BirthdayModule.cs @@ -18,8 +18,8 @@ public async Task Birthday() var tableText = await WebUtil.GetHtmlNodeInnerText(nextBirthday, "/html/body/table/tr[2]/td"); var message = $"**{tableText}**"; - await ReplyAsync(message).DeleteAfterTime(minutes: 3); - await Context.Message.DeleteAfterTime(minutes: 3); + await (ReplyAsync(message).DeleteAfterTime(minutes: 3) ?? Task.CompletedTask); + await (Context.Message.DeleteAfterTime(minutes: 3) ?? Task.CompletedTask); } [Command("Birthday"), Priority(27)] @@ -36,17 +36,21 @@ public async Task Birthday(IUser user) HtmlAgilityPack.HtmlNode? matchedNode = null; var matchedLength = int.MaxValue; - foreach (var row in relevantNodes) + if (relevantNodes != null) { - var nameNode = row.SelectSingleNode("td[2]"); - var name = nameNode.InnerText; + foreach (var row in relevantNodes) + { + var nameNode = row.SelectSingleNode("td[2]"); + if (nameNode == null) continue; + var name = nameNode.InnerText; - if (!name.ToLower().Contains(searchName.ToLower()) || name.Length >= matchedLength) - continue; + if (!name.ToLower().Contains(searchName.ToLower()) || name.Length >= matchedLength) + continue; - matchedNode = row; - matchedLength = name.Length; - if (name.Length == searchName.Length) break; + matchedNode = row; + matchedLength = name.Length; + if (name.Length == searchName.Length) break; + } } if (matchedNode != null) @@ -54,29 +58,32 @@ public async Task Birthday(IUser user) var dateNode = matchedNode.SelectSingleNode("td[1]"); var yearNode = matchedNode.SelectSingleNode("td[3]"); - var provider = CultureInfo.InvariantCulture; - var wrongFormat = "M/d/yyyy"; - - var dateString = dateNode.InnerText; - if (!yearNode.InnerText.Contains(" ")) dateString = dateString + "/" + yearNode.InnerText; - - dateString = dateString.Trim(); - - try - { - birthdate = DateTime.ParseExact(dateString, wrongFormat, provider); - } - catch (FormatException) + if (dateNode != null && yearNode != null) { - birthdate = DateTime.ParseExact(dateString, "M/d", provider); + var provider = CultureInfo.InvariantCulture; + var wrongFormat = "M/d/yyyy"; + + var dateString = dateNode.InnerText; + if (!yearNode.InnerText.Contains(" ")) dateString = dateString + "/" + yearNode.InnerText; + + dateString = dateString.Trim(); + + try + { + birthdate = DateTime.ParseExact(dateString, wrongFormat, provider); + } + catch (FormatException) + { + birthdate = DateTime.ParseExact(dateString, "M/d", provider); + } } } if (birthdate == default) { - await ReplyAsync( + await (ReplyAsync( $"Sorry, I couldn't find **{searchName}**'s birthday date. They can add it at https://docs.google.com/forms/d/e/1FAIpQLSfUglZtJ3pyMwhRk5jApYpvqT3EtKmLBXijCXYNwHY-v-lKxQ/viewform !") - .DeleteAfterSeconds(30); + .DeleteAfterSeconds(30) ?? Task.CompletedTask); } else { @@ -85,9 +92,9 @@ await ReplyAsync( $"**{searchName}**'s birthdate: __**{birthdate.ToString("dd MMMM yyyy", CultureInfo.InvariantCulture)}**__ " + $"({(int)((DateTime.Now - birthdate).TotalDays / 365)}yo)"; - await ReplyAsync(message).DeleteAfterTime(minutes: 3); + await (ReplyAsync(message).DeleteAfterTime(minutes: 3) ?? Task.CompletedTask); } - await Context.Message.DeleteAfterTime(minutes: 3); + await (Context.Message.DeleteAfterTime(minutes: 3) ?? Task.CompletedTask); } } diff --git a/DiscordBot/Modules/Casino/CasinoSlashModule.cs b/DiscordBot/Modules/Casino/CasinoSlashModule.cs index 72f8fa61..6f2a6c93 100644 --- a/DiscordBot/Modules/Casino/CasinoSlashModule.cs +++ b/DiscordBot/Modules/Casino/CasinoSlashModule.cs @@ -236,7 +236,7 @@ private async Task DisplayTransactionHistory(string? userId = null, int page = 1 } if (isAdminRequest) - queryUserId = targetUser.Id.ToString(); + queryUserId = targetUser!.Id.ToString(); } const int transactionsPerPage = 5; diff --git a/DiscordBot/Modules/CodeTipModule.cs b/DiscordBot/Modules/CodeTipModule.cs index 8e01e3da..2c129ea3 100644 --- a/DiscordBot/Modules/CodeTipModule.cs +++ b/DiscordBot/Modules/CodeTipModule.cs @@ -17,7 +17,7 @@ public async Task CodeTip(IUser? user = null) message += "When posting code, format it like so:" + Environment.NewLine; message += CodeCheckService.CodeFormattingExample; await Context.Message.DeleteAsync(); - await ReplyAsync(message).DeleteAfterSeconds(seconds: 60); + await ReplyAsync(message).DeleteAfterSeconds(seconds: 60)!; } [Command("DisableCodeTips"), Priority(91)] @@ -29,7 +29,7 @@ public async Task DisableCodeTips() { CodeCheckService.CodeReminderCooldown.SetPermanent(Context.User.Id, true); var uname = Context.User.GetUserPreferredName(); - await ReplyAsync($"{uname}, you will no longer be reminded about correct code formatting.").DeleteAfterTime(20); + await ReplyAsync($"{uname}, you will no longer be reminded about correct code formatting.").DeleteAfterTime(20)!; } } } diff --git a/DiscordBot/Modules/ConvertModule.cs b/DiscordBot/Modules/ConvertModule.cs index 493dcb57..fada1ba0 100644 --- a/DiscordBot/Modules/ConvertModule.cs +++ b/DiscordBot/Modules/ConvertModule.cs @@ -36,8 +36,8 @@ public async Task Translate(ulong messageId, string language = "en") public async Task Translate(string text, string language = "en") { var msg = await ReplyAsync($"Here: "); - await Context.Message.DeleteAfterSeconds(seconds: 1); - await msg.DeleteAfterSeconds(seconds: 20); + await Context.Message.DeleteAfterSeconds(seconds: 1)!; + await msg.DeleteAfterSeconds(seconds: 20)!; } [Command("CurrencyName"), Priority(29)] diff --git a/DiscordBot/Modules/EmbedModule.cs b/DiscordBot/Modules/EmbedModule.cs index f73e70ee..29cfc343 100644 --- a/DiscordBot/Modules/EmbedModule.cs +++ b/DiscordBot/Modules/EmbedModule.cs @@ -22,7 +22,7 @@ public async Task EmbedCommand(IMessageChannel? channel = null, ulong messageId if (Context.Message.Attachments.Count < 1) { - await ReplyAsync($"{Context.User.Mention}, you must provide a JSON file or a JSON url.").DeleteAfterSeconds(5); + await ReplyAsync($"{Context.User.Mention}, you must provide a JSON file or a JSON url.").DeleteAfterSeconds(5)!; return; } var attachment = Context.Message.Attachments.ElementAt(0); @@ -47,19 +47,19 @@ public async Task EmbedCommand(string url, IMessageChannel? channel = null, ulon && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); if (!result) { - await ReplyAsync($"{Context.User.Mention}, the parameter is not a valid URL.").DeleteAfterSeconds(5); + await ReplyAsync($"{Context.User.Mention}, the parameter is not a valid URL.").DeleteAfterSeconds(5)!; return null; } - if (!EmbedParsingService.IsValidHost(uriResult.Host)) + if (!EmbedParsingService.IsValidHost(uriResult!.Host)) { - await ReplyAsync($"{Context.User.Mention}, supported URLs: [https://hastebin.com, https://pastebin.com, https://gdl.space, https://hastepaste.com, http://pastie.org].").DeleteAfterSeconds(5); + await ReplyAsync($"{Context.User.Mention}, supported URLs: [https://hastebin.com, https://pastebin.com, https://gdl.space, https://hastepaste.com, http://pastie.org].").DeleteAfterSeconds(5)!; return null; } string downloadUrl = EmbedParsingService.GetDownloadUrl(uriResult); var builtEmbed = await EmbedParsingService.BuildEmbedFromUrl(downloadUrl); if (builtEmbed.Length == 0) { - await ReplyAsync("Failed to generate embed from url.").DeleteAfterSeconds(seconds: 10f); + await ReplyAsync("Failed to generate embed from url.").DeleteAfterSeconds(seconds: 10f)!; return null; } return builtEmbed; @@ -108,7 +108,7 @@ private async Task SendEmbedToChannel(Discord.Embed embed, IMessageChannel chann // If no reaction, we assume it was bad and abort if (!confirmedEmbed) { - await ReplyAsync("Reaction not detected, embed aborted.").DeleteAfterSeconds(seconds: 5); + await ReplyAsync("Reaction not detected, embed aborted.").DeleteAfterSeconds(seconds: 5)!; return; } } @@ -118,7 +118,7 @@ private async Task SendEmbedToChannel(Discord.Embed embed, IMessageChannel chann var messageToEdit = await channel.GetMessageAsync(messageId) as IUserMessage; if (messageToEdit == null) { - await ReplyAsync($"Bot doesn't own the message ID ``{messageId}`` used").DeleteAfterSeconds(5); + await ReplyAsync($"Bot doesn't own the message ID ``{messageId}`` used").DeleteAfterSeconds(5)!; return; } @@ -128,13 +128,13 @@ await messageToEdit.ModifyAsync(x => x.Content = ""; x.Embed = embed; }); - await ReplyAsync("Message replaced!").DeleteAfterSeconds(5); + await ReplyAsync("Message replaced!").DeleteAfterSeconds(5)!; } else { await channel.SendMessageAsync(embed: embed); if (Context.Channel != channel) - await ReplyAsync("Embed Posted!").DeleteAfterSeconds(5); + await ReplyAsync("Embed Posted!").DeleteAfterSeconds(5)!; } } } \ No newline at end of file diff --git a/DiscordBot/Modules/FunModule.cs b/DiscordBot/Modules/FunModule.cs index 3b7f6ed5..26999e27 100644 --- a/DiscordBot/Modules/FunModule.cs +++ b/DiscordBot/Modules/FunModule.cs @@ -47,7 +47,7 @@ public async Task SlapUser(params IUser[] users) { await Context.Channel.SendMessageAsync( $"**{uname}** slaps away an invisible pest."); - await Context.Message.DeleteAfterSeconds(seconds: 1); + await Context.Message.DeleteAfterSeconds(seconds: 1)!; return; } @@ -73,7 +73,7 @@ await Context.Channel.SendMessageAsync( } await Context.Channel.SendMessageAsync(sb.ToString()); - await Context.Message.DeleteAfterSeconds(seconds: 1); + await Context.Message.DeleteAfterSeconds(seconds: 1)!; } [Command("CoinFlip"), Priority(22)] @@ -85,7 +85,7 @@ public async Task CoinFlip() var uname = Context.User.GetUserPreferredName(); await ReplyAsync($"**{uname}** flipped a coin and got **{coin[_random.Next() % 2]}**!"); - await Context.Message.DeleteAfterSeconds(seconds: 1); + await Context.Message.DeleteAfterSeconds(seconds: 1)!; } [Command("Roll"), Priority(23)] @@ -101,7 +101,7 @@ public async Task RollDice(int sides, int number) { if (sides < 1 || sides > 1000) { - await ReplyAsync("Invalid number of sides. Please choose a number between 1 and 1000.").DeleteAfterSeconds(seconds: 10); + await ReplyAsync("Invalid number of sides. Please choose a number between 1 and 1000.").DeleteAfterSeconds(seconds: 10)!; await Context.Message.DeleteAsync(); return; } @@ -117,7 +117,7 @@ public async Task RollDice(int sides, int number) message = " :x: " + message + " [Needed: " + number + "]"; await ReplyAsync(message); - await Context.Message.DeleteAfterSeconds(seconds: 1); + await Context.Message.DeleteAfterSeconds(seconds: 1)!; } [Command("D20"), Priority(23)] diff --git a/DiscordBot/Modules/ProfileModule.cs b/DiscordBot/Modules/ProfileModule.cs index 8e538427..26bc5510 100644 --- a/DiscordBot/Modules/ProfileModule.cs +++ b/DiscordBot/Modules/ProfileModule.cs @@ -15,7 +15,7 @@ public async Task KarmaDescription(int seconds = 60) { var uname = Context.User.GetUserPreferredName(); await ReplyAsync($"{uname}, Karma is tracked on your !profile which helps indicate how much you've helped others and provides a small increase in EXP gain."); - await Context.Message.DeleteAfterSeconds(seconds: seconds); + await (Context.Message?.DeleteAfterSeconds(seconds: seconds) ?? Task.CompletedTask); } [Command("JoinDate"), Priority(91)] @@ -46,12 +46,12 @@ public async Task DisplayProfile(IUser user) var profileCard = await ProfileCardService.GenerateProfileCard(user); if (string.IsNullOrEmpty(profileCard)) { - await ReplyAsync("Failed to generate profile card.").DeleteAfterSeconds(seconds: 10); + await (ReplyAsync("Failed to generate profile card.").DeleteAfterSeconds(seconds: 10) ?? Task.CompletedTask); return; } var profile = await Context.Channel.SendFileAsync(profileCard); - await profile.DeleteAfterTime(minutes: 3); + await (profile?.DeleteAfterTime(minutes: 3) ?? Task.CompletedTask); } catch (Exception e) { diff --git a/DiscordBot/Modules/QuoteModule.cs b/DiscordBot/Modules/QuoteModule.cs index 504ba67a..91ec6d3d 100644 --- a/DiscordBot/Modules/QuoteModule.cs +++ b/DiscordBot/Modules/QuoteModule.cs @@ -20,7 +20,7 @@ public async Task QuoteMessageCommand(ulong messageId, ulong channel) IMessageChannel targetChannel = (IMessageChannel)await Context.Client.GetChannelAsync(channel) ?? (IMessageChannel)await Context.Client.GetChannelAsync(messageId); if (targetChannel == null) { - await ReplyAsync("Channel or MessageID does not exist").DeleteAfterSeconds(seconds: 5); + await (ReplyAsync("Channel or MessageID does not exist").DeleteAfterSeconds(seconds: 5) ?? Task.CompletedTask); return; } @@ -38,13 +38,13 @@ public async Task QuoteMessage(ulong messageId, IMessageChannel? channel = null) var message = await channel.GetMessageAsync(messageId); if (message == null) { - await Context.Message.DeleteAfterSeconds(seconds: 1); - await ReplyAsync("No message with that id found.").DeleteAfterSeconds(seconds: 4); + await (Context.Message?.DeleteAfterSeconds(seconds: 1) ?? Task.CompletedTask); + await (ReplyAsync("No message with that id found.").DeleteAfterSeconds(seconds: 4) ?? Task.CompletedTask); return; } if (message.Author.IsBot) { - await Context.Message.DeleteAfterSeconds(seconds: 2); + await (Context.Message?.DeleteAfterSeconds(seconds: 2) ?? Task.CompletedTask); return; } @@ -78,6 +78,6 @@ public async Task QuoteMessage(ulong messageId, IMessageChannel? channel = null) builder.Description = msgContent; await ReplyAsync(embed: builder.Build()); - await Context.Message.DeleteAfterSeconds(1.0); + await (Context.Message?.DeleteAfterSeconds(1.0) ?? Task.CompletedTask); } } diff --git a/DiscordBot/Modules/RankModule.cs b/DiscordBot/Modules/RankModule.cs index 917bc38a..8b58d300 100644 --- a/DiscordBot/Modules/RankModule.cs +++ b/DiscordBot/Modules/RankModule.cs @@ -14,11 +14,13 @@ public class RankModule : ModuleBase [Alias("toplevel", "ranking")] public async Task TopLevel() { - var users = await DatabaseService.Query.GetTopLevel(10); + var query = DatabaseService.Query; + if (query == null) return; + var users = await query.GetTopLevel(10); var userList = users.Select(user => (ulong.Parse(user.UserID), user.Level)).ToList(); var embed = await GenerateRankEmbedFromList(userList, "Level"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); + await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); } [Command("TopKarma"), Priority(5)] @@ -26,11 +28,13 @@ public async Task TopLevel() [Alias("karmarank", "rankingkarma", "topk")] public async Task TopKarma() { - var users = await DatabaseService.Query.GetTopKarma(10); + var query = DatabaseService.Query; + if (query == null) return; + var users = await query.GetTopKarma(10); var userList = users.Select(user => (ulong.Parse(user.UserID), user.Karma)).ToList(); var embed = await GenerateRankEmbedFromList(userList, "Karma"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); + await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); } [Command("TopKarmaWeekly"), Priority(5)] @@ -38,11 +42,13 @@ public async Task TopKarma() [Alias("karmarankweekly", "rankingkarmaweekly", "topkw")] public async Task TopKarmaWeekly() { - var users = await DatabaseService.Query.GetTopKarmaWeekly(10); + var query = DatabaseService.Query; + if (query == null) return; + var users = await query.GetTopKarmaWeekly(10); var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaWeekly)).ToList(); var embed = await GenerateRankEmbedFromList(userList, "Weekly Karma"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); + await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); } [Command("TopKarmaMonthly"), Priority(5)] @@ -50,11 +56,13 @@ public async Task TopKarmaWeekly() [Alias("karmarankmonthly", "rankingkarmamonthly", "topkm")] public async Task TopKarmaMonthly() { - var users = await DatabaseService.Query.GetTopKarmaMonthly(10); + var query = DatabaseService.Query; + if (query == null) return; + var users = await query.GetTopKarmaMonthly(10); var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaMonthly)).ToList(); var embed = await GenerateRankEmbedFromList(userList, "Monthly Karma"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); + await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); } [Command("TopKarmaYearly"), Priority(5)] @@ -62,11 +70,13 @@ public async Task TopKarmaMonthly() [Alias("karmaranktearly", "rankingkarmayearly", "topky")] public async Task TopKarmaYearly() { - var users = await DatabaseService.Query.GetTopKarmaYearly(10); + var query = DatabaseService.Query; + if (query == null) return; + var users = await query.GetTopKarmaYearly(10); var userList = users.Select(user => (ulong.Parse(user.UserID), user.KarmaYearly)).ToList(); var embed = await GenerateRankEmbedFromList(userList, "Yearly Karma"); - await ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1); + await (ReplyAsync(embed: embed).DeleteAfterTime(minutes: 1) ?? Task.CompletedTask); } private async Task GenerateRankEmbedFromList(List<(ulong userID, int value)> data, string labelName) diff --git a/DiscordBot/Modules/ReminderModule.cs b/DiscordBot/Modules/ReminderModule.cs index 0b87b327..d86f41df 100644 --- a/DiscordBot/Modules/ReminderModule.cs +++ b/DiscordBot/Modules/ReminderModule.cs @@ -25,16 +25,15 @@ public async Task RemindMe(string time, [Remainder] string message) { if (Context.Message.MentionedEveryone || Context.Message.MentionedRoleIds.Count > 0) { - await ReplyAsync("You can't mention groups or roles in a reminder.").DeleteAfterSeconds(seconds: 5); + await (ReplyAsync("You can't mention groups or roles in a reminder.").DeleteAfterSeconds(seconds: 5) ?? Task.CompletedTask); return; } if (Context.Message.MentionedUserIds.Count > 0) { // IUserMessage does not guarantee .MentionedUsers so go through the class instead if possible - if (Context.Message is SocketMessage) + if (Context.Message is SocketMessage sm) { - var sm = (Context.Message as SocketMessage); // convert <@123> to **Joe** foreach (var user in sm.MentionedUsers) message = Regex.Replace(message, $"[<][@]{user.Id}[>]", user.GetUserPreferredName().ToBold()); @@ -43,7 +42,7 @@ public async Task RemindMe(string time, [Remainder] string message) } else { - await ReplyAsync($"You can't mention users in a reminder.\n`{message}`").DeleteAfterSeconds(seconds: 5); + await (ReplyAsync($"You can't mention users in a reminder.\n`{message}`").DeleteAfterSeconds(seconds: 5) ?? Task.CompletedTask); return; } } @@ -52,9 +51,9 @@ public async Task RemindMe(string time, [Remainder] string message) if (reminderDate < DateTime.Now) { // There isn't really a way to add negative time - await ReplyAsync( + await (ReplyAsync( "Invalid format for reminder.\\nCorrect Syntax: ``!remindme <1hour5m> ``") - .DeleteAfterSeconds(seconds: 10); + .DeleteAfterSeconds(seconds: 10) ?? Task.CompletedTask); return; } @@ -71,8 +70,8 @@ await ReplyAsync( // Check if user has to many reminders and tell them to delete some if so if (ReminderService.UserHasTooManyReminders(Context.User.Id)) { - await ReplyAsync("You have too many reminders! Please delete some before adding more.") - .DeleteAfterSeconds(seconds: 10); + await (ReplyAsync("You have too many reminders! Please delete some before adding more.") + .DeleteAfterSeconds(seconds: 10) ?? Task.CompletedTask); return; } @@ -94,9 +93,9 @@ await ReplyAsync("You have too many reminders! Please delete some before adding ReminderService.AddReminder(reminder); await Context.Message.AddReactionAsync(ReminderService.BotResponseEmoji); - await ReplyAsync( + await (ReplyAsync( $"Reminder set for {Utils.Utils.FormatTime((uint)(reminderDate - DateTime.Now).TotalSeconds)}") - .DeleteAfterSeconds(seconds: 10); + .DeleteAfterSeconds(seconds: 10) ?? Task.CompletedTask); } [Command("remindme"), HideFromHelp] @@ -138,7 +137,7 @@ public async Task Reminders(IUser user) var reminders = ReminderService.GetUserReminders(user.Id); if (reminders.Count == 0) { - await ReplyAsync($"{user.Username} has no reminders!").DeleteAfterSeconds(seconds: 5); + await (ReplyAsync($"{user.Username} has no reminders!").DeleteAfterSeconds(seconds: 5) ?? Task.CompletedTask); return; } @@ -154,9 +153,9 @@ public async Task Reminders(IUser user) $"[Link]({msgLink}) \"{reminder.Message}\""); } if (await Context.Guild.GetChannelAsync(Settings.BotCommandsChannel.Id) is IMessageChannel botCommands) - await botCommands + await (botCommands .SendMessageAsync(Context.User.Mention, false, embed.Build()) - .DeleteAfterSeconds(seconds: 30); + .DeleteAfterSeconds(seconds: 30) ?? Task.CompletedTask); } // Removes a users reminders for them @@ -165,7 +164,7 @@ await botCommands [Summary("Clears user reminders.")] public async Task RemoveReminders(IUser user, int index = 0) { - await Context.Message.DeleteAfterSeconds(seconds: 1); + await (Context.Message?.DeleteAfterSeconds(seconds: 1) ?? Task.CompletedTask); int removedReminders = ReminderService.RemoveReminders(user, index); if (removedReminders == 0) return; @@ -175,7 +174,7 @@ public async Task RemoveReminders(IUser user, int index = 0) return; } - await ReplyAsync($"{removedReminders.ToString()} Reminders removed.").DeleteAfterSeconds(seconds: 2); + await (ReplyAsync($"{removedReminders.ToString()} Reminders removed.").DeleteAfterSeconds(seconds: 2) ?? Task.CompletedTask); } [RequireModerator] @@ -185,15 +184,15 @@ public async Task RebootReminderService() { if (ReminderService.IsRunning) { - await ReplyAsync("Reminder service is still running.").DeleteAfterSeconds(seconds: 5); + await (ReplyAsync("Reminder service is still running.").DeleteAfterSeconds(seconds: 5) ?? Task.CompletedTask); return; } var result = ReminderService.RestartService(); if (result) - await ReplyAsync("Reminder service restarted.").DeleteAfterSeconds(seconds: 5); + await (ReplyAsync("Reminder service restarted.").DeleteAfterSeconds(seconds: 5) ?? Task.CompletedTask); else - await ReplyAsync("Reminder service failed to restart.").DeleteAfterSeconds(seconds: 5); + await (ReplyAsync("Reminder service failed to restart.").DeleteAfterSeconds(seconds: 5) ?? Task.CompletedTask); } #endregion diff --git a/DiscordBot/Modules/RulesModule.cs b/DiscordBot/Modules/RulesModule.cs index d1ab7734..867eb52a 100644 --- a/DiscordBot/Modules/RulesModule.cs +++ b/DiscordBot/Modules/RulesModule.cs @@ -33,7 +33,7 @@ public async Task RulesCommand(IMessageChannel channel) sentMessage = await dm.TrySendMessage($"{rule.Header}{(rule.Content.Length > 0 ? rule.Content : $"There is no special rule for {channel.Name} channel.\nPlease follow global rules (you can get them by typing `!globalrules`)")}"); if (!sentMessage) - await ReplyAsync("Could not send rules, your DMs are disabled.").DeleteAfterSeconds(seconds: 10); + await ReplyAsync("Could not send rules, your DMs are disabled.").DeleteAfterSeconds(seconds: 10)!; } [Command("GlobalRules"), Priority(99)] @@ -45,7 +45,7 @@ public async Task GlobalRules(int seconds = 60) await Context.Message.DeleteAsync(); if (!await dm.TrySendMessage(globalRules)) { - await ReplyAsync("Could not send rules, your DMs are disabled.").DeleteAfterSeconds(seconds: 10); + await ReplyAsync("Could not send rules, your DMs are disabled.").DeleteAfterSeconds(seconds: 10)!; } } @@ -55,9 +55,9 @@ public async Task ServerWelcome() { if (!await WelcomeService.DMFormattedWelcome((Context.User as SocketGuildUser)!)) { - await ReplyAsync("Could not send welcome, your DMs are disabled.").DeleteAfterSeconds(seconds: 2); + await ReplyAsync("Could not send welcome, your DMs are disabled.").DeleteAfterSeconds(seconds: 2)!; } - await Context.Message.DeleteAfterSeconds(seconds: 4); + await Context.Message.DeleteAfterSeconds(seconds: 4)!; } [Command("Channels"), Priority(92)] @@ -77,7 +77,7 @@ public async Task ChannelsDescription() { if (!await dm.TrySendMessage(message)) { - await ReplyAsync("Could not send channel descriptions, your DMs are disabled.").DeleteAfterSeconds(seconds: 10); + await ReplyAsync("Could not send channel descriptions, your DMs are disabled.").DeleteAfterSeconds(seconds: 10)!; break; } } @@ -145,7 +145,7 @@ private async Task ListFaqs(List faqs) keywordSb.Clear(); } - await ReplyAsync(sb.ToString()).DeleteAfterTime(minutes: 3); + await ReplyAsync(sb.ToString()).DeleteAfterTime(minutes: 3)!; } private Embed GetFaqEmbed(FaqData faq) diff --git a/DiscordBot/Modules/SearchModule.cs b/DiscordBot/Modules/SearchModule.cs index ed9a5e2c..fb528bb4 100644 --- a/DiscordBot/Modules/SearchModule.cs +++ b/DiscordBot/Modules/SearchModule.cs @@ -79,7 +79,7 @@ public async Task SearchManual(params string[] queries) } } else - await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10); + await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10)!; } [Command("Doc"), Priority(9)] @@ -116,7 +116,7 @@ public async Task SearchApi(params string[] queries) await message.ModifyAsync(msg => msg.Embed = embedBuilder.Build()); } else - await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10); + await ReplyAsync("No Results Found.").DeleteAfterSeconds(seconds: 10)!; } [Command("Wiki"), Priority(26)] diff --git a/DiscordBot/Modules/ServerModule.cs b/DiscordBot/Modules/ServerModule.cs index ed27b2c2..94a63848 100644 --- a/DiscordBot/Modules/ServerModule.cs +++ b/DiscordBot/Modules/ServerModule.cs @@ -29,7 +29,7 @@ public async Task DisplayHelp() } catch (Exception) { - await ReplyAsync($"Your direct messages are disabled, please use <#{Settings.BotCommandsChannel.Id}> instead!").DeleteAfterSeconds(10); + await ReplyAsync($"Your direct messages are disabled, please use <#{Settings.BotCommandsChannel.Id}> instead!").DeleteAfterSeconds(10)!; } } else @@ -51,9 +51,9 @@ public async Task Ping() var time = message.CreatedAt.Subtract(Context.Message.Timestamp); await message.ModifyAsync(m => m.Content = $"Pong (**{time.TotalMilliseconds}** *ms* / gateway **{ServerService.GetGatewayPing()}** *ms*)"); - await message.DeleteAfterTime(seconds: 10); + await message.DeleteAfterTime(seconds: 10)!; - await Context.Message.DeleteAfterTime(seconds: 5); + await Context.Message.DeleteAfterTime(seconds: 5)!; } [Command("Members"), Priority(90)] diff --git a/DiscordBot/Modules/TicketModule.cs b/DiscordBot/Modules/TicketModule.cs index 9da3098f..819538f1 100644 --- a/DiscordBot/Modules/TicketModule.cs +++ b/DiscordBot/Modules/TicketModule.cs @@ -34,7 +34,7 @@ public async Task Complaint() if (channels.Any(channel => channel.Name == channelName && (!categoryExist || ((INestedChannel)channel).CategoryId == Settings.ComplaintCategoryId))) { await ReplyAsync($"{Context.User.Mention}, you already have an open complaint! Please use that channel!") - .DeleteAfterSeconds(15); + .DeleteAfterSeconds(15)!; return; } diff --git a/DiscordBot/Modules/TipModule.cs b/DiscordBot/Modules/TipModule.cs index 9383d780..bf49cac9 100644 --- a/DiscordBot/Modules/TipModule.cs +++ b/DiscordBot/Modules/TipModule.cs @@ -42,7 +42,7 @@ public async Task Tip(params string[] keywords) var tips = TipService.GetTips(terms); if (tips.Count == 0) { - await ReplyAsync("No tips for the keywords provided were found.").DeleteAfterSeconds(5); + await ReplyAsync("No tips for the keywords provided were found.").DeleteAfterSeconds(5)!; return; } @@ -107,7 +107,7 @@ public async Task RemoveTip(ulong tipId) Tip? tip = TipService.GetTip(tipId); if (tip == null) { - await Context.Channel.SendMessageAsync("No such tip found to be removed.").DeleteAfterSeconds(5); + await Context.Channel.SendMessageAsync("No such tip found to be removed.").DeleteAfterSeconds(5)!; return; } @@ -122,7 +122,7 @@ public async Task ReplaceTip(ulong tipId, string content = "") Tip? tip = TipService.GetTip(tipId); if (tip == null) { - await Context.Channel.SendMessageAsync("No such tip found to be replaced.").DeleteAfterSeconds(5); + await Context.Channel.SendMessageAsync("No such tip found to be replaced.").DeleteAfterSeconds(5)!; return; } @@ -158,12 +158,12 @@ public async Task ListTips(params string[] keywords) tips = TipService.GetTips(terms); if (tips.Count == 0) { - await ReplyAsync("No tips for the keywords provided were found.").DeleteAfterSeconds(5); + await ReplyAsync("No tips for the keywords provided were found.").DeleteAfterSeconds(5)!; return; } if (tips.Count >= floodCount) { - await ReplyAsync($"Total of {tips.Count} tips found for the keywords provided; refine your search.").DeleteAfterSeconds(5); + await ReplyAsync($"Total of {tips.Count} tips found for the keywords provided; refine your search.").DeleteAfterSeconds(5)!; return; } } diff --git a/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs b/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs index f5638829..bed57f6a 100644 --- a/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs +++ b/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs @@ -26,6 +26,7 @@ public async Task CannedResponses(CannedHelp type) return; var embed = CannedResponseService.GetCannedResponse((CannedResponseType)type); + if (embed == null) return; await Context.Interaction.RespondAsync(string.Empty, embed: embed.Build()); } @@ -36,6 +37,7 @@ public async Task Resources(CannedResources type) return; var embed = CannedResponseService.GetCannedResponse((CannedResponseType)type); + if (embed == null) return; await Context.Interaction.RespondAsync(string.Empty, embed: embed.Build()); } } \ No newline at end of file diff --git a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs b/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs index 2aaa24fe..a43524d2 100644 --- a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs +++ b/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs @@ -23,6 +23,7 @@ public async Task RespondWithCannedResponse(CannedResponseType type) return; var embed = CannedResponseService.GetCannedResponse(type, Context.User); + if (embed == null) return; await Context.Message.DeleteAsync(); await ReplyAsync(string.Empty, false, embed.Build()); diff --git a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs b/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs index 91296c76..92a88d9f 100644 --- a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs +++ b/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs @@ -37,7 +37,7 @@ public async Task RespondWithErrorDocumentation(string error) foreach (var url in urls) { errorPage = await WebUtil.GetHtmlDocument($"{url}{error}"); - if (errorPage.DocumentNode.InnerHtml.Contains("Page not found")) + if (errorPage == null || errorPage.DocumentNode.InnerHtml.Contains("Page not found")) { continue; } @@ -81,7 +81,7 @@ await respondFailure( private async Task respondFailure(string errorMessage) { - await ReplyAsync(errorMessage).DeleteAfterSeconds(30); - await Context.Message.DeleteAfterSeconds(30); + await (ReplyAsync(errorMessage).DeleteAfterSeconds(30) ?? Task.CompletedTask); + await (Context.Message?.DeleteAfterSeconds(30) ?? Task.CompletedTask); } } \ No newline at end of file diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs b/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs index cc177e99..6f165e4d 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs +++ b/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs @@ -33,7 +33,7 @@ public async Task PendingQuestionsAsync() { if (!BotSettings.UnityHelpBabySitterEnabled) { - await ReplyAsync("UnityHelp Service currently disabled.").DeleteAfterSeconds(15); + await ReplyAsync("UnityHelp Service currently disabled.").DeleteAfterSeconds(15)!; return; } var trackedQuestionCount = HelpService.GetTrackedQuestionCount(); diff --git a/DiscordBot/Modules/Weather/WeatherModule.cs b/DiscordBot/Modules/Weather/WeatherModule.cs index f76b280a..ddd28aad 100644 --- a/DiscordBot/Modules/Weather/WeatherModule.cs +++ b/DiscordBot/Modules/Weather/WeatherModule.cs @@ -31,7 +31,7 @@ public async Task WeatherHelp() .WithDescription( "If the city isn't correct you will need to include the correct [city codes](https://www.iso.org/obp/ui/#search).\n**Example Usage**: *!Weather Wellington, UK*"); await Context.Message.DeleteAsync(); - await ReplyAsync(embed: builder.Build()).DeleteAfterSeconds(seconds: 30); + await ReplyAsync(embed: builder.Build()).DeleteAfterSeconds(seconds: 30)!; } #region Temperature @@ -39,7 +39,7 @@ public async Task WeatherHelp() private async Task TemperatureEmbed(string city, string replaceCityWith = "") { WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); - if (!await IsResultsValid(res)) + if (!await IsResultsValid(res) || res is null) return null; EmbedBuilder builder = new EmbedBuilder() @@ -88,7 +88,7 @@ public async Task Temperature(params string[] city) private async Task WeatherEmbed(string city, string replaceCityWith = "") { WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); - if (!await IsResultsValid(res)) + if (!await IsResultsValid(res) || res is null) return null; string extraInfo = string.Empty; @@ -167,13 +167,15 @@ public async Task CurentWeather(params string[] city) private async Task PollutionEmbed(string city, string replaceCityWith = "") { WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); - if (!await IsResultsValid(res)) + if (!await IsResultsValid(res) || res is null) return null; // We can't really combine the call as having WeatherResults helps with other details PollutionContainer.Result? polResult = await WeatherService.GetPollution(Math.Round(res.coord.Lon, 4), Math.Round(res.coord.Lat, 4)); + if (polResult is null) + return null; var comp = polResult.list[0].components; double combined = comp.CarbonMonoxide + comp.NitrogenMonoxide + comp.NitrogenDioxide + comp.Ozone + @@ -244,7 +246,7 @@ public async Task Pollution(params string[] city) private async Task TimeEmbed(string city, string replaceCityWith = "") { WeatherContainer.Result? res = await WeatherService.GetWeather(city: city); - if (!await IsResultsValid(res)) + if (!await IsResultsValid(res) || res is null) return null; var timezone = res.timezone / 3600; @@ -358,9 +360,9 @@ public async Task SetDefaultCity(params string[] city) var uname = Context.User.GetUserPreferredName(); var fullCityName = string.Join(" ", city); var (exists, result) = await WeatherService.CityExists(fullCityName); - if (!exists) + if (!exists || result is null) { - await ReplyAsync($"Sorry, {uname}, but I couldn't find a city with that name.").DeleteAfterSeconds(30); + await ReplyAsync($"Sorry, {uname}, but I couldn't find a city with that name.").DeleteAfterSeconds(30)!; await Context.Message.DeleteAsync(); return; } @@ -376,7 +378,7 @@ public async Task RemoveDefaultCity() var uname = Context.User.GetUserPreferredName(); if (!await UserExtendedService.DoesUserHaveDefaultCity(Context.User)) { - await ReplyAsync($"{uname}, you don't have a default city set.").DeleteAfterSeconds(30); + await ReplyAsync($"{uname}, you don't have a default city set.").DeleteAfterSeconds(30)!; await Context.Message.DeleteAsync(); return; } diff --git a/DiscordBot/Services/AirportService.cs b/DiscordBot/Services/AirportService.cs index 2e2c9e8b..4229459c 100644 --- a/DiscordBot/Services/AirportService.cs +++ b/DiscordBot/Services/AirportService.cs @@ -187,6 +187,8 @@ public AirportService(DiscordSocketClient client, ILoggingService loggingService var url = string.Format(_airLabsNearbyCityRoute, lat, lng); var result = await SerializeUtil.LoadUrlDeserializeResult(url); + if (result?.response?.airports == null) + return null; // Sort by popularity result.response.airports.Sort((a, b) => b.popularity.CompareTo(a.popularity)); diff --git a/DiscordBot/Services/AuditLogService.cs b/DiscordBot/Services/AuditLogService.cs index 3a85fef0..28cbaf5a 100644 --- a/DiscordBot/Services/AuditLogService.cs +++ b/DiscordBot/Services/AuditLogService.cs @@ -74,7 +74,7 @@ private async Task MessageUpdated(Cacheable before, SocketMessa if (content == after.Content) return; - if (content.Length == 0 && beforeMessage.Attachments.Count == 0) + if (content.Length == 0 && (beforeMessage?.Attachments.Count ?? 0) == 0) return; bool isTruncated = false; @@ -93,7 +93,7 @@ private async Task MessageUpdated(Cacheable before, SocketMessa if (isCached) { builder.AddField($"Previous message content {(isTruncated ? "(truncated)" : "")}", content); - if (beforeMessage.Attachments.Count > 0) + if (beforeMessage?.Attachments.Count > 0) { var attachments = beforeMessage.Attachments.Where(x => after.Attachments.All(y => y.Url != x.Url)); var removedAttachments = attachments.ToList(); diff --git a/DiscordBot/Services/Casino/CasinoService.cs b/DiscordBot/Services/Casino/CasinoService.cs index 653f72a9..833de4be 100644 --- a/DiscordBot/Services/Casino/CasinoService.cs +++ b/DiscordBot/Services/Casino/CasinoService.cs @@ -24,7 +24,8 @@ public async Task GetOrCreateCasinoUser(string userId) { try { - var user = await _databaseService.CasinoQuery.GetCasinoUser(userId); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + var user = await casinoQuery.GetCasinoUser(userId); if (user != null) return user; @@ -38,7 +39,7 @@ public async Task GetOrCreateCasinoUser(string userId) LastDailyReward = DateTime.UtcNow.AddDays(-1) // Set to a past date so user can claim their first daily reward immediately }; - var createdUser = await _databaseService.CasinoQuery.InsertCasinoUser(newUser); + var createdUser = await casinoQuery.InsertCasinoUser(newUser); await RecordTransaction(userId, _settings.CasinoStartingTokens, TransactionKind.TokenInitialisation); await _loggingService.LogChannelAndFile($"{ServiceName}: Created new casino user {userId} with {_settings.CasinoStartingTokens} starting tokens"); return createdUser; @@ -53,6 +54,7 @@ public async Task GetOrCreateCasinoUser(string userId) public async Task TransferTokens(string fromUserId, string toUserId, long amount) { + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); var fromUser = await GetOrCreateCasinoUser(fromUserId); var toUser = await GetOrCreateCasinoUser(toUserId); @@ -60,8 +62,8 @@ public async Task TransferTokens(string fromUserId, string toUserId, long return false; // Update balances - await _databaseService.CasinoQuery.UpdateTokens(fromUserId, fromUser.Tokens - amount, DateTime.UtcNow); - await _databaseService.CasinoQuery.UpdateTokens(toUserId, toUser.Tokens + amount, DateTime.UtcNow); + await casinoQuery.UpdateTokens(fromUserId, fromUser.Tokens - amount, DateTime.UtcNow); + await casinoQuery.UpdateTokens(toUserId, toUser.Tokens + amount, DateTime.UtcNow); // Record transactions await RecordTransaction(fromUserId, -amount, TransactionKind.Gift, new Dictionary @@ -80,12 +82,13 @@ public async Task UpdateUserTokens(string userId, long deltaTokens, TransactionK { try { + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); var user = await GetOrCreateCasinoUser(userId); var newBalance = user.Tokens + deltaTokens; // Prevent negative balance if (newBalance < 0) newBalance = 0; - await _databaseService.CasinoQuery.UpdateTokens(userId, newBalance, DateTime.UtcNow); + await casinoQuery.UpdateTokens(userId, newBalance, DateTime.UtcNow); await RecordTransaction(userId, deltaTokens, transactionType, details); } catch (Exception ex) @@ -98,7 +101,8 @@ public async Task UpdateUserTokens(string userId, long deltaTokens, TransactionK public async Task SetUserTokens(string userId, long amount, string adminUserId) { - await _databaseService.CasinoQuery.UpdateTokens(userId, amount, DateTime.UtcNow); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + await casinoQuery.UpdateTokens(userId, amount, DateTime.UtcNow); await RecordTransaction(userId, amount, TransactionKind.Admin, new Dictionary { @@ -109,25 +113,29 @@ public async Task SetUserTokens(string userId, long amount, string adminUserId) public async Task> GetLeaderboard(int limit = 10) { - var topUsers = await _databaseService.CasinoQuery.GetTopTokenHolders(limit); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + var topUsers = await casinoQuery.GetTopTokenHolders(limit); return topUsers.ToList(); } public async Task> GetUserTransactionHistory(string userId, int limit = 10) { await GetOrCreateCasinoUser(userId); - var transactions = await _databaseService.CasinoQuery.GetUserTransactionHistory(userId, limit); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + var transactions = await casinoQuery.GetUserTransactionHistory(userId, limit); return transactions.ToList(); } public async Task> GetAllRecentTransactions(int limit = 10) { - var transactions = await _databaseService.CasinoQuery.GetRecentTransactions(limit); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + var transactions = await casinoQuery.GetRecentTransactions(limit); return transactions.ToList(); } private async Task RecordTransaction(string userId, long amount, TransactionKind type, Dictionary? details = null) { + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); var transaction = new TokenTransaction { UserID = userId, @@ -137,7 +145,7 @@ private async Task RecordTransaction(string userId, long amount, TransactionKind Details = details }; - await _databaseService.CasinoQuery.InsertTransaction(transaction); + await casinoQuery.InsertTransaction(transaction); } #endregion @@ -148,7 +156,8 @@ public async Task> GetGameStatistics(IUser user) { try { - var gameTransactions = await _databaseService.CasinoQuery.GetTransactionsOfType(nameof(TransactionKind.Game)); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + var gameTransactions = await casinoQuery.GetTransactionsOfType(nameof(TransactionKind.Game)); // Group transactions by game type var gameGroups = gameTransactions @@ -206,7 +215,8 @@ public async Task GetGameLeaderboard(string? gameName = n { try { - var gameTransactions = await _databaseService.CasinoQuery.GetTransactionsOfType(nameof(TransactionKind.Game)); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + var gameTransactions = await casinoQuery.GetTransactionsOfType(nameof(TransactionKind.Game)); // Filter by game if specified var filteredTransactions = gameTransactions @@ -319,6 +329,7 @@ public bool IsChannelAllowed(ulong channelId) { try { + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); var user = await GetOrCreateCasinoUser(userId); var now = DateTime.UtcNow; var nextRewardTime = user.LastDailyReward.AddSeconds(_settings.CasinoDailyRewardIntervalSeconds); @@ -331,7 +342,7 @@ public bool IsChannelAllowed(ulong channelId) // User can claim daily reward var tokensAwarded = _settings.CasinoDailyRewardTokens; var newBalance = user.Tokens + tokensAwarded; - await _databaseService.CasinoQuery.UpdateTokensAndDailyReward(userId, newBalance, now, now); + await casinoQuery.UpdateTokensAndDailyReward(userId, newBalance, now, now); await RecordTransaction(userId, tokensAwarded, TransactionKind.DailyReward); await _loggingService.LogChannelAndFile($"{ServiceName}: User {userId} claimed daily reward of {tokensAwarded} tokens"); @@ -357,8 +368,9 @@ public async Task GetNextDailyRewardTime(string userId) public async Task ResetAllCasinoData() { - await _databaseService.CasinoQuery.ClearAllCasinoUsers(); - await _databaseService.CasinoQuery.ClearAllTransactions(); + var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); + await casinoQuery.ClearAllCasinoUsers(); + await casinoQuery.ClearAllTransactions(); await _loggingService.LogChannelAndFile($"{ServiceName}: All casino data has been reset."); } diff --git a/DiscordBot/Services/CodeCheckService.cs b/DiscordBot/Services/CodeCheckService.cs index cff549aa..56577d02 100644 --- a/DiscordBot/Services/CodeCheckService.cs +++ b/DiscordBot/Services/CodeCheckService.cs @@ -103,9 +103,9 @@ public async Task CodeCheck(SocketMessage messageParam) return; if (foundTrippleCodeBlock.Groups["CodeBlock"].Success) { - await messageParam.Channel.SendMessageAsync( + await (messageParam.Channel.SendMessageAsync( $"{messageParam.Author.Mention} when using code blocks remember to use the ***syntax highlights*** to improve readability.\n{_codeReminderFormattingExample}") - .DeleteAfterSeconds(seconds: 60); + .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask); return; } @@ -119,21 +119,21 @@ await messageParam.Channel.SendMessageAsync( if (!foundDoubleCodeBlock && hits >= 3) { - await messageParam.Channel.SendMessageAsync( + await (messageParam.Channel.SendMessageAsync( $"{messageParam.Author.Mention} are you sharing C# scripts? Remember to use codeblocks to help readability!\n{_codeReminderFormattingExample}") - .DeleteAfterSeconds(seconds: 60); + .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask); if (content.Length > _maxCodeBlockLengthWarning) { - await messageParam.Channel.SendMessageAsync( + await (messageParam.Channel.SendMessageAsync( "The code you're sharing is quite long, maybe use a free service like and share the link here instead.") - .DeleteAfterSeconds(seconds: 60); + .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask); } } else if (foundDoubleCodeBlock && hits > 0) { - await messageParam.Channel.SendMessageAsync( + await (messageParam.Channel.SendMessageAsync( $"{messageParam.Author.Mention} when using code blocks remember to use \\`\\`\\`cs as this will help improve readability for C# scripts.\n{_codeReminderFormattingExample}") - .DeleteAfterSeconds(seconds: 60); + .DeleteAfterSeconds(seconds: 60) ?? Task.CompletedTask); } } } diff --git a/DiscordBot/Services/CommandHandlingService.cs b/DiscordBot/Services/CommandHandlingService.cs index 57087cce..93675b64 100644 --- a/DiscordBot/Services/CommandHandlingService.cs +++ b/DiscordBot/Services/CommandHandlingService.cs @@ -264,7 +264,7 @@ private async Task HandleCommand(SocketMessage messageParam) } AddToCommandHistory(message, resultString); - await context.Channel.SendMessageAsync(resultString).DeleteAfterSeconds(10); + await context.Channel.SendMessageAsync(resultString).DeleteAfterSeconds(10)!; } private async Task HandleInteraction(SocketInteraction arg) diff --git a/DiscordBot/Services/CurrencyService.cs b/DiscordBot/Services/CurrencyService.cs index a890bea4..8afaf15a 100644 --- a/DiscordBot/Services/CurrencyService.cs +++ b/DiscordBot/Services/CurrencyService.cs @@ -39,7 +39,7 @@ public async Task GetConversion(string toCurrency, string fromCurrency = return -1; // json[fromCurrency][toCurrency] - var value = response.SelectToken($"{fromCurrency}.{toCurrency}"); + var value = response?.SelectToken($"{fromCurrency}.{toCurrency}"); if (value == null) return -1; @@ -72,6 +72,8 @@ private async Task BuildCurrencyList() { var url = ApiUrl + ValidCurrenciesEndpoint; var currencies = await WebUtil.GetObjectFromJson>(url); + if (currencies == null) + return; // Json is weird format of `Code: Name` each in dependant ie; {"1inch":"1inch Network","aave":"Aave"} foreach (var currency in currencies) diff --git a/DiscordBot/Services/DatabaseService.cs b/DiscordBot/Services/DatabaseService.cs index 61987bd2..d715cf3b 100644 --- a/DiscordBot/Services/DatabaseService.cs +++ b/DiscordBot/Services/DatabaseService.cs @@ -70,7 +70,9 @@ public DatabaseService(ILoggingService logging, BotSettings settings) // Test connection, if it fails we create the table and set keys try { - var userCount = await Query.TestConnection(); + var query = Query; + if (query == null) return; + var userCount = await query.TestConnection(); await _logging.LogAction( $"{ServiceName}: Connected to database successfully. {userCount} users in database.", ExtendedLogSeverity.Positive); @@ -118,7 +120,9 @@ await _logging.LogAction($"DatabaseService: Table '{UserProps.TableName}' genera // Create casino tables if they don't exist try { - var casinoUserCount = await CasinoQuery.TestCasinoConnection(); + var casinoQuery = CasinoQuery; + if (casinoQuery == null) return; + var casinoUserCount = await casinoQuery.TestCasinoConnection(); await _logging.LogAction( $"DatabaseService: Connected to casino tables successfully. {casinoUserCount} casino users in database.", ExtendedLogSeverity.Positive); @@ -185,7 +189,9 @@ await message.ModifyAsync(msg => if (!user.IsBot) { var userIdString = user.Id.ToString(); - var serverUser = await Query.GetUser(userIdString); + var q = Query; + if (q == null) continue; + var serverUser = await q.GetUser(userIdString); if (serverUser == null) { await GetOrAddUser(user as SocketGuildUser); @@ -266,9 +272,11 @@ public async Task DeleteUser(ulong id) { try { - var user = await Query.GetUser(id.ToString()); + var query = Query; + if (query == null) return; + var user = await query.GetUser(id.ToString()); if (user != null) - await Query.RemoveUser(user.UserID); + await query.RemoveUser(user.UserID); } catch (Exception e) { @@ -279,6 +287,8 @@ await _logging.Log(LogBehaviour.Console | LogBehaviour.File, public async Task UserExists(ulong id) { - return (await Query.GetUser(id.ToString()) != null); + var query = Query; + if (query == null) return false; + return (await query.GetUser(id.ToString()) != null); } } \ No newline at end of file diff --git a/DiscordBot/Services/EmbedParsingService.cs b/DiscordBot/Services/EmbedParsingService.cs index 5079af52..206c2588 100644 --- a/DiscordBot/Services/EmbedParsingService.cs +++ b/DiscordBot/Services/EmbedParsingService.cs @@ -90,6 +90,7 @@ public Discord.Embed BuildEmbed(string json) { var embedData = JsonConvert.DeserializeObject(json); var builder = new Discord.EmbedBuilder(); + if (embedData == null) return builder.Build(); if (!string.IsNullOrEmpty(embedData.title)) builder.Title = embedData.title; if (!string.IsNullOrEmpty(embedData.description)) builder.Description = embedData.description; diff --git a/DiscordBot/Services/EveryoneScoldService.cs b/DiscordBot/Services/EveryoneScoldService.cs index a514dba6..04f9d15d 100644 --- a/DiscordBot/Services/EveryoneScoldService.cs +++ b/DiscordBot/Services/EveryoneScoldService.cs @@ -27,10 +27,10 @@ private async Task ScoldForAtEveryoneUsage(SocketMessage messageParam) _everyoneScoldCooldown[messageParam.Author.Id] = DateTime.Now.AddSeconds(_settings.EveryoneScoldPeriodSeconds); - await messageParam.Channel.SendMessageAsync( + await (messageParam.Channel.SendMessageAsync( $"Please don't try to alert **everyone** on the server, {messageParam.Author.Mention}!\n" + "If you are asking a question, people will help you when they have time.") - .DeleteAfterTime(minutes: 2); + .DeleteAfterTime(minutes: 2) ?? Task.CompletedTask); } } } diff --git a/DiscordBot/Services/KarmaService.cs b/DiscordBot/Services/KarmaService.cs index 030f24a8..296c1e4b 100644 --- a/DiscordBot/Services/KarmaService.cs +++ b/DiscordBot/Services/KarmaService.cs @@ -70,12 +70,12 @@ private async Task Thanks(SocketMessage messageParam) { if (_thanksCooldown.HasUser(userId)) { - await messageParam.Channel.SendMessageAsync( - $"{messageParam.Author.Mention} you must wait " + + await messageParam.Channel!.SendMessageAsync( + $"{messageParam.Author!.Mention} you must wait " + $"{DateTime.Now - _thanksCooldown[userId]:ss} " + "seconds before giving another karma point." + Environment.NewLine + "(In the future, if you are trying to thank multiple people, include all their names in the thanks message.)") - .DeleteAfterTime(defaultDelTime); + .DeleteAfterTime(defaultDelTime)!; return; } @@ -90,11 +90,15 @@ await messageParam.Channel.SendMessageAsync( sb.Append(messageParam.Author.GetUserPreferredName().ToBold()); sb.Append(" gave karma to "); sb.Append(mentions.ToArray().ToUserPreferredNameArray().ToBoldArray().ToCommaList()); - foreach (var mention in mentions) - await _databaseService.Query.IncrementKarma(mention.Id.ToString()); + var dbQuery = _databaseService.Query; + if (dbQuery != null) + { + foreach (var mention in mentions) + await dbQuery.IncrementKarma(mention.Id.ToString()); - var authorKarmaGiven = await _databaseService.Query.GetKarmaGiven(messageParam.Author.Id.ToString()); - await _databaseService.Query.UpdateKarmaGiven(messageParam.Author.Id.ToString(), authorKarmaGiven + 1); + var authorKarmaGiven = await dbQuery.GetKarmaGiven(messageParam.Author.Id.ToString()); + await dbQuery.UpdateKarmaGiven(messageParam.Author.Id.ToString(), authorKarmaGiven + 1); + } sb.Append("."); diff --git a/DiscordBot/Services/ProfileCardService.cs b/DiscordBot/Services/ProfileCardService.cs index 9320f307..125d5c92 100644 --- a/DiscordBot/Services/ProfileCardService.cs +++ b/DiscordBot/Services/ProfileCardService.cs @@ -71,6 +71,8 @@ public async Task GenerateProfileCard(IUser user) using var profileCard = new MagickImageCollection(); var skin = GetSkinData(); + if (skin == null) + return profileCardPath; var profile = new ProfileData { Karma = karma, diff --git a/DiscordBot/Services/Recruitment/RecruitService.cs b/DiscordBot/Services/Recruitment/RecruitService.cs index f749c62f..dad9bcd8 100644 --- a/DiscordBot/Services/Recruitment/RecruitService.cs +++ b/DiscordBot/Services/Recruitment/RecruitService.cs @@ -108,6 +108,8 @@ public RecruitService(DiscordSocketClient client, ILoggingService logging, BotSe private async Task GatewayOnThreadCreated(SocketThreadChannel thread) { + if (_recruitChannel == null) + return; if (!thread.IsThreadInChannel(_recruitChannel.Id)) return; if (thread.Owner.IsUserBotOrWebhook()) @@ -200,6 +202,8 @@ private async Task GatewayOnMessageReceived(SocketMessage message) if (thread == null) return; + if (_recruitChannel == null) + return; if (!thread.IsThreadInChannel(_recruitChannel.Id)) return; if (message.Author.IsUserBotOrWebhook()) @@ -252,7 +256,7 @@ private async Task ThreadHandleShortMessage(SocketThreadChannel thread, IMessage if (message.Content.Length < MinimumLengthMessage) { var ourResponse = await thread.SendMessageAsync(embed: GetShortMessageEmbed()); - await ourResponse.DeleteAfterSeconds(ShortMessageNoticeDurationInSec); + await (ourResponse.DeleteAfterSeconds(ShortMessageNoticeDurationInSec) ?? Task.CompletedTask); } } @@ -263,7 +267,7 @@ private async Task GrantEditPermissions(SocketThreadChannel thread) await parentChannel.AddPermissionOverwriteAsync(thread.Owner, new OverwritePermissions(sendMessages: PermValue.Allow)); // We give them a bit of time to edit their post, then remove the permission - await message.DeleteAfterSeconds((_editTimePermissionInMin * 60) + 2); + await (message.DeleteAfterSeconds((_editTimePermissionInMin * 60) + 2) ?? Task.CompletedTask); await parentChannel.RemovePermissionOverwriteAsync(thread.Owner); // Lock the thread so anyone else can't post even when they have edit permissions @@ -382,7 +386,7 @@ private bool DoesThreadHaveAValidTag(SocketThreadChannel thread) private async Task DeleteThread(SocketThreadChannel thread) { await thread.SendMessageAsync(embed: GetDeletedMessageEmbed()); - await thread.DeleteAfterSeconds(TimeBeforeDeletingForumInSec); + await (thread.DeleteAfterSeconds(TimeBeforeDeletingForumInSec) ?? Task.CompletedTask); } private string GetDynamicTimeStampString(int addSeconds) diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index 6ee2a456..28115e03 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -518,12 +518,16 @@ private async Task CloseThreadInTime(ThreadContainer thread, string message, int await CancelPreviousWarning(thread, expectedShutdownTime); thread.CancellationToken ??= new CancellationTokenSource(); + if (threadChannel == null) + return; + // Send our message if (!string.IsNullOrEmpty(message)) { await threadChannel.SendMessageAsync(message); } thread.ExpectedShutdownTime = expectedShutdownTime; + // Wait for the time to pass await Task.Delay(minutes * 60 * 1000, thread.CancellationToken.Token); if (await IsTaskCancelled(thread)) return; @@ -541,6 +545,8 @@ private async Task RequestThreadShutdownInTime(ThreadContainer thread, string ms // Check if token already created, each thread shares its own token with any relevant action (close, delete, etc) await CancelPreviousWarning(thread, expectedWarnTime); thread.CancellationToken ??= new CancellationTokenSource(); + if (threadChannel == null) + return; thread.ExpectedShutdownTime = expectedWarnTime; await Task.Delay(minutes * 60 * 1000, thread.CancellationToken.Token); @@ -569,8 +575,10 @@ private async Task StealthDeleteThreadInTime(ThreadContainer thread) var threadChannel = _client.GetChannel(thread.ThreadId) as SocketThreadChannel; thread.CancellationToken ??= new CancellationTokenSource(); + if (threadChannel == null) + return; + thread.ExpectedShutdownTime = expectedShutdownTime; - // Wait for the time to pass await Task.Delay(NoResponseNotResolvedIdleTime * 60 * 1000, thread.CancellationToken.Token); if (await IsTaskCancelled(thread)) return; diff --git a/DiscordBot/Services/UpdateService.cs b/DiscordBot/Services/UpdateService.cs index 3ad301de..787db7bc 100644 --- a/DiscordBot/Services/UpdateService.cs +++ b/DiscordBot/Services/UpdateService.cs @@ -242,7 +242,9 @@ private async Task UpdateRssFeeds() if (job.TryGetValue("query", out var query)) { - var pages = JsonConvert.DeserializeObject>(job[query.Path]["pages"].ToString(), new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); + var pagesToken = job[query.Path]?["pages"]; + if (pagesToken == null) return (null, null, null); + var pages = JsonConvert.DeserializeObject>(pagesToken.ToString(), new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); if (pages != null && pages.Count > 0) { diff --git a/DiscordBot/Services/UserExtendedService.cs b/DiscordBot/Services/UserExtendedService.cs index 8adfadd1..730af460 100644 --- a/DiscordBot/Services/UserExtendedService.cs +++ b/DiscordBot/Services/UserExtendedService.cs @@ -18,8 +18,10 @@ public UserExtendedService(DatabaseService databaseService) public async Task SetUserDefaultCity(IUser user, string city) { + var query = _databaseService.Query; + if (query is null) return false; // Update Database - await _databaseService.Query.UpdateDefaultCity(user.Id.ToString(), city); + await query.UpdateDefaultCity(user.Id.ToString(), city); // Update Cache _cityCachedName[user.Id] = city; return true; @@ -31,8 +33,10 @@ public async Task DoesUserHaveDefaultCity(IUser user) if (_cityCachedName.ContainsKey(user.Id)) return true; + var query = _databaseService.Query; + if (query is null) return false; // Check database - var res = await _databaseService.Query.GetDefaultCity(user.Id.ToString()); + var res = await query.GetDefaultCity(user.Id.ToString()); if (string.IsNullOrEmpty(res)) return false; @@ -50,8 +54,10 @@ public async Task GetUserDefaultCity(IUser user) public async Task RemoveUserDefaultCity(IUser user) { + var query = _databaseService.Query; + if (query is null) return false; // Update Database - await _databaseService.Query.UpdateDefaultCity(user.Id.ToString(), null); + await query.UpdateDefaultCity(user.Id.ToString(), null); // Update Cache _cityCachedName.Remove(user.Id); return true; diff --git a/DiscordBot/Services/XpService.cs b/DiscordBot/Services/XpService.cs index 37c937bc..a683d752 100644 --- a/DiscordBot/Services/XpService.cs +++ b/DiscordBot/Services/XpService.cs @@ -58,6 +58,10 @@ private async Task UpdateXp(SocketMessage messageParam) if (user == null) return; + var query = _databaseService.Query; + if (query is null) + return; + bonusXp += baseXp * (1f + user.Karma / 100f); if (((IGuildUser)messageParam.Author).RoleIds.Count < 2) @@ -68,7 +72,7 @@ private async Task UpdateXp(SocketMessage messageParam) var xpGain = (int)Math.Round((baseXp + bonusXp) * reduceXp); - await _databaseService.Query.UpdateXp(userId.ToString(), user.Exp + (long)xpGain); + await query.UpdateXp(userId.ToString(), user.Exp + (long)xpGain); _loggingService.LogXp(messageParam.Channel.Name, messageParam.Author.Username, baseXp, bonusXp, reduceXp, xpGain); @@ -79,21 +83,24 @@ private async Task UpdateXp(SocketMessage messageParam) private async Task LevelUp(SocketMessage messageParam, ulong userId) { - var level = await _databaseService.Query.GetLevel(userId.ToString()); - var xp = await _databaseService.Query.GetXp(userId.ToString()); + var query = _databaseService.Query; + if (query is null) return; + + var level = await query.GetLevel(userId.ToString()); + var xp = await query.GetXp(userId.ToString()); var xpHigh = GetXpHigh(level); if (xp < xpHigh) return; - await _databaseService.Query.UpdateLevel(userId.ToString(), level + 1); + await query.UpdateLevel(userId.ToString(), level + 1); if (level <= 3) return; var msg = messageParam.Author.GetUserPreferredName().ToBold() + " has leveled up!"; - await messageParam.Channel.SendMessageAsync(msg).DeleteAfterTime(60); + await (messageParam.Channel.SendMessageAsync(msg).DeleteAfterTime(60) ?? Task.CompletedTask); } public double GetXpLow(int level) => 70d - 139.5d * (level + 1d) + 69.5 * Math.Pow(level + 1d, 2d); diff --git a/DiscordBot/Skin/CustomTextSkinModule.cs b/DiscordBot/Skin/CustomTextSkinModule.cs index fa04bdbb..76f61a56 100644 --- a/DiscordBot/Skin/CustomTextSkinModule.cs +++ b/DiscordBot/Skin/CustomTextSkinModule.cs @@ -27,7 +27,7 @@ public override Drawables GetDrawables(ProfileData data) var prop = typeof(ProfileData).GetProperty(match.ToString()!); if (prop == null) continue; var value = (dynamic?)prop.GetValue(data, null); - Text = Text.Replace("{" + match + "}", value.ToString()); + Text = Text.Replace("{" + match + "}", value?.ToString() ?? string.Empty); } /* ALL properties of ProfileData.cs can be used! * Like {Level} for ProfileData.Level diff --git a/DiscordBot/Utils/WebUtil.cs b/DiscordBot/Utils/WebUtil.cs index 4208c7a8..4a4e867f 100644 --- a/DiscordBot/Utils/WebUtil.cs +++ b/DiscordBot/Utils/WebUtil.cs @@ -54,7 +54,7 @@ public static async Task GetContent(string url) try { var doc = await GetHtmlDocument(url); - return doc.DocumentNode.SelectSingleNode(xpath); + return doc?.DocumentNode.SelectSingleNode(xpath); } catch (Exception) { @@ -70,7 +70,7 @@ public static async Task GetContent(string url) try { var doc = await GetHtmlDocument(url); - return doc.DocumentNode.SelectNodes(xpath); + return doc?.DocumentNode.SelectNodes(xpath); } catch (Exception) { From 01fe9b4c90f0277129a32aae75e76c6a50556bcd Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 15:14:22 +0200 Subject: [PATCH 30/48] =?UTF-8?q?deps:=20update=20HtmlAgilityPack=201.12.1?= =?UTF-8?q?=E2=86=921.12.4=20and=20Newtonsoft.Json=2013.0.3=E2=86=9213.0.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Safe patch updates with no breaking changes. --- DiscordBot/DiscordBot.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DiscordBot/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj index a760216e..120c8ac2 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -8,12 +8,12 @@ - + - + From 21254b0640a332d3f25a0a86b5b9a380994fe6ac Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 15:20:17 +0200 Subject: [PATCH 31/48] deps: remove unused Pathoschild.NaturalTimeParser dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Package was declared but never used — bot uses custom regex parser in Utils.ParseTimeFromString() instead. Removes NU1701 warnings. --- DiscordBot/DiscordBot.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/DiscordBot/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj index 120c8ac2..ea05f302 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -14,7 +14,6 @@ - From 30655c2dd0f4b913a275811124043f8795d6f0fc Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 15:25:42 +0200 Subject: [PATCH 32/48] =?UTF-8?q?deps!:=20upgrade=20Magick.NET-Q8-x64=207.?= =?UTF-8?q?5.0.1=20=E2=86=92=2014.11.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves 80+ security advisories (heap overflows, DoS, buffer overreads, code injection) and eliminates NETSDK1206 win7-x64 RID warning. API migration changes: - Drawables class moved from ImageMagick to ImageMagick.Drawing namespace - ISkinModule.GetDrawables return type: Drawables → IDrawables - StrokeAntialias(bool)/TextAntialias(bool) → Enable/Disable variants - MagickImage constructor: int → uint for dimensions - GetPixels().ToColor() returns IMagickColor? instead of MagickColor 15 files changed across Skin/ and ProfileCardService --- DiscordBot/DiscordBot.csproj | 2 +- DiscordBot/Services/ProfileCardService.cs | 4 ++-- DiscordBot/Skin/AvatarBorderSkinModule.cs | 3 ++- DiscordBot/Skin/BaseTextSkinModule.cs | 12 ++++++++---- DiscordBot/Skin/CustomTextSkinModule.cs | 14 +++++++++----- DiscordBot/Skin/ISkinModule.cs | 3 ++- DiscordBot/Skin/KarmaPointsSkinModule.cs | 3 ++- DiscordBot/Skin/KarmaRankSkinModule.cs | 3 ++- DiscordBot/Skin/LevelSkinModule.cs | 3 ++- .../Skin/RectangleSampleAvatarColorSkinModule.cs | 10 +++++++--- DiscordBot/Skin/TotalXpSkinModule.cs | 3 ++- DiscordBot/Skin/UsernameSkinModule.cs | 3 ++- DiscordBot/Skin/XpBarInfoSkinModule.cs | 3 ++- DiscordBot/Skin/XpBarSkinModule.cs | 3 ++- DiscordBot/Skin/XpRankSkinModule.cs | 3 ++- 15 files changed, 47 insertions(+), 25 deletions(-) diff --git a/DiscordBot/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj index ea05f302..927d4068 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -10,7 +10,7 @@ - + diff --git a/DiscordBot/Services/ProfileCardService.cs b/DiscordBot/Services/ProfileCardService.cs index 125d5c92..2221403e 100644 --- a/DiscordBot/Services/ProfileCardService.cs +++ b/DiscordBot/Services/ProfileCardService.cs @@ -116,7 +116,7 @@ public async Task GenerateProfileCard(IUser user) profile.Picture = new MagickImage($"{_settings.AssetsRootPath}/images/default.png"); } - profile.Picture.Resize(skin.AvatarSize, skin.AvatarSize); + profile.Picture.Resize((uint)skin.AvatarSize, (uint)skin.AvatarSize); profileCard.Add(background); foreach (var layer in skin.Layers) @@ -130,7 +130,7 @@ public async Task GenerateProfileCard(IUser user) background.Composite(image, (int)layer.StartX, (int)layer.StartY, CompositeOperator.Over); } - var l = new MagickImage(MagickColors.Transparent, (int)layer.Width, (int)layer.Height); + var l = new MagickImage(MagickColors.Transparent, (uint)layer.Width, (uint)layer.Height); foreach (var module in layer.Modules) module.GetDrawables(profile).Draw(l); background.Composite(l, (int)layer.StartX, (int)layer.StartY, CompositeOperator.Over); diff --git a/DiscordBot/Skin/AvatarBorderSkinModule.cs b/DiscordBot/Skin/AvatarBorderSkinModule.cs index 4e3ddc0a..48fee26c 100644 --- a/DiscordBot/Skin/AvatarBorderSkinModule.cs +++ b/DiscordBot/Skin/AvatarBorderSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -16,7 +17,7 @@ public AvatarBorderSkinModule() public string Type { get; set; } = string.Empty; - public Drawables GetDrawables(ProfileData data) + public IDrawables GetDrawables(ProfileData data) { var avatarContourStartX = StartX; var avatarContourStartY = StartY; diff --git a/DiscordBot/Skin/BaseTextSkinModule.cs b/DiscordBot/Skin/BaseTextSkinModule.cs index 5e59edf0..a9faae8e 100644 --- a/DiscordBot/Skin/BaseTextSkinModule.cs +++ b/DiscordBot/Skin/BaseTextSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -33,20 +34,23 @@ public BaseTextSkinModule() public virtual string Type { get; set; } = string.Empty; - public virtual Drawables GetDrawables(ProfileData data) + public virtual IDrawables GetDrawables(ProfileData data) { var position = new PointD(StartX, StartY); - return new Drawables() + var drawables = new Drawables() .FontPointSize(FontPointSize) .Font(Font) .StrokeColor(new MagickColor(StrokeColor)) .StrokeWidth(StrokeWidth) - .StrokeAntialias(StrokeAntiAlias) .FillColor(new MagickColor(FillColor)) - .TextAntialias(TextAntiAlias) .TextAlignment(TextAlignment) .TextKerning(TextKerning) .Text(position.X, position.Y, Text); + + if (StrokeAntiAlias) drawables.EnableStrokeAntialias(); else drawables.DisableStrokeAntialias(); + if (TextAntiAlias) drawables.EnableTextAntialias(); else drawables.DisableTextAntialias(); + + return drawables; } } \ No newline at end of file diff --git a/DiscordBot/Skin/CustomTextSkinModule.cs b/DiscordBot/Skin/CustomTextSkinModule.cs index 76f61a56..2a24a377 100644 --- a/DiscordBot/Skin/CustomTextSkinModule.cs +++ b/DiscordBot/Skin/CustomTextSkinModule.cs @@ -1,6 +1,7 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -15,7 +16,7 @@ public CustomTextSkinModule() FontPointSize = 15; } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { var textPosition = new PointD(StartX, StartY); @@ -34,16 +35,19 @@ public override Drawables GetDrawables(ProfileData data) * Or {Nickname} for ProfileData.Nickname */ - return new Drawables() + var drawables = new Drawables() .FontPointSize(FontPointSize) .Font(Font) .StrokeColor(new MagickColor(StrokeColor)) .StrokeWidth(StrokeWidth) - .StrokeAntialias(StrokeAntiAlias) .FillColor(new MagickColor(FillColor)) .TextAlignment(TextAlignment) - .TextAntialias(TextAntiAlias) .TextKerning(TextKerning) .Text(textPosition.X, textPosition.Y, $"{Text ?? Text}"); + + if (StrokeAntiAlias) drawables.EnableStrokeAntialias(); else drawables.DisableStrokeAntialias(); + if (TextAntiAlias) drawables.EnableTextAntialias(); else drawables.DisableTextAntialias(); + + return drawables; } } \ No newline at end of file diff --git a/DiscordBot/Skin/ISkinModule.cs b/DiscordBot/Skin/ISkinModule.cs index 4414f814..b56dde68 100644 --- a/DiscordBot/Skin/ISkinModule.cs +++ b/DiscordBot/Skin/ISkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -7,5 +8,5 @@ public interface ISkinModule { string Type { get; set; } - Drawables GetDrawables(ProfileData data); + IDrawables GetDrawables(ProfileData data); } \ No newline at end of file diff --git a/DiscordBot/Skin/KarmaPointsSkinModule.cs b/DiscordBot/Skin/KarmaPointsSkinModule.cs index 397a2950..0db247be 100644 --- a/DiscordBot/Skin/KarmaPointsSkinModule.cs +++ b/DiscordBot/Skin/KarmaPointsSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -14,7 +15,7 @@ public KarmaPointsSkinModule() FontPointSize = 17; } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { Text = $"{data.Karma}"; return base.GetDrawables(data); diff --git a/DiscordBot/Skin/KarmaRankSkinModule.cs b/DiscordBot/Skin/KarmaRankSkinModule.cs index fce379ec..231c64db 100644 --- a/DiscordBot/Skin/KarmaRankSkinModule.cs +++ b/DiscordBot/Skin/KarmaRankSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -14,7 +15,7 @@ public KarmaRankSkinModule() FontPointSize = 17; } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { Text = $"#{data.KarmaRank}"; return base.GetDrawables(data); diff --git a/DiscordBot/Skin/LevelSkinModule.cs b/DiscordBot/Skin/LevelSkinModule.cs index 587f095c..953a8fe5 100644 --- a/DiscordBot/Skin/LevelSkinModule.cs +++ b/DiscordBot/Skin/LevelSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -14,7 +15,7 @@ public LevelSkinModule() FontPointSize = 50; } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { Text = data.Level.ToString(); return base.GetDrawables(data); diff --git a/DiscordBot/Skin/RectangleSampleAvatarColorSkinModule.cs b/DiscordBot/Skin/RectangleSampleAvatarColorSkinModule.cs index 88bf5c8d..d2594233 100644 --- a/DiscordBot/Skin/RectangleSampleAvatarColorSkinModule.cs +++ b/DiscordBot/Skin/RectangleSampleAvatarColorSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -17,7 +18,7 @@ public class RectangleSampleAvatarColorSkinModule : ISkinModule public string Type { get; set; } = string.Empty; - public Drawables GetDrawables(ProfileData data) + public IDrawables GetDrawables(ProfileData data) { var color = DetermineColor(data.Picture); @@ -28,10 +29,13 @@ public Drawables GetDrawables(ProfileData data) private MagickColor DetermineColor(MagickImage dataPicture) { - //basically we let magick to choose what the main color by resizing to 1x1 var copy = new MagickImage(dataPicture); copy.Resize(1, 1); - var color = copy.GetPixels()[0, 0].ToColor(); + var pixels = copy.GetPixels(); + var pixelColor = pixels?[0, 0]?.ToColor(); + var color = pixelColor != null + ? new MagickColor(pixelColor.R, pixelColor.G, pixelColor.B) + : new MagickColor(DefaultColor); if (WhiteFix && color.R + color.G + color.B > 650) color = new MagickColor(DefaultColor); diff --git a/DiscordBot/Skin/TotalXpSkinModule.cs b/DiscordBot/Skin/TotalXpSkinModule.cs index 67d63331..cbcfde3c 100644 --- a/DiscordBot/Skin/TotalXpSkinModule.cs +++ b/DiscordBot/Skin/TotalXpSkinModule.cs @@ -1,6 +1,7 @@ using System.Globalization; using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -13,7 +14,7 @@ public TotalXpSkinModule() FontPointSize = 17; } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { Text = data.XpTotal.ToString("N0", new CultureInfo("en-US")); return base.GetDrawables(data); diff --git a/DiscordBot/Skin/UsernameSkinModule.cs b/DiscordBot/Skin/UsernameSkinModule.cs index da1a2427..c455a767 100644 --- a/DiscordBot/Skin/UsernameSkinModule.cs +++ b/DiscordBot/Skin/UsernameSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -13,7 +14,7 @@ public UsernameSkinModule() FillColor = MagickColors.DeepSkyBlue.ToString(); } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { Text = $"{data.Nickname ?? data.Username}"; return base.GetDrawables(data); diff --git a/DiscordBot/Skin/XpBarInfoSkinModule.cs b/DiscordBot/Skin/XpBarInfoSkinModule.cs index 5fdc99e6..b663f576 100644 --- a/DiscordBot/Skin/XpBarInfoSkinModule.cs +++ b/DiscordBot/Skin/XpBarInfoSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -13,7 +14,7 @@ public XpBarInfoSkinModule() FontPointSize = 17; } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { Text = $"{data.XpShown:#,##0} / {data.MaxXpShown:N0} ({Math.Floor(data.XpPercentage * 100):0}%)"; return base.GetDrawables(data); diff --git a/DiscordBot/Skin/XpBarSkinModule.cs b/DiscordBot/Skin/XpBarSkinModule.cs index c105a64c..39b8978f 100644 --- a/DiscordBot/Skin/XpBarSkinModule.cs +++ b/DiscordBot/Skin/XpBarSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -28,7 +29,7 @@ public XpBarSkinModule() public string Type { get; set; } = string.Empty; - public Drawables GetDrawables(ProfileData data) + public IDrawables GetDrawables(ProfileData data) { var xpBarOutsideRectangle = new RectangleD(StartX, StartY, StartX + Width, StartY + Height); diff --git a/DiscordBot/Skin/XpRankSkinModule.cs b/DiscordBot/Skin/XpRankSkinModule.cs index c00f18b5..695cdb34 100644 --- a/DiscordBot/Skin/XpRankSkinModule.cs +++ b/DiscordBot/Skin/XpRankSkinModule.cs @@ -1,5 +1,6 @@ using DiscordBot.Domain; using ImageMagick; +using ImageMagick.Drawing; namespace DiscordBot.Skin; @@ -12,7 +13,7 @@ public XpRankSkinModule() FontPointSize = 17; } - public override Drawables GetDrawables(ProfileData data) + public override IDrawables GetDrawables(ProfileData data) { Text = $"#{data.XpRank}"; return base.GetDrawables(data); From 233cfbb841b58b8bf5555cc930bbec236bbca808 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 15:37:20 +0200 Subject: [PATCH 33/48] =?UTF-8?q?fix:=20resolve=20CS1998=20warnings=20?= =?UTF-8?q?=E2=80=94=20remove=20async=20from=20sync=20methods=20and=20dele?= =?UTF-8?q?te=20dead=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete dead stub AirportService.GetFlightTickets (no callers, returned null) - Delete dead CommandHandlingService.GetCommandHistory (no callers) - Remove async from XpService.UpdateXp (async work is in nested Task.Run) --- DiscordBot/Services/AirportService.cs | 6 ------ DiscordBot/Services/CommandHandlingService.cs | 15 --------------- DiscordBot/Services/XpService.cs | 10 ++++++---- 3 files changed, 6 insertions(+), 25 deletions(-) diff --git a/DiscordBot/Services/AirportService.cs b/DiscordBot/Services/AirportService.cs index 4229459c..f8516013 100644 --- a/DiscordBot/Services/AirportService.cs +++ b/DiscordBot/Services/AirportService.cs @@ -196,12 +196,6 @@ public AirportService(DiscordSocketClient client, ILoggingService loggingService return result.response.airports.FirstOrDefault(a => !string.IsNullOrEmpty(a.iata_code)); } - public async Task GetFlightTickets(string from, string to) - { - - return null; - } - #region Utility Methods public async Task GetValidationToken() diff --git a/DiscordBot/Services/CommandHandlingService.cs b/DiscordBot/Services/CommandHandlingService.cs index 93675b64..7c9cf674 100644 --- a/DiscordBot/Services/CommandHandlingService.cs +++ b/DiscordBot/Services/CommandHandlingService.cs @@ -300,19 +300,4 @@ public void AddToCommandHistory(SocketUserMessage message, string? error = defau _commandHistory.RemoveAt(0); } - public async Task GetCommandHistory(int count = 10) - { - if (count > _commandHistory.Count) - count = _commandHistory.Count; - if (count == 0) - count = 10; - - var commandHistory = new StringBuilder(); - for (var i = _commandHistory.Count - 1; i >= 0 && count > 0; i--, count--) - { - var command = _commandHistory[i]; - commandHistory.AppendLine($"{command.Time} - {command.User}[{command.UserId}] used {command.Command} in {command.Channel} {(string.IsNullOrEmpty(command.Error) ? string.Empty : $"Error: {command.Error}")}"); - } - return commandHistory.ToString(); - } } diff --git a/DiscordBot/Services/XpService.cs b/DiscordBot/Services/XpService.cs index a683d752..b9fa6406 100644 --- a/DiscordBot/Services/XpService.cs +++ b/DiscordBot/Services/XpService.cs @@ -35,17 +35,17 @@ public XpService(DiscordSocketClient client, DatabaseService databaseService, IL client.MessageReceived += EventGuard.Guarded(UpdateXp, nameof(UpdateXp)); } - private async Task UpdateXp(SocketMessage messageParam) + private Task UpdateXp(SocketMessage messageParam) { if (messageParam.Author.IsBot) - return; + return Task.CompletedTask; if (_noXpChannels.Contains(messageParam.Channel.Id)) - return; + return Task.CompletedTask; var userId = messageParam.Author.Id; if (_xpCooldown.HasUser(userId)) - return; + return Task.CompletedTask; var waitTime = _rand.Next(_xpMinCooldown, _xpMaxCooldown); float baseXp = _rand.Next(_xpMinPerMessage, _xpMaxPerMessage); @@ -79,6 +79,8 @@ private async Task UpdateXp(SocketMessage messageParam) await LevelUp(messageParam, userId); }); + + return Task.CompletedTask; } private async Task LevelUp(SocketMessage messageParam, ulong userId) From 8e9811669e8e4c146a984bd6122eaf248949b0dc Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 15:41:02 +0200 Subject: [PATCH 34/48] fix: replace obsolete APIs (SYSLIB0014, SYSLIB0021, SYSLIB0013) - Replace WebRequest/HttpWebRequest with HttpClient in InternetExtensions - Replace SHA256CryptoServiceProvider with SHA256.HashData in StringExtensions - Replace Uri.EscapeUriString with Uri.EscapeDataString in UpdateService --- DiscordBot/Extensions/InternetExtensions.cs | 15 ++++----------- DiscordBot/Extensions/StringExtensions.cs | 9 +-------- DiscordBot/Services/UpdateService.cs | 2 +- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/DiscordBot/Extensions/InternetExtensions.cs b/DiscordBot/Extensions/InternetExtensions.cs index 84b93099..8abf7d34 100644 --- a/DiscordBot/Extensions/InternetExtensions.cs +++ b/DiscordBot/Extensions/InternetExtensions.cs @@ -1,24 +1,17 @@ -using System.IO; using System.Net; +using System.Net.Http; namespace DiscordBot.Extensions; public static class InternetExtensions { - /// - /// Loads a webpage and returns the contents as a string, Return an empty string on failure. - /// + private static readonly HttpClient _httpClient = new(); + public static async Task GetHttpContents(string uri) { try { - var request = (HttpWebRequest)WebRequest.Create(uri); - request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; - - using var response = (HttpWebResponse)await request.GetResponseAsync(); - await using var stream = response.GetResponseStream(); - using var reader = new StreamReader(stream); - return await reader.ReadToEndAsync(); + return await _httpClient.GetStringAsync(uri); } catch (Exception e) { diff --git a/DiscordBot/Extensions/StringExtensions.cs b/DiscordBot/Extensions/StringExtensions.cs index ad14b5a9..fc0e3679 100644 --- a/DiscordBot/Extensions/StringExtensions.cs +++ b/DiscordBot/Extensions/StringExtensions.cs @@ -103,19 +103,12 @@ public static int CalculateLevenshteinDistance(this string source1, string sourc public static string GetSha256(this string input) { - var hash = new SHA256CryptoServiceProvider(); - // Convert the input string to a byte array and compute the hash. - var data = hash.ComputeHash(Encoding.UTF8.GetBytes(input)); + var data = SHA256.HashData(Encoding.UTF8.GetBytes(input)); - // Create a new Stringbuilder to collect the bytes - // and create a string. var sb = new StringBuilder(); - // Loop through each byte of the hashed data - // and format each one as a hexadecimal string. for (var i = 0; i < data.Length; i++) sb.Append(data[i].ToString("x2")); - // Return the hexadecimal string. return sb.ToString(); } diff --git a/DiscordBot/Services/UpdateService.cs b/DiscordBot/Services/UpdateService.cs index 787db7bc..41a00f22 100644 --- a/DiscordBot/Services/UpdateService.cs +++ b/DiscordBot/Services/UpdateService.cs @@ -222,7 +222,7 @@ private async Task UpdateRssFeeds() public async Task<(string? name, string? extract, string? url)> DownloadWikipediaArticle(string searchQuery) { - var wikiSearchUri = Uri.EscapeUriString(_settings.WikipediaSearchPage + searchQuery); + var wikiSearchUri = _settings.WikipediaSearchPage + Uri.EscapeDataString(searchQuery); var htmlWeb = new HtmlWeb { CaptureRedirect = true }; HtmlDocument wikiSearchResponse; From 264d52b08c0257c5fcb54e2841afa53ef5056e0f Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 15:46:42 +0200 Subject: [PATCH 35/48] =?UTF-8?q?fix:=20resolve=20all=20CS4014=20warnings?= =?UTF-8?q?=20=E2=80=94=20discard=20fire-and-forget,=20await=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _ = discard to 9 intentional fire-and-forget logging calls - Add _ = discard to 2 intentional Task.Run() background operations - Fix 2 bugs in TipService.ReplaceTip: await RemoveTip/AddTip to prevent race condition with CommitTipDatabase Build now produces 0 warnings, 0 errors. --- DiscordBot/Services/BirthdayAnnouncementService.cs | 10 +++++----- DiscordBot/Services/FeedService.cs | 2 +- DiscordBot/Services/Recruitment/RecruitService.cs | 4 ++-- DiscordBot/Services/Tips/TipService.cs | 8 ++++---- DiscordBot/Services/WelcomeService.cs | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/DiscordBot/Services/BirthdayAnnouncementService.cs b/DiscordBot/Services/BirthdayAnnouncementService.cs index 9f83b2f7..e4ae8fc7 100644 --- a/DiscordBot/Services/BirthdayAnnouncementService.cs +++ b/DiscordBot/Services/BirthdayAnnouncementService.cs @@ -68,7 +68,7 @@ private async Task CheckBirthdaysLoop() { _announcedToday.Clear(); _lastAnnouncementDate = DateTime.Today; - _loggingService.LogAction($"[{ServiceName}] New day detected, reset announced birthdays list.", ExtendedLogSeverity.Info); + _ = _loggingService.LogAction($"[{ServiceName}] New day detected, reset announced birthdays list.", ExtendedLogSeverity.Info); } await CheckAndAnnounceBirthdays(); @@ -100,7 +100,7 @@ private async Task CheckAndAnnounceBirthdays() var channel = _client.GetChannel(_settings.BirthdayAnnouncementChannel.Id) as SocketTextChannel; if (channel == null) { - _loggingService.LogAction($"[{ServiceName}] Could not find birthday announcement channel with ID {_settings.BirthdayAnnouncementChannel.Id}", ExtendedLogSeverity.Warning); + _ = _loggingService.LogAction($"[{ServiceName}] Could not find birthday announcement channel with ID {_settings.BirthdayAnnouncementChannel.Id}", ExtendedLogSeverity.Warning); return; } @@ -117,12 +117,12 @@ private async Task CheckAndAnnounceBirthdays() await channel.SendMessageAsync(message); _announcedToday.Add(announcementKey); - _loggingService.LogAction($"[{ServiceName}] Announced birthday for {birthday.Name}", ExtendedLogSeverity.Info); + _ = _loggingService.LogAction($"[{ServiceName}] Announced birthday for {birthday.Name}", ExtendedLogSeverity.Info); } } catch (Exception e) { - _loggingService.LogAction($"[{ServiceName}] Error checking birthdays: {e.Message}", ExtendedLogSeverity.LowWarning); + _ = _loggingService.LogAction($"[{ServiceName}] Error checking birthdays: {e.Message}", ExtendedLogSeverity.LowWarning); } } @@ -168,7 +168,7 @@ private async Task> GetTodaysBirthdays() } catch (Exception e) { - _loggingService.LogAction($"[{ServiceName}] Error fetching birthday data: {e.Message}", ExtendedLogSeverity.LowWarning); + _ = _loggingService.LogAction($"[{ServiceName}] Error fetching birthday data: {e.Message}", ExtendedLogSeverity.LowWarning); } return birthdays; diff --git a/DiscordBot/Services/FeedService.cs b/DiscordBot/Services/FeedService.cs index 71761b08..e03a7ebc 100644 --- a/DiscordBot/Services/FeedService.cs +++ b/DiscordBot/Services/FeedService.cs @@ -131,7 +131,7 @@ private async Task HandleFeed(FeedData feedData, ForumNewsFeed newsFeed, ulong c } catch (Exception e) { - _logging.LogChannelAndFile($"[{ServiceName}] Error generating release notes: {e}\nLikely updated format.", ExtendedLogSeverity.Warning); + _ = _logging.LogChannelAndFile($"[{ServiceName}] Error generating release notes: {e}\nLikely updated format.", ExtendedLogSeverity.Warning); releaseNotes = new List { "No release notes found" }; } newsContent = releaseNotes[0]; diff --git a/DiscordBot/Services/Recruitment/RecruitService.cs b/DiscordBot/Services/Recruitment/RecruitService.cs index dad9bcd8..30838f96 100644 --- a/DiscordBot/Services/Recruitment/RecruitService.cs +++ b/DiscordBot/Services/Recruitment/RecruitService.cs @@ -139,7 +139,7 @@ private async Task GatewayOnThreadCreated(SocketThreadChannel thread) return; } - Task.Run(async () => + _ = Task.Run(async () => { if (!DoesThreadHaveAValidTag(thread)) { @@ -169,7 +169,7 @@ private async Task GatewayOnThreadCreated(SocketThreadChannel thread) // Any Notices that we can recommend the user for improvement if (message.Content.Length < MinimumLengthMessage) { - Task.Run(() => ThreadHandleShortMessage(thread, message)); + _ = Task.Run(() => ThreadHandleShortMessage(thread, message)); } await Task.Delay(millisecondsDelay: 200); diff --git a/DiscordBot/Services/Tips/TipService.cs b/DiscordBot/Services/Tips/TipService.cs index db731843..ffc3087e 100644 --- a/DiscordBot/Services/Tips/TipService.cs +++ b/DiscordBot/Services/Tips/TipService.cs @@ -260,8 +260,8 @@ public async Task ReplaceTip(IUserMessage message, Tip tip, string content) return; } - RemoveTip(message, tip); - AddTip(message, string.Join(",", tip.Keywords), content); + await RemoveTip(message, tip); + await AddTip(message, string.Join(",", tip.Keywords), content); // REVIEW: causes two CommitTipDatabase calls } @@ -272,7 +272,7 @@ public async Task ReloadTipDatabase() { var json = File.ReadAllText(jsonPath); _tips = JsonConvert.DeserializeObject>>(json)!; - _loggingService.LogAction( + _ = _loggingService.LogAction( $"[{ServiceName}] Tip index has {_tips.Count} keywords.", ExtendedLogSeverity.Info); } @@ -304,7 +304,7 @@ public async Task ReloadTipDatabase() if (touched) { - _loggingService.LogAction( + _ = _loggingService.LogAction( $"[{ServiceName}] Tip index was de-duplicated.", ExtendedLogSeverity.Info); await CommitTipDatabase(); diff --git a/DiscordBot/Services/WelcomeService.cs b/DiscordBot/Services/WelcomeService.cs index 092522ae..8a096e9d 100644 --- a/DiscordBot/Services/WelcomeService.cs +++ b/DiscordBot/Services/WelcomeService.cs @@ -177,7 +177,7 @@ private async Task DelayedWelcomeService() // Restart unless shutdown was requested if (!_shutdownToken.IsCancellationRequested) - Task.Run(DelayedWelcomeService); + _ = Task.Run(DelayedWelcomeService); } } From 5baf562b7aa2e54c3394cc3890fb89942da31968 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 16:00:01 +0200 Subject: [PATCH 36/48] refactor: remove dead code detected by IDE0051/IDE0052 analyzers - Add .editorconfig with IDE0051/IDE0052 warnings for unused private members - Enable EnforceCodeStyleInBuild in csproj - Remove 3 unused private consts (NextBirthdayUrl, 2x ServiceName) - Remove 3 unread fields from Program.cs (commandHandlingService, unityHelpService, recruitService) - Remove 9 unread DI-injected fields and their constructor params across 7 services - Suppress IDE0051 in Modules/ directory (Discord.Net discovers handlers via reflection) --- .editorconfig | 10 ++++++++++ DiscordBot/DiscordBot.csproj | 1 + DiscordBot/Program.cs | 10 +++------- DiscordBot/Services/AirportService.cs | 8 +------- DiscordBot/Services/BirthdayAnnouncementService.cs | 3 +-- DiscordBot/Services/Casino/GameService.cs | 7 +------ DiscordBot/Services/DuelService.cs | 4 +--- DiscordBot/Services/ProfileCardService.cs | 2 -- DiscordBot/Services/Recruitment/RecruitService.cs | 4 +--- DiscordBot/Services/UnityHelp/CannedResponseService.cs | 2 -- DiscordBot/Services/UnityHelp/UnityHelpService.cs | 4 +--- DiscordBot/Services/UpdateService.cs | 5 +---- DiscordBot/Services/WeatherService.cs | 4 +--- 13 files changed, 22 insertions(+), 42 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..ba610bc3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*.cs] +# Unused private members +dotnet_diagnostic.IDE0051.severity = warning +dotnet_diagnostic.IDE0052.severity = warning + +# Discord.Net discovers command handlers via reflection — they appear unused to static analysis +[DiscordBot/Modules/**.cs] +dotnet_diagnostic.IDE0051.severity = none diff --git a/DiscordBot/DiscordBot.csproj b/DiscordBot/DiscordBot.csproj index 927d4068..7cd3ea3a 100644 --- a/DiscordBot/DiscordBot.csproj +++ b/DiscordBot/DiscordBot.csproj @@ -4,6 +4,7 @@ net8.0 12 enable + true diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index c63b368c..bba0b7e0 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -20,15 +20,11 @@ public class Program private static BotSettings _settings = null!; private static UserSettings _userSettings = null!; private DiscordSocketClient _client = null!; - private CommandHandlingService _commandHandlingService = null!; private CommandService _commandService = null!; private InteractionService _interactionService = null!; private IServiceProvider _services = null!; - private UnityHelpService _unityHelpService = null!; - private RecruitService _recruitService = null!; - private readonly CancellationTokenSource _cts = new(); public static void Main(string[] args) => @@ -67,7 +63,7 @@ private async Task MainAsync() }); _services = ConfigureServices(); - _commandHandlingService = _services.GetRequiredService(); + _services.GetRequiredService(); // Announce, and Log bot started to track issues a bit easier var logger = _services.GetRequiredService(); @@ -75,8 +71,8 @@ private async Task MainAsync() LoggingService.LogToConsole("Bot is connected.", ExtendedLogSeverity.Positive); - _unityHelpService = _services.GetRequiredService(); - _recruitService = _services.GetRequiredService(); + _services.GetRequiredService(); + _services.GetRequiredService(); _services.GetRequiredService(); _services.GetRequiredService(); _services.GetRequiredService(); diff --git a/DiscordBot/Services/AirportService.cs b/DiscordBot/Services/AirportService.cs index f8516013..31613df8 100644 --- a/DiscordBot/Services/AirportService.cs +++ b/DiscordBot/Services/AirportService.cs @@ -1,7 +1,6 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Text; -using Discord.WebSocket; using DiscordBot.Settings; using DiscordBot.Utils; using Newtonsoft.Json; @@ -10,9 +9,6 @@ namespace DiscordBot.Services; public class AirportService { - private readonly DiscordSocketClient _client; - private readonly ILoggingService _loggingService; - #region Amadeus private readonly string _flightApiKey; @@ -171,10 +167,8 @@ public class AirLabsSuperRoot #endregion // AirLabs - public AirportService(DiscordSocketClient client, ILoggingService loggingService, BotSettings botSettings) + public AirportService(BotSettings botSettings) { - _client = client; - _loggingService = loggingService; _flightApiKey = botSettings.FlightAPIKey; _flightSecret = botSettings.FlightAPISecret; diff --git a/DiscordBot/Services/BirthdayAnnouncementService.cs b/DiscordBot/Services/BirthdayAnnouncementService.cs index e4ae8fc7..d8f80217 100644 --- a/DiscordBot/Services/BirthdayAnnouncementService.cs +++ b/DiscordBot/Services/BirthdayAnnouncementService.cs @@ -21,8 +21,7 @@ public class BirthdayAnnouncementService private readonly HashSet _announcedToday = new(); private DateTime _lastAnnouncementDate = DateTime.Today; - // URLs for birthday data from the existing !bday command - private const string NextBirthdayUrl = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&range=C15:C15"; + // URL for birthday data from the existing !bday command private const string BirthdayTableUrl = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&gid=318080247&range=B:D"; public BirthdayAnnouncementService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings, diff --git a/DiscordBot/Services/Casino/GameService.cs b/DiscordBot/Services/Casino/GameService.cs index abaddfcf..e799b916 100644 --- a/DiscordBot/Services/Casino/GameService.cs +++ b/DiscordBot/Services/Casino/GameService.cs @@ -1,21 +1,16 @@ using Discord.WebSocket; using DiscordBot.Domain; using DiscordBot.Modules; -using DiscordBot.Settings; namespace DiscordBot.Services; public class GameService { - private readonly ILoggingService _loggingService; - private readonly BotSettings _settings; private readonly System.Collections.Concurrent.ConcurrentDictionary _activeSessions = new(); private readonly CasinoService _casinoService; - public GameService(ILoggingService loggingService, BotSettings settings, CasinoService casinoService) + public GameService(CasinoService casinoService) { - _loggingService = loggingService; - _settings = settings; _casinoService = casinoService; } diff --git a/DiscordBot/Services/DuelService.cs b/DiscordBot/Services/DuelService.cs index f6ef4121..bec08cbb 100644 --- a/DiscordBot/Services/DuelService.cs +++ b/DiscordBot/Services/DuelService.cs @@ -7,7 +7,6 @@ public class DuelService { private readonly ConcurrentDictionary _activeDuels = new(); private readonly Random _random = new(); - private readonly ILoggingService _loggingService; private static readonly string[] NormalWinMessages = { @@ -21,9 +20,8 @@ public class DuelService "{winner} overwhelms {loser} with superior technique and emerges victorious!" }; - public DuelService(ILoggingService loggingService) + public DuelService() { - _loggingService = loggingService; } public bool IsDuelActive(string duelKey) => _activeDuels.ContainsKey(duelKey); diff --git a/DiscordBot/Services/ProfileCardService.cs b/DiscordBot/Services/ProfileCardService.cs index 2221403e..c3b6d8c6 100644 --- a/DiscordBot/Services/ProfileCardService.cs +++ b/DiscordBot/Services/ProfileCardService.cs @@ -10,8 +10,6 @@ namespace DiscordBot.Services; public class ProfileCardService { - private const string ServiceName = "ProfileCardService"; - private readonly DatabaseService _databaseService; private readonly ILoggingService _loggingService; private readonly IHttpClientFactory _httpClientFactory; diff --git a/DiscordBot/Services/Recruitment/RecruitService.cs b/DiscordBot/Services/Recruitment/RecruitService.cs index 30838f96..592138c4 100644 --- a/DiscordBot/Services/Recruitment/RecruitService.cs +++ b/DiscordBot/Services/Recruitment/RecruitService.cs @@ -9,7 +9,6 @@ public class RecruitService private const string ServiceName = "RecruitmentService"; private readonly DiscordSocketClient _client; - private readonly ILoggingService _logging; private SocketRole ModeratorRole { get; set; } #region Extra Details @@ -50,10 +49,9 @@ public class RecruitService #endregion // Configuration - public RecruitService(DiscordSocketClient client, ILoggingService logging, BotSettings settings) + public RecruitService(DiscordSocketClient client, BotSettings settings) { _client = client; - _logging = logging; ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.ModeratorRoleId); if (!settings.RecruitmentServiceEnabled) diff --git a/DiscordBot/Services/UnityHelp/CannedResponseService.cs b/DiscordBot/Services/UnityHelp/CannedResponseService.cs index 1a5bca3e..40543739 100644 --- a/DiscordBot/Services/UnityHelp/CannedResponseService.cs +++ b/DiscordBot/Services/UnityHelp/CannedResponseService.cs @@ -2,8 +2,6 @@ namespace DiscordBot.Service; public class CannedResponseService { - private const string ServiceName = "CannedResponseService"; - #region Configuration public enum CannedResponseType diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index 28115e03..6f188c3a 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -11,7 +11,6 @@ public class UnityHelpService private const string ServiceName = "UnityHelpService"; private readonly DiscordSocketClient _client; - private readonly ILoggingService _logging; private SocketRole ModeratorRole { get; set; } #region Configuration @@ -77,10 +76,9 @@ public class UnityHelpService #endregion // Extra Details - public UnityHelpService(DiscordSocketClient client, BotSettings settings, ILoggingService logging) + public UnityHelpService(DiscordSocketClient client, BotSettings settings) { _client = client; - _logging = logging; ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.ModeratorRoleId); diff --git a/DiscordBot/Services/UpdateService.cs b/DiscordBot/Services/UpdateService.cs index 41a00f22..d5093607 100644 --- a/DiscordBot/Services/UpdateService.cs +++ b/DiscordBot/Services/UpdateService.cs @@ -54,18 +54,15 @@ public class UpdateService private string[][] _apiDatabase = null!; private BotData _botData = null!; - private readonly DiscordSocketClient _client; private List _faqData = null!; private FeedData _feedData = null!; private string[][] _manualDatabase = null!; private UserData _userData = null!; - public UpdateService(DiscordSocketClient client, - DatabaseService databaseService, BotSettings settings, FeedService feedService, ILoggingService loggingService, + public UpdateService(DatabaseService databaseService, BotSettings settings, FeedService feedService, ILoggingService loggingService, CancellationTokenSource cts) { - _client = client; _feedService = feedService; _loggingService = (loggingService as LoggingService)!; diff --git a/DiscordBot/Services/WeatherService.cs b/DiscordBot/Services/WeatherService.cs index d82e2894..c02c4166 100644 --- a/DiscordBot/Services/WeatherService.cs +++ b/DiscordBot/Services/WeatherService.cs @@ -9,13 +9,11 @@ public class WeatherService { private const string ServiceName = "FeedService"; - private readonly DiscordSocketClient _client; private readonly ILoggingService _loggingService; private readonly string _weatherApiKey; - public WeatherService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings) + public WeatherService(ILoggingService loggingService, BotSettings settings) { - _client = client; _loggingService = loggingService; _weatherApiKey = settings.WeatherAPIKey; From 36ede1e39bd54e9bd34ce587e60e92c54ecee54e Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 16:55:32 +0200 Subject: [PATCH 37/48] refactor(tips): remove unused DumpTipDatabase method --- DiscordBot/Services/Tips/TipService.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/DiscordBot/Services/Tips/TipService.cs b/DiscordBot/Services/Tips/TipService.cs index ffc3087e..7a3c3757 100644 --- a/DiscordBot/Services/Tips/TipService.cs +++ b/DiscordBot/Services/Tips/TipService.cs @@ -325,11 +325,6 @@ await File.WriteAllTextAsync(jsonPath, settings)); } - public string DumpTipDatabase() - { - return JsonConvert.SerializeObject(_tips); - } - public Tip? GetTip(ulong Id) { foreach (var kvp in _tips) From aebbb6c5f0e7eadd5bdbd2728025665dac550764 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 17:33:52 +0200 Subject: [PATCH 38/48] refactor(settings): clean up dead configs, fix naming and casing, restrict GatewayIntents - Remove dead K8s config entries: TipsAuthorRoleId, IPGeolocationAPIKey, announcementsChannel, Administrator, isSomeoneThere, thanksReminderCooldown - Rename TagUnitHelpResolvedTag -> TagUnityHelpResolved (typo + redundant suffix) - Standardize JSON key casing to PascalCase across Settings.example.json and both K8s bot-config.yaml files - Restrict GatewayIntents.All to minimal required set (Guilds, GuildMembers, GuildMessages, GuildMessageReactions, DirectMessages, MessageContent) - Remove outdated 'Currently Unused' comments on Invite setting - Remove dead isSomeoneThere from UserSettings.json and TODO from UserSettings.cs --- DiscordBot/Program.cs | 7 +++- .../Services/UnityHelp/UnityHelpService.cs | 2 +- DiscordBot/Settings/Deserialized/Settings.cs | 2 +- .../Settings/Deserialized/UserSettings.cs | 2 - DiscordBot/Settings/Settings.example.json | 32 ++++++++-------- DiscordBot/Settings/UserSettings.json | 7 +--- k8s/dev/bot-config.yaml | 37 ++++++++---------- k8s/dev/bot-settings-config.yaml | 9 +---- k8s/prod/bot-config.yaml | 38 ++++++++----------- k8s/prod/bot-settings-config.yaml | 9 +---- 10 files changed, 57 insertions(+), 88 deletions(-) diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index bba0b7e0..9163ba2f 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -42,7 +42,12 @@ private async Task MainAsync() LogLevel = LogSeverity.Verbose, AlwaysDownloadUsers = true, MessageCacheSize = 1024, - GatewayIntents = GatewayIntents.All, + GatewayIntents = GatewayIntents.Guilds + | GatewayIntents.GuildMembers + | GatewayIntents.GuildMessages + | GatewayIntents.GuildMessageReactions + | GatewayIntents.DirectMessages + | GatewayIntents.MessageContent, }); _client.Log += LoggingService.DiscordNetLogger; diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index 6f188c3a..c95f70d2 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -94,7 +94,7 @@ public UnityHelpService(DiscordSocketClient client, BotSettings settings) { LoggingService.LogToConsole($"[{ServiceName}] Help channel not found", LogSeverity.Error); } - var resolvedTag = _helpChannel!.Tags.FirstOrDefault(x => x.Id == ulong.Parse(settings.TagUnitHelpResolvedTag)); + var resolvedTag = _helpChannel!.Tags.FirstOrDefault(x => x.Id == ulong.Parse(settings.TagUnityHelpResolved)); if (resolvedTag == null || resolvedTag.Id <= 0) LoggingService.LogToConsole($"[{ServiceName}] Resolved tag not found", LogSeverity.Error); _resolvedForumTag = resolvedTag; diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index f2f62870..1d5da554 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -114,7 +114,7 @@ public class BotSettings #endregion // Tips - public string TagUnitHelpResolvedTag { get; set; } = string.Empty; + public string TagUnityHelpResolved { get; set; } = string.Empty; #endregion // Unity Help Threads diff --git a/DiscordBot/Settings/Deserialized/UserSettings.cs b/DiscordBot/Settings/Deserialized/UserSettings.cs index e981b8ca..a6092f2b 100644 --- a/DiscordBot/Settings/Deserialized/UserSettings.cs +++ b/DiscordBot/Settings/Deserialized/UserSettings.cs @@ -12,6 +12,4 @@ public class UserSettings public int XpMaxCooldown { get; set; } = 180; public int CodeReminderCooldown { get; set; } = 86400; - - //TODO Introduce notice for asking for help "Can someone help" when they haven't posted in a couple minutes would be a giveaway that they should be reminded to post their question, and not just ask if someone is there. } \ No newline at end of file diff --git a/DiscordBot/Settings/Settings.example.json b/DiscordBot/Settings/Settings.example.json index 417103c3..d5aa2fcf 100644 --- a/DiscordBot/Settings/Settings.example.json +++ b/DiscordBot/Settings/Settings.example.json @@ -1,19 +1,19 @@ { /* Auth Info requires creating a Bot which can be done through https://discordapp.com/developers/applications/ (Make sure to give it Administrator Perms)*/ /* Auth info */ - "token": "Y O U R _ B O T _ T O K E N", - "invite": "InviteLink", // Currently Unused + "Token": "Y O U R _ B O T _ T O K E N", + "Invite": "InviteLink", /* DB Info*/ "DbConnectionString": "Host=localhost;Port=5432;Database=udcbot;Username=udcbot;Password=USERPASSWORD", /*Server Info*/ - "serverRootPath": "./SERVER", - "assetsRootPath": "./Assets", + "ServerRootPath": "./SERVER", + "AssetsRootPath": "./Assets", /* Base info */ - "prefix": "!", + "Prefix": "!", "ModeratorRoleId": "0", - "guildId": "0", // Replace with your servers guild ID + "GuildId": "0", // Replace with your servers guild ID /* Channel IDs for certain channels. */ - "generalChannel": { // Off-topic + "GeneralChannel": { // Off-topic "desc": "General-Chat Channel", "id": "0" }, @@ -21,15 +21,15 @@ "desc": "Introductions Channel", "id": "0" }, - "botAnnouncementChannel": { // Most bot logs will go here + "BotAnnouncementChannel": { // Most bot logs will go here "desc": "Bot-Announcement Channel", "id": "0" }, - "botCommandsChannel": { + "BotCommandsChannel": { "desc": "Bot-Commands Channel", "id": "0" }, - "unityNewsChannel": { + "UnityNewsChannel": { "desc": "Unity News Channel", "id": "0" }, @@ -37,12 +37,12 @@ "SubsNewsRoleId": "0", "SubsReleasesRoleId": "0", /*Complaints Channels Stuff*/ - "complaintCategoryId": "0", - "complaintChannelPrefix": "Complaint", - "closedComplaintChannelPrefix": "Closed-", - "closedComplaintCategoryId": "662084543662129175", + "ComplaintCategoryId": "0", + "ComplaintChannelPrefix": "Complaint", + "ClosedComplaintChannelPrefix": "Closed-", + "ClosedComplaintCategoryId": "662084543662129175", /*Commands Configuration*/ - "wikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", + "WikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", "EveryoneScoldPeriodSeconds": "21600", /*API Keys*/ "WeatherAPIKey": "", // Key for openweathermap.org @@ -62,7 +62,7 @@ "EditPermissionAccessTimeMin": 3, /* Unity Help Service */ "UnityHelpBabySitterEnabled": false, - "genericHelpChannel": { + "GenericHelpChannel": { // Unity-help "desc": "Unity-Help Channel", "id": "0" diff --git a/DiscordBot/Settings/UserSettings.json b/DiscordBot/Settings/UserSettings.json index 578c6a26..89fce836 100644 --- a/DiscordBot/Settings/UserSettings.json +++ b/DiscordBot/Settings/UserSettings.json @@ -30,10 +30,5 @@ "xpMinCooldown": 60, "xpMaxCooldown": 180, /*Code parameters*/ - "codeReminderCooldown": 86400, - "isSomeoneThere": [ - "is anyone around?", - "can someone help?", - "can someone help me?" - ] + "codeReminderCooldown": 86400 } diff --git a/k8s/dev/bot-config.yaml b/k8s/dev/bot-config.yaml index 7af6183b..439a7c09 100644 --- a/k8s/dev/bot-config.yaml +++ b/k8s/dev/bot-config.yaml @@ -12,35 +12,30 @@ metadata: data: Settings.json: | { - "token": "${BOT_TOKEN}", + "Token": "${BOT_TOKEN}", "DbConnectionString": "Host=postgresql;Port=5432;Database=udcbot;Username=udcbot;Password=${DB_PASSWORD}", - "invite": "InviteLink", // Currently Unused + "Invite": "InviteLink", /*Server Info*/ - "serverRootPath": "./SERVER", - "assetsRootPath": "./Assets", + "ServerRootPath": "./SERVER", + "AssetsRootPath": "./Assets", /* Base info */ - "prefix": "!", - "Administrator": "838030241103478805", + "Prefix": "!", "ModeratorRoleId": "769010537119088690", - "guildId": "566084539664039938", // Replace with your servers guild ID + "GuildId": "566084539664039938", // Replace with your servers guild ID /* Channel IDs for certain channels. */ - "generalChannel": { // Off-topic + "GeneralChannel": { // Off-topic "desc": "General-Chat Channel", "id": "566084539664039944" }, - "botAnnouncementChannel": { // Most bot logs will go here + "BotAnnouncementChannel": { // Most bot logs will go here "desc": "Bot-Announcement Channel", "id": "567628191221547008" }, - "announcementsChannel": { // Not used by bot - "desc": "General Announcement Channel", - "id": "838030934728376320" // Currently Unused 29/04/21 - }, - "botCommandsChannel": { + "BotCommandsChannel": { "desc": "Bot-Commands Channel", "id": "599583999379243008" }, - "unityNewsChannel": { + "UnityNewsChannel": { "desc": "Unity News Channel", "id": "1022102744552710154" }, @@ -55,13 +50,11 @@ data: /* Role Ids */ "SubsReleasesRoleId": "769870886743703584", "TipsUserRoleId": "603187742096228374", - "TipsAuthorRoleId": "603187742096228374", /*Complaints Channels Stuff*/ - "complaintCategoryId": "874631331810799626", - "complaintChannelPrefix": "Complaint", + "ComplaintCategoryId": "874631331810799626", + "ComplaintChannelPrefix": "Complaint", /*Commands Configuration*/ - "wikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", - "IPGeolocationAPIKey": "${IPGEO_KEY}", + "WikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", "WeatherAPIKey": "${WEATHER_KEY}", "FlightAPIKey": "${FLIGHT_KEY}", "FlightAPISecret": "${FLIGHT_SECRET}", @@ -78,11 +71,11 @@ data: "TagPositionFilled": "1134674041450545283", /* Unity Help Service */ "UnityHelpBabySitterEnabled": true, - "genericHelpChannel": { // Unity-help + "GenericHelpChannel": { // Unity-help "desc": "Unity-Help Channel", "id": "1028254982748778516" }, - "TagUnitHelpResolvedTag": "1028255134356086784", + "TagUnityHelpResolved": "1028255134356086784", "IntroductionChannel": { "desc": "Introduction Channel", "id": "1198575542467838044" diff --git a/k8s/dev/bot-settings-config.yaml b/k8s/dev/bot-settings-config.yaml index 445f79a0..39db80f9 100644 --- a/k8s/dev/bot-settings-config.yaml +++ b/k8s/dev/bot-settings-config.yaml @@ -197,7 +197,6 @@ data: "谢谢" ], "thanksCooldown": 60, //In seconds - "thanksReminderCooldown": 86400, //24 hours in seconds "thanksMinJoinTime": 600, /*Xp parameters*/ @@ -207,13 +206,7 @@ data: "xpMaxCooldown": 180, /*Code parameters*/ - "codeReminderCooldown": 86400, - - "isSomeoneThere": [ - "is anyone around?", - "can someone help?", - "can someone help me?" - ] + "codeReminderCooldown": 86400 } --- apiVersion: v1 diff --git a/k8s/prod/bot-config.yaml b/k8s/prod/bot-config.yaml index 2ca82987..81080bec 100644 --- a/k8s/prod/bot-config.yaml +++ b/k8s/prod/bot-config.yaml @@ -12,32 +12,26 @@ metadata: data: Settings.json: | { - "token": "${BOT_TOKEN}", + "Token": "${BOT_TOKEN}", "DbConnectionString": "Host=postgresql;Port=5432;Database=udcbot;Username=udcbot;Password=${DB_PASSWORD}", - "invite": "https://discord.gg/bu3bbby", // Currently Unused - /* DB Info*/ + "Invite": "https://discord.gg/bu3bbby", /*Server Info*/ - "serverRootPath": "./SERVER", - "assetsRootPath": "./Assets", + "ServerRootPath": "./SERVER", + "AssetsRootPath": "./Assets", /* Base info */ - "prefix": "!", - "Administrator": "493514411026153482", + "Prefix": "!", "ModeratorRoleId": "493514490504019969", - "guildId": "493510779866316801", // Replace with your servers guild ID + "GuildId": "493510779866316801", // Replace with your servers guild ID /* Channel IDs for certain channels. */ - "generalChannel": { // Off-topic + "GeneralChannel": { // Off-topic "desc": "General-Chat Channel", "id": "493511024037724180" }, - "botAnnouncementChannel": { // Most bot logs will go here + "BotAnnouncementChannel": { // Most bot logs will go here "desc": "Bot-Announcement Channel", "id": "493512007144833055" }, - "announcementsChannel": { // Not used by bot - "desc": "General Announcement Channel", - "id": "493510992320528404" // Currently Unused 29/04/21 - }, - "botCommandsChannel": { + "BotCommandsChannel": { "desc": "Bot-Commands Channel", "id": "493512044973260811" }, @@ -49,15 +43,13 @@ data: "SubsNewsRoleId": "1209260621342707772", "SubsReleasesRoleId": "523205962279157771", "TipsUserRoleId": "493514563736698880", - "TipsAuthorRoleId": "99999", /*Complaints Channels Stuff*/ - "complaintCategoryId": "520853507851681797", - "complaintChannelPrefix": "Complaint", - "closedComplaintCategoryId": "662084543662129175", - "closedComplaintChannelPrefix": "Closed-", + "ComplaintCategoryId": "520853507851681797", + "ComplaintChannelPrefix": "Complaint", + "ClosedComplaintCategoryId": "662084543662129175", + "ClosedComplaintChannelPrefix": "Closed-", /*Commands Configuration*/ - "wikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", - "IPGeolocationAPIKey": "${IPGEO_KEY}", + "WikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", "WeatherAPIKey": "${WEATHER_KEY}", "FlightAPIKey": "${FLIGHT_KEY}", "FlightAPISecret": "${FLIGHT_SECRET}", @@ -88,7 +80,7 @@ data: "desc": "Unity-Help Channel", "id": "1019663870798856212" }, - "TagUnitHelpResolvedTag": "1019672922811551815", + "TagUnityHelpResolved": "1019672922811551815", "IntroductionChannel": { "desc": "Introduction Channel", "id": "768488410959708210" diff --git a/k8s/prod/bot-settings-config.yaml b/k8s/prod/bot-settings-config.yaml index edc70cd4..399cfcc0 100644 --- a/k8s/prod/bot-settings-config.yaml +++ b/k8s/prod/bot-settings-config.yaml @@ -133,7 +133,6 @@ data: "Slàinte" ], "thanksCooldown": 60, //In seconds - "thanksReminderCooldown": 86400, //24 hours in seconds "thanksMinJoinTime": 600, /*Xp parameters*/ @@ -143,13 +142,7 @@ data: "xpMaxCooldown": 180, /*Code parameters*/ - "codeReminderCooldown": 86400, - - "isSomeoneThere": [ - "is anyone around?", - "can someone help?", - "can someone help me?" - ] + "codeReminderCooldown": 86400 } --- apiVersion: v1 From af2415eca7e57b2d25340449331d3b92a754bdf3 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 17:47:52 +0200 Subject: [PATCH 39/48] feat(settings): add startup validation for BotSettings and UserSettings BotSettings.Validate() checks: - Fatal: Token, GuildId, Prefix, DbConnectionString, CasinoStartingTokens<0 - Warnings: ServerRootPath, 7 core channels, conditional checks for Birthday/Recruitment/UnityHelp/Casino settings when services are enabled UserSettings.Validate() checks: - XpMin/Max range inversions, ThanksCooldown<=0, empty Thanks list Bot now fails fast with clear error messages on critical misconfigurations instead of crashing at runtime with unhelpful NullReferenceExceptions. --- DiscordBot/Program.cs | 12 ++++ DiscordBot/Settings/Deserialized/Settings.cs | 60 +++++++++++++++++++ .../Settings/Deserialized/UserSettings.cs | 16 +++++ 3 files changed, 88 insertions(+) diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index 9163ba2f..a19d393a 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -153,5 +153,17 @@ private static void DeserializeSettings() _settings = SerializeUtil.DeserializeFile(@"Settings/Settings.json"); _rules = SerializeUtil.DeserializeFile(@"Settings/Rules.json"); _userSettings = SerializeUtil.DeserializeFile(@"Settings/UserSettings.json"); + + var (errors, warnings) = _settings.Validate(); + warnings.AddRange(_userSettings.Validate()); + foreach (var warning in warnings) + Console.WriteLine($"[Settings Warning] {warning}"); + if (errors.Count > 0) + { + foreach (var error in errors) + Console.Error.WriteLine($"[Settings Error] {error}"); + throw new InvalidOperationException( + $"Bot settings validation failed with {errors.Count} error(s). See output above."); + } } } diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index 1d5da554..d74684bc 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -16,6 +16,66 @@ public class BotSettings #endregion // Important + public (List Errors, List Warnings) Validate() + { + var errors = new List(); + var warnings = new List(); + + if (string.IsNullOrWhiteSpace(Token)) + errors.Add("Token is not configured — bot cannot authenticate"); + if (GuildId == 0) + errors.Add("GuildId is not configured"); + if (Prefix == '\0') + errors.Add("Prefix is not configured"); + if (string.IsNullOrWhiteSpace(DbConnectionString)) + errors.Add("DbConnectionString is not configured — database features will fail"); + + if (string.IsNullOrWhiteSpace(ServerRootPath)) + warnings.Add("ServerRootPath is empty — runtime data storage may fail"); + + ValidateChannel(warnings, GeneralChannel, nameof(GeneralChannel)); + ValidateChannel(warnings, IntroductionChannel, nameof(IntroductionChannel)); + ValidateChannel(warnings, BotAnnouncementChannel, nameof(BotAnnouncementChannel)); + ValidateChannel(warnings, BotCommandsChannel, nameof(BotCommandsChannel)); + ValidateChannel(warnings, UnityNewsChannel, nameof(UnityNewsChannel)); + ValidateChannel(warnings, UnityReleasesChannel, nameof(UnityReleasesChannel)); + ValidateChannel(warnings, RulesChannel, nameof(RulesChannel)); + + if (BirthdayAnnouncementEnabled) + ValidateChannel(warnings, BirthdayAnnouncementChannel, nameof(BirthdayAnnouncementChannel)); + + if (RecruitmentServiceEnabled) + { + ValidateChannel(warnings, RecruitmentChannel, nameof(RecruitmentChannel)); + if (string.IsNullOrWhiteSpace(TagLookingToHire)) + warnings.Add("RecruitmentService enabled but TagLookingToHire is empty"); + if (string.IsNullOrWhiteSpace(TagLookingForWork)) + warnings.Add("RecruitmentService enabled but TagLookingForWork is empty"); + if (string.IsNullOrWhiteSpace(TagUnpaidCollab)) + warnings.Add("RecruitmentService enabled but TagUnpaidCollab is empty"); + if (string.IsNullOrWhiteSpace(TagPositionFilled)) + warnings.Add("RecruitmentService enabled but TagPositionFilled is empty"); + } + + if (UnityHelpBabySitterEnabled) + { + ValidateChannel(warnings, GenericHelpChannel, nameof(GenericHelpChannel)); + if (string.IsNullOrWhiteSpace(TagUnityHelpResolved)) + warnings.Add("UnityHelpBabySitter enabled but TagUnityHelpResolved is empty"); + } + + if (CasinoEnabled && CasinoStartingTokens < 0) + errors.Add("CasinoStartingTokens is negative"); + + return (errors, warnings); + } + + private static void ValidateChannel(List warnings, ChannelInfo? channel, string name) + { + if (channel is null || channel.Id == 0) + warnings.Add($"{name} is not configured (null or Id=0)"); + } + #region Configuration public int WelcomeMessageDelaySeconds { get; set; } = 300; diff --git a/DiscordBot/Settings/Deserialized/UserSettings.cs b/DiscordBot/Settings/Deserialized/UserSettings.cs index a6092f2b..01fed4c4 100644 --- a/DiscordBot/Settings/Deserialized/UserSettings.cs +++ b/DiscordBot/Settings/Deserialized/UserSettings.cs @@ -12,4 +12,20 @@ public class UserSettings public int XpMaxCooldown { get; set; } = 180; public int CodeReminderCooldown { get; set; } = 86400; + + public List Validate() + { + var warnings = new List(); + + if (XpMinPerMessage > XpMaxPerMessage) + warnings.Add($"XpMinPerMessage ({XpMinPerMessage}) > XpMaxPerMessage ({XpMaxPerMessage})"); + if (XpMinCooldown > XpMaxCooldown) + warnings.Add($"XpMinCooldown ({XpMinCooldown}) > XpMaxCooldown ({XpMaxCooldown})"); + if (ThanksCooldown <= 0) + warnings.Add($"ThanksCooldown is {ThanksCooldown} — should be positive"); + if (Thanks.Count == 0) + warnings.Add("Thanks list is empty — thanks/karma feature will never trigger"); + + return warnings; + } } \ No newline at end of file From 858ac3ccd2bb356ff1618bba30cbdbb0a2444883 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 18:16:08 +0200 Subject: [PATCH 40/48] refactor!: split BotSettings into nested domain-specific config classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganize the flat 60+ property BotSettings class into 8 nested domain objects: ChannelSettings, RoleSettings, RecruitmentSettings, UnityHelpSettings, CasinoSettings, BirthdaySettings, ApiKeySettings, and FunCommandSettings. - Rewrite Settings.cs with typed nested classes and sensible defaults - Update all 24 consuming files to use new accessor paths - Restructure Settings.example.json, k8s dev/prod configs to nested JSON - Fix CS0168 unused variable in FunModule.cs - Update casino.md stale reference, mark S2/S3 in audit checklist - Add missing config entries (SubsNews, ClosedComplaint) to dev/example BREAKING CHANGE: Settings.json must use nested structure — flat property names (e.g. CasinoEnabled, ModeratorRoleId) are no longer recognized. See Settings.example.json for the new format. --- .../Attributes/BotCommandChannelAttribute.cs | 4 +- DiscordBot/Attributes/RoleAttributes.cs | 2 +- DiscordBot/Modules/AirportModule.cs | 4 +- DiscordBot/Modules/FunModule.cs | 10 +- DiscordBot/Modules/ReminderModule.cs | 2 +- DiscordBot/Modules/ServerModule.cs | 4 +- DiscordBot/Modules/TicketModule.cs | 22 +- DiscordBot/Modules/TipModule.cs | 4 +- .../UnityHelp/UnityHelpInteractiveModule.cs | 10 +- .../Modules/UnityHelp/UnityHelpModule.cs | 6 +- DiscordBot/Services/AirportService.cs | 6 +- DiscordBot/Services/AuditLogService.cs | 4 +- .../Services/BirthdayAnnouncementService.cs | 12 +- DiscordBot/Services/Casino/CasinoService.cs | 20 +- DiscordBot/Services/CodeCheckService.cs | 2 +- DiscordBot/Services/FeedService.cs | 6 +- DiscordBot/Services/LoggingService.cs | 6 +- .../Services/Recruitment/RecruitService.cs | 26 +- DiscordBot/Services/ReminderService.cs | 2 +- DiscordBot/Services/Tips/TipService.cs | 12 +- .../Services/UnityHelp/UnityHelpService.cs | 12 +- DiscordBot/Services/WeatherService.cs | 2 +- DiscordBot/Services/WelcomeService.cs | 6 +- DiscordBot/Services/XpService.cs | 2 +- DiscordBot/Settings/Deserialized/Settings.cs | 247 +++++++----------- DiscordBot/Settings/Settings.example.json | 122 ++++----- docs/casino.md | 2 +- docs/code-quality-audit.md | 6 +- k8s/dev/bot-config.yaml | 128 ++++----- k8s/prod/bot-config.yaml | 146 +++++------ 30 files changed, 376 insertions(+), 461 deletions(-) diff --git a/DiscordBot/Attributes/BotCommandChannelAttribute.cs b/DiscordBot/Attributes/BotCommandChannelAttribute.cs index cc2b525a..654f0fc9 100644 --- a/DiscordBot/Attributes/BotCommandChannelAttribute.cs +++ b/DiscordBot/Attributes/BotCommandChannelAttribute.cs @@ -11,12 +11,12 @@ public override async Task CheckPermissionsAsync(ICommandCon { var settings = services.GetRequiredService(); - if (context.Channel.Id == settings.BotCommandsChannel.Id) + if (context.Channel.Id == settings.Channels.BotCommands.Id) { return await Task.FromResult(PreconditionResult.FromSuccess()); } _ = context.Message.DeleteAfterSeconds(seconds: 10); - return await Task.FromResult(PreconditionResult.FromError($"This command can only be used in <#{settings.BotCommandsChannel.Id.ToString()}>.")); + return await Task.FromResult(PreconditionResult.FromError($"This command can only be used in <#{settings.Channels.BotCommands.Id.ToString()}>.")); } } \ No newline at end of file diff --git a/DiscordBot/Attributes/RoleAttributes.cs b/DiscordBot/Attributes/RoleAttributes.cs index a0c6d639..01b33b2b 100644 --- a/DiscordBot/Attributes/RoleAttributes.cs +++ b/DiscordBot/Attributes/RoleAttributes.cs @@ -28,7 +28,7 @@ public override Task CheckPermissionsAsync(ICommandContext c var settings = services.GetRequiredService(); - if (user.Roles.Any(x => x.Id == settings.ModeratorRoleId)) return Task.FromResult(PreconditionResult.FromSuccess()); + if (user.Roles.Any(x => x.Id == settings.Roles.Moderator)) return Task.FromResult(PreconditionResult.FromSuccess()); return Task.FromResult(PreconditionResult.FromError(user + " attempted to use a moderator command!")); } } \ No newline at end of file diff --git a/DiscordBot/Modules/AirportModule.cs b/DiscordBot/Modules/AirportModule.cs index 98b1993c..1b5b8f1b 100644 --- a/DiscordBot/Modules/AirportModule.cs +++ b/DiscordBot/Modules/AirportModule.cs @@ -41,9 +41,9 @@ public class FlightRoot public async Task FlyTo(string from, string to) { // Make sure command is in Bot-Commands or OffTopic - if (Context.Channel.Id != Settings.BotCommandsChannel.Id && Context.Channel.Id != Settings.GeneralChannel.Id) + if (Context.Channel.Id != Settings.Channels.BotCommands.Id && Context.Channel.Id != Settings.Channels.General.Id) { - await (ReplyAsync($"Command can only be used in <#{Settings.BotCommandsChannel.Id}> or <#{Settings.GeneralChannel.Id}>.").DeleteAfterSeconds(5f) ?? Task.CompletedTask); + await (ReplyAsync($"Command can only be used in <#{Settings.Channels.BotCommands.Id}> or <#{Settings.Channels.General.Id}>.").DeleteAfterSeconds(5f) ?? Task.CompletedTask); await (Context.Message.DeleteAfterSeconds(2f) ?? Task.CompletedTask); return; } diff --git a/DiscordBot/Modules/FunModule.cs b/DiscordBot/Modules/FunModule.cs index 26999e27..b1cb9471 100644 --- a/DiscordBot/Modules/FunModule.cs +++ b/DiscordBot/Modules/FunModule.cs @@ -23,21 +23,21 @@ public async Task SlapUser(params IUser[] users) try { if (_slapObjects.Count == 0) - _slapObjects.Load(Settings.UserModuleSlapObjectsTable!); + _slapObjects.Load(Settings.FunCommands.SlapObjectsTable!); } - catch (Exception e) + catch (Exception) { - await LoggingService.LogChannelAndFile($"Error while loading '{Settings.UserModuleSlapObjectsTable}'.\nEx:{e}", + await LoggingService.LogChannelAndFile($"Error while loading '{Settings.FunCommands.SlapObjectsTable}'.", ExtendedLogSeverity.LowWarning); return; } if (_slapObjects.Count == 0) - _slapObjects.Add(Settings.UserModuleSlapChoices); + _slapObjects.Add(Settings.FunCommands.SlapChoices); if (_slapObjects.Count == 0) _slapObjects.Add("fish|mallet"); if (_slapFails.Count == 0) - _slapFails.Add(Settings.UserModuleSlapFails); + _slapFails.Add(Settings.FunCommands.SlapFails); if (_slapFails.Count == 0) _slapFails.Add("hurting themselves"); diff --git a/DiscordBot/Modules/ReminderModule.cs b/DiscordBot/Modules/ReminderModule.cs index d86f41df..45f44205 100644 --- a/DiscordBot/Modules/ReminderModule.cs +++ b/DiscordBot/Modules/ReminderModule.cs @@ -152,7 +152,7 @@ public async Task Reminders(IUser user) $"#{index++} | {Utils.Utils.FormatTime((uint)(reminder.When - DateTime.Now).TotalSeconds)}", $"[Link]({msgLink}) \"{reminder.Message}\""); } - if (await Context.Guild.GetChannelAsync(Settings.BotCommandsChannel.Id) is IMessageChannel botCommands) + if (await Context.Guild.GetChannelAsync(Settings.Channels.BotCommands.Id) is IMessageChannel botCommands) await (botCommands .SendMessageAsync(Context.User.Mention, false, embed.Build()) .DeleteAfterSeconds(seconds: 30) ?? Task.CompletedTask); diff --git a/DiscordBot/Modules/ServerModule.cs b/DiscordBot/Modules/ServerModule.cs index 94a63848..e6fbf441 100644 --- a/DiscordBot/Modules/ServerModule.cs +++ b/DiscordBot/Modules/ServerModule.cs @@ -18,7 +18,7 @@ public class ServerModule : ModuleBase public async Task DisplayHelp() { var commandMessages = CommandHandlingService.GetCommandListMessages("UserModule", false, true, false); - if (Context.Channel.Id != Settings.BotCommandsChannel.Id) + if (Context.Channel.Id != Settings.Channels.BotCommands.Id) { try { @@ -29,7 +29,7 @@ public async Task DisplayHelp() } catch (Exception) { - await ReplyAsync($"Your direct messages are disabled, please use <#{Settings.BotCommandsChannel.Id}> instead!").DeleteAfterSeconds(10)!; + await ReplyAsync($"Your direct messages are disabled, please use <#{Settings.Channels.BotCommands.Id}> instead!").DeleteAfterSeconds(10)!; } } else diff --git a/DiscordBot/Modules/TicketModule.cs b/DiscordBot/Modules/TicketModule.cs index 819538f1..2274c6c6 100644 --- a/DiscordBot/Modules/TicketModule.cs +++ b/DiscordBot/Modules/TicketModule.cs @@ -24,14 +24,14 @@ public async Task Complaint() { await Context.Message.DeleteAsync(); - var categoryExist = (await Context.Guild.GetCategoriesAsync()).Any(category => category.Id == Settings.ComplaintCategoryId); + var categoryExist = (await Context.Guild.GetCategoriesAsync()).Any(category => category.Id == Settings.Channels.ComplaintCategoryId); var hash = Context.User.Id.ToString().GetSha256().Substring(0, 8); - var channelName = ParseToDiscordChannel($"{Settings.ComplaintChannelPrefix}-{hash}"); + var channelName = ParseToDiscordChannel($"{Settings.Channels.ComplaintPrefix}-{hash}"); var channels = await Context.Guild.GetChannelsAsync(); // Check if channel with same name already exist in the Complaint Category (if it exists). - if (channels.Any(channel => channel.Name == channelName && (!categoryExist || ((INestedChannel)channel).CategoryId == Settings.ComplaintCategoryId))) + if (channels.Any(channel => channel.Name == channelName && (!categoryExist || ((INestedChannel)channel).CategoryId == Settings.Channels.ComplaintCategoryId))) { await ReplyAsync($"{Context.User.Mention}, you already have an open complaint! Please use that channel!") .DeleteAfterSeconds(15)!; @@ -40,11 +40,11 @@ await ReplyAsync($"{Context.User.Mention}, you already have an open complaint! P var newChannel = await Context.Guild.CreateTextChannelAsync(channelName, x => { - if (categoryExist) x.CategoryId = Settings.ComplaintCategoryId; + if (categoryExist) x.CategoryId = Settings.Channels.ComplaintCategoryId; }); var userPerms = new OverwritePermissions(viewChannel: PermValue.Allow); - var modRole = Context.Guild.Roles.First(r => r.Id == Settings.ModeratorRoleId); + var modRole = Context.Guild.Roles.First(r => r.Id == Settings.Roles.Moderator); await newChannel.AddPermissionOverwriteAsync(Context.Guild.EveryoneRole, new OverwritePermissions(viewChannel: PermValue.Deny)); await newChannel.AddPermissionOverwriteAsync(Context.User, userPerms); await newChannel.AddPermissionOverwriteAsync(modRole, userPerms); @@ -66,9 +66,9 @@ public async Task Close() { await Context.Message.DeleteAsync(); - if (!Context.Channel.Name.StartsWith(Settings.ComplaintChannelPrefix.ToLower())) return; + if (!Context.Channel.Name.StartsWith(Settings.Channels.ComplaintPrefix.ToLower())) return; - var categoryExist = (await Context.Guild.GetCategoriesAsync()).Any(category => category.Id == Settings.ClosedComplaintCategoryId); + var categoryExist = (await Context.Guild.GetCategoriesAsync()).Any(category => category.Id == Settings.Channels.ClosedComplaintCategoryId); var currentChannel = await Context.Guild.GetChannelAsync(Context.Channel.Id); @@ -81,10 +81,10 @@ public async Task Close() await currentChannel.RemovePermissionOverwriteAsync(user); } - var newName = Settings.ClosedComplaintChannelPrefix + currentChannel.Name; + var newName = Settings.Channels.ClosedComplaintPrefix + currentChannel.Name; await currentChannel.ModifyAsync(x => { - if (categoryExist) x.CategoryId = Settings.ClosedComplaintCategoryId; + if (categoryExist) x.CategoryId = Settings.Channels.ClosedComplaintCategoryId; x.Name = newName; }); } @@ -98,8 +98,8 @@ private async Task Delete() { await Context.Message.DeleteAsync(); - if (Context.Channel.Name.StartsWith(Settings.ComplaintChannelPrefix.ToLower()) || - Context.Channel.Name.StartsWith(Settings.ClosedComplaintChannelPrefix.ToLower())) + if (Context.Channel.Name.StartsWith(Settings.Channels.ComplaintPrefix.ToLower()) || + Context.Channel.Name.StartsWith(Settings.Channels.ClosedComplaintPrefix.ToLower())) { await Context.Guild.GetChannelAsync(Context.Channel.Id).Result.DeleteAsync(); } diff --git a/DiscordBot/Modules/TipModule.cs b/DiscordBot/Modules/TipModule.cs index bf49cac9..9d9d0465 100644 --- a/DiscordBot/Modules/TipModule.cs +++ b/DiscordBot/Modules/TipModule.cs @@ -21,9 +21,9 @@ public class TipModule : ModuleBase private bool IsAuthorized(IUser user) { - if (user.HasRoleGroup(Settings.ModeratorRoleId)) + if (user.HasRoleGroup(Settings.Roles.Moderator)) return true; - if (user.HasRoleGroup(Settings.TipsUserRoleId)) + if (user.HasRoleGroup(Settings.Roles.TipsUser)) return true; return false; diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs b/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs index db752273..8b4a055b 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs +++ b/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs @@ -17,7 +17,7 @@ public class UnityHelpInteractiveModule : InteractionModuleBase [SlashCommand("resolve-question", "If in unity-help forum channel, resolve the thread")] public async Task ResolveQuestion() { - if (!BotSettings.UnityHelpBabySitterEnabled) + if (!BotSettings.UnityHelp.BabySitterEnabled) return; await Context.Interaction.DeferAsync(ephemeral: true); @@ -31,7 +31,7 @@ public async Task ResolveQuestion() if (!IsInHelpChannel()) { await Context.Interaction.FollowupAsync( - $"This command can only be used in <#{BotSettings.GenericHelpChannel.Id}> channels", ephemeral: true); + $"This command can only be used in <#{BotSettings.Channels.GenericHelp.Id}> channels", ephemeral: true); return; } @@ -45,7 +45,7 @@ await Context.Interaction.FollowupAsync( [MessageCommand("Correct Answer")] public async Task MarkResponseAnswer(IMessage targetResponse) { - if (!BotSettings.UnityHelpBabySitterEnabled) + if (!BotSettings.UnityHelp.BabySitterEnabled) return; await Context.Interaction.DeferAsync(ephemeral: true); @@ -57,7 +57,7 @@ public async Task MarkResponseAnswer(IMessage targetResponse) if (!IsInHelpChannel()) { await Context.Interaction.FollowupAsync( - $"This command can only be used in <#{BotSettings.GenericHelpChannel.Id}> channels", ephemeral: true); + $"This command can only be used in <#{BotSettings.Channels.GenericHelp.Id}> channels", ephemeral: true); return; } @@ -76,7 +76,7 @@ await Context.Interaction.FollowupAsync( #region Utility - private bool IsInHelpChannel() => Context.Channel.IsThreadInChannel(BotSettings.GenericHelpChannel.Id); + private bool IsInHelpChannel() => Context.Channel.IsThreadInChannel(BotSettings.Channels.GenericHelp.Id); private bool IsValidUser() => !Context.User.IsUserBotOrWebhook(); #endregion // Utility diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs b/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs index 6f165e4d..f9e8a0fc 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs +++ b/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs @@ -19,7 +19,7 @@ public class UnityHelpModule : ModuleBase [Summary("When a question is answered, use this command to mark it as resolved.")] public async Task ResolveAsync() { - if (!BotSettings.UnityHelpBabySitterEnabled) + if (!BotSettings.UnityHelp.BabySitterEnabled) return; if (!IsValidUser() || !IsInHelpChannel()) await Context.Message.DeleteAsync(); @@ -31,7 +31,7 @@ public async Task ResolveAsync() [RequireModerator, HideFromHelp, IgnoreBots] public async Task PendingQuestionsAsync() { - if (!BotSettings.UnityHelpBabySitterEnabled) + if (!BotSettings.UnityHelp.BabySitterEnabled) { await ReplyAsync("UnityHelp Service currently disabled.").DeleteAfterSeconds(15)!; return; @@ -42,7 +42,7 @@ public async Task PendingQuestionsAsync() #region Utility - private bool IsInHelpChannel() => Context.Channel.IsThreadInChannel(BotSettings.GenericHelpChannel.Id); + private bool IsInHelpChannel() => Context.Channel.IsThreadInChannel(BotSettings.Channels.GenericHelp.Id); private bool IsValidUser() => !Context.User.IsUserBotOrWebhook(); #endregion // Utility diff --git a/DiscordBot/Services/AirportService.cs b/DiscordBot/Services/AirportService.cs index 31613df8..6d234cdb 100644 --- a/DiscordBot/Services/AirportService.cs +++ b/DiscordBot/Services/AirportService.cs @@ -169,10 +169,10 @@ public class AirLabsSuperRoot public AirportService(BotSettings botSettings) { - _flightApiKey = botSettings.FlightAPIKey; - _flightSecret = botSettings.FlightAPISecret; + _flightApiKey = botSettings.ApiKeys.Flight; + _flightSecret = botSettings.ApiKeys.FlightSecret; - _airLabsAPIInclude = string.Format(_airLabsAPIInclude, botSettings.AirLabAPIKey); + _airLabsAPIInclude = string.Format(_airLabsAPIInclude, botSettings.ApiKeys.AirLab); _airLabsNearbyCityRoute += _airLabsAPIInclude + _airLabsAPIRequiredFields; } diff --git a/DiscordBot/Services/AuditLogService.cs b/DiscordBot/Services/AuditLogService.cs index 28cbaf5a..9e7031f4 100644 --- a/DiscordBot/Services/AuditLogService.cs +++ b/DiscordBot/Services/AuditLogService.cs @@ -23,8 +23,8 @@ public AuditLogService(DiscordSocketClient client, BotSettings settings, ILoggin client.UserLeft += EventGuard.Guarded(UserLeft, nameof(UserLeft)); client.GuildMemberUpdated += EventGuard.Guarded, SocketGuildUser>(GuildMemberUpdated, nameof(GuildMemberUpdated)); - if (settings.BotAnnouncementChannel != null) - _botAnnouncementChannel = client.GetChannel(settings.BotAnnouncementChannel.Id) as IMessageChannel; + if (settings.Channels.BotAnnouncement != null) + _botAnnouncementChannel = client.GetChannel(settings.Channels.BotAnnouncement.Id) as IMessageChannel; } private async Task MessageDeleted(Cacheable message, Cacheable channel) diff --git a/DiscordBot/Services/BirthdayAnnouncementService.cs b/DiscordBot/Services/BirthdayAnnouncementService.cs index d8f80217..7d9bf46e 100644 --- a/DiscordBot/Services/BirthdayAnnouncementService.cs +++ b/DiscordBot/Services/BirthdayAnnouncementService.cs @@ -39,20 +39,20 @@ private void Initialize() { if (IsRunning) return; - if (!_settings.BirthdayAnnouncementEnabled) + if (!_settings.Birthday.Enabled) { _loggingService.LogAction($"[{ServiceName}] Birthday announcement service is disabled in settings.", ExtendedLogSeverity.Info); return; } - if (_settings.BirthdayAnnouncementChannel?.Id == 0) + if (_settings.Channels.BirthdayAnnouncement?.Id == 0) { _loggingService.LogAction($"[{ServiceName}] Birthday announcement channel not configured.", ExtendedLogSeverity.Warning); return; } IsRunning = true; - _loggingService.LogAction($"[{ServiceName}] Starting birthday announcement service with {_settings.BirthdayCheckIntervalMinutes} minute intervals.", ExtendedLogSeverity.Info); + _loggingService.LogAction($"[{ServiceName}] Starting birthday announcement service with {_settings.Birthday.CheckIntervalMinutes} minute intervals.", ExtendedLogSeverity.Info); Task.Run(CheckBirthdaysLoop); } @@ -73,7 +73,7 @@ private async Task CheckBirthdaysLoop() await CheckAndAnnounceBirthdays(); // Wait for the configured interval - var intervalMs = _settings.BirthdayCheckIntervalMinutes * 60 * 1000; + var intervalMs = _settings.Birthday.CheckIntervalMinutes * 60 * 1000; await Task.Delay(intervalMs, _shutdownToken); } } @@ -96,10 +96,10 @@ private async Task CheckAndAnnounceBirthdays() return; // No birthdays today } - var channel = _client.GetChannel(_settings.BirthdayAnnouncementChannel.Id) as SocketTextChannel; + var channel = _client.GetChannel(_settings.Channels.BirthdayAnnouncement.Id) as SocketTextChannel; if (channel == null) { - _ = _loggingService.LogAction($"[{ServiceName}] Could not find birthday announcement channel with ID {_settings.BirthdayAnnouncementChannel.Id}", ExtendedLogSeverity.Warning); + _ = _loggingService.LogAction($"[{ServiceName}] Could not find birthday announcement channel with ID {_settings.Channels.BirthdayAnnouncement.Id}", ExtendedLogSeverity.Warning); return; } diff --git a/DiscordBot/Services/Casino/CasinoService.cs b/DiscordBot/Services/Casino/CasinoService.cs index 833de4be..182c5ddc 100644 --- a/DiscordBot/Services/Casino/CasinoService.cs +++ b/DiscordBot/Services/Casino/CasinoService.cs @@ -33,15 +33,15 @@ public async Task GetOrCreateCasinoUser(string userId) var newUser = new CasinoUser { UserID = userId, - Tokens = _settings.CasinoStartingTokens, + Tokens = _settings.Casino.StartingTokens, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, LastDailyReward = DateTime.UtcNow.AddDays(-1) // Set to a past date so user can claim their first daily reward immediately }; var createdUser = await casinoQuery.InsertCasinoUser(newUser); - await RecordTransaction(userId, _settings.CasinoStartingTokens, TransactionKind.TokenInitialisation); - await _loggingService.LogChannelAndFile($"{ServiceName}: Created new casino user {userId} with {_settings.CasinoStartingTokens} starting tokens"); + await RecordTransaction(userId, _settings.Casino.StartingTokens, TransactionKind.TokenInitialisation); + await _loggingService.LogChannelAndFile($"{ServiceName}: Created new casino user {userId} with {_settings.Casino.StartingTokens} starting tokens"); return createdUser; } catch (Exception ex) @@ -312,13 +312,13 @@ public async Task GetGameLeaderboard(string? gameName = n public bool IsChannelAllowed(ulong channelId) { - if (!_settings.CasinoEnabled) + if (!_settings.Casino.Enabled) return false; - if (_settings.CasinoAllowedChannels.Count == 0) + if (_settings.Casino.AllowedChannels.Count == 0) return true; // If no restrictions, allow all channels - return _settings.CasinoAllowedChannels.Contains(channelId); + return _settings.Casino.AllowedChannels.Contains(channelId); } #endregion @@ -332,7 +332,7 @@ public bool IsChannelAllowed(ulong channelId) var casinoQuery = _databaseService.CasinoQuery ?? throw new InvalidOperationException("Casino database is not available"); var user = await GetOrCreateCasinoUser(userId); var now = DateTime.UtcNow; - var nextRewardTime = user.LastDailyReward.AddSeconds(_settings.CasinoDailyRewardIntervalSeconds); + var nextRewardTime = user.LastDailyReward.AddSeconds(_settings.Casino.DailyRewardIntervalSeconds); if (now < nextRewardTime) { @@ -340,13 +340,13 @@ public bool IsChannelAllowed(ulong channelId) } // User can claim daily reward - var tokensAwarded = _settings.CasinoDailyRewardTokens; + var tokensAwarded = _settings.Casino.DailyRewardTokens; var newBalance = user.Tokens + tokensAwarded; await casinoQuery.UpdateTokensAndDailyReward(userId, newBalance, now, now); await RecordTransaction(userId, tokensAwarded, TransactionKind.DailyReward); await _loggingService.LogChannelAndFile($"{ServiceName}: User {userId} claimed daily reward of {tokensAwarded} tokens"); - return (true, tokensAwarded, newBalance, now.AddSeconds(_settings.CasinoDailyRewardIntervalSeconds)); + return (true, tokensAwarded, newBalance, now.AddSeconds(_settings.Casino.DailyRewardIntervalSeconds)); } catch (Exception ex) { @@ -359,7 +359,7 @@ public bool IsChannelAllowed(ulong channelId) public async Task GetNextDailyRewardTime(string userId) { var user = await GetOrCreateCasinoUser(userId); - return user.LastDailyReward.AddSeconds(_settings.CasinoDailyRewardIntervalSeconds); + return user.LastDailyReward.AddSeconds(_settings.Casino.DailyRewardIntervalSeconds); } #endregion diff --git a/DiscordBot/Services/CodeCheckService.cs b/DiscordBot/Services/CodeCheckService.cs index 56577d02..3557c410 100644 --- a/DiscordBot/Services/CodeCheckService.cs +++ b/DiscordBot/Services/CodeCheckService.cs @@ -86,7 +86,7 @@ private void SaveData() public async Task CodeCheck(SocketMessage messageParam) { - if (messageParam.Author.IsBot || messageParam.Channel.Id == _settings.GeneralChannel.Id) + if (messageParam.Author.IsBot || messageParam.Channel.Id == _settings.Channels.General.Id) return; if (messageParam.Content.Length < 200) diff --git a/DiscordBot/Services/FeedService.cs b/DiscordBot/Services/FeedService.cs index e03a7ebc..a6cd8d5a 100644 --- a/DiscordBot/Services/FeedService.cs +++ b/DiscordBot/Services/FeedService.cs @@ -202,17 +202,17 @@ private string GetSummary(ForumNewsFeed feed, SyndicationItem item) public async Task CheckUnityBetasAsync(FeedData feedData) { - await HandleFeed(feedData, _betaNews, _settings.UnityReleasesChannel.Id, _settings.SubsReleasesRoleId); + await HandleFeed(feedData, _betaNews, _settings.Channels.UnityReleases.Id, _settings.Roles.SubsReleases); } public async Task CheckUnityReleasesAsync(FeedData feedData) { - await HandleFeed(feedData, _releaseNews, _settings.UnityReleasesChannel.Id, _settings.SubsReleasesRoleId); + await HandleFeed(feedData, _releaseNews, _settings.Channels.UnityReleases.Id, _settings.Roles.SubsReleases); } public async Task CheckUnityBlogAsync(FeedData feedData) { - await HandleFeed(feedData, _blogNews, _settings.UnityNewsChannel.Id, _settings.SubsNewsRoleId); + await HandleFeed(feedData, _blogNews, _settings.Channels.UnityNews.Id, _settings.Roles.SubsNews); } #endregion // Feed Actions diff --git a/DiscordBot/Services/LoggingService.cs b/DiscordBot/Services/LoggingService.cs index 12b5bd93..447b06e2 100644 --- a/DiscordBot/Services/LoggingService.cs +++ b/DiscordBot/Services/LoggingService.cs @@ -98,15 +98,15 @@ public LoggingService(DiscordSocketClient client, BotSettings settings) } // INIT - if (settings.BotAnnouncementChannel == null) + if (settings.Channels.BotAnnouncement == null) { LogToConsole($"[{ServiceName}] Error: Logging Channel not set in settings.json", LogSeverity.Error); return; } - _logChannel = client.GetChannel(settings.BotAnnouncementChannel.Id) as ISocketMessageChannel; + _logChannel = client.GetChannel(settings.Channels.BotAnnouncement.Id) as ISocketMessageChannel; if (_logChannel == null) { - LogToConsole($"[{ServiceName}] Error: Logging Channel {settings.BotAnnouncementChannel.Id} not found", LogSeverity.Error); + LogToConsole($"[{ServiceName}] Error: Logging Channel {settings.Channels.BotAnnouncement.Id} not found", LogSeverity.Error); } } diff --git a/DiscordBot/Services/Recruitment/RecruitService.cs b/DiscordBot/Services/Recruitment/RecruitService.cs index 592138c4..3c6144fa 100644 --- a/DiscordBot/Services/Recruitment/RecruitService.cs +++ b/DiscordBot/Services/Recruitment/RecruitService.cs @@ -52,17 +52,17 @@ public class RecruitService public RecruitService(DiscordSocketClient client, BotSettings settings) { _client = client; - ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.ModeratorRoleId); + ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.Roles.Moderator); - if (!settings.RecruitmentServiceEnabled) + if (!settings.Recruitment.Enabled) { - LoggingService.LogServiceDisabled(ServiceName, nameof(settings.RecruitmentServiceEnabled)); + LoggingService.LogServiceDisabled(ServiceName, nameof(settings.Recruitment.Enabled)); return; } - _editTimePermissionInMin = settings.EditPermissionAccessTimeMin; + _editTimePermissionInMin = settings.Recruitment.EditPermissionAccessTimeMin; // Get target channel - _recruitChannel = _client.GetChannel(settings.RecruitmentChannel.Id) as IForumChannel; + _recruitChannel = _client.GetChannel(settings.Channels.Recruitment.Id) as IForumChannel; if (_recruitChannel == null) { LoggingService.LogToConsole("[{ServiceName}] Recruitment channel not found.", LogSeverity.Error); @@ -71,10 +71,10 @@ public RecruitService(DiscordSocketClient client, BotSettings settings) try { - var lookingToHire = ulong.Parse(settings.TagLookingToHire); - var lookingForWork = ulong.Parse(settings.TagLookingForWork); - var unpaidCollab = ulong.Parse(settings.TagUnpaidCollab); - var positionFilled = ulong.Parse(settings.TagPositionFilled); + var lookingToHire = ulong.Parse(settings.Recruitment.TagLookingToHire); + var lookingForWork = ulong.Parse(settings.Recruitment.TagLookingForWork); + var unpaidCollab = ulong.Parse(settings.Recruitment.TagUnpaidCollab); + var positionFilled = ulong.Parse(settings.Recruitment.TagPositionFilled); var availableTags = _recruitChannel.Tags; _tagIsHiring = availableTags.First(x => x.Id == lookingToHire); @@ -83,10 +83,10 @@ public RecruitService(DiscordSocketClient client, BotSettings settings) _tagPosFilled = availableTags.First(x => x.Id == positionFilled); // If any tags are null we print a logging warning - if (_tagIsHiring == null) StartUpTagMissing(lookingToHire, nameof(settings.TagLookingToHire)); - if (_tagWantsWork == null) StartUpTagMissing(lookingForWork, nameof(settings.TagLookingForWork)); - if (_tagUnpaidCollab == null) StartUpTagMissing(unpaidCollab, nameof(settings.TagUnpaidCollab)); - if (_tagPosFilled == null) StartUpTagMissing(positionFilled, nameof(settings.TagPositionFilled)); + if (_tagIsHiring == null) StartUpTagMissing(lookingToHire, nameof(settings.Recruitment.TagLookingToHire)); + if (_tagWantsWork == null) StartUpTagMissing(lookingForWork, nameof(settings.Recruitment.TagLookingForWork)); + if (_tagUnpaidCollab == null) StartUpTagMissing(unpaidCollab, nameof(settings.Recruitment.TagUnpaidCollab)); + if (_tagPosFilled == null) StartUpTagMissing(positionFilled, nameof(settings.Recruitment.TagPositionFilled)); } catch (Exception e) { diff --git a/DiscordBot/Services/ReminderService.cs b/DiscordBot/Services/ReminderService.cs index 8b3a64f8..811edf99 100644 --- a/DiscordBot/Services/ReminderService.cs +++ b/DiscordBot/Services/ReminderService.cs @@ -40,7 +40,7 @@ public ReminderService(DiscordSocketClient client, ILoggingService loggingServic { _client = client; _loggingService = loggingService; - _botCommandsChannel = settings.BotCommandsChannel; + _botCommandsChannel = settings.Channels.BotCommands; _serverRootPath = settings.ServerRootPath; _shutdownToken = cts.Token; diff --git a/DiscordBot/Services/Tips/TipService.cs b/DiscordBot/Services/Tips/TipService.cs index 7a3c3757..7b985260 100644 --- a/DiscordBot/Services/Tips/TipService.cs +++ b/DiscordBot/Services/Tips/TipService.cs @@ -40,14 +40,14 @@ public TipService(BotSettings settings, ILoggingService loggingService, IHttpCli return; } - if (string.IsNullOrEmpty(_settings.TipImageDirectory)) + if (string.IsNullOrEmpty(_settings.UnityHelp.TipImageDirectory)) { _loggingService.LogAction($"[{ServiceName}] TipImageDirectory not set, service will not run.", ExtendedLogSeverity.Warning); _isRunning = false; return; } - _imageDirectory = Path.Combine(_settings.ServerRootPath, _settings.TipImageDirectory); + _imageDirectory = Path.Combine(_settings.ServerRootPath, _settings.UnityHelp.TipImageDirectory); Initialize(); } @@ -67,14 +67,14 @@ private void Initialize() else { var directorySize = new DirectoryInfo(_imageDirectory).EnumerateFiles("*.*", SearchOption.AllDirectories).Sum(file => file.Length); - if (directorySize > _settings.TipMaxDirectoryFileSize) + if (directorySize > _settings.UnityHelp.TipMaxDirectoryFileSize) { - _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, exceeding the limit of {_settings.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB, no additional content will be added during this session.", ExtendedLogSeverity.Warning); + _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, exceeding the limit of {_settings.UnityHelp.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB, no additional content will be added during this session.", ExtendedLogSeverity.Warning); _readOnly = true; } else { - _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, within the limit of {_settings.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB.", ExtendedLogSeverity.Info); + _loggingService.LogAction($"[{ServiceName}] Tip directory size is {directorySize / 1024 / 1024f:.#} MB, within the limit of {_settings.UnityHelp.TipMaxDirectoryFileSize / 1024 / 1024f:.#} MB.", ExtendedLogSeverity.Info); _loggingService.LogAction($"[{ServiceName}] Tip directory contains {new DirectoryInfo(_imageDirectory).EnumerateFiles("*.*", SearchOption.AllDirectories).Count()} files.", ExtendedLogSeverity.Info); } @@ -104,7 +104,7 @@ private bool IsValidTipKeyword(string keyword) private bool IsValidTipAttachment(IAttachment attachment) { - if (attachment.Size > _settings.TipMaxImageFileSize) + if (attachment.Size > _settings.UnityHelp.TipMaxImageFileSize) return false; // Discord-friendly attachment image file formats only diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index c95f70d2..b194f85c 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -80,21 +80,21 @@ public UnityHelpService(DiscordSocketClient client, BotSettings settings) { _client = client; - ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.ModeratorRoleId); + ModeratorRole = _client.GetGuild(settings.GuildId).GetRole(settings.Roles.Moderator); - if (!settings.UnityHelpBabySitterEnabled) + if (!settings.UnityHelp.BabySitterEnabled) { - LoggingService.LogServiceDisabled(ServiceName, nameof(settings.UnityHelpBabySitterEnabled)); + LoggingService.LogServiceDisabled(ServiceName, nameof(settings.UnityHelp.BabySitterEnabled)); return; } - // get the help channel settings.GenericHelpChannel - _helpChannel = (_client.GetChannel(settings.GenericHelpChannel.Id) as IForumChannel)!; + // get the help channel settings.Channels.GenericHelp + _helpChannel = (_client.GetChannel(settings.Channels.GenericHelp.Id) as IForumChannel)!; if (_helpChannel == null) { LoggingService.LogToConsole($"[{ServiceName}] Help channel not found", LogSeverity.Error); } - var resolvedTag = _helpChannel!.Tags.FirstOrDefault(x => x.Id == ulong.Parse(settings.TagUnityHelpResolved)); + var resolvedTag = _helpChannel!.Tags.FirstOrDefault(x => x.Id == ulong.Parse(settings.UnityHelp.TagResolved)); if (resolvedTag == null || resolvedTag.Id <= 0) LoggingService.LogToConsole($"[{ServiceName}] Resolved tag not found", LogSeverity.Error); _resolvedForumTag = resolvedTag; diff --git a/DiscordBot/Services/WeatherService.cs b/DiscordBot/Services/WeatherService.cs index c02c4166..0fd17065 100644 --- a/DiscordBot/Services/WeatherService.cs +++ b/DiscordBot/Services/WeatherService.cs @@ -15,7 +15,7 @@ public class WeatherService public WeatherService(ILoggingService loggingService, BotSettings settings) { _loggingService = loggingService; - _weatherApiKey = settings.WeatherAPIKey; + _weatherApiKey = settings.ApiKeys.Weather; if (string.IsNullOrWhiteSpace(_weatherApiKey)) { diff --git a/DiscordBot/Services/WelcomeService.cs b/DiscordBot/Services/WelcomeService.cs index 8a096e9d..f86628c5 100644 --- a/DiscordBot/Services/WelcomeService.cs +++ b/DiscordBot/Services/WelcomeService.cs @@ -106,7 +106,7 @@ private async Task UserJoined(SocketGuildUser user) // Send them the Welcome DM first. await DMFormattedWelcome(user); - var socketTextChannel = _client.GetChannel(_settings.GeneralChannel.Id) as SocketTextChannel; + var socketTextChannel = _client.GetChannel(_settings.Channels.General.Id) as SocketTextChannel; await _databaseService.GetOrAddUser(user); await _loggingService.LogChannelAndFile( @@ -190,7 +190,7 @@ private async Task ProcessWelcomeUser(ulong userID, IUser? user = null) if (user == null) return; - var offTopic = await _client.GetChannelAsync(_settings.GeneralChannel.Id) as SocketTextChannel; + var offTopic = await _client.GetChannelAsync(_settings.Channels.General.Id) as SocketTextChannel; if (user is not SocketGuildUser guildUser) return; var em = WelcomeMessage(guildUser); @@ -218,7 +218,7 @@ public Embed GetWelcomeEmbed(string username = "") ":white_small_square: Do not post the same question in multiple channels.\n" + ":white_small_square: Only post links to your games in the appropriate channels.\n" + ":white_small_square: Some channels have additional rules, please check pinned messages.\n" + - $":white_small_square: A more inclusive list of rules can be found in {(_settings.RulesChannel is null || _settings.RulesChannel.Id == 0 ? "#rules" : $"<#{_settings.RulesChannel.Id.ToString()}>")}" + $":white_small_square: A more inclusive list of rules can be found in {(_settings.Channels.Rules is null || _settings.Channels.Rules.Id == 0 ? "#rules" : $"<#{_settings.Channels.Rules.Id.ToString()}>")}" ) .AddField("__PROGRAMMING RESOURCES__", ":white_small_square: Official Unity [Manual](https://docs.unity3d.com/Manual/index.html)\n" + diff --git a/DiscordBot/Services/XpService.cs b/DiscordBot/Services/XpService.cs index b9fa6406..c381f06b 100644 --- a/DiscordBot/Services/XpService.cs +++ b/DiscordBot/Services/XpService.cs @@ -30,7 +30,7 @@ public XpService(DiscordSocketClient client, DatabaseService databaseService, IL _xpMinCooldown = userSettings.XpMinCooldown; _xpMaxCooldown = userSettings.XpMaxCooldown; - _noXpChannels = new List { settings.BotCommandsChannel.Id }; + _noXpChannels = new List { settings.Channels.BotCommands.Id }; client.MessageReceived += EventGuard.Guarded(UpdateXp, nameof(UpdateXp)); } diff --git a/DiscordBot/Settings/Deserialized/Settings.cs b/DiscordBot/Settings/Deserialized/Settings.cs index d74684bc..6e9ea102 100644 --- a/DiscordBot/Settings/Deserialized/Settings.cs +++ b/DiscordBot/Settings/Deserialized/Settings.cs @@ -2,19 +2,26 @@ namespace DiscordBot.Settings; public class BotSettings { - #region Important Settings - public string Token { get; set; } = string.Empty; public string Invite { get; set; } = string.Empty; - public string DbConnectionString { get; set; } = string.Empty; public string ServerRootPath { get; set; } = string.Empty; public string AssetsRootPath { get; set; } = "./Assets"; public char Prefix { get; set; } public ulong GuildId { get; set; } public bool LogCommandExecutions { get; set; } = true; + public int WelcomeMessageDelaySeconds { get; set; } = 300; + public ulong EveryoneScoldPeriodSeconds { get; set; } = 21600; + public string WikipediaSearchPage { get; set; } = string.Empty; - #endregion // Important + public ChannelSettings Channels { get; set; } = new(); + public RoleSettings Roles { get; set; } = new(); + public RecruitmentSettings Recruitment { get; set; } = new(); + public UnityHelpSettings UnityHelp { get; set; } = new(); + public CasinoSettings Casino { get; set; } = new(); + public BirthdaySettings Birthday { get; set; } = new(); + public ApiKeySettings ApiKeys { get; set; } = new(); + public FunCommandSettings FunCommands { get; set; } = new(); public (List Errors, List Warnings) Validate() { @@ -33,39 +40,39 @@ public class BotSettings if (string.IsNullOrWhiteSpace(ServerRootPath)) warnings.Add("ServerRootPath is empty — runtime data storage may fail"); - ValidateChannel(warnings, GeneralChannel, nameof(GeneralChannel)); - ValidateChannel(warnings, IntroductionChannel, nameof(IntroductionChannel)); - ValidateChannel(warnings, BotAnnouncementChannel, nameof(BotAnnouncementChannel)); - ValidateChannel(warnings, BotCommandsChannel, nameof(BotCommandsChannel)); - ValidateChannel(warnings, UnityNewsChannel, nameof(UnityNewsChannel)); - ValidateChannel(warnings, UnityReleasesChannel, nameof(UnityReleasesChannel)); - ValidateChannel(warnings, RulesChannel, nameof(RulesChannel)); + ValidateChannel(warnings, Channels.General, nameof(Channels.General)); + ValidateChannel(warnings, Channels.Introduction, nameof(Channels.Introduction)); + ValidateChannel(warnings, Channels.BotAnnouncement, nameof(Channels.BotAnnouncement)); + ValidateChannel(warnings, Channels.BotCommands, nameof(Channels.BotCommands)); + ValidateChannel(warnings, Channels.UnityNews, nameof(Channels.UnityNews)); + ValidateChannel(warnings, Channels.UnityReleases, nameof(Channels.UnityReleases)); + ValidateChannel(warnings, Channels.Rules, nameof(Channels.Rules)); - if (BirthdayAnnouncementEnabled) - ValidateChannel(warnings, BirthdayAnnouncementChannel, nameof(BirthdayAnnouncementChannel)); + if (Birthday.Enabled) + ValidateChannel(warnings, Channels.BirthdayAnnouncement, nameof(Channels.BirthdayAnnouncement)); - if (RecruitmentServiceEnabled) + if (Recruitment.Enabled) { - ValidateChannel(warnings, RecruitmentChannel, nameof(RecruitmentChannel)); - if (string.IsNullOrWhiteSpace(TagLookingToHire)) - warnings.Add("RecruitmentService enabled but TagLookingToHire is empty"); - if (string.IsNullOrWhiteSpace(TagLookingForWork)) - warnings.Add("RecruitmentService enabled but TagLookingForWork is empty"); - if (string.IsNullOrWhiteSpace(TagUnpaidCollab)) - warnings.Add("RecruitmentService enabled but TagUnpaidCollab is empty"); - if (string.IsNullOrWhiteSpace(TagPositionFilled)) - warnings.Add("RecruitmentService enabled but TagPositionFilled is empty"); + ValidateChannel(warnings, Channels.Recruitment, nameof(Channels.Recruitment)); + if (string.IsNullOrWhiteSpace(Recruitment.TagLookingToHire)) + warnings.Add("Recruitment enabled but TagLookingToHire is empty"); + if (string.IsNullOrWhiteSpace(Recruitment.TagLookingForWork)) + warnings.Add("Recruitment enabled but TagLookingForWork is empty"); + if (string.IsNullOrWhiteSpace(Recruitment.TagUnpaidCollab)) + warnings.Add("Recruitment enabled but TagUnpaidCollab is empty"); + if (string.IsNullOrWhiteSpace(Recruitment.TagPositionFilled)) + warnings.Add("Recruitment enabled but TagPositionFilled is empty"); } - if (UnityHelpBabySitterEnabled) + if (UnityHelp.BabySitterEnabled) { - ValidateChannel(warnings, GenericHelpChannel, nameof(GenericHelpChannel)); - if (string.IsNullOrWhiteSpace(TagUnityHelpResolved)) - warnings.Add("UnityHelpBabySitter enabled but TagUnityHelpResolved is empty"); + ValidateChannel(warnings, Channels.GenericHelp, nameof(Channels.GenericHelp)); + if (string.IsNullOrWhiteSpace(UnityHelp.TagResolved)) + warnings.Add("UnityHelp BabySitter enabled but TagResolved is empty"); } - if (CasinoEnabled && CasinoStartingTokens < 0) - errors.Add("CasinoStartingTokens is negative"); + if (Casino.Enabled && Casino.StartingTokens < 0) + errors.Add("Casino.StartingTokens is negative"); return (errors, warnings); } @@ -75,148 +82,88 @@ private static void ValidateChannel(List warnings, ChannelInfo? channel, if (channel is null || channel.Id == 0) warnings.Add($"{name} is not configured (null or Id=0)"); } +} - #region Configuration - - public int WelcomeMessageDelaySeconds { get; set; } = 300; - // How long between when the bot will scold a user for trying to ping everyone. Default 6 hours - public ulong EveryoneScoldPeriodSeconds { get; set; } = 21600; - - #region Fun Commands - - public string? UserModuleSlapObjectsTable { get; set; } = null; - //NOTE: Deserializer will not override a List from the json if a default one is made here. - public List UserModuleSlapChoices { get; set; } = []; - // = { "trout", "duck", "truck", "paddle", "magikarp", "sausage", "student loan", - // "life choice", "bug report", "unhandled exception", "null pointer", "keyboard", - // "cheese wheel", "banana peel", "unresolved bug", "low poly donut" }; - public List UserModuleSlapFails { get; set; } = []; - // = { "hurting themselves" }; - - #endregion // Fun Commands - - #region Service Enabling - // Used for enabling/disabling services in the bot - - public bool RecruitmentServiceEnabled { get; set; } = false; - public bool UnityHelpBabySitterEnabled { get; set; } = false; - - #endregion // Service Enabling - - #region Birthday Announcements - - public bool BirthdayAnnouncementEnabled { get; set; } = true; - public int BirthdayCheckIntervalMinutes { get; set; } = 240; // Check every 4 hours by default - public ChannelInfo BirthdayAnnouncementChannel { get; set; } = null!; - - #endregion // Birthday Announcements - - #endregion // Configuration - - #region Channels - - public ChannelInfo IntroductionChannel { get; set; } = null!; - public ChannelInfo GeneralChannel { get; set; } = null!; - public ChannelInfo GenericHelpChannel { get; set; } = null!; - - public ChannelInfo BotAnnouncementChannel { get; set; } = null!; - public ChannelInfo BotCommandsChannel { get; set; } = null!; - public ChannelInfo UnityNewsChannel { get; set; } = null!; - public ChannelInfo UnityReleasesChannel { get; set; } = null!; - public ChannelInfo RulesChannel { get; set; } = null!; - - // Recruitment Channels - - public ChannelInfo RecruitmentChannel { get; set; } = null!; - - public ChannelInfo MemeChannel { get; set; } = null!; - - #region Complaint Channel +public class ChannelSettings +{ + public ChannelInfo Introduction { get; set; } = null!; + public ChannelInfo General { get; set; } = null!; + public ChannelInfo GenericHelp { get; set; } = null!; + public ChannelInfo BotAnnouncement { get; set; } = null!; + public ChannelInfo BotCommands { get; set; } = null!; + public ChannelInfo UnityNews { get; set; } = null!; + public ChannelInfo UnityReleases { get; set; } = null!; + public ChannelInfo Rules { get; set; } = null!; + public ChannelInfo Recruitment { get; set; } = null!; + public ChannelInfo Meme { get; set; } = null!; + public ChannelInfo BirthdayAnnouncement { get; set; } = null!; public ulong ComplaintCategoryId { get; set; } - public string ComplaintChannelPrefix { get; set; } = string.Empty; + public string ComplaintPrefix { get; set; } = string.Empty; public ulong ClosedComplaintCategoryId { get; set; } - public string ClosedComplaintChannelPrefix { get; set; } = string.Empty; - - #endregion // Complaint Channel - - #endregion // Channels - - #region User Roles - - public ulong SubsReleasesRoleId { get; set; } - public ulong SubsNewsRoleId { get; set; } - public ulong ModeratorRoleId { get; set; } - public ulong TipsUserRoleId { get; set; } // e.g., Helpers - - #endregion // User Roles + public string ClosedComplaintPrefix { get; set; } = string.Empty; +} - #region Recruitment Thread +public class RoleSettings +{ + public ulong SubsReleases { get; set; } + public ulong SubsNews { get; set; } + public ulong Moderator { get; set; } + public ulong TipsUser { get; set; } +} +public class RecruitmentSettings +{ + public bool Enabled { get; set; } = false; public string TagLookingToHire { get; set; } = string.Empty; public string TagLookingForWork { get; set; } = string.Empty; public string TagUnpaidCollab { get; set; } = string.Empty; public string TagPositionFilled { get; set; } = string.Empty; - public int EditPermissionAccessTimeMin { get; set; } = 3; +} - #endregion // Recruitment Thread Tags - - #region Unity Help Threads - - #region Tips - +public class UnityHelpSettings +{ + public bool BabySitterEnabled { get; set; } = false; + public string TagResolved { get; set; } = string.Empty; public string TipImageDirectory { get; set; } = string.Empty; + public int TipMaxImageFileSize { get; set; } = 1024 * 1024 * 10; + public int TipMaxDirectoryFileSize { get; set; } = 1024 * 1024 * 1024; +} - public int TipMaxImageFileSize { get; set; } = 1024 * 1024 * 10; // 10MB - // Unlikely, but we prevent exploitation by limiting the max directory size to avoid VPS disk space issues - public int TipMaxDirectoryFileSize { get; set; } = 1024 * 1024 * 1024; // 1GB - - #endregion // Tips - - public string TagUnityHelpResolved { get; set; } = string.Empty; - - #endregion // Unity Help Threads - - #region API Keys - - public string WeatherAPIKey { get; set; } = string.Empty; - - public string FlightAPIKey { get; set; } = string.Empty; - public string FlightAPISecret { get; set; } = string.Empty; - - public string AirLabAPIKey { get; set; } = string.Empty; - - #endregion // API Keys - - #region Casino Settings - - public bool CasinoEnabled { get; set; } = true; - public long CasinoStartingTokens { get; set; } = 1000; - public List CasinoAllowedChannels { get; set; } = new List(); - public int CasinoGameTimeoutMinutes { get; set; } = 5; - - // Daily Reward Settings - public long CasinoDailyRewardTokens { get; set; } = 100; - public int CasinoDailyRewardIntervalSeconds { get; set; } = 86400; // 24 hours = 86400 seconds - - #endregion // Casino Settings - - #region Other - - public string WikipediaSearchPage { get; set; } = string.Empty; +public class CasinoSettings +{ + public bool Enabled { get; set; } = true; + public long StartingTokens { get; set; } = 1000; + public List AllowedChannels { get; set; } = new(); + public int GameTimeoutMinutes { get; set; } = 5; + public long DailyRewardTokens { get; set; } = 100; + public int DailyRewardIntervalSeconds { get; set; } = 86400; +} - #endregion // Other +public class BirthdaySettings +{ + public bool Enabled { get; set; } = true; + public int CheckIntervalMinutes { get; set; } = 240; +} +public class ApiKeySettings +{ + public string Weather { get; set; } = string.Empty; + public string Flight { get; set; } = string.Empty; + public string FlightSecret { get; set; } = string.Empty; + public string AirLab { get; set; } = string.Empty; } -#region Channel Information +public class FunCommandSettings +{ + public string? SlapObjectsTable { get; set; } = null; + public List SlapChoices { get; set; } = []; + public List SlapFails { get; set; } = []; +} -// Channel Information. Description and Channel ID public class ChannelInfo { public string Desc { get; set; } = string.Empty; public ulong Id { get; set; } } - -#endregion diff --git a/DiscordBot/Settings/Settings.example.json b/DiscordBot/Settings/Settings.example.json index d5aa2fcf..3871ab2b 100644 --- a/DiscordBot/Settings/Settings.example.json +++ b/DiscordBot/Settings/Settings.example.json @@ -1,77 +1,77 @@ { - /* Auth Info requires creating a Bot which can be done through https://discordapp.com/developers/applications/ (Make sure to give it Administrator Perms)*/ - /* Auth info */ + /* Auth Info — create a Bot at https://discordapp.com/developers/applications/ */ "Token": "Y O U R _ B O T _ T O K E N", "Invite": "InviteLink", - /* DB Info*/ "DbConnectionString": "Host=localhost;Port=5432;Database=udcbot;Username=udcbot;Password=USERPASSWORD", - /*Server Info*/ "ServerRootPath": "./SERVER", "AssetsRootPath": "./Assets", - /* Base info */ "Prefix": "!", - "ModeratorRoleId": "0", - "GuildId": "0", // Replace with your servers guild ID - /* Channel IDs for certain channels. */ - "GeneralChannel": { // Off-topic - "desc": "General-Chat Channel", - "id": "0" + "GuildId": "0", + "WikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", + "EveryoneScoldPeriodSeconds": "21600", + + "Channels": { + "General": { "desc": "General-Chat Channel", "id": "0" }, + "Introduction": { "desc": "Introductions Channel", "id": "0" }, + "BotAnnouncement": { "desc": "Bot-Announcement Channel", "id": "0" }, + "BotCommands": { "desc": "Bot-Commands Channel", "id": "0" }, + "UnityNews": { "desc": "Unity News Channel", "id": "0" }, + "UnityReleases": { "desc": "Unity Releases Channel", "id": "0" }, + "Rules": { "desc": "Rules Channel", "id": "0" }, + "Recruitment": { "desc": "Channel for job postings", "id": "0" }, + "GenericHelp": { "desc": "Unity-Help Channel", "id": "0" }, + "Meme": { "desc": "Meme Channel", "id": "0" }, + "BirthdayAnnouncement": { "desc": "Channel for birthday announcements", "id": "0" }, + "ComplaintCategoryId": "0", + "ComplaintPrefix": "Complaint", + "ClosedComplaintCategoryId": "0", + "ClosedComplaintPrefix": "Closed-" }, - "IntroductionChannel": { // Introductions - "desc": "Introductions Channel", - "id": "0" + + "Roles": { + "Moderator": "0", + "SubsReleases": "0", + "SubsNews": "0", + "TipsUser": "0" }, - "BotAnnouncementChannel": { // Most bot logs will go here - "desc": "Bot-Announcement Channel", - "id": "0" + + "Recruitment": { + "Enabled": false, + "TagLookingToHire": "0", + "TagLookingForWork": "0", + "TagUnpaidCollab": "0", + "TagPositionFilled": "0", + "EditPermissionAccessTimeMin": 3 }, - "BotCommandsChannel": { - "desc": "Bot-Commands Channel", - "id": "0" + + "UnityHelp": { + "BabySitterEnabled": false, + "TipImageDirectory": "tips" }, - "UnityNewsChannel": { - "desc": "Unity News Channel", - "id": "0" + + "Casino": { + "Enabled": true, + "StartingTokens": 1000, + "AllowedChannels": [], + "DailyRewardTokens": 100, + "DailyRewardIntervalSeconds": 86400 }, - /* Role Ids */ - "SubsNewsRoleId": "0", - "SubsReleasesRoleId": "0", - /*Complaints Channels Stuff*/ - "ComplaintCategoryId": "0", - "ComplaintChannelPrefix": "Complaint", - "ClosedComplaintChannelPrefix": "Closed-", - "ClosedComplaintCategoryId": "662084543662129175", - /*Commands Configuration*/ - "WikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", - "EveryoneScoldPeriodSeconds": "21600", - /*API Keys*/ - "WeatherAPIKey": "", // Key for openweathermap.org - "FlightAPIKey": "", - "FlightAPISecret": "", - "AirLabAPIKey": "", - /* Recruitment Service */ - "RecruitmentServiceEnabled": false, - "RecruitmentChannel": { // Recruitment - "desc": "Channel for job postings", - "id": "0" + + "Birthday": { + "Enabled": true, + "CheckIntervalMinutes": 240 }, - "TagLookingToHire": "0", - "TagLookingForWork": "0", - "TagUnpaidCollab": "0", - "TagPositionFilled": "0", - "EditPermissionAccessTimeMin": 3, - /* Unity Help Service */ - "UnityHelpBabySitterEnabled": false, - "GenericHelpChannel": { - // Unity-help - "desc": "Unity-Help Channel", - "id": "0" + + "ApiKeys": { + "Weather": "", + "Flight": "", + "FlightSecret": "", + "AirLab": "" }, - /* Birthday Announcement Service */ - "BirthdayAnnouncementEnabled": true, - "BirthdayCheckIntervalMinutes": 240, // Check every 4 hours - "BirthdayAnnouncementChannel": { - "desc": "Channel for birthday announcements (e.g., #offtopic-chat)", - "id": "0" + + "FunCommands": { + "SlapObjectsTable": null, + "SlapChoices": [], + "SlapFails": [] } } \ No newline at end of file diff --git a/docs/casino.md b/docs/casino.md index 116eabce..81811f4b 100644 --- a/docs/casino.md +++ b/docs/casino.md @@ -236,7 +236,7 @@ Game End Condition → GameService.EndGame() → Payout Calculation → CasinoSe ### Configuration - Channel restrictions via `CasinoService.IsChannelAllowed()` -- Starting token amounts in `BotSettings.CasinoStartingTokens` +- Starting token amounts in `BotSettings.Casino.StartingTokens` - Daily reward amounts and cooldowns - Game-specific parameters (max players, betting limits) diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md index 4e421dc0..ef7139ee 100644 --- a/docs/code-quality-audit.md +++ b/docs/code-quality-audit.md @@ -30,7 +30,7 @@ issues common in organically grown projects. The most impactful problems are: | Class | File | Responsibilities | Severity | |-------|------|-----------------|----------| | `UserService` | `Services/UserService.cs` | XP, karma, muting, code formatting warnings, everyone-mention scold, profile card generation, welcome messages, avatar ops, data persistence, level calculation (~11 concerns) | Critical | -| `BotSettings` | `Settings/Deserialized/Settings.cs` | 60+ properties across channels, roles, API keys, casino, tips, recruitment, weather in one flat class | High | +| `BotSettings` | `Settings/Deserialized/Settings.cs` | ~~60+ properties across channels, roles, API keys, casino, tips, recruitment, weather in one flat class~~ Split into 8 nested domain classes ✅ | ~~High~~ Done | | `UserModule` | `Modules/UserModule.cs` | 1 000+ lines; text commands, web scraping, role management, search, profile display all in one module | High | | `UpdateService` | `Services/UpdateService.cs` | Bot data, user muting lifecycle, FAQ loading, RSS feeds, Wikipedia downloading (5 concerns) | High | | `CasinoSlashModule` | `Modules/Casino/CasinoSlashModule.cs` | 500+ lines; token commands, game commands, admin commands, statistics, nested `TokenCommands` class | High | @@ -371,8 +371,8 @@ no `.cs` files and no test framework configured. ### Short-term (Architecture) 1. ~~Split `UserService` into focused services~~ ✅ -2. Split `BotSettings` into domain-specific config classes -3. Add `BotSettings.Validate()` post-deserialization +2. ~~Split `BotSettings` into domain-specific config classes~~ ✅ +3. ~~Add `BotSettings.Validate()` post-deserialization~~ ✅ 4. ~~Extract business logic from command handlers into services~~ ✅ 5. ~~Register `IHttpClientFactory` in DI; remove manual `HttpClient` creation~~ ✅ 6. ~~Add graceful shutdown support with `CancellationToken`~~ ✅ diff --git a/k8s/dev/bot-config.yaml b/k8s/dev/bot-config.yaml index 439a7c09..1c9f60e7 100644 --- a/k8s/dev/bot-config.yaml +++ b/k8s/dev/bot-config.yaml @@ -15,85 +15,71 @@ data: "Token": "${BOT_TOKEN}", "DbConnectionString": "Host=postgresql;Port=5432;Database=udcbot;Username=udcbot;Password=${DB_PASSWORD}", "Invite": "InviteLink", - /*Server Info*/ "ServerRootPath": "./SERVER", "AssetsRootPath": "./Assets", - /* Base info */ "Prefix": "!", - "ModeratorRoleId": "769010537119088690", - "GuildId": "566084539664039938", // Replace with your servers guild ID - /* Channel IDs for certain channels. */ - "GeneralChannel": { // Off-topic - "desc": "General-Chat Channel", - "id": "566084539664039944" - }, - "BotAnnouncementChannel": { // Most bot logs will go here - "desc": "Bot-Announcement Channel", - "id": "567628191221547008" - }, - "BotCommandsChannel": { - "desc": "Bot-Commands Channel", - "id": "599583999379243008" - }, - "UnityNewsChannel": { - "desc": "Unity News Channel", - "id": "1022102744552710154" + "GuildId": "566084539664039938", + "WikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", + + "Channels": { + "General": { "desc": "General-Chat Channel", "id": "566084539664039944" }, + "Introduction": { "desc": "Introduction Channel", "id": "1198575542467838044" }, + "BotAnnouncement": { "desc": "Bot-Announcement Channel", "id": "567628191221547008" }, + "BotCommands": { "desc": "Bot-Commands Channel", "id": "599583999379243008" }, + "UnityNews": { "desc": "Unity News Channel", "id": "1022102744552710154" }, + "UnityReleases": { "desc": "Unity Releases Channel", "id": "1022102744552710154" }, + "Rules": { "desc": "The Rules", "id": "825932695698669618" }, + "Recruitment": { "desc": "Channel for job postings", "id": "1134672948356202678" }, + "GenericHelp": { "desc": "Unity-Help Channel", "id": "1028254982748778516" }, + "BirthdayAnnouncement": { "desc": "Channel for birthday announcements", "id": "566084539664039944" }, + "ComplaintCategoryId": "874631331810799626", + "ComplaintPrefix": "Complaint", + "ClosedComplaintCategoryId": "0", + "ClosedComplaintPrefix": "Closed-" }, - "UnityReleasesChannel": { - "desc": "Unity Releases Channel", - "id": "1022102744552710154" + + "Roles": { + "Moderator": "769010537119088690", + "SubsReleases": "769870886743703584", + "SubsNews": "0", + "TipsUser": "603187742096228374" }, - "RulesChannel": { - "desc": "The Rules", - "id": "825932695698669618" + + "ApiKeys": { + "Weather": "${WEATHER_KEY}", + "Flight": "${FLIGHT_KEY}", + "FlightSecret": "${FLIGHT_SECRET}", + "AirLab": "${AIRLAB_KEY}" }, - /* Role Ids */ - "SubsReleasesRoleId": "769870886743703584", - "TipsUserRoleId": "603187742096228374", - /*Complaints Channels Stuff*/ - "ComplaintCategoryId": "874631331810799626", - "ComplaintChannelPrefix": "Complaint", - /*Commands Configuration*/ - "WikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", - "WeatherAPIKey": "${WEATHER_KEY}", - "FlightAPIKey": "${FLIGHT_KEY}", - "FlightAPISecret": "${FLIGHT_SECRET}", - "AirLabAPIKey": "${AIRLAB_KEY}", - /* Job Recruitment Service */ - "RecruitmentChannel": { - "desc": "Channel for job postings", - "id": "1134672948356202678" + + "Recruitment": { + "Enabled": true, + "TagLookingToHire": "1134673961779724480", + "TagLookingForWork": "1134673990993051658", + "TagUnpaidCollab": "1134674009292804117", + "TagPositionFilled": "1134674041450545283" }, - "RecruitmentServiceEnabled": true, - "TagLookingToHire": "1134673961779724480", - "TagLookingForWork": "1134673990993051658", - "TagUnpaidCollab": "1134674009292804117", - "TagPositionFilled": "1134674041450545283", - /* Unity Help Service */ - "UnityHelpBabySitterEnabled": true, - "GenericHelpChannel": { // Unity-help - "desc": "Unity-Help Channel", - "id": "1028254982748778516" + + "UnityHelp": { + "BabySitterEnabled": true, + "TagResolved": "1028255134356086784", + "TipImageDirectory": "tips" }, - "TagUnityHelpResolved": "1028255134356086784", - "IntroductionChannel": { - "desc": "Introduction Channel", - "id": "1198575542467838044" + + "FunCommands": { + "SlapObjectsTable": "Settings/udc-slap.txt", + "SlapChoices": [ + "developer manual", + "devonlepment server" + ], + "SlapFails": [ + "developing a rash", + "developing a skin condition" + ] }, - "UserModuleSlapObjectsTable": "Settings/udc-slap.txt", - "UserModuleSlapChoices": [ - "developer manual", - "devonlepment server" - ], - "UserModuleSlapFails": [ - "developing a rash", - "developing a skin condition" - ], - "TipImageDirectory": "tips", - "BirthdayAnnouncementEnabled": true, - "BirthdayCheckIntervalMinutes": 1, - "BirthdayAnnouncementChannel": { - "desc": "Channel for birthday announcements", - "id": "566084539664039944" + + "Birthday": { + "Enabled": true, + "CheckIntervalMinutes": 1 } } diff --git a/k8s/prod/bot-config.yaml b/k8s/prod/bot-config.yaml index 81080bec..de4335cd 100644 --- a/k8s/prod/bot-config.yaml +++ b/k8s/prod/bot-config.yaml @@ -15,96 +15,78 @@ data: "Token": "${BOT_TOKEN}", "DbConnectionString": "Host=postgresql;Port=5432;Database=udcbot;Username=udcbot;Password=${DB_PASSWORD}", "Invite": "https://discord.gg/bu3bbby", - /*Server Info*/ "ServerRootPath": "./SERVER", "AssetsRootPath": "./Assets", - /* Base info */ "Prefix": "!", - "ModeratorRoleId": "493514490504019969", - "GuildId": "493510779866316801", // Replace with your servers guild ID - /* Channel IDs for certain channels. */ - "GeneralChannel": { // Off-topic - "desc": "General-Chat Channel", - "id": "493511024037724180" - }, - "BotAnnouncementChannel": { // Most bot logs will go here - "desc": "Bot-Announcement Channel", - "id": "493512007144833055" - }, - "BotCommandsChannel": { - "desc": "Bot-Commands Channel", - "id": "493512044973260811" - }, - "RulesChannel": { - "desc": "The Rules", - "id": "519890141805019137" - }, - /* Role Ids */ - "SubsNewsRoleId": "1209260621342707772", - "SubsReleasesRoleId": "523205962279157771", - "TipsUserRoleId": "493514563736698880", - /*Complaints Channels Stuff*/ - "ComplaintCategoryId": "520853507851681797", - "ComplaintChannelPrefix": "Complaint", - "ClosedComplaintCategoryId": "662084543662129175", - "ClosedComplaintChannelPrefix": "Closed-", - /*Commands Configuration*/ + "GuildId": "493510779866316801", "WikipediaSearchPage": "https://en.wikipedia.org/w/api.php?action=query&format=json&prop=extracts|info&generator=prefixsearch&redirects=1&converttitles=1&utf8=1&formatversion=2&exchars=750&exintro=1&explaintext=1&exsectionformat=plain&inprop=url&gpslimit=5&gpsprofile=fuzzy&gpssearch=", - "WeatherAPIKey": "${WEATHER_KEY}", - "FlightAPIKey": "${FLIGHT_KEY}", - "FlightAPISecret": "${FLIGHT_SECRET}", - "AirLabAPIKey": "${AIRLAB_KEY}", - /* Feed Service */ - "UnityNewsChannel": { - "desc": "Unity News Channel", - "id": "1142423451383119975" + + "Channels": { + "General": { "desc": "General-Chat Channel", "id": "493511024037724180" }, + "Introduction": { "desc": "Introduction Channel", "id": "768488410959708210" }, + "BotAnnouncement": { "desc": "Bot-Announcement Channel", "id": "493512007144833055" }, + "BotCommands": { "desc": "Bot-Commands Channel", "id": "493512044973260811" }, + "UnityNews": { "desc": "Unity News Channel", "id": "1142423451383119975" }, + "UnityReleases": { "desc": "Unity Releases Channel", "id": "1142423451383119975" }, + "Rules": { "desc": "The Rules", "id": "519890141805019137" }, + "Recruitment": { "desc": "Channel for job postings", "id": "1019677109171527750" }, + "GenericHelp": { "desc": "Unity-Help Channel", "id": "1019663870798856212" }, + "BirthdayAnnouncement": { "desc": "Channel for birthday announcements", "id": "493511024037724180" }, + "ComplaintCategoryId": "520853507851681797", + "ComplaintPrefix": "Complaint", + "ClosedComplaintCategoryId": "662084543662129175", + "ClosedComplaintPrefix": "Closed-" + }, + + "Roles": { + "Moderator": "493514490504019969", + "SubsReleases": "523205962279157771", + "SubsNews": "1209260621342707772", + "TipsUser": "493514563736698880" }, - "UnityReleasesChannel": { - "desc": "Unity Releases Channel", - "id": "1142423451383119975" + + "ApiKeys": { + "Weather": "${WEATHER_KEY}", + "Flight": "${FLIGHT_KEY}", + "FlightSecret": "${FLIGHT_SECRET}", + "AirLab": "${AIRLAB_KEY}" }, - /* Recruitment Service */ - "RecruitmentServiceEnabled": false, - "RecruitmentChannel": { - "desc": "Channel for job postings", - "id": "1019677109171527750" + + "Recruitment": { + "Enabled": false, + "TagLookingToHire": "1019680606067630151", + "TagLookingForWork": "1019680763756695653", + "TagUnpaidCollab": "1019680795641774110", + "TagPositionFilled": "1052258665530408991", + "EditPermissionAccessTimeMin": 3 }, - "TagLookingToHire": "1019680606067630151", - "TagLookingForWork": "1019680763756695653", - "TagUnpaidCollab": "1019680795641774110", - "TagPositionFilled": "1052258665530408991", - "EditPermissionAccessTimeMin": 3, - /* Unity Help Service */ - "UnityHelpBabySitterEnabled": false, - "GenericHelpChannel": { // Unity-help - "desc": "Unity-Help Channel", - "id": "1019663870798856212" + + "UnityHelp": { + "BabySitterEnabled": false, + "TagResolved": "1019672922811551815", + "TipImageDirectory": "tips" }, - "TagUnityHelpResolved": "1019672922811551815", - "IntroductionChannel": { - "desc": "Introduction Channel", - "id": "768488410959708210" + + "FunCommands": { + "SlapObjectsTable": "Settings/udc-slap.txt", + "SlapChoices": [ + "trout", "duck", "truck", "paddle", "magikarp", "sausage", "student loan", + "life choice", "bug report", "unhandled exception", "null pointer", "keyboard", + "kinematic rigidbody", "gameobject", "trigger collider", "update cycle", "json file", + "large language model", "hosting invoice", "quality of life patch", + "game jam submission", "bucket of fried chicken", "anime waifu pillow", + "network-attached storage cabinet", "baguette", "moldy cheese", + "cup noodle", "game jam submission", "game library listing", + "cheese wheel", "banana peel", "unresolved bug", "low poly donut" + ], + "SlapFails": [ + "hurting themselves", "making themselves look foolish", "tripping on it", + "dropping it on their toes", "breaking their screen with it" + ] }, - "UserModuleSlapObjectsTable": "Settings/udc-slap.txt", - "UserModuleSlapChoices": [ - "trout", "duck", "truck", "paddle", "magikarp", "sausage", "student loan", - "life choice", "bug report", "unhandled exception", "null pointer", "keyboard", - "kinematic rigidbody", "gameobject", "trigger collider", "update cycle", "json file", - "large language model", "hosting invoice", "quality of life patch", - "game jam submission", "bucket of fried chicken", "anime waifu pillow", - "network-attached storage cabinet", "baguette", "moldy cheese", - "cup noodle", "game jam submission", "game library listing", - "cheese wheel", "banana peel", "unresolved bug", "low poly donut" - ], - "UserModuleSlapFails": [ - "hurting themselves", "making themselves look foolish", "tripping on it", - "dropping it on their toes", "breaking their screen with it" - ], - "TipImageDirectory": "tips", - "BirthdayAnnouncementEnabled": true, - "BirthdayCheckIntervalMinutes": 60, - "BirthdayAnnouncementChannel": { - "desc": "Channel for birthday announcements", - "id": "493511024037724180" + + "Birthday": { + "Enabled": true, + "CheckIntervalMinutes": 60 } } From 0b675aae1032f182c56829ce664de0734bc3f8ab Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 20:21:43 +0200 Subject: [PATCH 41/48] test: add xUnit test project with 163 tests for critical paths - Create DiscordBot.Tests project (xUnit 2.9.3, runner 3.0.2) - Tests cover: StringExtensions, DateExtensions, MathUtility StringUtil, BlackjackHelper, PokerHelper, Card/Deck, RPS, TokenTransaction, BotSettings validation, UserSettings validation - Rename Dotnet.yml -> ci.yml (consistent kebab-case naming) - Add test step and concurrency control to CI workflow - Update audit checklist (Long-term 1) --- .github/workflows/{Dotnet.yml => ci.yml} | 13 +- DiscordBot.Tests/DiscordBot.Tests.csproj | 24 +++ .../Domain/Casino/BlackjackHelperTests.cs | 126 +++++++++++++ .../Domain/Casino/CardAndDeckTests.cs | 168 ++++++++++++++++++ .../Domain/Casino/PokerHelperTests.cs | 146 +++++++++++++++ .../Domain/Casino/RockPaperScissorsTests.cs | 97 ++++++++++ .../Domain/Casino/TokenTransactionTests.cs | 94 ++++++++++ .../Extensions/DateExtensionsTests.cs | 27 +++ .../Extensions/StringExtensionsTests.cs | 139 +++++++++++++++ DiscordBot.Tests/GlobalUsings.cs | 1 + .../Settings/BotSettingsValidationTests.cs | 129 ++++++++++++++ .../Settings/UserSettingsValidationTests.cs | 45 +++++ DiscordBot.Tests/Utils/MathUtilityTests.cs | 34 ++++ DiscordBot.Tests/Utils/StringUtilTests.cs | 58 ++++++ DiscordBot.sln | 30 +++- docs/code-quality-audit.md | 2 +- 16 files changed, 1125 insertions(+), 8 deletions(-) rename .github/workflows/{Dotnet.yml => ci.yml} (58%) create mode 100644 DiscordBot.Tests/DiscordBot.Tests.csproj create mode 100644 DiscordBot.Tests/Domain/Casino/BlackjackHelperTests.cs create mode 100644 DiscordBot.Tests/Domain/Casino/CardAndDeckTests.cs create mode 100644 DiscordBot.Tests/Domain/Casino/PokerHelperTests.cs create mode 100644 DiscordBot.Tests/Domain/Casino/RockPaperScissorsTests.cs create mode 100644 DiscordBot.Tests/Domain/Casino/TokenTransactionTests.cs create mode 100644 DiscordBot.Tests/Extensions/DateExtensionsTests.cs create mode 100644 DiscordBot.Tests/Extensions/StringExtensionsTests.cs create mode 100644 DiscordBot.Tests/GlobalUsings.cs create mode 100644 DiscordBot.Tests/Settings/BotSettingsValidationTests.cs create mode 100644 DiscordBot.Tests/Settings/UserSettingsValidationTests.cs create mode 100644 DiscordBot.Tests/Utils/MathUtilityTests.cs create mode 100644 DiscordBot.Tests/Utils/StringUtilTests.cs diff --git a/.github/workflows/Dotnet.yml b/.github/workflows/ci.yml similarity index 58% rename from .github/workflows/Dotnet.yml rename to .github/workflows/ci.yml index 45d91c75..be48ce22 100644 --- a/.github/workflows/Dotnet.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: .NET Build +name: .NET Build & Test on: pull_request: @@ -7,6 +7,10 @@ on: permissions: contents: read +concurrency: + group: dotnet-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: build: name: Build & Test @@ -15,13 +19,16 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup .NET Core + - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x - - name: Install dependencies + - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal diff --git a/DiscordBot.Tests/DiscordBot.Tests.csproj b/DiscordBot.Tests/DiscordBot.Tests.csproj new file mode 100644 index 00000000..be444c4d --- /dev/null +++ b/DiscordBot.Tests/DiscordBot.Tests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/DiscordBot.Tests/Domain/Casino/BlackjackHelperTests.cs b/DiscordBot.Tests/Domain/Casino/BlackjackHelperTests.cs new file mode 100644 index 00000000..0895c578 --- /dev/null +++ b/DiscordBot.Tests/Domain/Casino/BlackjackHelperTests.cs @@ -0,0 +1,126 @@ +using DiscordBot.Domain; + +namespace DiscordBot.Tests.Domain.Casino; + +public class BlackjackHelperTests +{ + private static Card C(int value, CardSuit suit = CardSuit.Hearts) => new(value, suit); + + [Fact] + public void CalculateHandValue_SimpleHand() + { + var cards = new List { C(5), C(6) }; + Assert.Equal(11, BlackjackHelper.CalculateHandValue(cards)); + } + + [Fact] + public void CalculateHandValue_FaceCards_Worth10() + { + var cards = new List { C(11), C(12) }; + Assert.Equal(20, BlackjackHelper.CalculateHandValue(cards)); + } + + [Fact] + public void CalculateHandValue_AceAs11() + { + var cards = new List { C(1), C(5) }; + Assert.Equal(16, BlackjackHelper.CalculateHandValue(cards)); + } + + [Fact] + public void CalculateHandValue_AceReducedTo1WhenBusting() + { + var cards = new List { C(1), C(10), C(5) }; + Assert.Equal(16, BlackjackHelper.CalculateHandValue(cards)); + } + + [Fact] + public void CalculateHandValue_TwoAces() + { + var cards = new List { C(1), C(1) }; + Assert.Equal(12, BlackjackHelper.CalculateHandValue(cards)); + } + + [Fact] + public void CalculateHandValue_TwoAcesAndNine_Makes21() + { + var cards = new List { C(1), C(1), C(9) }; + Assert.Equal(21, BlackjackHelper.CalculateHandValue(cards)); + } + + [Fact] + public void CalculateHandValue_EmptyHand_ReturnsZero() + { + Assert.Equal(0, BlackjackHelper.CalculateHandValue([])); + } + + [Fact] + public void IsBlackjack_AceAndKing_True() + { + var cards = new List { C(1), C(13) }; + Assert.True(BlackjackHelper.IsBlackjack(cards)); + } + + [Fact] + public void IsBlackjack_AceAndTen_True() + { + var cards = new List { C(1), C(10) }; + Assert.True(BlackjackHelper.IsBlackjack(cards)); + } + + [Fact] + public void IsBlackjack_ThreeCardsTotaling21_False() + { + var cards = new List { C(7), C(7), C(7) }; + Assert.False(BlackjackHelper.IsBlackjack(cards)); + } + + [Fact] + public void IsBlackjack_TwoCardsNot21_False() + { + var cards = new List { C(10), C(9) }; + Assert.False(BlackjackHelper.IsBlackjack(cards)); + } + + [Fact] + public void IsBusted_Over21_True() + { + var cards = new List { C(10), C(10), C(5) }; + Assert.True(BlackjackHelper.IsBusted(cards)); + } + + [Fact] + public void IsBusted_Exactly21_False() + { + var cards = new List { C(10), C(10), C(1) }; + Assert.False(BlackjackHelper.IsBusted(cards)); + } + + [Fact] + public void IsBusted_Under21_False() + { + var cards = new List { C(5), C(6) }; + Assert.False(BlackjackHelper.IsBusted(cards)); + } + + [Fact] + public void IsSoft17_AceAnd6_True() + { + var cards = new List { C(1), C(6) }; + Assert.True(BlackjackHelper.IsSoft17(cards)); + } + + [Fact] + public void IsSoft17_TenAnd7_Hard17_False() + { + var cards = new List { C(10), C(7) }; + Assert.False(BlackjackHelper.IsSoft17(cards)); + } + + [Fact] + public void IsSoft17_Not17_False() + { + var cards = new List { C(1), C(5) }; + Assert.False(BlackjackHelper.IsSoft17(cards)); + } +} diff --git a/DiscordBot.Tests/Domain/Casino/CardAndDeckTests.cs b/DiscordBot.Tests/Domain/Casino/CardAndDeckTests.cs new file mode 100644 index 00000000..6159f2fd --- /dev/null +++ b/DiscordBot.Tests/Domain/Casino/CardAndDeckTests.cs @@ -0,0 +1,168 @@ +using DiscordBot.Domain; + +namespace DiscordBot.Tests.Domain.Casino; + +public class CardAndDeckTests +{ + [Theory] + [InlineData(1, CardSuit.Hearts, "A♥️")] + [InlineData(11, CardSuit.Spades, "J♠️")] + [InlineData(12, CardSuit.Diamonds, "Q♦️")] + [InlineData(13, CardSuit.Clubs, "K♣️")] + [InlineData(10, CardSuit.Hearts, "10♥️")] + [InlineData(5, CardSuit.Diamonds, "5♦️")] + public void Card_GetDisplayName(int value, CardSuit suit, string expected) + { + var card = new Card(value, suit); + Assert.Equal(expected, card.GetDisplayName()); + } + + [Fact] + public void Card_Joker_DisplayName() + { + var card = new Card(0, CardSuit.Joker); + Assert.Equal("🃏", card.GetDisplayName()); + } + + [Fact] + public void Card_Equals_SameValueAndSuit() + { + var a = new Card(5, CardSuit.Hearts); + var b = new Card(5, CardSuit.Hearts); + Assert.Equal(a, b); + } + + [Fact] + public void Card_NotEquals_DifferentSuit() + { + var a = new Card(5, CardSuit.Hearts); + var b = new Card(5, CardSuit.Spades); + Assert.NotEqual(a, b); + } + + [Fact] + public void Card_CompareTo_ByValue() + { + var low = new Card(3, CardSuit.Hearts); + var high = new Card(10, CardSuit.Hearts); + Assert.True(low.CompareTo(high) < 0); + } + + [Fact] + public void Card_GetHashCode_EqualCards() + { + var a = new Card(7, CardSuit.Clubs); + var b = new Card(7, CardSuit.Clubs); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + [Fact] + public void Deck_StandardDeck_Has52Cards() + { + var deck = new Deck(); + Assert.Equal(52, deck.CardsRemaining); + } + + [Fact] + public void Deck_WithJokers_Has54Cards() + { + var deck = new Deck(jokerCount: 2); + Assert.Equal(54, deck.CardsRemaining); + } + + [Fact] + public void Deck_DoubleDeck_Has104Cards() + { + var deck = new Deck(times: 2); + Assert.Equal(104, deck.CardsRemaining); + } + + [Fact] + public void Deck_DrawCard_ReducesCount() + { + var deck = new Deck(); + deck.DrawCard(); + Assert.Equal(51, deck.CardsRemaining); + } + + [Fact] + public void Deck_DrawCard_EmptyDeck_ReturnsNull() + { + var deck = new Deck(new List()); + Assert.Null(deck.DrawCard()); + } + + [Fact] + public void Deck_DrawCards_ReturnsExactCount() + { + var deck = new Deck(); + var cards = deck.DrawCards(5); + Assert.Equal(5, cards.Count); + Assert.Equal(47, deck.CardsRemaining); + } + + [Fact] + public void Deck_DrawCards_MoreThanRemaining_ReturnsAvailable() + { + var deck = new Deck(new List { new(1, CardSuit.Hearts), new(2, CardSuit.Hearts) }); + var cards = deck.DrawCards(5); + Assert.Equal(2, cards.Count); + Assert.True(deck.IsEmpty); + } + + [Fact] + public void Deck_Shuffle_CountUnchanged() + { + var deck = new Deck(); + deck.Shuffle(); + Assert.Equal(52, deck.CardsRemaining); + } + + [Fact] + public void Deck_Reset_RestoresAllCards() + { + var deck = new Deck(); + deck.DrawCards(10); + Assert.Equal(42, deck.CardsRemaining); + deck.Reset(); + Assert.Equal(52, deck.CardsRemaining); + } + + [Fact] + public void Deck_IsEmpty_ForEmptyDeck() + { + var deck = new Deck(new List()); + Assert.True(deck.IsEmpty); + } + + [Fact] + public void Deck_IsEmpty_FalseForNewDeck() + { + var deck = new Deck(); + Assert.False(deck.IsEmpty); + } + + [Fact] + public void Deck_PeekTop_DoesNotRemoveCards() + { + var deck = new Deck(); + var peeked = deck.PeekTop(3); + Assert.Equal(3, peeked.Count); + Assert.Equal(52, deck.CardsRemaining); + } + + [Fact] + public void Deck_StandardDeck_ContainsAllSuitsAndValues() + { + var deck = new Deck(); + var allCards = deck.DrawCards(52); + var suits = new[] { CardSuit.Hearts, CardSuit.Diamonds, CardSuit.Clubs, CardSuit.Spades }; + foreach (var suit in suits) + { + for (int value = 1; value <= 13; value++) + { + Assert.Contains(allCards, c => c.Value == value && c.Suit == suit); + } + } + } +} diff --git a/DiscordBot.Tests/Domain/Casino/PokerHelperTests.cs b/DiscordBot.Tests/Domain/Casino/PokerHelperTests.cs new file mode 100644 index 00000000..6cf6da7f --- /dev/null +++ b/DiscordBot.Tests/Domain/Casino/PokerHelperTests.cs @@ -0,0 +1,146 @@ +using DiscordBot.Domain; + +namespace DiscordBot.Tests.Domain.Casino; + +public class PokerHelperTests +{ + private static Card C(int value, CardSuit suit = CardSuit.Hearts) => new(value, suit); + + [Fact] + public void EvaluateHand_RoyalFlush() + { + var cards = new List { C(1, CardSuit.Spades), C(13, CardSuit.Spades), C(12, CardSuit.Spades), C(11, CardSuit.Spades), C(10, CardSuit.Spades) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.RoyalFlush, hand.Rank); + } + + [Fact] + public void EvaluateHand_StraightFlush() + { + var cards = new List { C(9, CardSuit.Hearts), C(8, CardSuit.Hearts), C(7, CardSuit.Hearts), C(6, CardSuit.Hearts), C(5, CardSuit.Hearts) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.StraightFlush, hand.Rank); + } + + [Fact] + public void EvaluateHand_WheelStraightFlush() + { + var cards = new List { C(1, CardSuit.Clubs), C(2, CardSuit.Clubs), C(3, CardSuit.Clubs), C(4, CardSuit.Clubs), C(5, CardSuit.Clubs) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.StraightFlush, hand.Rank); + Assert.Contains("Wheel", hand.Description); + } + + [Fact] + public void EvaluateHand_FourOfAKind() + { + var cards = new List { C(7), C(7, CardSuit.Diamonds), C(7, CardSuit.Clubs), C(7, CardSuit.Spades), C(2) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.FourOfAKind, hand.Rank); + } + + [Fact] + public void EvaluateHand_FullHouse() + { + var cards = new List { C(10), C(10, CardSuit.Diamonds), C(10, CardSuit.Clubs), C(4), C(4, CardSuit.Spades) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.FullHouse, hand.Rank); + } + + [Fact] + public void EvaluateHand_Flush() + { + var cards = new List { C(2, CardSuit.Diamonds), C(5, CardSuit.Diamonds), C(8, CardSuit.Diamonds), C(10, CardSuit.Diamonds), C(13, CardSuit.Diamonds) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.Flush, hand.Rank); + } + + [Fact] + public void EvaluateHand_Straight() + { + var cards = new List { C(9, CardSuit.Hearts), C(8, CardSuit.Diamonds), C(7, CardSuit.Clubs), C(6, CardSuit.Spades), C(5, CardSuit.Hearts) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.Straight, hand.Rank); + } + + [Fact] + public void EvaluateHand_WheelStraight() + { + var cards = new List { C(1, CardSuit.Hearts), C(2, CardSuit.Diamonds), C(3, CardSuit.Clubs), C(4, CardSuit.Spades), C(5, CardSuit.Hearts) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.Straight, hand.Rank); + Assert.Contains("Wheel", hand.Description); + } + + [Fact] + public void EvaluateHand_ThreeOfAKind() + { + var cards = new List { C(9), C(9, CardSuit.Diamonds), C(9, CardSuit.Clubs), C(3), C(7, CardSuit.Spades) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.ThreeOfAKind, hand.Rank); + } + + [Fact] + public void EvaluateHand_TwoPair() + { + var cards = new List { C(8), C(8, CardSuit.Diamonds), C(5, CardSuit.Clubs), C(5, CardSuit.Spades), C(2) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.TwoPair, hand.Rank); + } + + [Fact] + public void EvaluateHand_OnePair() + { + var cards = new List { C(11), C(11, CardSuit.Diamonds), C(3, CardSuit.Clubs), C(7, CardSuit.Spades), C(9) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.OnePair, hand.Rank); + } + + [Fact] + public void EvaluateHand_HighCard() + { + var cards = new List { C(1), C(10, CardSuit.Diamonds), C(7, CardSuit.Clubs), C(4, CardSuit.Spades), C(2) }; + var hand = PokerHelper.EvaluateHand(cards); + Assert.Equal(PokerHandRank.HighCard, hand.Rank); + } + + [Fact] + public void EvaluateHand_WrongCardCount_Throws() + { + var cards = new List { C(1), C(2), C(3), C(4) }; + Assert.Throws(() => PokerHelper.EvaluateHand(cards)); + } + + [Fact] + public void CompareHands_HigherRankWins() + { + var flush = PokerHelper.EvaluateHand([C(2, CardSuit.Hearts), C(5, CardSuit.Hearts), C(8, CardSuit.Hearts), C(10, CardSuit.Hearts), C(13, CardSuit.Hearts)]); + var pair = PokerHelper.EvaluateHand([C(3), C(3, CardSuit.Diamonds), C(7, CardSuit.Clubs), C(9, CardSuit.Spades), C(11)]); + Assert.True(PokerHelper.CompareHands(flush, pair) > 0); + } + + [Fact] + public void CompareHands_KickerBreaksTie() + { + var pairHigh = PokerHelper.EvaluateHand([C(10), C(10, CardSuit.Diamonds), C(1, CardSuit.Clubs), C(7, CardSuit.Spades), C(3)]); + var pairLow = PokerHelper.EvaluateHand([C(10, CardSuit.Clubs), C(10, CardSuit.Spades), C(9), C(7, CardSuit.Diamonds), C(3, CardSuit.Diamonds)]); + Assert.True(PokerHelper.CompareHands(pairHigh, pairLow) > 0); + } + + [Fact] + public void CompareHands_IdenticalHands_ReturnsZero() + { + var hand1 = PokerHelper.EvaluateHand([C(1), C(13, CardSuit.Diamonds), C(10, CardSuit.Clubs), C(7, CardSuit.Spades), C(4)]); + var hand2 = PokerHelper.EvaluateHand([C(1, CardSuit.Diamonds), C(13, CardSuit.Clubs), C(10, CardSuit.Spades), C(7), C(4, CardSuit.Diamonds)]); + Assert.Equal(0, PokerHelper.CompareHands(hand1, hand2)); + } + + [Fact] + public void CompareHands_NullHandling() + { + var hand = PokerHelper.EvaluateHand([C(1), C(13, CardSuit.Diamonds), C(10, CardSuit.Clubs), C(7, CardSuit.Spades), C(4)]); + Assert.True(PokerHelper.CompareHands(hand, null!) > 0); + Assert.True(PokerHelper.CompareHands(null!, hand) < 0); + Assert.Equal(0, PokerHelper.CompareHands(null!, null!)); + } +} diff --git a/DiscordBot.Tests/Domain/Casino/RockPaperScissorsTests.cs b/DiscordBot.Tests/Domain/Casino/RockPaperScissorsTests.cs new file mode 100644 index 00000000..fcd66705 --- /dev/null +++ b/DiscordBot.Tests/Domain/Casino/RockPaperScissorsTests.cs @@ -0,0 +1,97 @@ +using DiscordBot.Domain; + +namespace DiscordBot.Tests.Domain.Casino; + +public class RockPaperScissorsTests +{ + private static (RockPaperScissors game, GamePlayer p1, GamePlayer p2) CreateGame() + { + var game = new RockPaperScissors(); + var p1 = new GamePlayer { Bet = 100 }; + var p2 = new GamePlayer { Bet = 100 }; + game.StartGame([p1, p2]); + return (game, p1, p2); + } + + [Theory] + [InlineData(RockPaperScissorsPlayerAction.Rock, RockPaperScissorsPlayerAction.Scissors, GamePlayerResult.Won)] + [InlineData(RockPaperScissorsPlayerAction.Paper, RockPaperScissorsPlayerAction.Rock, GamePlayerResult.Won)] + [InlineData(RockPaperScissorsPlayerAction.Scissors, RockPaperScissorsPlayerAction.Paper, GamePlayerResult.Won)] + [InlineData(RockPaperScissorsPlayerAction.Scissors, RockPaperScissorsPlayerAction.Rock, GamePlayerResult.Lost)] + [InlineData(RockPaperScissorsPlayerAction.Rock, RockPaperScissorsPlayerAction.Paper, GamePlayerResult.Lost)] + [InlineData(RockPaperScissorsPlayerAction.Paper, RockPaperScissorsPlayerAction.Scissors, GamePlayerResult.Lost)] + [InlineData(RockPaperScissorsPlayerAction.Rock, RockPaperScissorsPlayerAction.Rock, GamePlayerResult.Tie)] + [InlineData(RockPaperScissorsPlayerAction.Paper, RockPaperScissorsPlayerAction.Paper, GamePlayerResult.Tie)] + [InlineData(RockPaperScissorsPlayerAction.Scissors, RockPaperScissorsPlayerAction.Scissors, GamePlayerResult.Tie)] + public void AllCombinations_CorrectResult(RockPaperScissorsPlayerAction p1Choice, RockPaperScissorsPlayerAction p2Choice, GamePlayerResult expectedP1Result) + { + var (game, p1, p2) = CreateGame(); + game.DoPlayerAction(p1, p1Choice); + game.DoPlayerAction(p2, p2Choice); + Assert.Equal(expectedP1Result, game.GetPlayerGameResult(p1)); + } + + [Fact] + public void NoChoice_ReturnsNoResult() + { + var (game, p1, _) = CreateGame(); + Assert.Equal(GamePlayerResult.NoResult, game.GetPlayerGameResult(p1)); + } + + [Fact] + public void Payout_WinnerGainsTotalPot() + { + var (game, p1, p2) = CreateGame(); + game.DoPlayerAction(p1, RockPaperScissorsPlayerAction.Rock); + game.DoPlayerAction(p2, RockPaperScissorsPlayerAction.Scissors); + var results = game.EndGame(); + var p1Payout = results.First(r => r.player == p1).payout; + Assert.Equal(100, p1Payout); // wins 200 total pot - 100 bet = 100 net + } + + [Fact] + public void Payout_LoserLosesBet() + { + var (game, p1, p2) = CreateGame(); + game.DoPlayerAction(p1, RockPaperScissorsPlayerAction.Rock); + game.DoPlayerAction(p2, RockPaperScissorsPlayerAction.Scissors); + var results = game.EndGame(); + var p2Payout = results.First(r => r.player == p2).payout; + Assert.Equal(-100, p2Payout); + } + + [Fact] + public void Payout_TieIsZero() + { + var (game, p1, p2) = CreateGame(); + game.DoPlayerAction(p1, RockPaperScissorsPlayerAction.Rock); + game.DoPlayerAction(p2, RockPaperScissorsPlayerAction.Rock); + var results = game.EndGame(); + Assert.All(results, r => Assert.Equal(0, r.payout)); + } + + [Fact] + public void DoPlayerAction_AlreadyChosen_Throws() + { + var (game, p1, _) = CreateGame(); + game.DoPlayerAction(p1, RockPaperScissorsPlayerAction.Rock); + Assert.Throws(() => game.DoPlayerAction(p1, RockPaperScissorsPlayerAction.Paper)); + } + + [Fact] + public void ShouldFinish_BothChosen_True() + { + var (game, p1, p2) = CreateGame(); + game.DoPlayerAction(p1, RockPaperScissorsPlayerAction.Rock); + game.DoPlayerAction(p2, RockPaperScissorsPlayerAction.Scissors); + Assert.True(game.ShouldFinish()); + } + + [Fact] + public void ShouldFinish_OneChosen_False() + { + var (game, p1, _) = CreateGame(); + game.DoPlayerAction(p1, RockPaperScissorsPlayerAction.Rock); + Assert.False(game.ShouldFinish()); + } +} diff --git a/DiscordBot.Tests/Domain/Casino/TokenTransactionTests.cs b/DiscordBot.Tests/Domain/Casino/TokenTransactionTests.cs new file mode 100644 index 00000000..2e2d43b0 --- /dev/null +++ b/DiscordBot.Tests/Domain/Casino/TokenTransactionTests.cs @@ -0,0 +1,94 @@ +using DiscordBot.Domain; + +namespace DiscordBot.Tests.Domain.Casino; + +public class TokenTransactionTests +{ + private static TokenTransaction Create() => new() { UserID = "123" }; + + [Theory] + [InlineData("TokenInitialisation", TransactionKind.TokenInitialisation)] + [InlineData("DailyReward", TransactionKind.DailyReward)] + [InlineData("Gift", TransactionKind.Gift)] + [InlineData("Game", TransactionKind.Game)] + [InlineData("Admin", TransactionKind.Admin)] + public void Kind_Get_ValidString_ReturnsCorrectEnum(string type, TransactionKind expected) + { + var tx = Create(); + tx.TransactionType = type; + Assert.Equal(expected, tx.Kind); + } + + [Fact] + public void Kind_Get_InvalidString_FallsBackToAdmin() + { + var tx = Create(); + tx.TransactionType = "SomethingInvalid"; + Assert.Equal(TransactionKind.Admin, tx.Kind); + } + + [Fact] + public void Kind_Set_UpdatesTransactionType() + { + var tx = Create(); + tx.Kind = TransactionKind.Gift; + Assert.Equal("Gift", tx.TransactionType); + } + + [Fact] + public void Description_Set_ValidJson_ParsedToDict() + { + var tx = Create(); + tx.Description = """{"game":"blackjack","result":"win"}"""; + Assert.NotNull(tx.Details); + Assert.Equal("blackjack", tx.Details!["game"]); + Assert.Equal("win", tx.Details["result"]); + } + + [Fact] + public void Description_Set_PlainText_FallbackDict() + { + var tx = Create(); + tx.Description = "some plain text"; + Assert.NotNull(tx.Details); + Assert.Equal("some plain text", tx.Details!["text"]); + } + + [Fact] + public void Description_Set_NullOrEmpty_EmptyDict() + { + var tx = Create(); + tx.Description = null; + Assert.NotNull(tx.Details); + Assert.Empty(tx.Details!); + + tx.Description = ""; + Assert.Empty(tx.Details!); + } + + [Fact] + public void Description_Get_SerializesToJson() + { + var tx = Create(); + tx.Details = new Dictionary { ["key"] = "val" }; + var json = tx.Description; + Assert.Contains("\"key\"", json); + Assert.Contains("\"val\"", json); + } + + [Fact] + public void Description_Get_EmptyDetails_ReturnsNull() + { + var tx = Create(); + tx.Details = new Dictionary(); + Assert.Null(tx.Description); + } + + [Fact] + public void Description_Get_NullDetails_ReturnsNull() + { + var tx = Create(); + tx.Details = null; + Assert.Null(tx.Description); + } +} diff --git a/DiscordBot.Tests/Extensions/DateExtensionsTests.cs b/DiscordBot.Tests/Extensions/DateExtensionsTests.cs new file mode 100644 index 00000000..b5a42a70 --- /dev/null +++ b/DiscordBot.Tests/Extensions/DateExtensionsTests.cs @@ -0,0 +1,27 @@ +using DiscordBot.Extensions; + +namespace DiscordBot.Tests.Extensions; + +public class DateExtensionsTests +{ + [Fact] + public void ToUnixTimestamp_Epoch_ReturnsZero() + { + var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(0, epoch.ToUnixTimestamp()); + } + + [Fact] + public void ToUnixTimestamp_KnownDate_ReturnsExpected() + { + var date = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + Assert.Equal(946684800, date.ToUnixTimestamp()); + } + + [Fact] + public void ToUnixTimestamp_ReturnsPositiveForRecentDate() + { + var result = DateTime.UtcNow.ToUnixTimestamp(); + Assert.True(result > 0); + } +} diff --git a/DiscordBot.Tests/Extensions/StringExtensionsTests.cs b/DiscordBot.Tests/Extensions/StringExtensionsTests.cs new file mode 100644 index 00000000..1db11f26 --- /dev/null +++ b/DiscordBot.Tests/Extensions/StringExtensionsTests.cs @@ -0,0 +1,139 @@ +using DiscordBot.Extensions; + +namespace DiscordBot.Tests.Extensions; + +public class StringExtensionsTests +{ + [Theory] + [InlineData(null, 5, null)] + [InlineData("", 5, "")] + [InlineData("abc", 5, "abc")] + [InlineData("abcde", 5, "abcde")] + [InlineData("abcdef", 5, "abcde")] + public void Truncate_ReturnsExpected(string? input, int max, string? expected) + { + Assert.Equal(expected, input!.Truncate(max)); + } + + [Fact] + public void CalculateLevenshteinDistance_IdenticalStrings_ReturnsZero() + { + Assert.Equal(0, "kitten".CalculateLevenshteinDistance("kitten")); + } + + [Fact] + public void CalculateLevenshteinDistance_EmptySource_ReturnsTargetLength() + { + Assert.Equal(5, "".CalculateLevenshteinDistance("hello")); + } + + [Fact] + public void CalculateLevenshteinDistance_EmptyTarget_ReturnsSourceLength() + { + Assert.Equal(5, "hello".CalculateLevenshteinDistance("")); + } + + [Theory] + [InlineData("kitten", "sitting", 3)] + [InlineData("saturday", "sunday", 3)] + public void CalculateLevenshteinDistance_KnownPairs(string a, string b, int expected) + { + Assert.Equal(expected, a.CalculateLevenshteinDistance(b)); + } + + [Fact] + public void MessageSplit_ShortString_ReturnsSingleElement() + { + var result = "hello\nworld".MessageSplit(100); + Assert.Single(result); + } + + [Fact] + public void MessageSplit_LongString_SplitsAtNewlines() + { + var input = string.Join("\n", Enumerable.Range(1, 50).Select(i => new string('x', 50))); + var result = input.MessageSplit(200); + Assert.True(result.Count > 1); + } + + [Fact] + public void EscapeDiscordMarkup_EscapesSpecialChars() + { + var result = "hello *world* ~test~ __under__ `code`".EscapeDiscordMarkup(); + Assert.Contains(@"\*", result); + Assert.Contains(@"\~", result); + Assert.Contains(@"\_", result); + Assert.Contains(@"\`", result); + } + + [Fact] + public void EscapeDiscordMarkup_NoSpecialChars_Unchanged() + { + Assert.Equal("hello world", "hello world".EscapeDiscordMarkup()); + } + + [Theory] + [InlineData("HELLO WORLD!", true)] + [InlineData("ABC 123", false)] + [InlineData("Hello", false)] + [InlineData("ALL CAPS!!!", true)] + public void IsAllCaps_ReturnsExpected(string input, bool expected) + { + Assert.Equal(expected, input.IsAllCaps()); + } + + [Theory] + [InlineData("hello", "Hello")] + [InlineData("", "")] + [InlineData("A", "A")] + [InlineData("abc", "Abc")] + public void ToCapitalizeFirstLetter_ReturnsExpected(string input, string expected) + { + Assert.Equal(expected, input.ToCapitalizeFirstLetter()); + } + + [Fact] + public void ToCapitalizeFirstLetter_Null_ReturnsEmpty() + { + Assert.Equal(string.Empty, ((string)null!).ToCapitalizeFirstLetter()); + } + + [Fact] + public void ToCommaList_SingleItem() + { + Assert.Equal("apples", new[] { "apples" }.ToCommaList()); + } + + [Fact] + public void ToCommaList_TwoItems() + { + Assert.Equal("apples and bananas", new[] { "apples", "bananas" }.ToCommaList()); + } + + [Fact] + public void ToCommaList_ThreeItems() + { + Assert.Equal("apples, bananas, and cherries", new[] { "apples", "bananas", "cherries" }.ToCommaList()); + } + + [Fact] + public void ToCommaList_CustomConjunction() + { + Assert.Equal("apples or bananas", new[] { "apples", "bananas" }.ToCommaList("or")); + } + + [Fact] + public void GetSha256_KnownInput_ProducesConsistentHash() + { + var hash1 = "test".GetSha256(); + var hash2 = "test".GetSha256(); + Assert.Equal(hash1, hash2); + Assert.Equal(64, hash1.Length); + } + + [Fact] + public void GetSha256_DifferentInputs_ProduceDifferentHashes() + { + Assert.NotEqual("abc".GetSha256(), "def".GetSha256()); + } +} diff --git a/DiscordBot.Tests/GlobalUsings.cs b/DiscordBot.Tests/GlobalUsings.cs new file mode 100644 index 00000000..c802f448 --- /dev/null +++ b/DiscordBot.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs b/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs new file mode 100644 index 00000000..daa92f26 --- /dev/null +++ b/DiscordBot.Tests/Settings/BotSettingsValidationTests.cs @@ -0,0 +1,129 @@ +using DiscordBot.Settings; + +namespace DiscordBot.Tests.Settings; + +public class BotSettingsValidationTests +{ + private static BotSettings CreateValid() => new() + { + Token = "test-token", + GuildId = 123456, + Prefix = '!', + DbConnectionString = "Host=localhost;Database=test", + ServerRootPath = "/data", + Channels = new ChannelSettings + { + General = new ChannelInfo { Id = 1 }, + Introduction = new ChannelInfo { Id = 2 }, + BotAnnouncement = new ChannelInfo { Id = 3 }, + BotCommands = new ChannelInfo { Id = 4 }, + UnityNews = new ChannelInfo { Id = 5 }, + UnityReleases = new ChannelInfo { Id = 6 }, + Rules = new ChannelInfo { Id = 7 }, + Recruitment = new ChannelInfo { Id = 8 }, + GenericHelp = new ChannelInfo { Id = 9 }, + BirthdayAnnouncement = new ChannelInfo { Id = 10 }, + Meme = new ChannelInfo { Id = 11 }, + } + }; + + [Fact] + public void ValidSettings_NoErrors() + { + var (errors, _) = CreateValid().Validate(); + Assert.Empty(errors); + } + + [Fact] + public void MissingToken_Error() + { + var settings = CreateValid(); + settings.Token = ""; + var (errors, _) = settings.Validate(); + Assert.Contains(errors, e => e.Contains("Token")); + } + + [Fact] + public void MissingGuildId_Error() + { + var settings = CreateValid(); + settings.GuildId = 0; + var (errors, _) = settings.Validate(); + Assert.Contains(errors, e => e.Contains("GuildId")); + } + + [Fact] + public void MissingPrefix_Error() + { + var settings = CreateValid(); + settings.Prefix = '\0'; + var (errors, _) = settings.Validate(); + Assert.Contains(errors, e => e.Contains("Prefix")); + } + + [Fact] + public void MissingDbConnectionString_Error() + { + var settings = CreateValid(); + settings.DbConnectionString = ""; + var (errors, _) = settings.Validate(); + Assert.Contains(errors, e => e.Contains("DbConnectionString")); + } + + [Fact] + public void EmptyServerRootPath_Warning() + { + var settings = CreateValid(); + settings.ServerRootPath = ""; + var (_, warnings) = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("ServerRootPath")); + } + + [Fact] + public void MissingChannel_Warning() + { + var settings = CreateValid(); + settings.Channels.General = new ChannelInfo { Id = 0 }; + var (_, warnings) = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("General")); + } + + [Fact] + public void NegativeCasinoStartingTokens_Error() + { + var settings = CreateValid(); + settings.Casino = new CasinoSettings { Enabled = true, StartingTokens = -1 }; + var (errors, _) = settings.Validate(); + Assert.Contains(errors, e => e.Contains("StartingTokens")); + } + + [Fact] + public void RecruitmentEnabled_MissingTags_Warnings() + { + var settings = CreateValid(); + settings.Recruitment = new RecruitmentSettings { Enabled = true }; + var (_, warnings) = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("TagLookingToHire")); + Assert.Contains(warnings, w => w.Contains("TagLookingForWork")); + Assert.Contains(warnings, w => w.Contains("TagUnpaidCollab")); + Assert.Contains(warnings, w => w.Contains("TagPositionFilled")); + } + + [Fact] + public void RecruitmentDisabled_NoTagWarnings() + { + var settings = CreateValid(); + settings.Recruitment = new RecruitmentSettings { Enabled = false }; + var (_, warnings) = settings.Validate(); + Assert.DoesNotContain(warnings, w => w.Contains("TagLooking")); + } + + [Fact] + public void UnityHelpEnabled_MissingTagResolved_Warning() + { + var settings = CreateValid(); + settings.UnityHelp = new UnityHelpSettings { BabySitterEnabled = true, TagResolved = "" }; + var (_, warnings) = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("TagResolved")); + } +} diff --git a/DiscordBot.Tests/Settings/UserSettingsValidationTests.cs b/DiscordBot.Tests/Settings/UserSettingsValidationTests.cs new file mode 100644 index 00000000..6245c9a6 --- /dev/null +++ b/DiscordBot.Tests/Settings/UserSettingsValidationTests.cs @@ -0,0 +1,45 @@ +using DiscordBot.Settings; + +namespace DiscordBot.Tests.Settings; + +public class UserSettingsValidationTests +{ + [Fact] + public void DefaultSettings_NoWarnings() + { + var settings = new UserSettings(); + Assert.Empty(settings.Validate()); + } + + [Fact] + public void XpMinGreaterThanMax_Warning() + { + var settings = new UserSettings { XpMinPerMessage = 50, XpMaxPerMessage = 10 }; + var warnings = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("XpMinPerMessage")); + } + + [Fact] + public void CooldownMinGreaterThanMax_Warning() + { + var settings = new UserSettings { XpMinCooldown = 200, XpMaxCooldown = 60 }; + var warnings = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("XpMinCooldown")); + } + + [Fact] + public void ThanksCooldownZeroOrNegative_Warning() + { + var settings = new UserSettings { ThanksCooldown = 0 }; + var warnings = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("ThanksCooldown")); + } + + [Fact] + public void EmptyThanksList_Warning() + { + var settings = new UserSettings { Thanks = [] }; + var warnings = settings.Validate(); + Assert.Contains(warnings, w => w.Contains("Thanks list is empty")); + } +} diff --git a/DiscordBot.Tests/Utils/MathUtilityTests.cs b/DiscordBot.Tests/Utils/MathUtilityTests.cs new file mode 100644 index 00000000..20eddf7b --- /dev/null +++ b/DiscordBot.Tests/Utils/MathUtilityTests.cs @@ -0,0 +1,34 @@ +using DiscordBot.Utils; + +namespace DiscordBot.Tests.Utils; + +public class MathUtilityTests +{ + [Theory] + [InlineData(0f, 32f)] + [InlineData(100f, 212f)] + [InlineData(-40f, -40f)] + [InlineData(37f, 98.6f)] + public void CelsiusToFahrenheit_KnownValues(float celsius, float expectedF) + { + Assert.Equal(expectedF, MathUtility.CelsiusToFahrenheit(celsius), 1); + } + + [Theory] + [InlineData(32f, 0f)] + [InlineData(212f, 100f)] + [InlineData(-40f, -40f)] + public void FahrenheitToCelsius_KnownValues(float fahrenheit, float expectedC) + { + Assert.Equal(expectedC, MathUtility.FahrenheitToCelsius(fahrenheit), 0); + } + + [Fact] + public void RoundTrip_CelsiusToFahrenheitAndBack() + { + var original = 25f; + var fahrenheit = MathUtility.CelsiusToFahrenheit(original); + var backToCelsius = MathUtility.FahrenheitToCelsius(fahrenheit); + Assert.Equal(original, backToCelsius, 0); + } +} diff --git a/DiscordBot.Tests/Utils/StringUtilTests.cs b/DiscordBot.Tests/Utils/StringUtilTests.cs new file mode 100644 index 00000000..782458e5 --- /dev/null +++ b/DiscordBot.Tests/Utils/StringUtilTests.cs @@ -0,0 +1,58 @@ +using DiscordBot.Utils; + +namespace DiscordBot.Tests.Utils; + +public class StringUtilTests +{ + [Theory] + [InlineData("$100", true)] + [InlineData("100$", true)] + [InlineData("USD 50", true)] + [InlineData("€200", true)] + [InlineData("50 GBP", true)] + [InlineData("100 euros", true)] + [InlineData("£30", true)] + [InlineData("hello world", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void ContainsCurrencySymbol_ReturnsExpected(string? input, bool expected) + { + Assert.Equal(expected, input!.ContainsCurrencySymbol()); + } + + [Theory] + [InlineData("This is a rev-share project", true)] + [InlineData("Looking for revshare", true)] + [InlineData("Doing rev share work", true)] + [InlineData("Revenue sharing model", false)] + [InlineData("hello world", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void ContainsRevShare_ReturnsExpected(string? input, bool expected) + { + Assert.Equal(expected, input!.ContainsRevShare()); + } + + [Fact] + public void SanitizeEveryoneHereMentions_SanitizesEveryone() + { + var result = "Hey @everyone check this out".SanitizeEveryoneHereMentions(); + Assert.Contains("@\u200beveryone", result); + Assert.Equal("Hey @\u200beveryone check this out", result); + } + + [Fact] + public void SanitizeEveryoneHereMentions_SanitizesHere() + { + var result = "Hey @here check this out".SanitizeEveryoneHereMentions(); + Assert.Contains("@\u200bhere", result); + Assert.Equal("Hey @\u200bhere check this out", result); + } + + [Fact] + public void SanitizeEveryoneHereMentions_NoMentions_Unchanged() + { + var input = "Hello world"; + Assert.Equal(input, input.SanitizeEveryoneHereMentions()); + } +} diff --git a/DiscordBot.sln b/DiscordBot.sln index 27eccdb4..cb6106d3 100644 --- a/DiscordBot.sln +++ b/DiscordBot.sln @@ -5,20 +5,42 @@ VisualStudioVersion = 15.0.27421.1 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordBot", "DiscordBot\DiscordBot.csproj", "{D021BBDF-02DC-4938-B035-75D7EDBDBAC2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordBot.Tests", "DiscordBot.Tests\DiscordBot.Tests.csproj", "{7271E3E3-989C-423E-9954-2401FCA8A230}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Debug|x64.ActiveCfg = Debug|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Debug|x64.Build.0 = Debug|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Debug|x86.ActiveCfg = Debug|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Debug|x86.Build.0 = Debug|Any CPU {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Release|Any CPU.ActiveCfg = Release|Any CPU {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Release|Any CPU.Build.0 = Release|Any CPU - {8C06F0F6-2B30-4931-B93E-AE005F92C676}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8C06F0F6-2B30-4931-B93E-AE005F92C676}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8C06F0F6-2B30-4931-B93E-AE005F92C676}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8C06F0F6-2B30-4931-B93E-AE005F92C676}.Release|Any CPU.Build.0 = Release|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Release|x64.ActiveCfg = Release|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Release|x64.Build.0 = Release|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Release|x86.ActiveCfg = Release|Any CPU + {D021BBDF-02DC-4938-B035-75D7EDBDBAC2}.Release|x86.Build.0 = Release|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Debug|x64.ActiveCfg = Debug|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Debug|x64.Build.0 = Debug|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Debug|x86.ActiveCfg = Debug|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Debug|x86.Build.0 = Debug|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Release|Any CPU.Build.0 = Release|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Release|x64.ActiveCfg = Release|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Release|x64.Build.0 = Release|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Release|x86.ActiveCfg = Release|Any CPU + {7271E3E3-989C-423E-9954-2401FCA8A230}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md index ef7139ee..5b9f015d 100644 --- a/docs/code-quality-audit.md +++ b/docs/code-quality-audit.md @@ -391,7 +391,7 @@ no `.cs` files and no test framework configured. ### Long-term (Sustainability) -1. Set up test project with xUnit and write tests for critical paths +1. ✅ Set up test project with xUnit and write tests for critical paths 2. Split `ICasinoRepo` into focused interfaces 3. Extract `IWebClient` / `IHtmlParser` from `WebUtil` for testability 4. Implement session expiry and cleanup for casino game sessions From e4e0575abfa777cfa27b4ec2c50947a24cf01dd4 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 20:25:04 +0200 Subject: [PATCH 42/48] =?UTF-8?q?docs:=20mark=20M4=20(config=20validation)?= =?UTF-8?q?=20as=20done=20=E2=80=94=20covered=20by=20S3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/code-quality-audit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md index 5b9f015d..81a338e0 100644 --- a/docs/code-quality-audit.md +++ b/docs/code-quality-audit.md @@ -383,7 +383,7 @@ no `.cs` files and no test framework configured. 1. Create `EmbedFactory` to reduce embed construction duplication 2. Create `SafeFireAndForget()` extension to replace `#pragma` + `Task.Run` 3. ~~Consolidate `ContainsInviteLink()` overloads~~ ✅ removed (dead code) -4. Add configuration validation for all settings +4. ~~Add configuration validation for all settings~~ ✅ (covered by S3) 5. Audit service lifetimes — consider `Scoped` for interaction-scoped services 6. Remove all dead/commented-out code 7. Standardize naming conventions (event handlers, async methods, service From bbef8b6c5799ef84094a00c7a4c960e6ebc94a38 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 20:31:44 +0200 Subject: [PATCH 43/48] refactor: add SafeFireAndForget extension, remove all CS4014 pragmas - Add TaskExtensions.SafeFireAndForget() using ContinueWith to catch and log exceptions from fire-and-forget tasks - Filter OperationCanceledException from error logging - Replace 8 #pragma CS4014 + Task.Run sites in UnityHelpService - Simplify OnReactionAdded handler (remove unnecessary Task.Run wrapper) - Zero pragmas remaining in codebase --- DiscordBot/Extensions/TaskExtensions.cs | 12 ++++++ .../Services/UnityHelp/UnityHelpService.cs | 39 ++++++------------- docs/code-quality-audit.md | 2 +- 3 files changed, 24 insertions(+), 29 deletions(-) diff --git a/DiscordBot/Extensions/TaskExtensions.cs b/DiscordBot/Extensions/TaskExtensions.cs index 101bb9cc..a6a088d8 100644 --- a/DiscordBot/Extensions/TaskExtensions.cs +++ b/DiscordBot/Extensions/TaskExtensions.cs @@ -26,6 +26,18 @@ public static Func Guarded(Func public static class TaskExtensions { + public static void SafeFireAndForget(this Task task, string? caller = null) + { + task.ContinueWith(t => + { + if (t.Exception is not { } ex) return; + if (ex.InnerException is OperationCanceledException) return; + + var prefix = caller != null ? $"[{caller}] " : ""; + LoggingService.LogToConsole($"{prefix}Fire-and-forget exception: {ex}", LogSeverity.Error); + }, TaskContinuationOptions.OnlyOnFaulted); + } + public static Task? DeleteAfterTime(this IDeletable message, int seconds = 0, int minutes = 0, int hours = 0, int days = 0) => message?.DeleteAfterTimeSpan(new TimeSpan(days, hours, minutes, seconds)); public static Task? DeleteAfterSeconds(this IDeletable message, double seconds) => message?.DeleteAfterTimeSpan(TimeSpan.FromSeconds(seconds)); diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/UnityHelp/UnityHelpService.cs index b194f85c..1978b4e1 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/UnityHelp/UnityHelpService.cs @@ -137,13 +137,11 @@ private async Task LoadActiveThreads() if (threadContainer.IsResolved) { // Run in new task so we don't block the other threads from being processed -#pragma warning disable CS4014 - Task.Run(() => CloseThreadInTime(threadContainer, string.Empty, + CloseThreadInTime(threadContainer, string.Empty, TimeBeforeClosedForResolvedTag, (threadContainer.PinnedAnswer != 0 ? _resolvedWarnOfPendingCloseEmbedHasPin - : _resolvedWarnOfPendingCloseEmbedNoPin))); -#pragma warning restore CS4014 + : _resolvedWarnOfPendingCloseEmbedNoPin)).SafeFireAndForget(ServiceName); } else { @@ -215,7 +213,7 @@ private Task GatewayOnThreadCreated(SocketThreadChannel thread) return Task.CompletedTask; LoggingService.DebugLog($"[{ServiceName}] New Thread Created: {thread.Id} - {thread.Name}", LogSeverity.Debug); - Task.Run(() => OnThreadCreated(thread)); + OnThreadCreated(thread).SafeFireAndForget(ServiceName); return Task.CompletedTask; } @@ -290,9 +288,7 @@ private async Task GatewayOnThreadUpdated(Cacheable LoggingService.DebugLog($"[{ServiceName}] Thread Updated: {after.Id} - {after.Name}", LogSeverity.Debug); -#pragma warning disable CS4014 - Task.Run(() => OnThreadUpdated(beforeThread, afterThread)); -#pragma warning restore CS4014 + OnThreadUpdated(beforeThread, afterThread).SafeFireAndForget(ServiceName); } #endregion // Thread Update @@ -312,9 +308,7 @@ private async Task GatewayOnThreadDeleted(Cacheable LoggingService.DebugLog($"[{ServiceName}] Thread Deleted: {threadId.Id}", LogSeverity.Debug); var thread = await threadId.GetOrDownloadAsync(); -#pragma warning disable CS4014 - Task.Run(() => OnThreadDeleted(thread)); -#pragma warning restore CS4014 + OnThreadDeleted(thread).SafeFireAndForget(ServiceName); } #endregion // Thread Deleted @@ -357,9 +351,7 @@ private async Task OnMessageReceived(SocketMessage message) // If Author is only one who has interacted with the thread, we don't need to update anything else if (!thread.HasInteraction && message.Author.Id == thread.Owner) { -#pragma warning disable CS4014 - Task.Run(() => StealthDeleteThreadInTime(thread)); -#pragma warning restore CS4014 + StealthDeleteThreadInTime(thread).SafeFireAndForget(ServiceName); return; } @@ -386,7 +378,7 @@ private Task GatewayOnMessageReceived(SocketMessage message) return Task.CompletedTask; LoggingService.DebugLog($"[{ServiceName}] Help Message Received: {message.Id} - {message.Content}", LogSeverity.Debug); - Task.Run(() => OnMessageReceived(message)); + OnMessageReceived(message).SafeFireAndForget(ServiceName); return Task.CompletedTask; } @@ -427,9 +419,7 @@ private async Task GatewayOnMessageUpdated(Cacheable before, So return; LoggingService.DebugLog($"[{ServiceName}] Help Message Updated: {after.Id} - {after.Content}", LogSeverity.Debug); -#pragma warning disable CS4014 - Task.Run(() => OnMessageUpdated(beforeMsg, after, (channel as SocketThreadChannel)!)); -#pragma warning restore CS4014 + OnMessageUpdated(beforeMsg, after, (channel as SocketThreadChannel)!).SafeFireAndForget(ServiceName); if (after.Reactions.ContainsKey(CloseEmoji)) { @@ -458,17 +448,10 @@ private async Task OnReactionAdded(Cacheable messageCache, if (message == null || message.Author.Id != _client.CurrentUser.Id) return; -#pragma warning disable CS4014 - Task.Run(async () => -#pragma warning restore CS4014 - { - // Check the owner is the one reacting - var threadOwner = channel.Owner.Id; - if (reaction.UserId != threadOwner) - return; + if (reaction.UserId != channel.Owner.Id) + return; - await CloseThread(channel, true); - }); + CloseThread(channel, true).SafeFireAndForget(ServiceName); } public async Task OnUserRequestChannelClose(IUser user, SocketThreadChannel channel) diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md index 81a338e0..7594083a 100644 --- a/docs/code-quality-audit.md +++ b/docs/code-quality-audit.md @@ -381,7 +381,7 @@ no `.cs` files and no test framework configured. ### Medium-term (Quality) 1. Create `EmbedFactory` to reduce embed construction duplication -2. Create `SafeFireAndForget()` extension to replace `#pragma` + `Task.Run` +2. ~~Create `SafeFireAndForget()` extension to replace `#pragma` + `Task.Run`~~ ✅ 3. ~~Consolidate `ContainsInviteLink()` overloads~~ ✅ removed (dead code) 4. ~~Add configuration validation for all settings~~ ✅ (covered by S3) 5. Audit service lifetimes — consider `Scoped` for interaction-scoped services From 8a22c5935e904dd4587f378509fb991deae2e29b Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Tue, 7 Apr 2026 20:40:00 +0200 Subject: [PATCH 44/48] refactor(skin): consolidate duplicate RectangleD struct into Domain namespace Remove Skin/RectangleD.cs which was identical to Domain/RectangleD.cs. Consumer files already had 'using DiscordBot.Domain' so no code changes needed. --- DiscordBot/Skin/RectangleD.cs | 17 ------------- docs/code-quality-audit.md | 48 +++++++++++++++++------------------ 2 files changed, 24 insertions(+), 41 deletions(-) delete mode 100644 DiscordBot/Skin/RectangleD.cs diff --git a/DiscordBot/Skin/RectangleD.cs b/DiscordBot/Skin/RectangleD.cs deleted file mode 100644 index dc4da6ed..00000000 --- a/DiscordBot/Skin/RectangleD.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace DiscordBot.Skin; - -public struct RectangleD -{ - public double UpperLeftX; - public double UpperLeftY; - public double LowerRightX; - public double LowerRightY; - - public RectangleD(double upperLeftX, double upperLeftY, double lowerRightX, double lowerRightY) - { - UpperLeftX = upperLeftX; - UpperLeftY = upperLeftY; - LowerRightX = lowerRightX; - LowerRightY = lowerRightY; - } -} \ No newline at end of file diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md index 7594083a..e18823c6 100644 --- a/docs/code-quality-audit.md +++ b/docs/code-quality-audit.md @@ -270,7 +270,7 @@ signal handling, no resource cleanup on exit. | Issue | Severity | Details | |-------|----------|---------| -| Duplicate `RectangleD` struct | Low | Defined in both `Skin/RectangleD.cs` and `Domain/RectangleD.cs` | +| ✅ ~~Duplicate `RectangleD` struct~~ | ~~Low~~ | ~~Resolved — kept `Domain/RectangleD.cs`, deleted `Skin/RectangleD.cs`~~ | | Reflection in `SkinModuleJsonConverter` | Medium | `Type.GetType()` on every deserialization; no caching; no null check | | Magic threshold in avatar color sampling | Medium | `650` RGB sum threshold unexplained | | Inconsistent coordinate types | Low | Some modules use `int`, others use `double` | @@ -356,34 +356,34 @@ no `.cs` files and no test framework configured. ### Immediate (Bugs) -1. ~~Fix `_isInitialized` race condition in `Program.cs` — use - `Interlocked.CompareExchange`~~ ✅ -2. ~~Replace `List` with `ConcurrentDictionary` in - `GameService.cs`~~ ✅ -3. ~~Fix double `i++` in `EmbedModule.cs` reaction-polling loop (and add - `break` after confirmation)~~ ✅ -4. ~~Fix sunrise/sunset copy-paste bug in `WeatherModule.cs`~~ ✅ -5. ~~Add `using` to all `HttpClient` instances or switch to `IHttpClientFactory`; - replace deprecated `WebClient` usage~~ ✅ -6. ~~Add null checks in `RoleAttributes.cs` for DM context safety~~ ✅ -7. ~~Wrap all `async void` event handlers in try-catch~~ ✅ +1. ✅ ~~Fix `_isInitialized` race condition in `Program.cs` — use + `Interlocked.CompareExchange`~~ +2. ✅ ~~Replace `List` with `ConcurrentDictionary` in + `GameService.cs`~~ +3. ✅ ~~Fix double `i++` in `EmbedModule.cs` reaction-polling loop (and add + `break` after confirmation)~~ +4. ✅ ~~Fix sunrise/sunset copy-paste bug in `WeatherModule.cs`~~ +5. ✅ ~~Add `using` to all `HttpClient` instances or switch to `IHttpClientFactory`; + replace deprecated `WebClient` usage~~ +6. ✅ ~~Add null checks in `RoleAttributes.cs` for DM context safety~~ +7. ✅ ~~Wrap all `async void` event handlers in try-catch~~ ### Short-term (Architecture) -1. ~~Split `UserService` into focused services~~ ✅ -2. ~~Split `BotSettings` into domain-specific config classes~~ ✅ -3. ~~Add `BotSettings.Validate()` post-deserialization~~ ✅ -4. ~~Extract business logic from command handlers into services~~ ✅ -5. ~~Register `IHttpClientFactory` in DI; remove manual `HttpClient` creation~~ ✅ -6. ~~Add graceful shutdown support with `CancellationToken`~~ ✅ -7. ~~Move static module state (`_activeDuels`) to services~~ ✅ +1. ✅ ~~Split `UserService` into focused services~~ +2. ✅ ~~Split `BotSettings` into domain-specific config classes~~ +3. ✅ ~~Add `BotSettings.Validate()` post-deserialization~~ +4. ✅ ~~Extract business logic from command handlers into services~~ +5. ✅ ~~Register `IHttpClientFactory` in DI; remove manual `HttpClient` creation~~ +6. ✅ ~~Add graceful shutdown support with `CancellationToken`~~ +7. ✅ ~~Move static module state (`_activeDuels`) to services~~ ### Medium-term (Quality) 1. Create `EmbedFactory` to reduce embed construction duplication -2. ~~Create `SafeFireAndForget()` extension to replace `#pragma` + `Task.Run`~~ ✅ -3. ~~Consolidate `ContainsInviteLink()` overloads~~ ✅ removed (dead code) -4. ~~Add configuration validation for all settings~~ ✅ (covered by S3) +2. ✅ ~~Create `SafeFireAndForget()` extension to replace `#pragma` + `Task.Run`~~ +3. ✅ ~~Consolidate `ContainsInviteLink()` overloads~~ — removed (dead code) +4. ✅ ~~Add configuration validation for all settings~~ — covered by S3 5. Audit service lifetimes — consider `Scoped` for interaction-scoped services 6. Remove all dead/commented-out code 7. Standardize naming conventions (event handlers, async methods, service @@ -391,12 +391,12 @@ no `.cs` files and no test framework configured. ### Long-term (Sustainability) -1. ✅ Set up test project with xUnit and write tests for critical paths +1. ✅ ~~Set up test project with xUnit and write tests for critical paths~~ 2. Split `ICasinoRepo` into focused interfaces 3. Extract `IWebClient` / `IHtmlParser` from `WebUtil` for testability 4. Implement session expiry and cleanup for casino game sessions 5. Refactor skin module hierarchy — intermediate base classes, coordinate config -6. Consolidate duplicate `RectangleD` struct +6. ✅ ~~Consolidate duplicate `RectangleD` struct~~ 7. Replace `string[][]` database in `UpdateService` with typed structures --- From 459d08fb66ee82ea1205f0e32c0b7c2aeed2af4d Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Wed, 8 Apr 2026 09:41:58 +0200 Subject: [PATCH 45/48] refactor(utils): extract IWebClient interface from static WebUtil for DI and testability - Create IWebClient interface with all 8 HTTP/HTML methods - Rename WebUtil to WebClient, make non-static, implement IWebClient - Inject IHttpClientFactory (creates fresh HttpClient per request for handler rotation) - Register IWebClient singleton in DI container - Update 5 consumers: property injection for modules, constructor for services - IHtmlParser split intentionally skipped (thin wrappers coupling fetch+parse) --- DiscordBot/Modules/BirthdayModule.cs | 6 ++-- .../Modules/UnityHelp/GeneralHelpModule.cs | 3 +- DiscordBot/Program.cs | 1 + .../Services/BirthdayAnnouncementService.cs | 6 ++-- DiscordBot/Services/CurrencyService.cs | 10 ++++-- DiscordBot/Services/FeedService.cs | 6 ++-- DiscordBot/Utils/IWebClient.cs | 15 ++++++++ DiscordBot/Utils/{WebUtil.cs => WebClient.cs} | 36 +++++++++++-------- docs/code-quality-audit.md | 6 ++-- 9 files changed, 62 insertions(+), 27 deletions(-) create mode 100644 DiscordBot/Utils/IWebClient.cs rename DiscordBot/Utils/{WebUtil.cs => WebClient.cs} (70%) diff --git a/DiscordBot/Modules/BirthdayModule.cs b/DiscordBot/Modules/BirthdayModule.cs index f37e30e4..09b44373 100644 --- a/DiscordBot/Modules/BirthdayModule.cs +++ b/DiscordBot/Modules/BirthdayModule.cs @@ -8,6 +8,8 @@ namespace DiscordBot.Modules; [Group("UserModule"), Alias("")] public class BirthdayModule : ModuleBase { + public IWebClient WebClient { get; set; } = null!; + [Command("Birthday"), HideFromHelp] [Summary("Display next member birthday.")] [Alias("bday")] @@ -15,7 +17,7 @@ public async Task Birthday() { const string nextBirthday = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&range=C15:C15"; - var tableText = await WebUtil.GetHtmlNodeInnerText(nextBirthday, "/html/body/table/tr[2]/td"); + var tableText = await WebClient.GetHtmlNodeInnerText(nextBirthday, "/html/body/table/tr[2]/td"); var message = $"**{tableText}**"; await (ReplyAsync(message).DeleteAfterTime(minutes: 3) ?? Task.CompletedTask); @@ -29,7 +31,7 @@ public async Task Birthday(IUser user) { var searchName = user.Username; const string birthdayTable = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&gid=318080247&range=B:D"; - var relevantNodes = await WebUtil.GetHtmlNodes(birthdayTable, "/html/body/table/tr"); + var relevantNodes = await WebClient.GetHtmlNodes(birthdayTable, "/html/body/table/tr"); var birthdate = default(DateTime); diff --git a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs b/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs index 92a88d9f..12303f50 100644 --- a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs +++ b/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs @@ -11,6 +11,7 @@ public class GeneralHelpModule : ModuleBase #region Dependency Injection public BotSettings BotSettings { get; set; } = null!; + public IWebClient WebClient { get; set; } = null!; #endregion // Dependency Injection @@ -36,7 +37,7 @@ public async Task RespondWithErrorDocumentation(string error) foreach (var url in urls) { - errorPage = await WebUtil.GetHtmlDocument($"{url}{error}"); + errorPage = await WebClient.GetHtmlDocument($"{url}{error}"); if (errorPage == null || errorPage.DocumentNode.InnerHtml.Contains("Page not found")) { continue; diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index a19d393a..e58942f0 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -107,6 +107,7 @@ private async Task MainAsync() private IServiceProvider ConfigureServices() => new ServiceCollection() .AddHttpClient() + .AddSingleton() .AddSingleton(_cts) .AddSingleton(_settings) .AddSingleton(_rules) diff --git a/DiscordBot/Services/BirthdayAnnouncementService.cs b/DiscordBot/Services/BirthdayAnnouncementService.cs index 7d9bf46e..3e6aba35 100644 --- a/DiscordBot/Services/BirthdayAnnouncementService.cs +++ b/DiscordBot/Services/BirthdayAnnouncementService.cs @@ -15,6 +15,7 @@ public class BirthdayAnnouncementService private readonly DiscordSocketClient _client; private readonly ILoggingService _loggingService; private readonly BotSettings _settings; + private readonly IWebClient _webClient; private readonly CancellationToken _shutdownToken; // Track birthdays that have been announced today to avoid spam @@ -25,11 +26,12 @@ public class BirthdayAnnouncementService private const string BirthdayTableUrl = "https://docs.google.com/spreadsheets/d/10iGiKcrBl1fjoBNTzdtjEVYEgOfTveRXdI5cybRTnj4/gviz/tq?tqx=out:html&gid=318080247&range=B:D"; public BirthdayAnnouncementService(DiscordSocketClient client, ILoggingService loggingService, BotSettings settings, - CancellationTokenSource cts) + IWebClient webClient, CancellationTokenSource cts) { _client = client; _loggingService = loggingService; _settings = settings; + _webClient = webClient; _shutdownToken = cts.Token; Initialize(); @@ -131,7 +133,7 @@ private async Task> GetTodaysBirthdays() try { - var relevantNodes = await WebUtil.GetHtmlNodes(BirthdayTableUrl, "/html/body/table/tr"); + var relevantNodes = await _webClient.GetHtmlNodes(BirthdayTableUrl, "/html/body/table/tr"); if (relevantNodes == null) { return birthdays; diff --git a/DiscordBot/Services/CurrencyService.cs b/DiscordBot/Services/CurrencyService.cs index 8afaf15a..450eff65 100644 --- a/DiscordBot/Services/CurrencyService.cs +++ b/DiscordBot/Services/CurrencyService.cs @@ -6,6 +6,7 @@ namespace DiscordBot.Services; public class CurrencyService { private const string ServiceName = "CurrencyService"; + private readonly IWebClient _webClient; #region Configuration @@ -26,6 +27,11 @@ private class Currency private static readonly string ApiUrl = $"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{TargetDate}/v{ApiVersion}/"; + public CurrencyService(IWebClient webClient) + { + _webClient = webClient; + } + public async Task GetConversion(string toCurrency, string fromCurrency = "usd") { toCurrency = toCurrency.ToLower(); @@ -34,7 +40,7 @@ public async Task GetConversion(string toCurrency, string fromCurrency = var url = $"{ApiUrl}{ExchangeRatesEndpoint}/{fromCurrency.ToLower()}.min.json"; // Check if success - var (success, response) = await WebUtil.TryGetObjectFromJson(url); + var (success, response) = await _webClient.TryGetObjectFromJson(url); if (!success) return -1; @@ -71,7 +77,7 @@ public async Task IsCurrency(string currency) private async Task BuildCurrencyList() { var url = ApiUrl + ValidCurrenciesEndpoint; - var currencies = await WebUtil.GetObjectFromJson>(url); + var currencies = await _webClient.GetObjectFromJson>(url); if (currencies == null) return; diff --git a/DiscordBot/Services/FeedService.cs b/DiscordBot/Services/FeedService.cs index a6cd8d5a..c6638544 100644 --- a/DiscordBot/Services/FeedService.cs +++ b/DiscordBot/Services/FeedService.cs @@ -14,6 +14,7 @@ public class FeedService private readonly BotSettings _settings; private readonly ILoggingService _logging; + private readonly IWebClient _webClient; private readonly ReleaseNotesParser _releaseNotesParser; #region Configurable Settings @@ -62,11 +63,12 @@ private class ForumNewsFeed #endregion // Configurable Settings - public FeedService(DiscordSocketClient client, BotSettings settings, ILoggingService logging, ReleaseNotesParser releaseNotesParser) + public FeedService(DiscordSocketClient client, BotSettings settings, ILoggingService logging, IWebClient webClient, ReleaseNotesParser releaseNotesParser) { _client = client; _settings = settings; _logging = logging; + _webClient = webClient; _releaseNotesParser = releaseNotesParser; } @@ -75,7 +77,7 @@ private async Task GetFeedData(string url) SyndicationFeed? feed = null; try { - var content = await Utils.WebUtil.GetXMLContent(url); + var content = await _webClient.GetXMLContent(url); var reader = XmlReader.Create(new StringReader(content)); feed = SyndicationFeed.Load(reader); } diff --git a/DiscordBot/Utils/IWebClient.cs b/DiscordBot/Utils/IWebClient.cs new file mode 100644 index 00000000..267e37c4 --- /dev/null +++ b/DiscordBot/Utils/IWebClient.cs @@ -0,0 +1,15 @@ +using HtmlAgilityPack; + +namespace DiscordBot.Utils; + +public interface IWebClient +{ + Task GetContent(string url); + Task GetHtmlDocument(string url); + Task GetHtmlNode(string url, string xpath); + Task GetHtmlNodes(string url, string xpath); + Task GetHtmlNodeInnerText(string url, string xpath); + Task GetXMLContent(string url); + Task GetObjectFromJson(string url); + Task<(bool success, T? result)> TryGetObjectFromJson(string url); +} diff --git a/DiscordBot/Utils/WebUtil.cs b/DiscordBot/Utils/WebClient.cs similarity index 70% rename from DiscordBot/Utils/WebUtil.cs rename to DiscordBot/Utils/WebClient.cs index 4a4e867f..521c1bef 100644 --- a/DiscordBot/Utils/WebUtil.cs +++ b/DiscordBot/Utils/WebClient.cs @@ -5,23 +5,29 @@ namespace DiscordBot.Utils; -public static class WebUtil +public class WebClient : IWebClient { - private static readonly HttpClient SharedClient = new(); + private readonly IHttpClientFactory _httpClientFactory; + + public WebClient(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } /// /// Returns the content of a URL as a string, or an empty string if the request fails. /// - public static async Task GetContent(string url) + public async Task GetContent(string url) { try { - var response = await SharedClient.GetAsync(url); + using var client = _httpClientFactory.CreateClient(); + var response = await client.GetAsync(url); return await response.Content.ReadAsStringAsync(); } catch (Exception e) { - LoggingService.LogToConsole($"[WebUtil] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); + LoggingService.LogToConsole($"[WebClient] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); return ""; } } @@ -30,7 +36,7 @@ public static async Task GetContent(string url) /// Returns the Html document of a url, or null if the request fails. /// Internally calls GetContent and parses the result. /// - public static async Task GetHtmlDocument(string url) + public async Task GetHtmlDocument(string url) { try { @@ -49,7 +55,7 @@ public static async Task GetContent(string url) /// Returns the Html node of a url and xpath, or null if the request fails. /// Internally calls GetHtmlDocument and parses the result with xpath. /// - public static async Task GetHtmlNode(string url, string xpath) + public async Task GetHtmlNode(string url, string xpath) { try { @@ -65,7 +71,7 @@ public static async Task GetContent(string url) /// /// Returns the Html nodes of a url and xpath, or null if the request fails. /// - public static async Task GetHtmlNodes(string url, string xpath) + public async Task GetHtmlNodes(string url, string xpath) { try { @@ -81,7 +87,7 @@ public static async Task GetContent(string url) /// /// Returns the decoded inner text of a url and xpath, or an empty string if the request fails. /// - public static async Task GetHtmlNodeInnerText(string url, string xpath) + public async Task GetHtmlNodeInnerText(string url, string xpath) { try { @@ -97,7 +103,7 @@ public static async Task GetContent(string url) /// /// Returns the content of a url as a sanitized XML string, or an empty string if the request fails. /// - public static async Task GetXMLContent(string url) + public async Task GetXMLContent(string url) { try { @@ -109,7 +115,7 @@ public static async Task GetXMLContent(string url) } catch (Exception e) { - LoggingService.LogToConsole($"[WebUtil] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); + LoggingService.LogToConsole($"[WebClient] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); return string.Empty; } } @@ -117,7 +123,7 @@ public static async Task GetXMLContent(string url) /// /// Returns a deserialized object from a JSON string. If the string is empty or can't be deserialized, it returns the default value of the type. /// - public static async Task GetObjectFromJson(string url) + public async Task GetObjectFromJson(string url) { try { @@ -126,7 +132,7 @@ public static async Task GetXMLContent(string url) } catch (Exception e) { - LoggingService.LogToConsole($"[WebUtil] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); + LoggingService.LogToConsole($"[WebClient] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); return default; } } @@ -134,7 +140,7 @@ public static async Task GetXMLContent(string url) /// /// Returns a deserialized object from a JSON string, or null if the string is empty or can't be deserialized. /// - public static async Task<(bool success, T? result)> TryGetObjectFromJson(string url) + public async Task<(bool success, T? result)> TryGetObjectFromJson(string url) { try { @@ -144,7 +150,7 @@ public static async Task GetXMLContent(string url) } catch (Exception e) { - LoggingService.LogToConsole($"[WebUtil] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); + LoggingService.LogToConsole($"[WebClient] Failed to get content from {url}: {e.Message}", ExtendedLogSeverity.LowWarning); return (false, default); } } diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md index e18823c6..b17188d7 100644 --- a/docs/code-quality-audit.md +++ b/docs/code-quality-audit.md @@ -34,7 +34,7 @@ issues common in organically grown projects. The most impactful problems are: | `UserModule` | `Modules/UserModule.cs` | 1 000+ lines; text commands, web scraping, role management, search, profile display all in one module | High | | `UpdateService` | `Services/UpdateService.cs` | Bot data, user muting lifecycle, FAQ loading, RSS feeds, Wikipedia downloading (5 concerns) | High | | `CasinoSlashModule` | `Modules/Casino/CasinoSlashModule.cs` | 500+ lines; token commands, game commands, admin commands, statistics, nested `TokenCommands` class | High | -| `WebUtil` | `Utils/WebUtil.cs` | HTTP fetching, HTML parsing, JSON deserialization, error handling, logging (5 concerns) | Medium | +| ~~`WebUtil`~~ | ~~`Utils/WebClient.cs`~~ | ~~Refactored to `IWebClient` / `WebClient` with DI~~ ✅ | ~~Medium~~ | | `ICasinoRepo` | `Extensions/CasinoRepository.cs` | 37+ SQL method signatures in one interface | Medium | **Recommended splits:** @@ -135,7 +135,7 @@ role-add / logging / cooldown / DM logic. | Location | Issue | |----------|-------| -| `WebUtil.cs` — `new HttpClient()` per call | Starves sockets under load. Use `IHttpClientFactory` or a static instance. | +| ~~`WebUtil.cs` — `new HttpClient()` per call~~ | ~~Fixed: `WebClient` now uses `IHttpClientFactory`~~ ✅ | | `AirportService.cs` — `HttpClient client = new()` | Created without `using`, never disposed. | | `DatabaseService.cs` — `Query` property | Returns new `MySqlConnection` each access; may never be disposed by caller. | | `UserService.cs` — `GenerateProfileCard()` | MagickImage objects not consistently disposed. | @@ -393,7 +393,7 @@ no `.cs` files and no test framework configured. 1. ✅ ~~Set up test project with xUnit and write tests for critical paths~~ 2. Split `ICasinoRepo` into focused interfaces -3. Extract `IWebClient` / `IHtmlParser` from `WebUtil` for testability +3. ✅ ~~Extract `IWebClient` from `WebUtil` for testability~~ (`IHtmlParser` split intentionally skipped — thin wrappers) 4. Implement session expiry and cleanup for casino game sessions 5. Refactor skin module hierarchy — intermediate base classes, coordinate config 6. ✅ ~~Consolidate duplicate `RectangleD` struct~~ From cadb1b50c1e9a16d5bcbf988197bb27a388616c2 Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Wed, 8 Apr 2026 09:47:10 +0200 Subject: [PATCH 46/48] refactor(domain): replace string[][] with typed DocEntry record for Unity doc entries - Create DocEntry(PageName, Title) record in Domain namespace - Add DocEntryJsonConverter for backward-compatible JSON serialization - Update UnityDocParser, UpdateService, SearchService to use DocEntry[] - SearchModule unchanged (already consumed typed DocSearchResult) --- DiscordBot/Domain/DocEntry.cs | 39 +++++++++++++++++++++++++++ DiscordBot/Services/SearchService.cs | 9 ++++--- DiscordBot/Services/UnityDocParser.cs | 7 ++--- DiscordBot/Services/UpdateService.cs | 13 ++++----- docs/code-quality-audit.md | 2 +- 5 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 DiscordBot/Domain/DocEntry.cs diff --git a/DiscordBot/Domain/DocEntry.cs b/DiscordBot/Domain/DocEntry.cs new file mode 100644 index 00000000..de6f3c02 --- /dev/null +++ b/DiscordBot/Domain/DocEntry.cs @@ -0,0 +1,39 @@ +using Newtonsoft.Json; + +namespace DiscordBot.Domain; + +[JsonConverter(typeof(DocEntryJsonConverter))] +public record DocEntry(string PageName, string Title); + +internal class DocEntryJsonConverter : JsonConverter +{ + public override DocEntry? ReadJson(JsonReader reader, Type objectType, DocEntry? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.StartArray) + { + var arr = serializer.Deserialize(reader); + if (arr is { Length: >= 2 }) + return new DocEntry(arr[0], arr[1]); + return null; + } + + if (reader.TokenType == JsonToken.StartObject) + { + var obj = Newtonsoft.Json.Linq.JObject.Load(reader); + return new DocEntry( + obj.Value("PageName") ?? "", + obj.Value("Title") ?? ""); + } + + return null; + } + + public override void WriteJson(JsonWriter writer, DocEntry? value, JsonSerializer serializer) + { + if (value == null) { writer.WriteNull(); return; } + writer.WriteStartArray(); + writer.WriteValue(value.PageName); + writer.WriteValue(value.Title); + writer.WriteEndArray(); + } +} diff --git a/DiscordBot/Services/SearchService.cs b/DiscordBot/Services/SearchService.cs index c21ab5bb..aecad872 100644 --- a/DiscordBot/Services/SearchService.cs +++ b/DiscordBot/Services/SearchService.cs @@ -1,4 +1,5 @@ using System.Net; +using DiscordBot.Domain; using HtmlAgilityPack; namespace DiscordBot.Services; @@ -43,14 +44,14 @@ public List SearchDuckDuckGo(string query, uint maxResults = 3, st return results; } - public DocSearchResult? FindBestMatch(string query, string[][] database, string baseUrl) + public DocSearchResult? FindBestMatch(string query, DocEntry[] database, string baseUrl) { var minimumScore = double.MaxValue; - string[]? mostSimilarPage = null; + DocEntry? mostSimilarPage = null; foreach (var p in database) { - var curScore = CalculateScore(p[1], query); + var curScore = CalculateScore(p.Title, query); if (curScore < minimumScore) { minimumScore = curScore; @@ -59,7 +60,7 @@ public List SearchDuckDuckGo(string query, uint maxResults = 3, st } if (mostSimilarPage == null) return null; - return new DocSearchResult(mostSimilarPage[0], mostSimilarPage[1], baseUrl); + return new DocSearchResult(mostSimilarPage.PageName, mostSimilarPage.Title, baseUrl); } public string? FetchPageDescription(string url, string descriptionXPath, string? nextSiblingFilter = null) diff --git a/DiscordBot/Services/UnityDocParser.cs b/DiscordBot/Services/UnityDocParser.cs index 72929408..eb10c521 100644 --- a/DiscordBot/Services/UnityDocParser.cs +++ b/DiscordBot/Services/UnityDocParser.cs @@ -1,12 +1,13 @@ +using DiscordBot.Domain; using HtmlAgilityPack; namespace DiscordBot.Services; public static class UnityDocParser { - public static string[][] ConvertJsToArray(string data, bool isManual) + public static DocEntry[] ConvertJsToArray(string data, bool isManual) { - var list = new List(); + var list = new List(); string pagesInput; if (isManual) @@ -23,7 +24,7 @@ public static string[][] ConvertJsToArray(string data, bool isManual) foreach (var s in pagesInput.Split("],[")) { var ps = s.Split(","); - list.Add(new[] { ps[0].Replace("\"", ""), ps[1].Replace("\"", "") }); + list.Add(new DocEntry(ps[0].Replace("\"", ""), ps[1].Replace("\"", ""))); } return list.ToArray(); diff --git a/DiscordBot/Services/UpdateService.cs b/DiscordBot/Services/UpdateService.cs index d5093607..be480645 100644 --- a/DiscordBot/Services/UpdateService.cs +++ b/DiscordBot/Services/UpdateService.cs @@ -1,6 +1,7 @@ using System.IO; using System.Text.RegularExpressions; using Discord.WebSocket; +using DiscordBot.Domain; using DiscordBot.Settings; using DiscordBot.Utils; using HtmlAgilityPack; @@ -51,13 +52,13 @@ public class UpdateService private readonly FeedService _feedService; private readonly BotSettings _settings; private readonly CancellationToken _token; - private string[][] _apiDatabase = null!; + private DocEntry[] _apiDatabase = null!; private BotData _botData = null!; private List _faqData = null!; private FeedData _feedData = null!; - private string[][] _manualDatabase = null!; + private DocEntry[] _manualDatabase = null!; private UserData _userData = null!; public UpdateService(DatabaseService databaseService, BotSettings settings, FeedService feedService, ILoggingService loggingService, @@ -107,14 +108,14 @@ private async Task SaveDataToFile() catch (OperationCanceledException) { } } - public async Task GetManualDatabase() + public async Task GetManualDatabase() { if (_manualDatabase == null) await LoadDocDatabase(); return _manualDatabase; } - public async Task GetApiDatabase() + public async Task GetApiDatabase() { if (_apiDatabase == null) await LoadDocDatabase(); @@ -129,9 +130,9 @@ private async Task LoadDocDatabase() File.Exists($"{_settings.ServerRootPath}/unityapi.json")) { var json = await File.ReadAllTextAsync($"{_settings.ServerRootPath}/unitymanual.json", _token); - _manualDatabase = JsonConvert.DeserializeObject(json)!; + _manualDatabase = JsonConvert.DeserializeObject(json)!; json = await File.ReadAllTextAsync($"{_settings.ServerRootPath}/unityapi.json", _token); - _apiDatabase = JsonConvert.DeserializeObject(json)!; + _apiDatabase = JsonConvert.DeserializeObject(json)!; } else await DownloadDocDatabase(); diff --git a/docs/code-quality-audit.md b/docs/code-quality-audit.md index b17188d7..9611b2ce 100644 --- a/docs/code-quality-audit.md +++ b/docs/code-quality-audit.md @@ -397,7 +397,7 @@ no `.cs` files and no test framework configured. 4. Implement session expiry and cleanup for casino game sessions 5. Refactor skin module hierarchy — intermediate base classes, coordinate config 6. ✅ ~~Consolidate duplicate `RectangleD` struct~~ -7. Replace `string[][]` database in `UpdateService` with typed structures +7. ✅ ~~Replace `string[][]` database in `UpdateService` with typed `DocEntry` record~~ --- From aaab7789bc3a6b22b75cbded5ab93b8481d5417f Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Wed, 8 Apr 2026 11:51:51 +0200 Subject: [PATCH 47/48] refactor: reorganize Modules and Services into domain-based directories Group Modules/ and Services/ into domain subdirectories: - Profiles/ (profile, rank, birthday, karma, XP) - Server/ (server mgmt, moderation, embeds, quotes, reminders) - Fun/ (fun, duels, casino) - Utils/ (search, convert, airport, weather) - Code/ (code tips, tips, Unity help/docs/feeds) Core services (Database, CommandHandling, Logging, Update) remain at Services/ root. Namespaces updated to match directory structure. All new sub-namespaces added to GlobalUsings.cs for backward compatibility. Fixed pre-existing typo namespace (DiscordBot.Service -> DiscordBot.Services.Code.Unity.UnityHelp). Build: 0 errors, 0 warnings. Tests: 163/163 passing. --- DiscordBot/GlobalUsings.cs | 26 +++- .../Modules/{ => Code}/CodeTipModule.cs | 2 +- DiscordBot/Modules/{ => Code}/TipModule.cs | 4 +- .../UnityHelp/CannedInteractiveModule.cs | 5 +- .../Unity}/UnityHelp/CannedResponseModule.cs | 5 +- .../Unity}/UnityHelp/GeneralHelpModule.cs | 2 +- .../UnityHelp/UnityHelpInteractiveModule.cs | 2 +- .../Unity}/UnityHelp/UnityHelpModule.cs | 2 +- .../Casino/CasinoSlashModule.Games.cs | 2 +- .../{ => Fun}/Casino/CasinoSlashModule.cs | 2 +- .../Modules/{ => Fun}/DuelSlashModule.cs | 2 +- DiscordBot/Modules/{ => Fun}/FunModule.cs | 2 +- .../Modules/{ => Profiles}/BirthdayModule.cs | 2 +- .../Modules/{ => Profiles}/ProfileModule.cs | 2 +- .../Modules/{ => Profiles}/RankModule.cs | 2 +- .../Modules/{ => Server}/EmbedModule.cs | 2 +- .../Modules/{ => Server}/QuoteModule.cs | 2 +- .../Modules/{ => Server}/ReminderModule.cs | 12 +- .../Modules/{ => Server}/RulesModule.cs | 2 +- .../Modules/{ => Server}/ServerModule.cs | 2 +- .../Modules/{ => Server}/ServerSlashModule.cs | 2 +- .../Modules/{ => Server}/TicketModule.cs | 2 +- .../Modules/{ => Utils}/AirportModule.cs | 3 +- .../Modules/{ => Utils}/ConvertModule.cs | 2 +- .../Modules/{ => Utils}/SearchModule.cs | 2 +- .../{ => Utils}/Weather/WeatherContainers.cs | 2 +- .../{ => Utils}/Weather/WeatherModule.cs | 3 +- DiscordBot/Program.cs | 2 - .../Services/{ => Code}/CodeCheckService.cs | 2 +- .../{ => Code}/Tips/Components/Tip.cs | 2 +- .../Services/{ => Code}/Tips/TipService.cs | 3 +- .../Services/{ => Code/Unity}/FeedService.cs | 4 +- .../{ => Code/Unity}/ReleaseNotesParser.cs | 2 +- .../{ => Code/Unity}/UnityDocParser.cs | 2 +- .../Unity}/UnityHelp/CannedResponseService.cs | 2 +- .../UnityHelp/Components/HelpBotMessage.cs | 2 +- .../UnityHelp/Components/ThreadContainer.cs | 2 +- .../Unity}/UnityHelp/UnityHelpService.cs | 3 +- .../{ => Fun}/Casino/CasinoService.cs | 2 +- .../Services/{ => Fun}/Casino/GameService.cs | 2 +- .../{ => Fun}/Casino/TransactionFormatter.cs | 2 +- DiscordBot/Services/{ => Fun}/DuelService.cs | 2 +- DiscordBot/Services/{ => Fun}/MikuService.cs | 2 +- .../BirthdayAnnouncementService.cs | 2 +- .../{ => Profiles}/KarmaResetService.cs | 2 +- .../Services/{ => Profiles}/KarmaService.cs | 2 +- .../{ => Profiles}/ProfileCardService.cs | 2 +- .../{ => Profiles}/UserExtendedService.cs | 2 +- .../Services/{ => Profiles}/XpService.cs | 2 +- .../Services/Recruitment/RecruitService.cs | 2 +- .../Services/{ => Server}/AuditLogService.cs | 2 +- .../{ => Server}/EmbedParsingService.cs | 2 +- .../{ => Server}/EveryoneScoldService.cs | 2 +- .../Services/{ => Server}/ReminderService.cs | 6 +- .../Services/{ => Server}/ServerService.cs | 2 +- .../Services/{ => Server}/WelcomeService.cs | 2 +- .../Services/{ => Utils}/AirportService.cs | 2 +- .../Services/{ => Utils}/CurrencyService.cs | 4 +- .../Services/{ => Utils}/SearchService.cs | 2 +- .../{ => Utils/Weather}/WeatherService.cs | 3 +- docs/codebase.md | 71 +++++++---- .../done/module-service-reorganization.md | 118 ++++++++++++++++++ 62 files changed, 260 insertions(+), 104 deletions(-) rename DiscordBot/Modules/{ => Code}/CodeTipModule.cs (97%) rename DiscordBot/Modules/{ => Code}/TipModule.cs (98%) rename DiscordBot/Modules/{ => Code/Unity}/UnityHelp/CannedInteractiveModule.cs (91%) rename DiscordBot/Modules/{ => Code/Unity}/UnityHelp/CannedResponseModule.cs (97%) rename DiscordBot/Modules/{ => Code/Unity}/UnityHelp/GeneralHelpModule.cs (98%) rename DiscordBot/Modules/{ => Code/Unity}/UnityHelp/UnityHelpInteractiveModule.cs (98%) rename DiscordBot/Modules/{ => Code/Unity}/UnityHelp/UnityHelpModule.cs (97%) rename DiscordBot/Modules/{ => Fun}/Casino/CasinoSlashModule.Games.cs (99%) rename DiscordBot/Modules/{ => Fun}/Casino/CasinoSlashModule.cs (99%) rename DiscordBot/Modules/{ => Fun}/DuelSlashModule.cs (99%) rename DiscordBot/Modules/{ => Fun}/FunModule.cs (99%) rename DiscordBot/Modules/{ => Profiles}/BirthdayModule.cs (98%) rename DiscordBot/Modules/{ => Profiles}/ProfileModule.cs (98%) rename DiscordBot/Modules/{ => Profiles}/RankModule.cs (99%) rename DiscordBot/Modules/{ => Server}/EmbedModule.cs (99%) rename DiscordBot/Modules/{ => Server}/QuoteModule.cs (98%) rename DiscordBot/Modules/{ => Server}/ReminderModule.cs (92%) rename DiscordBot/Modules/{ => Server}/RulesModule.cs (99%) rename DiscordBot/Modules/{ => Server}/ServerModule.cs (98%) rename DiscordBot/Modules/{ => Server}/ServerSlashModule.cs (99%) rename DiscordBot/Modules/{ => Server}/TicketModule.cs (99%) rename DiscordBot/Modules/{ => Utils}/AirportModule.cs (98%) rename DiscordBot/Modules/{ => Utils}/ConvertModule.cs (99%) rename DiscordBot/Modules/{ => Utils}/SearchModule.cs (99%) rename DiscordBot/Modules/{ => Utils}/Weather/WeatherContainers.cs (98%) rename DiscordBot/Modules/{ => Utils}/Weather/WeatherModule.cs (99%) rename DiscordBot/Services/{ => Code}/CodeCheckService.cs (99%) rename DiscordBot/Services/{ => Code}/Tips/Components/Tip.cs (84%) rename DiscordBot/Services/{ => Code}/Tips/TipService.cs (99%) rename DiscordBot/Services/{ => Code/Unity}/FeedService.cs (98%) rename DiscordBot/Services/{ => Code/Unity}/ReleaseNotesParser.cs (99%) rename DiscordBot/Services/{ => Code/Unity}/UnityDocParser.cs (94%) rename DiscordBot/Services/{ => Code/Unity}/UnityHelp/CannedResponseService.cs (99%) rename DiscordBot/Services/{ => Code/Unity}/UnityHelp/Components/HelpBotMessage.cs (88%) rename DiscordBot/Services/{ => Code/Unity}/UnityHelp/Components/ThreadContainer.cs (95%) rename DiscordBot/Services/{ => Code/Unity}/UnityHelp/UnityHelpService.cs (99%) rename DiscordBot/Services/{ => Fun}/Casino/CasinoService.cs (99%) rename DiscordBot/Services/{ => Fun}/Casino/GameService.cs (98%) rename DiscordBot/Services/{ => Fun}/Casino/TransactionFormatter.cs (98%) rename DiscordBot/Services/{ => Fun}/DuelService.cs (98%) rename DiscordBot/Services/{ => Fun}/MikuService.cs (98%) rename DiscordBot/Services/{ => Profiles}/BirthdayAnnouncementService.cs (99%) rename DiscordBot/Services/{ => Profiles}/KarmaResetService.cs (99%) rename DiscordBot/Services/{ => Profiles}/KarmaService.cs (99%) rename DiscordBot/Services/{ => Profiles}/ProfileCardService.cs (99%) rename DiscordBot/Services/{ => Profiles}/UserExtendedService.cs (97%) rename DiscordBot/Services/{ => Profiles}/XpService.cs (98%) rename DiscordBot/Services/{ => Server}/AuditLogService.cs (99%) rename DiscordBot/Services/{ => Server}/EmbedParsingService.cs (99%) rename DiscordBot/Services/{ => Server}/EveryoneScoldService.cs (97%) rename DiscordBot/Services/{ => Server}/ReminderService.cs (96%) rename DiscordBot/Services/{ => Server}/ServerService.cs (86%) rename DiscordBot/Services/{ => Server}/WelcomeService.cs (99%) rename DiscordBot/Services/{ => Utils}/AirportService.cs (99%) rename DiscordBot/Services/{ => Utils}/CurrencyService.cs (98%) rename DiscordBot/Services/{ => Utils}/SearchService.cs (99%) rename DiscordBot/Services/{ => Utils/Weather}/WeatherService.cs (95%) create mode 100644 docs/plans/done/module-service-reorganization.md diff --git a/DiscordBot/GlobalUsings.cs b/DiscordBot/GlobalUsings.cs index 35eb99f9..40ce2d6f 100644 --- a/DiscordBot/GlobalUsings.cs +++ b/DiscordBot/GlobalUsings.cs @@ -11,4 +11,28 @@ // Our code global using DiscordBot.Extensions; -global using DiscordBot.Services.Logging; \ No newline at end of file +global using DiscordBot.Services.Logging; + +// Module sub-namespaces +global using DiscordBot.Modules.Profiles; +global using DiscordBot.Modules.Server; +global using DiscordBot.Modules.Fun; +global using DiscordBot.Modules.Fun.Casino; +global using DiscordBot.Modules.Utils; +global using DiscordBot.Modules.Utils.Weather; +global using DiscordBot.Modules.Code; +global using DiscordBot.Modules.Code.Unity.UnityHelp; + +// Service sub-namespaces +global using DiscordBot.Services.Profiles; +global using DiscordBot.Services.Server; +global using DiscordBot.Services.Fun; +global using DiscordBot.Services.Fun.Casino; +global using DiscordBot.Services.Utils; +global using DiscordBot.Services.Utils.Weather; +global using DiscordBot.Services.Code; +global using DiscordBot.Services.Code.Tips; +global using DiscordBot.Services.Code.Tips.Components; +global using DiscordBot.Services.Code.Unity; +global using DiscordBot.Services.Code.Unity.UnityHelp; +global using DiscordBot.Services.Recruitment; \ No newline at end of file diff --git a/DiscordBot/Modules/CodeTipModule.cs b/DiscordBot/Modules/Code/CodeTipModule.cs similarity index 97% rename from DiscordBot/Modules/CodeTipModule.cs rename to DiscordBot/Modules/Code/CodeTipModule.cs index 2c129ea3..9bb2593c 100644 --- a/DiscordBot/Modules/CodeTipModule.cs +++ b/DiscordBot/Modules/Code/CodeTipModule.cs @@ -1,7 +1,7 @@ using Discord.Commands; using DiscordBot.Services; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Code; [Group("UserModule"), Alias("")] public class CodeTipModule : ModuleBase diff --git a/DiscordBot/Modules/TipModule.cs b/DiscordBot/Modules/Code/TipModule.cs similarity index 98% rename from DiscordBot/Modules/TipModule.cs rename to DiscordBot/Modules/Code/TipModule.cs index 9d9d0465..cd9068c0 100644 --- a/DiscordBot/Modules/TipModule.cs +++ b/DiscordBot/Modules/Code/TipModule.cs @@ -2,12 +2,10 @@ using Discord.Commands; using DiscordBot.Attributes; using DiscordBot.Services; -using DiscordBot.Services.Tips; -using DiscordBot.Services.Tips.Components; using DiscordBot.Settings; // ReSharper disable all UnusedMember.Local -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Code; public class TipModule : ModuleBase { diff --git a/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs b/DiscordBot/Modules/Code/Unity/UnityHelp/CannedInteractiveModule.cs similarity index 91% rename from DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs rename to DiscordBot/Modules/Code/Unity/UnityHelp/CannedInteractiveModule.cs index bed57f6a..40d9fd71 100644 --- a/DiscordBot/Modules/UnityHelp/CannedInteractiveModule.cs +++ b/DiscordBot/Modules/Code/Unity/UnityHelp/CannedInteractiveModule.cs @@ -1,12 +1,11 @@ using Discord.Commands; using Discord.Interactions; using Discord.WebSocket; -using DiscordBot.Service; using DiscordBot.Services; using DiscordBot.Settings; -using static DiscordBot.Service.CannedResponseService; +using static DiscordBot.Services.Code.Unity.UnityHelp.CannedResponseService; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Code.Unity.UnityHelp; public class CannedInteractiveModule : InteractionModuleBase { diff --git a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs b/DiscordBot/Modules/Code/Unity/UnityHelp/CannedResponseModule.cs similarity index 97% rename from DiscordBot/Modules/UnityHelp/CannedResponseModule.cs rename to DiscordBot/Modules/Code/Unity/UnityHelp/CannedResponseModule.cs index a43524d2..35eaf3b8 100644 --- a/DiscordBot/Modules/UnityHelp/CannedResponseModule.cs +++ b/DiscordBot/Modules/Code/Unity/UnityHelp/CannedResponseModule.cs @@ -1,11 +1,10 @@ using Discord.Commands; using DiscordBot.Attributes; -using DiscordBot.Service; using DiscordBot.Services; using DiscordBot.Settings; -using static DiscordBot.Service.CannedResponseService; +using static DiscordBot.Services.Code.Unity.UnityHelp.CannedResponseService; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Code.Unity.UnityHelp; public class CannedResponseModule : ModuleBase { diff --git a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs b/DiscordBot/Modules/Code/Unity/UnityHelp/GeneralHelpModule.cs similarity index 98% rename from DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs rename to DiscordBot/Modules/Code/Unity/UnityHelp/GeneralHelpModule.cs index 12303f50..ec2bf906 100644 --- a/DiscordBot/Modules/UnityHelp/GeneralHelpModule.cs +++ b/DiscordBot/Modules/Code/Unity/UnityHelp/GeneralHelpModule.cs @@ -4,7 +4,7 @@ using DiscordBot.Utils; using HtmlAgilityPack; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Code.Unity.UnityHelp; public class GeneralHelpModule : ModuleBase { diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs b/DiscordBot/Modules/Code/Unity/UnityHelp/UnityHelpInteractiveModule.cs similarity index 98% rename from DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs rename to DiscordBot/Modules/Code/Unity/UnityHelp/UnityHelpInteractiveModule.cs index 8b4a055b..d4257899 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpInteractiveModule.cs +++ b/DiscordBot/Modules/Code/Unity/UnityHelp/UnityHelpInteractiveModule.cs @@ -3,7 +3,7 @@ using DiscordBot.Settings; using Discord.WebSocket; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Code.Unity.UnityHelp; public class UnityHelpInteractiveModule : InteractionModuleBase { diff --git a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs b/DiscordBot/Modules/Code/Unity/UnityHelp/UnityHelpModule.cs similarity index 97% rename from DiscordBot/Modules/UnityHelp/UnityHelpModule.cs rename to DiscordBot/Modules/Code/Unity/UnityHelp/UnityHelpModule.cs index f9e8a0fc..ec4ef2da 100644 --- a/DiscordBot/Modules/UnityHelp/UnityHelpModule.cs +++ b/DiscordBot/Modules/Code/Unity/UnityHelp/UnityHelpModule.cs @@ -4,7 +4,7 @@ using DiscordBot.Services; using DiscordBot.Settings; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Code.Unity.UnityHelp; public class UnityHelpModule : ModuleBase { diff --git a/DiscordBot/Modules/Casino/CasinoSlashModule.Games.cs b/DiscordBot/Modules/Fun/Casino/CasinoSlashModule.Games.cs similarity index 99% rename from DiscordBot/Modules/Casino/CasinoSlashModule.Games.cs rename to DiscordBot/Modules/Fun/Casino/CasinoSlashModule.Games.cs index 87d2ac4a..846cf528 100644 --- a/DiscordBot/Modules/Casino/CasinoSlashModule.Games.cs +++ b/DiscordBot/Modules/Fun/Casino/CasinoSlashModule.Games.cs @@ -3,7 +3,7 @@ using DiscordBot.Domain; using DiscordBot.Services; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Fun.Casino; public enum CasinoGame { diff --git a/DiscordBot/Modules/Casino/CasinoSlashModule.cs b/DiscordBot/Modules/Fun/Casino/CasinoSlashModule.cs similarity index 99% rename from DiscordBot/Modules/Casino/CasinoSlashModule.cs rename to DiscordBot/Modules/Fun/Casino/CasinoSlashModule.cs index 6f2a6c93..ff23eb41 100644 --- a/DiscordBot/Modules/Casino/CasinoSlashModule.cs +++ b/DiscordBot/Modules/Fun/Casino/CasinoSlashModule.cs @@ -4,7 +4,7 @@ using DiscordBot.Services; using DiscordBot.Settings; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Fun.Casino; [Group("casino", "Casino games and token management")] public partial class CasinoSlashModule : InteractionModuleBase diff --git a/DiscordBot/Modules/DuelSlashModule.cs b/DiscordBot/Modules/Fun/DuelSlashModule.cs similarity index 99% rename from DiscordBot/Modules/DuelSlashModule.cs rename to DiscordBot/Modules/Fun/DuelSlashModule.cs index ec6c8751..b6c84516 100644 --- a/DiscordBot/Modules/DuelSlashModule.cs +++ b/DiscordBot/Modules/Fun/DuelSlashModule.cs @@ -1,7 +1,7 @@ using Discord.Interactions; using DiscordBot.Services; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Fun; public class DuelSlashModule : InteractionModuleBase { diff --git a/DiscordBot/Modules/FunModule.cs b/DiscordBot/Modules/Fun/FunModule.cs similarity index 99% rename from DiscordBot/Modules/FunModule.cs rename to DiscordBot/Modules/Fun/FunModule.cs index b1cb9471..4533675f 100644 --- a/DiscordBot/Modules/FunModule.cs +++ b/DiscordBot/Modules/Fun/FunModule.cs @@ -4,7 +4,7 @@ using DiscordBot.Settings; using DiscordBot.Data; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Fun; [Group("UserModule"), Alias("")] public class FunModule : ModuleBase diff --git a/DiscordBot/Modules/BirthdayModule.cs b/DiscordBot/Modules/Profiles/BirthdayModule.cs similarity index 98% rename from DiscordBot/Modules/BirthdayModule.cs rename to DiscordBot/Modules/Profiles/BirthdayModule.cs index 09b44373..943b63a6 100644 --- a/DiscordBot/Modules/BirthdayModule.cs +++ b/DiscordBot/Modules/Profiles/BirthdayModule.cs @@ -3,7 +3,7 @@ using DiscordBot.Attributes; using DiscordBot.Utils; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Profiles; [Group("UserModule"), Alias("")] public class BirthdayModule : ModuleBase diff --git a/DiscordBot/Modules/ProfileModule.cs b/DiscordBot/Modules/Profiles/ProfileModule.cs similarity index 98% rename from DiscordBot/Modules/ProfileModule.cs rename to DiscordBot/Modules/Profiles/ProfileModule.cs index 26bc5510..de984466 100644 --- a/DiscordBot/Modules/ProfileModule.cs +++ b/DiscordBot/Modules/Profiles/ProfileModule.cs @@ -1,7 +1,7 @@ using Discord.Commands; using DiscordBot.Services; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Profiles; [Group("UserModule"), Alias("")] public class ProfileModule : ModuleBase diff --git a/DiscordBot/Modules/RankModule.cs b/DiscordBot/Modules/Profiles/RankModule.cs similarity index 99% rename from DiscordBot/Modules/RankModule.cs rename to DiscordBot/Modules/Profiles/RankModule.cs index 8b58d300..801dd7c4 100644 --- a/DiscordBot/Modules/RankModule.cs +++ b/DiscordBot/Modules/Profiles/RankModule.cs @@ -1,7 +1,7 @@ using Discord.Commands; using DiscordBot.Services; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Profiles; [Group("UserModule"), Alias("")] public class RankModule : ModuleBase diff --git a/DiscordBot/Modules/EmbedModule.cs b/DiscordBot/Modules/Server/EmbedModule.cs similarity index 99% rename from DiscordBot/Modules/EmbedModule.cs rename to DiscordBot/Modules/Server/EmbedModule.cs index 29cfc343..5192ed09 100644 --- a/DiscordBot/Modules/EmbedModule.cs +++ b/DiscordBot/Modules/Server/EmbedModule.cs @@ -3,7 +3,7 @@ using DiscordBot.Services; // ReSharper disable all UnusedMember.Local -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Server; [RequireAdmin] public class EmbedModule : ModuleBase diff --git a/DiscordBot/Modules/QuoteModule.cs b/DiscordBot/Modules/Server/QuoteModule.cs similarity index 98% rename from DiscordBot/Modules/QuoteModule.cs rename to DiscordBot/Modules/Server/QuoteModule.cs index 91ec6d3d..1a0a996d 100644 --- a/DiscordBot/Modules/QuoteModule.cs +++ b/DiscordBot/Modules/Server/QuoteModule.cs @@ -2,7 +2,7 @@ using Discord.Commands; using DiscordBot.Attributes; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Server; [Group("UserModule"), Alias("")] public class QuoteModule : ModuleBase diff --git a/DiscordBot/Modules/ReminderModule.cs b/DiscordBot/Modules/Server/ReminderModule.cs similarity index 92% rename from DiscordBot/Modules/ReminderModule.cs rename to DiscordBot/Modules/Server/ReminderModule.cs index 45f44205..ac204de5 100644 --- a/DiscordBot/Modules/ReminderModule.cs +++ b/DiscordBot/Modules/Server/ReminderModule.cs @@ -5,7 +5,7 @@ using DiscordBot.Attributes; using System.Text.RegularExpressions; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Server; [Group("UserModule"), Alias("")] public class ReminderModule : ModuleBase @@ -47,7 +47,7 @@ public async Task RemindMe(string time, [Remainder] string message) } } - var reminderDate = Utils.Utils.ParseTimeFromString(time); + var reminderDate = global::DiscordBot.Utils.Utils.ParseTimeFromString(time); if (reminderDate < DateTime.Now) { // There isn't really a way to add negative time @@ -93,8 +93,9 @@ public async Task RemindMe(string time, [Remainder] string message) ReminderService.AddReminder(reminder); await Context.Message.AddReactionAsync(ReminderService.BotResponseEmoji); + var formattedTime = global::DiscordBot.Utils.Utils.FormatTime((uint)(reminderDate - DateTime.Now).TotalSeconds); await (ReplyAsync( - $"Reminder set for {Utils.Utils.FormatTime((uint)(reminderDate - DateTime.Now).TotalSeconds)}") + $"Reminder set for {formattedTime}") .DeleteAfterSeconds(seconds: 10) ?? Task.CompletedTask); } @@ -147,9 +148,10 @@ public async Task Reminders(IUser user) int index = 1; foreach (var reminder in reminders) { - var msgLink = Utils.Utils.MessageLinkBack(Context.Guild.Id, reminder.ChannelId, reminder.MessageId); + var msgLink = global::DiscordBot.Utils.Utils.MessageLinkBack(Context.Guild.Id, reminder.ChannelId, reminder.MessageId); + var timeLeft = global::DiscordBot.Utils.Utils.FormatTime((uint)(reminder.When - DateTime.Now).TotalSeconds); embed.AddField( - $"#{index++} | {Utils.Utils.FormatTime((uint)(reminder.When - DateTime.Now).TotalSeconds)}", + $"#{index++} | {timeLeft}", $"[Link]({msgLink}) \"{reminder.Message}\""); } if (await Context.Guild.GetChannelAsync(Settings.Channels.BotCommands.Id) is IMessageChannel botCommands) diff --git a/DiscordBot/Modules/RulesModule.cs b/DiscordBot/Modules/Server/RulesModule.cs similarity index 99% rename from DiscordBot/Modules/RulesModule.cs rename to DiscordBot/Modules/Server/RulesModule.cs index 867eb52a..83b1df89 100644 --- a/DiscordBot/Modules/RulesModule.cs +++ b/DiscordBot/Modules/Server/RulesModule.cs @@ -5,7 +5,7 @@ using DiscordBot.Settings; using DiscordBot.Attributes; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Server; [Group("UserModule"), Alias("")] public class RulesModule : ModuleBase diff --git a/DiscordBot/Modules/ServerModule.cs b/DiscordBot/Modules/Server/ServerModule.cs similarity index 98% rename from DiscordBot/Modules/ServerModule.cs rename to DiscordBot/Modules/Server/ServerModule.cs index e6fbf441..7cc81097 100644 --- a/DiscordBot/Modules/ServerModule.cs +++ b/DiscordBot/Modules/Server/ServerModule.cs @@ -3,7 +3,7 @@ using DiscordBot.Services; using DiscordBot.Settings; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Server; [Group("UserModule"), Alias("")] public class ServerModule : ModuleBase diff --git a/DiscordBot/Modules/ServerSlashModule.cs b/DiscordBot/Modules/Server/ServerSlashModule.cs similarity index 99% rename from DiscordBot/Modules/ServerSlashModule.cs rename to DiscordBot/Modules/Server/ServerSlashModule.cs index 078715b7..fa02212e 100644 --- a/DiscordBot/Modules/ServerSlashModule.cs +++ b/DiscordBot/Modules/Server/ServerSlashModule.cs @@ -2,7 +2,7 @@ using DiscordBot.Services; using DiscordBot.Settings; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Server; public class ServerSlashModule : InteractionModuleBase { diff --git a/DiscordBot/Modules/TicketModule.cs b/DiscordBot/Modules/Server/TicketModule.cs similarity index 99% rename from DiscordBot/Modules/TicketModule.cs rename to DiscordBot/Modules/Server/TicketModule.cs index 2274c6c6..2fb5ef10 100644 --- a/DiscordBot/Modules/TicketModule.cs +++ b/DiscordBot/Modules/Server/TicketModule.cs @@ -4,7 +4,7 @@ using DiscordBot.Settings; // ReSharper disable all UnusedMember.Local -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Server; public class TicketModule : ModuleBase { diff --git a/DiscordBot/Modules/AirportModule.cs b/DiscordBot/Modules/Utils/AirportModule.cs similarity index 98% rename from DiscordBot/Modules/AirportModule.cs rename to DiscordBot/Modules/Utils/AirportModule.cs index 1b5b8f1b..0d543cc8 100644 --- a/DiscordBot/Modules/AirportModule.cs +++ b/DiscordBot/Modules/Utils/AirportModule.cs @@ -1,9 +1,8 @@ using Discord.Commands; -using DiscordBot.Modules.Weather; using DiscordBot.Services; using DiscordBot.Settings; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Utils; // Allows UserModule !help to show commands from this module [Group("UserModule"), Alias("")] diff --git a/DiscordBot/Modules/ConvertModule.cs b/DiscordBot/Modules/Utils/ConvertModule.cs similarity index 99% rename from DiscordBot/Modules/ConvertModule.cs rename to DiscordBot/Modules/Utils/ConvertModule.cs index fada1ba0..293bc7e5 100644 --- a/DiscordBot/Modules/ConvertModule.cs +++ b/DiscordBot/Modules/Utils/ConvertModule.cs @@ -3,7 +3,7 @@ using DiscordBot.Services; using DiscordBot.Utils; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Utils; [Group("UserModule"), Alias("")] public class ConvertModule : ModuleBase diff --git a/DiscordBot/Modules/SearchModule.cs b/DiscordBot/Modules/Utils/SearchModule.cs similarity index 99% rename from DiscordBot/Modules/SearchModule.cs rename to DiscordBot/Modules/Utils/SearchModule.cs index fb528bb4..2c3a14c0 100644 --- a/DiscordBot/Modules/SearchModule.cs +++ b/DiscordBot/Modules/Utils/SearchModule.cs @@ -5,7 +5,7 @@ using DiscordBot.Settings; using DiscordBot.Attributes; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Utils; [Group("UserModule"), Alias("")] public class SearchModule : ModuleBase diff --git a/DiscordBot/Modules/Weather/WeatherContainers.cs b/DiscordBot/Modules/Utils/Weather/WeatherContainers.cs similarity index 98% rename from DiscordBot/Modules/Weather/WeatherContainers.cs rename to DiscordBot/Modules/Utils/Weather/WeatherContainers.cs index cfb61689..dde28698 100644 --- a/DiscordBot/Modules/Weather/WeatherContainers.cs +++ b/DiscordBot/Modules/Utils/Weather/WeatherContainers.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace DiscordBot.Modules.Weather; +namespace DiscordBot.Modules.Utils.Weather; #region Weather Results diff --git a/DiscordBot/Modules/Weather/WeatherModule.cs b/DiscordBot/Modules/Utils/Weather/WeatherModule.cs similarity index 99% rename from DiscordBot/Modules/Weather/WeatherModule.cs rename to DiscordBot/Modules/Utils/Weather/WeatherModule.cs index ddd28aad..a4abfe1f 100644 --- a/DiscordBot/Modules/Weather/WeatherModule.cs +++ b/DiscordBot/Modules/Utils/Weather/WeatherModule.cs @@ -1,10 +1,9 @@ using Discord.Commands; using DiscordBot.Attributes; -using DiscordBot.Modules.Weather; using DiscordBot.Services; using Newtonsoft.Json; -namespace DiscordBot.Modules; +namespace DiscordBot.Modules.Utils.Weather; // https://openweathermap.org/current#call // Allows UserModule !help to show commands from this module diff --git a/DiscordBot/Program.cs b/DiscordBot/Program.cs index e58942f0..e5174511 100644 --- a/DiscordBot/Program.cs +++ b/DiscordBot/Program.cs @@ -2,9 +2,7 @@ using Discord.Commands; using Discord.Interactions; using Discord.WebSocket; -using DiscordBot.Service; using DiscordBot.Services; -using DiscordBot.Services.Tips; using DiscordBot.Settings; using DiscordBot.Utils; using Microsoft.Extensions.DependencyInjection; diff --git a/DiscordBot/Services/CodeCheckService.cs b/DiscordBot/Services/Code/CodeCheckService.cs similarity index 99% rename from DiscordBot/Services/CodeCheckService.cs rename to DiscordBot/Services/Code/CodeCheckService.cs index 3557c410..7dca07ac 100644 --- a/DiscordBot/Services/CodeCheckService.cs +++ b/DiscordBot/Services/Code/CodeCheckService.cs @@ -2,7 +2,7 @@ using Discord.WebSocket; using DiscordBot.Settings; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Code; public class CodeCheckService { diff --git a/DiscordBot/Services/Tips/Components/Tip.cs b/DiscordBot/Services/Code/Tips/Components/Tip.cs similarity index 84% rename from DiscordBot/Services/Tips/Components/Tip.cs rename to DiscordBot/Services/Code/Tips/Components/Tip.cs index 187bdb8a..c8032ebf 100644 --- a/DiscordBot/Services/Tips/Components/Tip.cs +++ b/DiscordBot/Services/Code/Tips/Components/Tip.cs @@ -1,6 +1,6 @@ using Discord; -namespace DiscordBot.Services.Tips.Components; +namespace DiscordBot.Services.Code.Tips.Components; public class Tip : IEntity { diff --git a/DiscordBot/Services/Tips/TipService.cs b/DiscordBot/Services/Code/Tips/TipService.cs similarity index 99% rename from DiscordBot/Services/Tips/TipService.cs rename to DiscordBot/Services/Code/Tips/TipService.cs index 7b985260..718ac914 100644 --- a/DiscordBot/Services/Tips/TipService.cs +++ b/DiscordBot/Services/Code/Tips/TipService.cs @@ -5,11 +5,10 @@ using System.Net.Http; using Discord; using Discord.WebSocket; -using DiscordBot.Services.Tips.Components; using DiscordBot.Settings; using Newtonsoft.Json; -namespace DiscordBot.Services.Tips; +namespace DiscordBot.Services.Code.Tips; public class TipService { diff --git a/DiscordBot/Services/FeedService.cs b/DiscordBot/Services/Code/Unity/FeedService.cs similarity index 98% rename from DiscordBot/Services/FeedService.cs rename to DiscordBot/Services/Code/Unity/FeedService.cs index c6638544..488f6d19 100644 --- a/DiscordBot/Services/FeedService.cs +++ b/DiscordBot/Services/Code/Unity/FeedService.cs @@ -5,7 +5,7 @@ using DiscordBot.Settings; using DiscordBot.Utils; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Code.Unity; public class FeedService { @@ -189,7 +189,7 @@ private async Task AddTagsToPost(IForumChannel channel, IThreadChannel post, Lis private string GetSummary(ForumNewsFeed feed, SyndicationItem item) { - var summary = Utils.Utils.RemoveHtmlTags(item.Summary.Text); + var summary = global::DiscordBot.Utils.Utils.RemoveHtmlTags(item.Summary.Text); // If it is too long, we truncate it var summaryLength = summary.Length; diff --git a/DiscordBot/Services/ReleaseNotesParser.cs b/DiscordBot/Services/Code/Unity/ReleaseNotesParser.cs similarity index 99% rename from DiscordBot/Services/ReleaseNotesParser.cs rename to DiscordBot/Services/Code/Unity/ReleaseNotesParser.cs index f3216811..3391c829 100644 --- a/DiscordBot/Services/ReleaseNotesParser.cs +++ b/DiscordBot/Services/Code/Unity/ReleaseNotesParser.cs @@ -1,6 +1,6 @@ using HtmlAgilityPack; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Code.Unity; public class ReleaseNotesParser { diff --git a/DiscordBot/Services/UnityDocParser.cs b/DiscordBot/Services/Code/Unity/UnityDocParser.cs similarity index 94% rename from DiscordBot/Services/UnityDocParser.cs rename to DiscordBot/Services/Code/Unity/UnityDocParser.cs index eb10c521..6630edda 100644 --- a/DiscordBot/Services/UnityDocParser.cs +++ b/DiscordBot/Services/Code/Unity/UnityDocParser.cs @@ -1,7 +1,7 @@ using DiscordBot.Domain; using HtmlAgilityPack; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Code.Unity; public static class UnityDocParser { diff --git a/DiscordBot/Services/UnityHelp/CannedResponseService.cs b/DiscordBot/Services/Code/Unity/UnityHelp/CannedResponseService.cs similarity index 99% rename from DiscordBot/Services/UnityHelp/CannedResponseService.cs rename to DiscordBot/Services/Code/Unity/UnityHelp/CannedResponseService.cs index 40543739..7ea74154 100644 --- a/DiscordBot/Services/UnityHelp/CannedResponseService.cs +++ b/DiscordBot/Services/Code/Unity/UnityHelp/CannedResponseService.cs @@ -1,4 +1,4 @@ -namespace DiscordBot.Service; +namespace DiscordBot.Services.Code.Unity.UnityHelp; public class CannedResponseService { diff --git a/DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs b/DiscordBot/Services/Code/Unity/UnityHelp/Components/HelpBotMessage.cs similarity index 88% rename from DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs rename to DiscordBot/Services/Code/Unity/UnityHelp/Components/HelpBotMessage.cs index 35a40bd2..2ab19a3b 100644 --- a/DiscordBot/Services/UnityHelp/Components/HelpBotMessage.cs +++ b/DiscordBot/Services/Code/Unity/UnityHelp/Components/HelpBotMessage.cs @@ -1,4 +1,4 @@ -namespace DiscordBot.Services.UnityHelp; +namespace DiscordBot.Services.Code.Unity.UnityHelp; public enum HelpMessageType { diff --git a/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs b/DiscordBot/Services/Code/Unity/UnityHelp/Components/ThreadContainer.cs similarity index 95% rename from DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs rename to DiscordBot/Services/Code/Unity/UnityHelp/Components/ThreadContainer.cs index 35ff5d8f..de98d804 100644 --- a/DiscordBot/Services/UnityHelp/Components/ThreadContainer.cs +++ b/DiscordBot/Services/Code/Unity/UnityHelp/Components/ThreadContainer.cs @@ -1,4 +1,4 @@ -namespace DiscordBot.Services.UnityHelp; +namespace DiscordBot.Services.Code.Unity.UnityHelp; public class ThreadContainer { diff --git a/DiscordBot/Services/UnityHelp/UnityHelpService.cs b/DiscordBot/Services/Code/Unity/UnityHelp/UnityHelpService.cs similarity index 99% rename from DiscordBot/Services/UnityHelp/UnityHelpService.cs rename to DiscordBot/Services/Code/Unity/UnityHelp/UnityHelpService.cs index 1978b4e1..18f6d6a6 100644 --- a/DiscordBot/Services/UnityHelp/UnityHelpService.cs +++ b/DiscordBot/Services/Code/Unity/UnityHelp/UnityHelpService.cs @@ -1,8 +1,7 @@ using Discord.WebSocket; using DiscordBot.Settings; -using DiscordBot.Services.UnityHelp; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Code.Unity.UnityHelp; // TODO : (James) Better Slash Command Support diff --git a/DiscordBot/Services/Casino/CasinoService.cs b/DiscordBot/Services/Fun/Casino/CasinoService.cs similarity index 99% rename from DiscordBot/Services/Casino/CasinoService.cs rename to DiscordBot/Services/Fun/Casino/CasinoService.cs index 182c5ddc..b405705d 100644 --- a/DiscordBot/Services/Casino/CasinoService.cs +++ b/DiscordBot/Services/Fun/Casino/CasinoService.cs @@ -1,7 +1,7 @@ using DiscordBot.Domain; using DiscordBot.Settings; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Fun.Casino; public class CasinoService { diff --git a/DiscordBot/Services/Casino/GameService.cs b/DiscordBot/Services/Fun/Casino/GameService.cs similarity index 98% rename from DiscordBot/Services/Casino/GameService.cs rename to DiscordBot/Services/Fun/Casino/GameService.cs index e799b916..97c030bc 100644 --- a/DiscordBot/Services/Casino/GameService.cs +++ b/DiscordBot/Services/Fun/Casino/GameService.cs @@ -2,7 +2,7 @@ using DiscordBot.Domain; using DiscordBot.Modules; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Fun.Casino; public class GameService { diff --git a/DiscordBot/Services/Casino/TransactionFormatter.cs b/DiscordBot/Services/Fun/Casino/TransactionFormatter.cs similarity index 98% rename from DiscordBot/Services/Casino/TransactionFormatter.cs rename to DiscordBot/Services/Fun/Casino/TransactionFormatter.cs index 254baeab..59e884f8 100644 --- a/DiscordBot/Services/Casino/TransactionFormatter.cs +++ b/DiscordBot/Services/Fun/Casino/TransactionFormatter.cs @@ -1,7 +1,7 @@ using Discord.WebSocket; using DiscordBot.Domain; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Fun.Casino; public class TransactionFormatter { diff --git a/DiscordBot/Services/DuelService.cs b/DiscordBot/Services/Fun/DuelService.cs similarity index 98% rename from DiscordBot/Services/DuelService.cs rename to DiscordBot/Services/Fun/DuelService.cs index bec08cbb..77f3477b 100644 --- a/DiscordBot/Services/DuelService.cs +++ b/DiscordBot/Services/Fun/DuelService.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; using Discord.WebSocket; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Fun; public class DuelService { diff --git a/DiscordBot/Services/MikuService.cs b/DiscordBot/Services/Fun/MikuService.cs similarity index 98% rename from DiscordBot/Services/MikuService.cs rename to DiscordBot/Services/Fun/MikuService.cs index 958e873c..d114c805 100644 --- a/DiscordBot/Services/MikuService.cs +++ b/DiscordBot/Services/Fun/MikuService.cs @@ -3,7 +3,7 @@ using DiscordBot.Data; using DiscordBot.Settings; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Fun; public class MikuService { diff --git a/DiscordBot/Services/BirthdayAnnouncementService.cs b/DiscordBot/Services/Profiles/BirthdayAnnouncementService.cs similarity index 99% rename from DiscordBot/Services/BirthdayAnnouncementService.cs rename to DiscordBot/Services/Profiles/BirthdayAnnouncementService.cs index 3e6aba35..1c528733 100644 --- a/DiscordBot/Services/BirthdayAnnouncementService.cs +++ b/DiscordBot/Services/Profiles/BirthdayAnnouncementService.cs @@ -4,7 +4,7 @@ using DiscordBot.Utils; using HtmlAgilityPack; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Profiles; public class BirthdayAnnouncementService { diff --git a/DiscordBot/Services/KarmaResetService.cs b/DiscordBot/Services/Profiles/KarmaResetService.cs similarity index 99% rename from DiscordBot/Services/KarmaResetService.cs rename to DiscordBot/Services/Profiles/KarmaResetService.cs index d47084ef..71bf4303 100644 --- a/DiscordBot/Services/KarmaResetService.cs +++ b/DiscordBot/Services/Profiles/KarmaResetService.cs @@ -2,7 +2,7 @@ using Insight.Database; using Npgsql; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Profiles; /// /// Replaces MySQL EVENT scheduler — resets weekly/monthly/yearly karma columns on schedule. diff --git a/DiscordBot/Services/KarmaService.cs b/DiscordBot/Services/Profiles/KarmaService.cs similarity index 99% rename from DiscordBot/Services/KarmaService.cs rename to DiscordBot/Services/Profiles/KarmaService.cs index 296c1e4b..cf731cd8 100644 --- a/DiscordBot/Services/KarmaService.cs +++ b/DiscordBot/Services/Profiles/KarmaService.cs @@ -3,7 +3,7 @@ using Discord.WebSocket; using DiscordBot.Settings; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Profiles; public class KarmaService { diff --git a/DiscordBot/Services/ProfileCardService.cs b/DiscordBot/Services/Profiles/ProfileCardService.cs similarity index 99% rename from DiscordBot/Services/ProfileCardService.cs rename to DiscordBot/Services/Profiles/ProfileCardService.cs index c3b6d8c6..06c99baa 100644 --- a/DiscordBot/Services/ProfileCardService.cs +++ b/DiscordBot/Services/Profiles/ProfileCardService.cs @@ -6,7 +6,7 @@ using ImageMagick; using Newtonsoft.Json; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Profiles; public class ProfileCardService { diff --git a/DiscordBot/Services/UserExtendedService.cs b/DiscordBot/Services/Profiles/UserExtendedService.cs similarity index 97% rename from DiscordBot/Services/UserExtendedService.cs rename to DiscordBot/Services/Profiles/UserExtendedService.cs index 730af460..baf72b9e 100644 --- a/DiscordBot/Services/UserExtendedService.cs +++ b/DiscordBot/Services/Profiles/UserExtendedService.cs @@ -1,4 +1,4 @@ -namespace DiscordBot.Services; +namespace DiscordBot.Services.Profiles; /// /// May be renamed later. diff --git a/DiscordBot/Services/XpService.cs b/DiscordBot/Services/Profiles/XpService.cs similarity index 98% rename from DiscordBot/Services/XpService.cs rename to DiscordBot/Services/Profiles/XpService.cs index c381f06b..c3ab38ee 100644 --- a/DiscordBot/Services/XpService.cs +++ b/DiscordBot/Services/Profiles/XpService.cs @@ -1,7 +1,7 @@ using Discord.WebSocket; using DiscordBot.Settings; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Profiles; public class XpService { diff --git a/DiscordBot/Services/Recruitment/RecruitService.cs b/DiscordBot/Services/Recruitment/RecruitService.cs index 3c6144fa..49c8bfa2 100644 --- a/DiscordBot/Services/Recruitment/RecruitService.cs +++ b/DiscordBot/Services/Recruitment/RecruitService.cs @@ -2,7 +2,7 @@ using DiscordBot.Settings; using DiscordBot.Utils; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Recruitment; public class RecruitService { diff --git a/DiscordBot/Services/AuditLogService.cs b/DiscordBot/Services/Server/AuditLogService.cs similarity index 99% rename from DiscordBot/Services/AuditLogService.cs rename to DiscordBot/Services/Server/AuditLogService.cs index 9e7031f4..a2c7dc2e 100644 --- a/DiscordBot/Services/AuditLogService.cs +++ b/DiscordBot/Services/Server/AuditLogService.cs @@ -2,7 +2,7 @@ using Discord.WebSocket; using DiscordBot.Settings; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Server; public class AuditLogService { diff --git a/DiscordBot/Services/EmbedParsingService.cs b/DiscordBot/Services/Server/EmbedParsingService.cs similarity index 99% rename from DiscordBot/Services/EmbedParsingService.cs rename to DiscordBot/Services/Server/EmbedParsingService.cs index 206c2588..3ddf428a 100644 --- a/DiscordBot/Services/EmbedParsingService.cs +++ b/DiscordBot/Services/Server/EmbedParsingService.cs @@ -2,7 +2,7 @@ using System.Text; using Newtonsoft.Json; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Server; public class EmbedParsingService { diff --git a/DiscordBot/Services/EveryoneScoldService.cs b/DiscordBot/Services/Server/EveryoneScoldService.cs similarity index 97% rename from DiscordBot/Services/EveryoneScoldService.cs rename to DiscordBot/Services/Server/EveryoneScoldService.cs index 04f9d15d..90ad1d7a 100644 --- a/DiscordBot/Services/EveryoneScoldService.cs +++ b/DiscordBot/Services/Server/EveryoneScoldService.cs @@ -1,7 +1,7 @@ using Discord.WebSocket; using DiscordBot.Settings; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Server; public class EveryoneScoldService { diff --git a/DiscordBot/Services/ReminderService.cs b/DiscordBot/Services/Server/ReminderService.cs similarity index 96% rename from DiscordBot/Services/ReminderService.cs rename to DiscordBot/Services/Server/ReminderService.cs index 811edf99..8e61b127 100644 --- a/DiscordBot/Services/ReminderService.cs +++ b/DiscordBot/Services/Server/ReminderService.cs @@ -1,7 +1,7 @@ using Discord.WebSocket; using DiscordBot.Settings; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Server; [Serializable] public class ReminderItem @@ -64,11 +64,11 @@ private void Initialize() // Serialize Reminders to file public void SaveReminders() { - Utils.SerializeUtil.SerializeFile($"{_serverRootPath}/reminders.json", _reminders); + global::DiscordBot.Utils.SerializeUtil.SerializeFile($"{_serverRootPath}/reminders.json", _reminders); } private void LoadReminders() { - _reminders = Utils.SerializeUtil.DeserializeFile>($"{_serverRootPath}/reminders.json"); + _reminders = global::DiscordBot.Utils.SerializeUtil.DeserializeFile>($"{_serverRootPath}/reminders.json"); } public void AddReminder(ReminderItem reminder) { diff --git a/DiscordBot/Services/ServerService.cs b/DiscordBot/Services/Server/ServerService.cs similarity index 86% rename from DiscordBot/Services/ServerService.cs rename to DiscordBot/Services/Server/ServerService.cs index bdcd8919..caa27d82 100644 --- a/DiscordBot/Services/ServerService.cs +++ b/DiscordBot/Services/Server/ServerService.cs @@ -1,6 +1,6 @@ using Discord.WebSocket; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Server; public class ServerService { diff --git a/DiscordBot/Services/WelcomeService.cs b/DiscordBot/Services/Server/WelcomeService.cs similarity index 99% rename from DiscordBot/Services/WelcomeService.cs rename to DiscordBot/Services/Server/WelcomeService.cs index f86628c5..1bb3ce36 100644 --- a/DiscordBot/Services/WelcomeService.cs +++ b/DiscordBot/Services/Server/WelcomeService.cs @@ -2,7 +2,7 @@ using Discord.WebSocket; using DiscordBot.Settings; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Server; public class WelcomeService { diff --git a/DiscordBot/Services/AirportService.cs b/DiscordBot/Services/Utils/AirportService.cs similarity index 99% rename from DiscordBot/Services/AirportService.cs rename to DiscordBot/Services/Utils/AirportService.cs index 6d234cdb..92816b4a 100644 --- a/DiscordBot/Services/AirportService.cs +++ b/DiscordBot/Services/Utils/AirportService.cs @@ -5,7 +5,7 @@ using DiscordBot.Utils; using Newtonsoft.Json; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Utils; public class AirportService { diff --git a/DiscordBot/Services/CurrencyService.cs b/DiscordBot/Services/Utils/CurrencyService.cs similarity index 98% rename from DiscordBot/Services/CurrencyService.cs rename to DiscordBot/Services/Utils/CurrencyService.cs index 450eff65..770fffe1 100644 --- a/DiscordBot/Services/CurrencyService.cs +++ b/DiscordBot/Services/Utils/CurrencyService.cs @@ -1,7 +1,7 @@ -using DiscordBot.Utils; +using DiscordBot.Utils; using Newtonsoft.Json.Linq; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Utils; public class CurrencyService { diff --git a/DiscordBot/Services/SearchService.cs b/DiscordBot/Services/Utils/SearchService.cs similarity index 99% rename from DiscordBot/Services/SearchService.cs rename to DiscordBot/Services/Utils/SearchService.cs index aecad872..5ea87e54 100644 --- a/DiscordBot/Services/SearchService.cs +++ b/DiscordBot/Services/Utils/SearchService.cs @@ -2,7 +2,7 @@ using DiscordBot.Domain; using HtmlAgilityPack; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Utils; public class SearchService { diff --git a/DiscordBot/Services/WeatherService.cs b/DiscordBot/Services/Utils/Weather/WeatherService.cs similarity index 95% rename from DiscordBot/Services/WeatherService.cs rename to DiscordBot/Services/Utils/Weather/WeatherService.cs index 0fd17065..55e83e04 100644 --- a/DiscordBot/Services/WeatherService.cs +++ b/DiscordBot/Services/Utils/Weather/WeatherService.cs @@ -1,9 +1,8 @@ using Discord.WebSocket; using DiscordBot.Settings; using DiscordBot.Utils; -using DiscordBot.Modules.Weather; -namespace DiscordBot.Services; +namespace DiscordBot.Services.Utils.Weather; public class WeatherService { diff --git a/docs/codebase.md b/docs/codebase.md index a9ea5736..09225e15 100644 --- a/docs/codebase.md +++ b/docs/codebase.md @@ -64,29 +64,42 @@ DiscordBot/ │ └── ... │ ├── Modules/ # Discord command handlers (text + slash) -│ ├── UserModule.cs # General user commands (text) -│ ├── UserSlashModule.cs # User slash commands -│ ├── ModerationModule.cs # Mod commands -│ ├── TipModule.cs # Tip system -│ ├── ReminderModule.cs # Reminders -│ ├── TicketModule.cs # Support tickets -│ ├── EmbedModule.cs # Embed generation -│ ├── AirportModule.cs # Flight lookups -│ ├── Casino/ # Casino slash commands -│ ├── UnityHelp/ # Help forum, canned responses, FAQ -│ └── Weather/ # Weather commands +│ ├── Profiles/ # User profile, rank & birthday commands +│ │ ├── ProfileModule.cs +│ │ ├── RankModule.cs +│ │ └── BirthdayModule.cs +│ ├── Server/ # Server management, moderation, embeds, quotes, reminders +│ │ ├── ServerModule.cs / ServerSlashModule.cs +│ │ ├── TicketModule.cs +│ │ ├── RulesModule.cs +│ │ ├── EmbedModule.cs +│ │ ├── QuoteModule.cs +│ │ └── ReminderModule.cs +│ ├── Fun/ # Entertainment & games +│ │ ├── FunModule.cs +│ │ ├── DuelSlashModule.cs +│ │ └── Casino/ # Casino slash commands +│ ├── Utils/ # Search, conversion, flights, weather +│ │ ├── SearchModule.cs +│ │ ├── ConvertModule.cs +│ │ ├── AirportModule.cs +│ │ └── Weather/ # Weather commands +│ └── Code/ # Coding tips, Unity help +│ ├── CodeTipModule.cs +│ ├── TipModule.cs +│ └── Unity/UnityHelp/ # Help forum, canned responses, FAQ │ ├── Services/ # Business logic and background services -│ ├── CommandHandlingService.cs # Command routing -│ ├── DatabaseService.cs # MySQL connection/queries -│ ├── UserService.cs # XP, levels, karma, profile cards -│ ├── LoggingService.cs # Console/channel/file logging -│ ├── ModerationService.cs # Audit logging, invite enforcement -│ ├── Casino/ # Token management, game sessions -│ ├── Moderation/ # Moderation sub-services -│ ├── Recruitment/ # Recruitment workflow -│ ├── Tips/ # Tip database management -│ └── UnityHelp/ # Help thread management +│ ├── CommandHandlingService.cs # Command routing (core) +│ ├── DatabaseService.cs # PostgreSQL connection/queries (core) +│ ├── LoggingService.cs # Console/channel/file logging (core) +│ ├── UpdateService.cs # Update checking (core) +│ ├── Profiles/ # Profile cards, XP, karma, birthdays +│ ├── Server/ # Welcome, audit log, embed parsing, reminders +│ ├── Fun/ # Duels, Miku, Casino/ +│ ├── Utils/ # Search, airport, currency, Weather/ +│ ├── Code/ # Code checking, Tips/, Unity/ (docs, feeds, UnityHelp/) +│ └── Recruitment/ # Recruitment workflow │ ├── Settings/ # Configuration files │ ├── Settings.json # Main config (gitignored) @@ -123,9 +136,9 @@ DiscordBot/ | What | Where | |------|-------| -| New text command | `Modules/` — add to existing module or create `*Module.cs` | -| New slash command | `Modules/` — add to existing module or create `*SlashModule.cs` | -| New business logic | `Services/` — create `*Service.cs`, register in `Program.cs` | +| New text command | `Modules//` — add to existing module or create `*Module.cs` | +| New slash command | `Modules//` — add to existing module or create `*SlashModule.cs` | +| New business logic | `Services//` — create `*Service.cs`, register in `Program.cs` | | New DB queries | `Extensions/` — add to `*Repository.cs` | | New game type | `Domain/Casino/Games/` — implement `ICasinoGame` | | New precondition | `Attributes/` — extend `PreconditionAttribute` | @@ -133,6 +146,16 @@ DiscordBot/ | Static assets | `Assets/` — fonts, images, skins (baked into Docker image) | | Runtime data | `SERVER/` — auto-generated, gitignored | +### Module/Service Domain Groups + +| Domain | Modules | Services | +|--------|---------|----------| +| **Profiles** | ProfileModule, RankModule, BirthdayModule | ProfileCardService, XpService, KarmaService, KarmaResetService, UserExtendedService, BirthdayAnnouncementService | +| **Server** | ServerModule, ServerSlashModule, TicketModule, RulesModule, EmbedModule, QuoteModule, ReminderModule | ServerService, WelcomeService, AuditLogService, EveryoneScoldService, EmbedParsingService, ReminderService | +| **Fun** | FunModule, DuelSlashModule, Casino/ | DuelService, MikuService, Casino/ | +| **Utils** | SearchModule, ConvertModule, AirportModule, Weather/ | SearchService, AirportService, CurrencyService, Weather/ | +| **Code** | CodeTipModule, TipModule, Unity/UnityHelp/ | CodeCheckService, Tips/, Unity/ (feeds, docs, UnityHelp/) | + ### Testing - Tests go in `DiscordBot.Tests/` diff --git a/docs/plans/done/module-service-reorganization.md b/docs/plans/done/module-service-reorganization.md new file mode 100644 index 00000000..5a72e61e --- /dev/null +++ b/docs/plans/done/module-service-reorganization.md @@ -0,0 +1,118 @@ +# Module & Service Directory Reorganization + +## Status: In Progress + +## Summary + +Reorganize flat Modules/ and Services/ directories into domain-based subdirectories. Namespaces will match directory structure. + +## Modules Layout + +``` +Modules/ +├── Profiles/ +│ ├── ProfileModule.cs +│ ├── RankModule.cs +│ └── BirthdayModule.cs +├── Server/ +│ ├── ServerModule.cs +│ ├── ServerSlashModule.cs +│ ├── TicketModule.cs +│ ├── RulesModule.cs +│ ├── EmbedModule.cs +│ ├── QuoteModule.cs +│ └── ReminderModule.cs +├── Fun/ +│ ├── FunModule.cs +│ ├── DuelSlashModule.cs +│ └── Casino/ +│ ├── CasinoSlashModule.cs +│ └── CasinoSlashModule.Games.cs +├── Utils/ +│ ├── SearchModule.cs +│ ├── ConvertModule.cs +│ ├── AirportModule.cs +│ └── Weather/ +│ ├── WeatherModule.cs +│ └── WeatherContainers.cs +└── Code/ + ├── CodeTipModule.cs + ├── TipModule.cs + └── Unity/ + └── UnityHelp/ + ├── CannedInteractiveModule.cs + ├── CannedResponseModule.cs + ├── GeneralHelpModule.cs + ├── UnityHelpInteractiveModule.cs + └── UnityHelpModule.cs +``` + +## Services Layout + +``` +Services/ +├── DatabaseService.cs (root - core) +├── CommandHandlingService.cs (root - core) +├── LoggingService.cs (root - core) +├── UpdateService.cs (root - core) +├── Profiles/ +│ ├── ProfileCardService.cs +│ ├── XpService.cs +│ ├── KarmaService.cs +│ ├── KarmaResetService.cs +│ ├── UserExtendedService.cs +│ └── BirthdayAnnouncementService.cs +├── Server/ +│ ├── ServerService.cs +│ ├── WelcomeService.cs +│ ├── AuditLogService.cs +│ ├── EveryoneScoldService.cs +│ ├── EmbedParsingService.cs +│ └── ReminderService.cs +├── Fun/ +│ ├── DuelService.cs +│ ├── MikuService.cs +│ └── Casino/ +│ ├── CasinoService.cs +│ ├── GameService.cs +│ └── TransactionFormatter.cs +├── Utils/ +│ ├── SearchService.cs +│ ├── AirportService.cs +│ ├── CurrencyService.cs +│ └── Weather/ +│ └── WeatherService.cs +├── Code/ +│ ├── CodeCheckService.cs +│ ├── Tips/ +│ │ ├── TipService.cs +│ │ └── Components/ +│ │ └── Tip.cs +│ └── Unity/ +│ ├── UnityDocParser.cs +│ ├── ReleaseNotesParser.cs +│ ├── FeedService.cs +│ └── UnityHelp/ +│ ├── CannedResponseService.cs +│ ├── UnityHelpService.cs +│ └── Components/ +│ ├── HelpBotMessage.cs +│ └── ThreadContainer.cs +└── Recruitment/ + └── RecruitService.cs +``` + +## Namespace Strategy + +New namespaces match directory paths. All new sub-namespaces added to `GlobalUsings.cs` to avoid mass-editing using statements across the project. + +## Checklist + +- [ ] Create directory structure +- [ ] Move Module files +- [ ] Move Service files +- [ ] Update namespace declarations in moved files +- [ ] Update GlobalUsings.cs with new namespaces +- [ ] Build and verify compilation +- [ ] Run tests +- [ ] Update documentation From 387bec933ad9bc91ae2331b7db9fd462ae7eb1ec Mon Sep 17 00:00:00 2001 From: Pierre Demessence Date: Wed, 8 Apr 2026 11:55:17 +0200 Subject: [PATCH 48/48] refactor: move RecruitService from Recruitment/ to Server/ Recruitment is a server management concern. Move RecruitService.cs into Services/Server/ and update namespace accordingly. --- DiscordBot/GlobalUsings.cs | 3 +-- .../Services/{Recruitment => Server}/RecruitService.cs | 2 +- docs/codebase.md | 5 ++--- 3 files changed, 4 insertions(+), 6 deletions(-) rename DiscordBot/Services/{Recruitment => Server}/RecruitService.cs (99%) diff --git a/DiscordBot/GlobalUsings.cs b/DiscordBot/GlobalUsings.cs index 40ce2d6f..f21d7d9f 100644 --- a/DiscordBot/GlobalUsings.cs +++ b/DiscordBot/GlobalUsings.cs @@ -34,5 +34,4 @@ global using DiscordBot.Services.Code.Tips; global using DiscordBot.Services.Code.Tips.Components; global using DiscordBot.Services.Code.Unity; -global using DiscordBot.Services.Code.Unity.UnityHelp; -global using DiscordBot.Services.Recruitment; \ No newline at end of file +global using DiscordBot.Services.Code.Unity.UnityHelp; \ No newline at end of file diff --git a/DiscordBot/Services/Recruitment/RecruitService.cs b/DiscordBot/Services/Server/RecruitService.cs similarity index 99% rename from DiscordBot/Services/Recruitment/RecruitService.cs rename to DiscordBot/Services/Server/RecruitService.cs index 49c8bfa2..bd2d1fd4 100644 --- a/DiscordBot/Services/Recruitment/RecruitService.cs +++ b/DiscordBot/Services/Server/RecruitService.cs @@ -2,7 +2,7 @@ using DiscordBot.Settings; using DiscordBot.Utils; -namespace DiscordBot.Services.Recruitment; +namespace DiscordBot.Services.Server; public class RecruitService { diff --git a/docs/codebase.md b/docs/codebase.md index 09225e15..006d425e 100644 --- a/docs/codebase.md +++ b/docs/codebase.md @@ -98,8 +98,7 @@ DiscordBot/ │ ├── Server/ # Welcome, audit log, embed parsing, reminders │ ├── Fun/ # Duels, Miku, Casino/ │ ├── Utils/ # Search, airport, currency, Weather/ -│ ├── Code/ # Code checking, Tips/, Unity/ (docs, feeds, UnityHelp/) -│ └── Recruitment/ # Recruitment workflow +│ └── Code/ # Code checking, Tips/, Unity/ (docs, feeds, UnityHelp/) │ ├── Settings/ # Configuration files │ ├── Settings.json # Main config (gitignored) @@ -151,7 +150,7 @@ DiscordBot/ | Domain | Modules | Services | |--------|---------|----------| | **Profiles** | ProfileModule, RankModule, BirthdayModule | ProfileCardService, XpService, KarmaService, KarmaResetService, UserExtendedService, BirthdayAnnouncementService | -| **Server** | ServerModule, ServerSlashModule, TicketModule, RulesModule, EmbedModule, QuoteModule, ReminderModule | ServerService, WelcomeService, AuditLogService, EveryoneScoldService, EmbedParsingService, ReminderService | +| **Server** | ServerModule, ServerSlashModule, TicketModule, RulesModule, EmbedModule, QuoteModule, ReminderModule | ServerService, WelcomeService, AuditLogService, EveryoneScoldService, EmbedParsingService, ReminderService, RecruitService | | **Fun** | FunModule, DuelSlashModule, Casino/ | DuelService, MikuService, Casino/ | | **Utils** | SearchModule, ConvertModule, AirportModule, Weather/ | SearchService, AirportService, CurrencyService, Weather/ | | **Code** | CodeTipModule, TipModule, Unity/UnityHelp/ | CodeCheckService, Tips/, Unity/ (feeds, docs, UnityHelp/) |